Files
Void-Bastion/server/nakama/runtime/build/index.js
T

783 lines
51 KiB
JavaScript

var GAME_VERSION = "0.25";
var MAP_COUNT = 7;
var DIFFICULTY_COUNT = 3;
var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"];
var MATCH_MODULE = "void_bastion_coop";
var ROOM_COLLECTION = "private_rooms";
var ACTIVITY_COLLECTION = "player_activity";
var ACTIVITY_KEY = "summary";
var STATS_COLLECTION = "internal_stats";
var STATS_KEY = "global";
var PROMO_CODE_COLLECTION = "unlock_codes";
var PROMO_UNLOCK_COLLECTION = "player_unlocks";
var PROMO_UNLOCK_KEY = "website_codes";
var PROMO_ATTEMPT_COLLECTION = "unlock_attempts";
var DIAG_PARTICIPANT_COLLECTION = "online_diag_participants";
var DIAG_REPORT_COLLECTION = "online_diag_reports";
var DIAG_RETENTION_MS = 14 * 86400000;
var SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
var OP_SNAPSHOT = 1, OP_EVENT = 2, OP_BUILD = 10, OP_UPGRADE = 11, OP_SELL = 12, OP_START_WAVE = 13, OP_READY = 14;
var TICK_RATE = 10, SNAPSHOT_TICKS = 1, GRID = 80, WORLD_WIDTH = 2960, MIRROR_X = 1480;
var REACTOR_X = 1480, REACTOR_Y = 440, REACTOR_RADIUS = 112, PLAYFIELD_TOP = 84, PLAYFIELD_BOTTOM = 758;
var LEFT_PATH = [[-40,120],[1160,120],[1160,360],[360,360],[360,680],[1400,680],[1400,520],[1480,520]];
var RIGHT_PATH = [];
for (var pathIndex = 0; pathIndex < LEFT_PATH.length; pathIndex++) RIGHT_PATH.push([WORLD_WIDTH - LEFT_PATH[pathIndex][0], LEFT_PATH[pathIndex][1]]);
var PATHS = [LEFT_PATH, RIGHT_PATH];
var TOWERS = [
{cost:120,damage:22,range:190,cooldown:0.55,targetClass:"ground"},
{cost:180,damage:12,range:165,cooldown:0.18,targetClass:"both",bothScale:0.82},
{cost:250,damage:65,range:260,cooldown:1.40,targetClass:"ground"},
{cost:230,damage:34,range:240,cooldown:1.05,targetClass:"both",bothScale:0.72},
{cost:210,damage:20,range:215,cooldown:0.92,targetClass:"both",bothScale:1},
{cost:300,damage:77,range:330,cooldown:1.85,targetClass:"both",bothScale:0.70},
{cost:210,damage:20,range:230,cooldown:0.42,targetClass:"air"}
];
var ENEMIES = [
{category:"air",hp:70,speed:105,reward:18,breachDamage:1},
{category:"air",hp:185,speed:72,reward:34,breachDamage:2},
{category:"air",hp:520,speed:43,reward:78,breachDamage:3},
{category:"air",hp:52,speed:178,reward:22,breachDamage:2},
{category:"air",hp:260,speed:58,reward:52,breachDamage:2},
{category:"air",hp:740,speed:48,reward:108,breachDamage:4},
{category:"ground",hp:88,speed:142,reward:22,breachDamage:1},
{category:"ground",hp:390,speed:61,reward:61,breachDamage:3},
{category:"ground",hp:980,speed:36,reward:132,breachDamage:5}
];
var DIFFICULTIES = [
{hp:1,speed:1,count:1,income:1,spawnDelay:1,startCredits:450},
{hp:1.32,speed:1.07,count:1.24,income:0.94,spawnDelay:0.91,startCredits:430},
{hp:1.72,speed:1.15,count:1.48,income:0.84,spawnDelay:0.82,startCredits:400}
];
function clampInteger(value, minimum, maximum) {
var parsed = Math.floor(Number(value));
if (!isFinite(parsed) || parsed < minimum || parsed > maximum) throw new Error("numeric value outside allowed range");
return parsed;
}
function leaderboardId(mapIndex, difficulty, mode) { return mode + "_map" + mapIndex + "_difficulty" + difficulty; }
function rpcServerInfo(ctx, logger, nk, payload) {
return JSON.stringify({game:"void_bastion",version:GAME_VERSION,leaderboards:MAP_COUNT*DIFFICULTY_COUNT*VALID_MODES.length,authoritative_multiplayer:true,status:"online"});
}
function normalizeMetricLabel(value, fallback) {
var normalized = String(value || "").toLowerCase().replace(/[^a-z0-9._-]/g, "").substring(0, 32);
return normalized || fallback;
}
function utcDay(timestamp) {
return new Date(timestamp).toISOString().slice(0, 10);
}
function readStorageRecord(nk, collection, key, userId) {
var records = nk.storageRead([{collection:collection,key:key,userId:userId}]);
return records.length ? records[0] : null;
}
function emptyStats(now) {
return {total_players:0,platforms:{},versions:{},daily_active:{},created_at:now,updated_at:now};
}
function incrementMetric(metrics, key, amount) {
metrics[key] = Math.max(0, Math.floor(Number(metrics[key] || 0)) + amount);
if (metrics[key] === 0) delete metrics[key];
}
function trimDailyActivity(daily, currentDay) {
var minimum = Date.parse(currentDay + "T00:00:00.000Z") - 35 * 86400000;
var days = Object.keys(daily);
for (var i = 0; i < days.length; i++) {
if (Date.parse(days[i] + "T00:00:00.000Z") < minimum) delete daily[days[i]];
}
}
function writeStats(nk, previousRecord, value) {
var write = {collection:STATS_COLLECTION,key:STATS_KEY,userId:SYSTEM_USER_ID,value:value,permissionRead:0,permissionWrite:0};
if (previousRecord && previousRecord.version) write.version = previousRecord.version;
else write.version = "*";
nk.storageWrite([write]);
}
function updatePlayerStats(nk, playerRecord, platform, version, day, now) {
for (var attempt = 0; attempt < 3; attempt++) {
var statsRecord = readStorageRecord(nk, STATS_COLLECTION, STATS_KEY, SYSTEM_USER_ID);
var stats = statsRecord && statsRecord.value ? statsRecord.value : emptyStats(now);
stats.platforms = stats.platforms || {};
stats.versions = stats.versions || {};
stats.daily_active = stats.daily_active || {};
if (!playerRecord) {
stats.total_players = Math.max(0, Math.floor(Number(stats.total_players || 0))) + 1;
incrementMetric(stats.platforms, platform, 1);
incrementMetric(stats.versions, version, 1);
} else {
var oldPlatform = normalizeMetricLabel(playerRecord.value.platform, "unknown");
var oldVersion = normalizeMetricLabel(playerRecord.value.version, "unknown");
if (oldPlatform !== platform) { incrementMetric(stats.platforms, oldPlatform, -1); incrementMetric(stats.platforms, platform, 1); }
if (oldVersion !== version) { incrementMetric(stats.versions, oldVersion, -1); incrementMetric(stats.versions, version, 1); }
}
if (!playerRecord || String(playerRecord.value.last_seen_day || "") !== day) incrementMetric(stats.daily_active, day, 1);
trimDailyActivity(stats.daily_active, day);
stats.updated_at = now;
try { writeStats(nk, statsRecord, stats); return; }
catch (error) { if (attempt === 2) throw error; }
}
}
function removePlayerStats(nk, playerRecord, now) {
if (!playerRecord || !playerRecord.value) return;
for (var attempt = 0; attempt < 3; attempt++) {
var statsRecord = readStorageRecord(nk, STATS_COLLECTION, STATS_KEY, SYSTEM_USER_ID);
if (!statsRecord || !statsRecord.value) return;
var stats = statsRecord.value;
stats.platforms = stats.platforms || {};
stats.versions = stats.versions || {};
stats.total_players = Math.max(0, Math.floor(Number(stats.total_players || 0)) - 1);
incrementMetric(stats.platforms, normalizeMetricLabel(playerRecord.value.platform, "unknown"), -1);
incrementMetric(stats.versions, normalizeMetricLabel(playerRecord.value.version, "unknown"), -1);
stats.updated_at = now;
try { writeStats(nk, statsRecord, stats); return; }
catch (error) { if (attempt === 2) throw error; }
}
}
function rpcRecordActivity(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { request = {}; }
var platform = normalizeMetricLabel(request.platform, "unknown");
var version = normalizeMetricLabel(request.version, "unknown");
var now = Date.now(), day = utcDay(now);
var playerRecord = readStorageRecord(nk, ACTIVITY_COLLECTION, ACTIVITY_KEY, ctx.userId);
updatePlayerStats(nk, playerRecord, platform, version, day, now);
nk.storageWrite([{collection:ACTIVITY_COLLECTION,key:ACTIVITY_KEY,userId:ctx.userId,value:{first_seen:playerRecord&&playerRecord.value?Number(playerRecord.value.first_seen||now):now,last_seen:now,last_seen_day:day,platform:platform,version:version},permissionRead:0,permissionWrite:0}]);
return JSON.stringify({accepted:true});
}
function rpcPrivateStats(ctx, logger, nk, payload) {
if (ctx.userId) throw new Error("server-to-server access required");
var record = readStorageRecord(nk, STATS_COLLECTION, STATS_KEY, SYSTEM_USER_ID);
var stats = record && record.value ? record.value : emptyStats(Date.now());
return JSON.stringify(stats);
}
function rpcCreateUnlockCode(ctx, logger, nk, payload) {
if (ctx.userId) throw new Error("server-to-server access required");
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { throw new Error("invalid JSON payload"); }
var reward = String(request.reward || "");
if (["all_towers", "all_maps", "all"].indexOf(reward) < 0) throw new Error("invalid unlock reward");
var maxUses = clampInteger(request.max_uses === undefined ? 1 : request.max_uses, 1, 50000);
var note = String(request.note || "").trim();
if (note.length > 160) throw new Error("note exceeds 160 characters");
for (var attempt = 0; attempt < 5; attempt++) {
var code = nk.uuidv4().replace(/-/g, "").substring(0, 24).toUpperCase();
var codeKey = nk.sha256Hash(code);
if (readStorageRecord(nk, PROMO_CODE_COLLECTION, codeKey, SYSTEM_USER_ID)) continue;
nk.storageWrite([{collection:PROMO_CODE_COLLECTION,key:codeKey,userId:SYSTEM_USER_ID,value:{code:code,reward:reward,note:note,max_uses:maxUses,uses:0,redeemed_users:[],created_at:Date.now()},permissionRead:0,permissionWrite:0,version:"*"}]);
return JSON.stringify({accepted:true,code:code,reward:reward,note:note,max_uses:maxUses,uses:0,status:"open"});
}
throw new Error("could not allocate unique unlock code");
}
function rpcListUnlockCodes(ctx, logger, nk, payload) {
if (ctx.userId) throw new Error("server-to-server access required");
var codes = [], cursor = "", pages = 0;
do {
var result = nk.storageList(SYSTEM_USER_ID, PROMO_CODE_COLLECTION, 100, cursor);
var objects = Array.isArray(result) ? result : (result && Array.isArray(result.objects) ? result.objects : []);
for (var i = 0; i < objects.length; i++) {
var object = objects[i], value = object && object.value ? object.value : {};
var uses = Math.max(0, Math.floor(Number(value.uses || 0)));
var maxUses = Math.max(1, Math.floor(Number(value.max_uses || 1)));
codes.push({
code:String(value.code || ""),
code_key:String(object && object.key ? object.key : ""),
code_id:String(object && object.key ? object.key : "").slice(-8),
reward:String(value.reward || ""),
note:String(value.note || ""),
uses:uses,
max_uses:maxUses,
created_at:Number(value.created_at || 0),
status:uses >= maxUses ? "redeemed" : (uses > 0 ? "partial" : "open")
});
}
cursor = result && !Array.isArray(result) ? String(result.cursor || "") : "";
pages++;
} while (cursor && pages < 10);
codes.sort(function(a, b) { return b.created_at - a.created_at; });
return JSON.stringify({codes:codes,truncated:!!cursor});
}
function rpcDeleteUnlockCode(ctx, logger, nk, payload) {
if (ctx.userId) throw new Error("server-to-server access required");
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { throw new Error("invalid JSON payload"); }
var codeKey = String(request.code_key || "").toLowerCase();
if (!/^[a-f0-9]{64}$/.test(codeKey)) return JSON.stringify({accepted:false,error:"invalid"});
for (var attempt = 0; attempt < 3; attempt++) {
var record = readStorageRecord(nk, PROMO_CODE_COLLECTION, codeKey, SYSTEM_USER_ID);
if (!record || !record.value) return JSON.stringify({accepted:false,error:"not_found"});
var value = record.value;
if (Number(value.uses || 0) !== 0 || (Array.isArray(value.redeemed_users) && value.redeemed_users.length > 0)) {
return JSON.stringify({accepted:false,error:"already_used"});
}
if (!record.version) throw new Error("unlock code has no storage version");
try {
nk.storageDelete([{collection:PROMO_CODE_COLLECTION,key:codeKey,userId:SYSTEM_USER_ID,version:record.version}]);
return JSON.stringify({accepted:true});
} catch (error) {
if (attempt === 2) throw error;
}
}
return JSON.stringify({accepted:false,error:"temporary"});
}
function allowPromoAttempt(ctx, nk) {
var now = Date.now(), key = "user_" + ctx.userId;
for (var attempt = 0; attempt < 3; attempt++) {
var record = readStorageRecord(nk, PROMO_ATTEMPT_COLLECTION, key, SYSTEM_USER_ID);
var value = record && record.value ? record.value : {window_started:now,count:0};
if (now - Number(value.window_started || 0) >= 5 * 60 * 1000) value = {window_started:now,count:0};
if (Number(value.count || 0) >= 10) return false;
value.count = Number(value.count || 0) + 1;
var write = {collection:PROMO_ATTEMPT_COLLECTION,key:key,userId:SYSTEM_USER_ID,value:value,permissionRead:0,permissionWrite:0,version:record && record.version ? record.version : "*"};
try { nk.storageWrite([write]); return true; }
catch (error) { if (attempt === 2) return false; }
}
return false;
}
function writePromoUnlocks(nk, userId, reward) {
for (var attempt = 0; attempt < 3; attempt++) {
var record = readStorageRecord(nk, PROMO_UNLOCK_COLLECTION, PROMO_UNLOCK_KEY, userId);
var value = record && record.value ? record.value : {all_towers:false,all_maps:false};
if (reward === "all_towers" || reward === "all") value.all_towers = true;
if (reward === "all_maps" || reward === "all") value.all_maps = true;
var write = {collection:PROMO_UNLOCK_COLLECTION,key:PROMO_UNLOCK_KEY,userId:userId,value:value,permissionRead:0,permissionWrite:0,version:record && record.version ? record.version : "*"};
try { nk.storageWrite([write]); return value; }
catch (error) { if (attempt === 2) throw error; }
}
throw new Error("could not save player unlocks");
}
function rpcRedeemUnlockCode(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
if (!allowPromoAttempt(ctx, nk)) return JSON.stringify({accepted:false,error:"rate_limited"});
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { return JSON.stringify({accepted:false,error:"invalid"}); }
var code = String(request.code || "").toUpperCase().replace(/[\s-]/g, "");
if (!/^[A-F0-9]{24}$/.test(code)) return JSON.stringify({accepted:false,error:"invalid"});
var codeKey = nk.sha256Hash(code);
for (var attempt = 0; attempt < 4; attempt++) {
var record = readStorageRecord(nk, PROMO_CODE_COLLECTION, codeKey, SYSTEM_USER_ID);
if (!record || !record.value) return JSON.stringify({accepted:false,error:"invalid"});
var value = record.value;
var redeemed = Array.isArray(value.redeemed_users) ? value.redeemed_users : [];
if (redeemed.indexOf(ctx.userId) >= 0) {
var existingUnlocks = writePromoUnlocks(nk, ctx.userId, String(value.reward || ""));
return JSON.stringify({accepted:true,already_redeemed:true,reward:value.reward,all_towers:!!existingUnlocks.all_towers,all_maps:!!existingUnlocks.all_maps});
}
if (Number(value.uses || 0) >= Number(value.max_uses || 1)) return JSON.stringify({accepted:false,error:"exhausted"});
value.redeemed_users = redeemed.concat([ctx.userId]);
value.uses = Number(value.uses || 0) + 1;
try {
nk.storageWrite([{collection:PROMO_CODE_COLLECTION,key:codeKey,userId:SYSTEM_USER_ID,value:value,permissionRead:0,permissionWrite:0,version:record.version}]);
var unlocks = writePromoUnlocks(nk, ctx.userId, String(value.reward || ""));
return JSON.stringify({accepted:true,reward:value.reward,all_towers:!!unlocks.all_towers,all_maps:!!unlocks.all_maps});
} catch (error) {
if (attempt === 3) throw error;
}
}
return JSON.stringify({accepted:false,error:"temporary"});
}
function rpcGetPromoUnlocks(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var record = readStorageRecord(nk, PROMO_UNLOCK_COLLECTION, PROMO_UNLOCK_KEY, ctx.userId);
var value = record && record.value ? record.value : {all_towers:false,all_maps:false};
return JSON.stringify({accepted:true,all_towers:!!value.all_towers,all_maps:!!value.all_maps});
}
function rpcSubmitRun(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var run;
try { run = JSON.parse(payload || "{}"); } catch (error) { throw new Error("invalid JSON payload"); }
var mapIndex = clampInteger(run.map,0,MAP_COUNT-1), difficulty = clampInteger(run.difficulty,0,DIFFICULTY_COUNT-1);
var mode = String(run.mode || "");
if (VALID_MODES.indexOf(mode) < 0) throw new Error("invalid game mode");
var wave = clampInteger(run.wave,1,mode === "mission" ? 15 : 9999), score = clampInteger(run.score,0,1000000000);
if (score > wave*5000+100000) throw new Error("implausible score");
var id = leaderboardId(mapIndex,difficulty,mode);
var publicName = "PILOT";
try {
var account = nk.accountGetId(ctx.userId);
var storedName = account && account.user ? normalizeDisplayName(account.user.displayName) : "";
if (!validateDisplayName(storedName)) publicName = storedName;
} catch (error) {
logger.warn("Could not resolve display name for leaderboard user %s: %s", ctx.userId, error);
}
var metadata = {map:mapIndex,difficulty:difficulty,mode:mode,wave:wave,displayName:publicName,clientVersion:String(run.client_version||"unknown"),submittedAt:Date.now()};
var record = nk.leaderboardRecordWrite(id,ctx.userId,publicName,score,wave,metadata,null);
return JSON.stringify({accepted:true,leaderboard_id:id,rank:record.rank,score:Number(record.score),wave:wave});
}
function normalizeDisplayName(value) {
return String(value || "").trim().replace(/\s+/g, " ").substring(0, 20);
}
function validateDisplayName(value) {
var name = normalizeDisplayName(value);
if (name.length < 3) return "name_too_short";
if (name.length > 20) return "name_too_long";
if (!/^[A-Za-z0-9 ÄÖÜäöüß_-]+$/.test(name)) return "invalid_characters";
var reserved = ["ADMIN","ADMINISTRATOR","GERGAMES","MODERATOR","SUPPORT","SYSTEM"];
if (reserved.indexOf(name.toUpperCase()) >= 0) return "reserved_name";
return "";
}
function rpcUpdateDisplayName(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { return JSON.stringify({accepted:false,error:"invalid_json"}); }
var name = normalizeDisplayName(request.display_name), errorCode = validateDisplayName(name);
if (errorCode) return JSON.stringify({accepted:false,error:errorCode});
nk.accountUpdateId(ctx.userId, null, name, null, null, null, null, null);
return JSON.stringify({accepted:true,display_name:name});
}
function rpcFindPlayerByName(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { return JSON.stringify({found:false,error:"invalid_json"}); }
var name = normalizeDisplayName(request.display_name);
if (validateDisplayName(name)) return JSON.stringify({found:false,error:"invalid_name"});
var rows = nk.sqlQuery("SELECT id, display_name FROM users WHERE lower(display_name) = lower($1) LIMIT 3", [name]);
var candidates = rows.filter(function(row) { return String(row.id) !== ctx.userId; });
if (!candidates.length) return JSON.stringify({found:false,error:"not_found"});
if (candidates.length !== 1 || rows.length > 2) return JSON.stringify({found:false,error:"ambiguous"});
return JSON.stringify({found:true,user_id:String(candidates[0].id),display_name:String(candidates[0].display_name)});
}
function rpcDeleteAccount(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var playerRecord = readStorageRecord(nk, ACTIVITY_COLLECTION, ACTIVITY_KEY, ctx.userId);
removePlayerStats(nk, playerRecord, Date.now());
nk.accountDeleteId(ctx.userId, false);
return JSON.stringify({accepted:true});
}
function normalizeRoomCode(value) {
return String(value || "").toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 6);
}
function generateRoomCode() {
var alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789", code = "";
for (var i = 0; i < 6; i++) code += alphabet.charAt(Math.floor(Math.random() * alphabet.length));
return code;
}
function privateRoomRecord(nk, code) {
var records = nk.storageRead([{collection:ROOM_COLLECTION,key:code,userId:SYSTEM_USER_ID}]);
return records.length ? records[0] : null;
}
function deletePrivateRoomRecord(nk, code) {
if (code) nk.storageDelete([{collection:ROOM_COLLECTION,key:code,userId:SYSTEM_USER_ID}]);
}
function rpcCreatePrivateRoom(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { request = {}; }
var difficulty = Math.max(0, Math.min(2, Math.floor(Number(request.difficulty || 1))));
var code = "";
for (var attempt = 0; attempt < 24; attempt++) {
var candidate = generateRoomCode();
if (!privateRoomRecord(nk, candidate)) { code = candidate; break; }
}
if (!code) throw new Error("room code allocation failed");
var matchId = nk.matchCreate(MATCH_MODULE, {difficulty:difficulty,mode:"private",roomCode:code,hostUserId:ctx.userId});
nk.storageWrite([{collection:ROOM_COLLECTION,key:code,userId:SYSTEM_USER_ID,value:{match_id:matchId,host_user_id:ctx.userId,created_at:Date.now()},permissionRead:0,permissionWrite:0}]);
return JSON.stringify({created:true,room_code:code,match_id:matchId});
}
function rpcJoinPrivateRoom(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { request = {}; }
var code = normalizeRoomCode(request.room_code);
if (code.length !== 6) return JSON.stringify({found:false,error:"invalid_room_code"});
var record = privateRoomRecord(nk, code);
if (!record || !record.value || !record.value.match_id) return JSON.stringify({found:false,error:"room_not_found"});
return JSON.stringify({found:true,room_code:code,match_id:String(record.value.match_id)});
}
function rpcInviteFriend(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { return JSON.stringify({accepted:false,error:"invalid_json"}); }
var friendId = String(request.friend_id || ""), code = normalizeRoomCode(request.room_code);
if (!/^[0-9a-f-]{36}$/i.test(friendId) || code.length !== 6 || friendId === ctx.userId)
return JSON.stringify({accepted:false,error:"invalid_invite"});
var room = privateRoomRecord(nk, code);
if (!room || !room.value || room.value.host_user_id !== ctx.userId)
return JSON.stringify({accepted:false,error:"not_room_host"});
var mutual = false, cursor = "";
for (var page = 0; page < 10 && !mutual; page++) {
var result = nk.friendsList(ctx.userId, 100, 0, cursor);
var list = Array.isArray(result) ? result : (result && Array.isArray(result.friends) ? result.friends : []);
for (var i = 0; i < list.length; i++) {
if (list[i].state === 0 && list[i].user && String(list[i].user.userId || list[i].user.id || "") === friendId) { mutual = true; break; }
}
cursor = result && result.cursor ? result.cursor : "";
if (!cursor) break;
}
if (!mutual) return JSON.stringify({accepted:false,error:"not_mutual_friends"});
var rateKey = "invite_" + friendId;
var previous = readStorageRecord(nk, "friend_invite_limits", rateKey, ctx.userId);
var now = Date.now();
if (previous && previous.value && now - Number(previous.value.sent_at || 0) < 15000)
return JSON.stringify({accepted:false,error:"invite_cooldown"});
var senderName = String(ctx.username || "Pilot");
var senders = nk.usersGetId([ctx.userId]);
if (senders.length && senders[0].displayName) senderName = String(senders[0].displayName);
nk.storageWrite([{collection:"friend_invite_limits",key:rateKey,userId:ctx.userId,value:{sent_at:now},permissionRead:0,permissionWrite:0}]);
nk.notificationsSend([{userId:friendId,subject:"Void Bastion Invite",content:{room_code:code,from:senderName.slice(0,32)},code:101,senderId:ctx.userId,persistent:false}]);
return JSON.stringify({accepted:true});
}
function matchmakerMatched(ctx, logger, nk, matches) {
var difficulty = 1;
if (matches.length && matches[0].properties && matches[0].properties.difficulty !== undefined) difficulty = Math.max(0,Math.min(2,Math.floor(Number(matches[0].properties.difficulty))));
return nk.matchCreate(MATCH_MODULE,{matchedUsers:matches.length,difficulty:difficulty});
}
function initialState(params) {
var difficulty = Math.max(0,Math.min(2,Math.floor(Number(params.difficulty||1))));
var credits = DIFFICULTIES[difficulty].startCredits;
return {presences:{},slots:{},players:{},ready:{},started:false,pausedForReconnect:false,reconnectDeadlineTick:0,gameOver:false,difficulty:difficulty,mode:String(params.mode||"quick"),roomCode:normalizeRoomCode(params.roomCode),hostUserId:String(params.hostUserId||""),emptyTicks:0,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,waveSchedule:[],spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],shots:[],lastSnapshotTick:0,lastLoopStartMs:0};
}
function matchLabel(state) { return state.mode === "private" ? state.roomCode : JSON.stringify({game:"void_bastion",mode:state.mode,open:!state.started&&!state.gameOver}); }
function matchInit(ctx,logger,nk,params) { var state=initialState(params||{}); return {state:state,tickRate:TICK_RATE,label:matchLabel(state)}; }
function matchJoinAttempt(ctx,logger,nk,dispatcher,tick,state,presence,metadata) { return {state:state,accept:state.slots[presence.userId]!==undefined||Object.keys(state.slots).length<2}; }
function firstFreeSlot(state) {
var used={},ids=Object.keys(state.slots);
for (var i=0;i<ids.length;i++) used[state.slots[ids[i]]]=true;
return used[0] ? 1 : 0;
}
function snapshot(state,tick) {
var lobby=[null,null],ids=Object.keys(state.slots);
for (var i=0;i<ids.length;i++) {
var id=ids[i],slot=state.slots[id],player=state.players[id]||{};
lobby[slot]={user_id:id,slot:slot,name:String(player.name||"PILOT"),ready:!!state.ready[id],connected:state.presences[id]!==undefined};
}
var towers=state.towers.map(function(tower) {
return {id:tower.id,x:tower.x,y:tower.y,type:tower.type,level:tower.level,owner:tower.owner,angle:Math.round(tower.angle*1000)/1000};
});
var enemies=state.enemies.map(function(enemy) {
return {id:enemy.id,type:enemy.type,x:Math.round(enemy.x*10)/10,y:Math.round(enemy.y*10)/10,path_index:enemy.path_index,route_id:enemy.route_id,hp:Math.round(enemy.hp*10)/10,max_hp:Math.round(enemy.max_hp*10)/10,angle:Math.round(enemy.angle*1000)/1000,boss:enemy.boss,shield:enemy.shield,shield_hp:Math.round(enemy.shield_hp*10)/10,max_shield_hp:Math.round(enemy.max_shield_hp*10)/10};
});
return {version:GAME_VERSION,tick:tick,started:state.started,connection_interrupted:state.pausedForReconnect,reconnect_seconds:state.pausedForReconnect?Math.max(0,Math.ceil((state.reconnectDeadlineTick-tick)/TICK_RATE)):0,game_over:state.gameOver,difficulty:state.difficulty,slots:state.slots,players:lobby,credits:state.credits,reactor_lives:state.reactorLives,wave:state.wave,wave_active:state.waveActive,enemies_to_spawn:state.spawnRemaining,wave_types_remaining:state.waveSchedule.map(function(entry){return entry.type;}),towers:towers,enemies:enemies,shots:state.shots||[]};
}
function broadcastSnapshot(dispatcher,state,tick) { dispatcher.broadcastMessage(OP_SNAPSHOT,JSON.stringify(snapshot(state,tick)),null,null,true); state.shots=[]; state.lastSnapshotTick=tick; }
function broadcastEvent(dispatcher,type,data) { var payload=data||{}; payload.type=type; dispatcher.broadcastMessage(OP_EVENT,JSON.stringify(payload),null,null,true); }
function matchJoin(ctx,logger,nk,dispatcher,tick,state,presences) {
var rejoined=false;
for (var i=0;i<presences.length;i++) {
var p=presences[i];
if (state.slots[p.userId]!==undefined) rejoined=true;
state.presences[p.userId]=p;
if (state.slots[p.userId]===undefined) state.slots[p.userId]=firstFreeSlot(state);
if (state.ready[p.userId]===undefined) state.ready[p.userId]=false;
state.players[p.userId]={name:String(p.username||"PILOT").substring(0,24)};
}
if (state.started&&Object.keys(state.presences).length===2) { state.pausedForReconnect=false; state.reconnectDeadlineTick=0; if (rejoined) broadcastEvent(dispatcher,"partner_rejoined",{}); }
state.emptyTicks=0;
if (ctx.matchId) {
var roundId = nk.sha256Hash(ctx.matchId).slice(0, 16);
var participantSlots = {}, participantIds = Object.keys(state.slots);
for (var participantIndex = 0; participantIndex < participantIds.length; participantIndex++) {
var participantId = participantIds[participantIndex];
participantSlots[nk.sha256Hash(ctx.matchId + ":" + participantId)] = state.slots[participantId];
}
nk.storageWrite([{collection:DIAG_PARTICIPANT_COLLECTION,key:roundId,userId:SYSTEM_USER_ID,
value:{participants:participantSlots,created_at:Date.now()},permissionRead:0,permissionWrite:0}]);
}
dispatcher.matchLabelUpdate(matchLabel(state));
broadcastEvent(dispatcher,"players",{count:Object.keys(state.presences).length,started:state.started,slots:state.slots});
broadcastSnapshot(dispatcher,state,tick);
return {state:state};
}
function matchLeave(ctx,logger,nk,dispatcher,tick,state,presences) {
for (var i=0;i<presences.length;i++) { delete state.presences[presences[i].userId]; state.ready[presences[i].userId]=false; }
if (state.started) { state.pausedForReconnect=true; state.reconnectDeadlineTick=tick+TICK_RATE*60; }
dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"partner_left",{reconnect_seconds:60}); broadcastSnapshot(dispatcher,state,tick); return {state:state};
}
function parseMessage(nk,message) { try { return JSON.parse(nk.binaryToString(message.data)||"{}"); } catch (error) { return null; } }
function towerDamage(t) { return TOWERS[t.type].damage*(1+(t.level-1)*0.52); }
function towerRange(t) { return TOWERS[t.type].range*(1+(t.level-1)*0.08); }
function towerCooldown(t) { return TOWERS[t.type].cooldown/(1+(t.level-1)*0.12); }
function upgradeCost(t) { return Math.floor(TOWERS[t.type].cost*(0.72+t.level*0.28)); }
function distanceSquared(ax,ay,bx,by) { var dx=ax-bx,dy=ay-by; return dx*dx+dy*dy; }
function distanceToSegment(px,py,ax,ay,bx,by) {
var abx=bx-ax,aby=by-ay,len=abx*abx+aby*aby,t=len>0?((px-ax)*abx+(py-ay)*aby)/len:0;
t=Math.max(0,Math.min(1,t)); return Math.sqrt(distanceSquared(px,py,ax+abx*t,ay+aby*t));
}
function validBuildPosition(state,slot,x,y) {
var sx=Math.floor(x/GRID)*GRID+GRID*0.5,sy=Math.floor(y/GRID)*GRID+GRID*0.5;
if (Math.abs(sx-x)>1||Math.abs(sy-y)>1||y<PLAYFIELD_TOP||y>PLAYFIELD_BOTTOM) return false;
if (slot===0&&(x<120||x>=MIRROR_X-GRID*0.5)) return false;
if (slot===1&&(x<=MIRROR_X+GRID*0.5||x>WORLD_WIDTH-120)) return false;
if (distanceSquared(x,y,REACTOR_X,REACTOR_Y)<=REACTOR_RADIUS*REACTOR_RADIUS) return false;
for (var r=0;r<PATHS.length;r++) for (var i=0;i<PATHS[r].length-1;i++) if (distanceToSegment(x,y,PATHS[r][i][0],PATHS[r][i][1],PATHS[r][i+1][0],PATHS[r][i+1][1])<GRID*0.62) return false;
for (var j=0;j<state.towers.length;j++) if (distanceSquared(x,y,state.towers[j].x,state.towers[j].y)<GRID*GRID*0.52) return false;
return true;
}
function handleBuild(state,slot,payload) {
var type=Math.floor(Number(payload.tower_type)),x=Number(payload.x),y=Number(payload.y);
if (!isFinite(x)||!isFinite(y)||type<0||type>=TOWERS.length||!validBuildPosition(state,slot,x,y)||state.credits[slot]<TOWERS[type].cost) return false;
state.credits[slot]-=TOWERS[type].cost;
state.towers.push({id:state.nextTowerId++,x:x,y:y,type:type,level:1,owner:slot,cooldown:0.15,angle:slot===0?0:Math.PI}); return true;
}
function ownedTowerIndex(state,slot,id) { for (var i=0;i<state.towers.length;i++) if (state.towers[i].id===id&&state.towers[i].owner===slot) return i; return -1; }
function handleUpgrade(state,slot,payload) {
var i=ownedTowerIndex(state,slot,Math.floor(Number(payload.tower_id)));
if (i<0||state.towers[i].level>=3) return false;
var cost=upgradeCost(state.towers[i]); if (state.credits[slot]<cost) return false;
state.credits[slot]-=cost; state.towers[i].level+=1; return true;
}
function handleSell(state,slot,payload) {
var i=ownedTowerIndex(state,slot,Math.floor(Number(payload.tower_id))); if (i<0) return false;
var tower=state.towers[i]; state.credits[slot]+=Math.floor(TOWERS[tower.type].cost*(0.55+tower.level*0.12)); state.towers.splice(i,1); return true;
}
function enemyTypeForWave(wave,roll,difficulty,boss) {
if (boss) return wave>=10?8:5;
var offset=difficulty===2?2:(difficulty===1?1:0);
if (wave>=10&&roll>=0.96) return 8;
if (wave>=8&&roll>=0.84&&roll<0.94) return 7;
if (wave>=3&&roll>=0.52&&roll<0.64) return 6;
if (wave>=10-offset&&roll>=0.94) return 5;
if (wave>=7-offset&&roll>=0.80) return 4;
if (wave>=4-offset&&roll>=0.65) return 3;
if (wave>=7&&roll>=0.45) return 2;
if (wave>=3&&roll>=0.28) return 1;
return 0;
}
function wavePopulation(state,wave) {
var scale=1+Math.max(0,wave-3)*0.08;
if (state.difficulty===2) scale+=Math.max(0,wave-4)*0.025;
return Math.ceil((7+wave*2)*DIFFICULTIES[state.difficulty].count*scale)*2;
}
function buildWaveSchedule(state,wave,count) {
var schedule=[],bossCount=wave>0&&wave%5===0?2:0;
for (var i=0;i<count;i++) {
var boss=i>=count-bossCount;
var roll=((i+1)*0.61803398875+wave*0.38196601125)%1;
schedule.push({type:enemyTypeForWave(wave,roll,state.difficulty,boss),boss:boss});
}
return schedule;
}
function startWave(state) {
if (state.waveActive||state.gameOver||!state.started) return false;
state.wave+=1; state.waveSchedule=buildWaveSchedule(state,state.wave,wavePopulation(state,state.wave)); state.spawnRemaining=state.waveSchedule.length; state.spawnTimer=0.1; state.waveActive=true; return true;
}
function spawnEnemy(state) {
var entry=state.waveSchedule.length?state.waveSchedule.shift():{type:0,boss:false};
var boss=entry.boss,type=entry.type;
var base=ENEMIES[type],d=DIFFICULTIES[state.difficulty];
var scale=(1+Math.max(0,state.wave-1)*0.20+Math.max(0,state.wave-3)*0.09+((state.difficulty===2)?Math.max(0,state.wave-5)*0.055:0))*(boss?3.25:1)*d.hp;
var routeId=state.nextSpawnRoute; state.nextSpawnRoute=(state.nextSpawnRoute+1)%2; var route=PATHS[routeId];
var hasShield=type===4||(state.wave>=4&&Math.random()<Math.min(0.42,0.10+state.wave*0.018+state.difficulty*0.06));
var maxShieldHp=hasShield?base.hp*scale*0.55:0;
if (boss && state.wave === 5) maxShieldHp *= 0.5;
state.enemies.push({id:state.nextEnemyId++,type:type,x:route[0][0],y:route[0][1],path_index:1,route_id:routeId,hp:base.hp*scale,max_hp:base.hp*scale,speed:base.speed*(1+state.wave*0.012)*(boss?0.82:1)*d.speed,reward:Math.floor((base.reward+state.wave)*(boss?4:1)*d.income),angle:0,boss:boss,shield:hasShield,shield_hp:maxShieldHp,max_shield_hp:maxShieldHp,shield_resistance:type===4?0.18:0.28});
}
function applyDamageToEnemy(enemy,damage,towerType) {
var applied=damage;
if (enemy.shield) {
if (towerType===1) {
var shieldDamage=Math.min(enemy.shield_hp,damage);
enemy.shield_hp-=shieldDamage;
if (enemy.shield_hp<=0) { enemy.shield=false; applied=damage-shieldDamage; }
else applied=0;
} else if (towerType!==5) applied*=enemy.shield_resistance||0.28;
}
enemy.hp-=applied;
}
function removeEnemy(state,index,owner) { state.credits[owner]+=state.enemies[index].reward; state.enemies.splice(index,1); }
function updateEnemies(state,delta) {
for (var i=state.enemies.length-1;i>=0;i--) {
var e=state.enemies[i],route=PATHS[e.route_id],target=route[e.path_index],dx=target[0]-e.x,dy=target[1]-e.y,dist=Math.sqrt(dx*dx+dy*dy);
e.angle=Math.atan2(dy,dx);
if (dist<=Math.max(9,e.speed*delta)) {
e.x=target[0]; e.y=target[1]; e.path_index+=1;
if (e.path_index>=route.length) { state.reactorLives=Math.max(0,state.reactorLives-(ENEMIES[e.type].breachDamage+(e.boss?4:0))); state.enemies.splice(i,1); if (state.reactorLives<=0) { state.gameOver=true; state.waveActive=false; state.spawnRemaining=0; } }
} else { e.x+=dx/dist*e.speed*delta; e.y+=dy/dist*e.speed*delta; }
}
}
function enemyProgress(e) { return e.path_index*100000-distanceSquared(e.x,e.y,REACTOR_X,REACTOR_Y); }
function updateTowers(state,delta) {
state.shots=state.shots||[];
for (var i=0;i<state.towers.length;i++) {
var t=state.towers[i]; t.cooldown-=delta; var range=towerRange(t),targetIndex=-1,best=-1,bestShield=false;
for (var j=0;j<state.enemies.length;j++) {
var candidate=state.enemies[j];
if ((TOWERS[t.type].targetClass==="both"||TOWERS[t.type].targetClass===ENEMIES[candidate.type].category)&&distanceSquared(t.x,t.y,candidate.x,candidate.y)<=range*range) {
var p=enemyProgress(candidate),shieldPriority=t.type===1&&candidate.shield===true;
if (targetIndex<0||(shieldPriority&&!bestShield)||(shieldPriority===bestShield&&p>best)) { best=p; bestShield=shieldPriority; targetIndex=j; }
}
}
if (targetIndex<0) continue;
var target=state.enemies[targetIndex]; t.angle=Math.atan2(target.y-t.y,target.x-t.x)+([0,3,5].indexOf(t.type)>=0?Math.PI:0);
if (t.cooldown>0) continue;
state.shots.push({tower_id:t.id,type:t.type,from_x:t.x,from_y:t.y,to_x:target.x,to_y:target.y});
var damage=towerDamage(t)*(TOWERS[t.type].targetClass==="both"?TOWERS[t.type].bothScale:1);
if (t.type===5) {
var aimLength=Math.sqrt(distanceSquared(t.x,t.y,target.x,target.y)),dx=(target.x-t.x)/aimLength,dy=(target.y-t.y)/aimLength,rangeSquared=range*range;
for (var railIndex=state.enemies.length-1;railIndex>=0;railIndex--) {
var railTarget=state.enemies[railIndex];
if (distanceSquared(t.x,t.y,railTarget.x,railTarget.y)>rangeSquared) continue;
var offsetX=railTarget.x-t.x,offsetY=railTarget.y-t.y,along=offsetX*dx+offsetY*dy,across=Math.abs(offsetX*dy-offsetY*dx);
if (along>=0&&along<=range&&across<=18) { applyDamageToEnemy(railTarget,damage,t.type); if (railTarget.hp<=0) removeEnemy(state,railIndex,t.owner); }
}
} else if (t.type===2||t.type===3||t.type===4) {
var radius=t.type===2?76:(t.type===3?92:118),splash=t.type===2?0.45:(t.type===3?0.62:1);
var tx=target.x,ty=target.y,targetId=target.id;
for (var k=state.enemies.length-1;k>=0;k--) if ((TOWERS[t.type].targetClass==="both"||TOWERS[t.type].targetClass===ENEMIES[state.enemies[k].type].category)&&distanceSquared(state.enemies[k].x,state.enemies[k].y,tx,ty)<=radius*radius) { applyDamageToEnemy(state.enemies[k],state.enemies[k].id===targetId?damage:damage*splash,t.type); if (state.enemies[k].hp<=0) removeEnemy(state,k,t.owner); }
} else { applyDamageToEnemy(target,damage,t.type); if (target.hp<=0) removeEnemy(state,targetIndex,t.owner); }
t.cooldown=towerCooldown(t);
}
}
function updateWave(state,delta,dispatcher) {
if (!state.waveActive||state.gameOver) return;
if (state.spawnRemaining>0) {
state.spawnTimer-=delta;
if (state.spawnTimer<=0) { spawnEnemy(state); state.spawnRemaining-=1; state.spawnTimer=Math.max(0.24,(0.82-state.wave*0.025)*DIFFICULTIES[state.difficulty].spawnDelay); }
} else if (!state.enemies.length) {
state.waveActive=false; var bonus=Math.floor((55+state.wave*8)*DIFFICULTIES[state.difficulty].income); state.credits[0]+=bonus; state.credits[1]+=bonus; broadcastEvent(dispatcher,"wave_complete",{wave:state.wave,bonus:bonus});
}
}
function handleCommand(nk,dispatcher,state,message) {
var slot=state.slots[message.sender.userId]; if (slot===undefined) return;
var payload=parseMessage(nk,message); if (payload===null) return; var changed=false;
if (message.opCode===OP_BUILD) changed=handleBuild(state,slot,payload);
else if (message.opCode===OP_UPGRADE) changed=handleUpgrade(state,slot,payload);
else if (message.opCode===OP_SELL) changed=handleSell(state,slot,payload);
else if (message.opCode===OP_START_WAVE) changed=startWave(state);
else if (message.opCode===OP_READY&&!state.started) {
state.ready[message.sender.userId]=payload.ready!==false;
var requestedName=normalizeDisplayName(payload.name||message.sender.username||"PILOT");
var name=validateDisplayName(requestedName) ? "PILOT" : requestedName;
state.players[message.sender.userId]={name:name||"PILOT"};
var ids=Object.keys(state.slots),allReady=ids.length===2&&Object.keys(state.presences).length===2;
for (var i=0;i<ids.length;i++) if (!state.ready[ids[i]]||state.presences[ids[i]]===undefined) allReady=false;
if (allReady) { state.started=true; state.pausedForReconnect=false; dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"match_started",{}); }
changed=true;
}
if (!changed) broadcastEvent(dispatcher,"command_rejected",{op_code:message.opCode,player:slot});
}
function matchLoop(ctx,logger,nk,dispatcher,tick,state,messages) {
var loopStartMs=Date.now();
if (state.lastLoopStartMs && loopStartMs-state.lastLoopStartMs>250 && logger.warn)
logger.warn("ONLINE DIAG tick_gap_ms=%d tick=%d",loopStartMs-state.lastLoopStartMs,tick);
state.lastLoopStartMs=loopStartMs;
state.shots=[];
if (!Object.keys(state.presences).length) {
state.emptyTicks += 1;
if (state.emptyTicks >= TICK_RATE * 60) { deletePrivateRoomRecord(nk,state.roomCode); return null; }
} else state.emptyTicks = 0;
for (var i=0;i<messages.length;i++) handleCommand(nk,dispatcher,state,messages[i]);
if (state.pausedForReconnect&&tick>=state.reconnectDeadlineTick) { state.gameOver=true; state.pausedForReconnect=false; state.waveActive=false; broadcastEvent(dispatcher,"partner_timeout",{}); }
if (state.started&&!state.pausedForReconnect&&!state.gameOver) { var delta=1/TICK_RATE; updateWave(state,delta,dispatcher); updateEnemies(state,delta); updateTowers(state,delta); }
if (tick-state.lastSnapshotTick>=SNAPSHOT_TICKS||messages.length) broadcastSnapshot(dispatcher,state,tick);
var loopMs=Date.now()-loopStartMs;
if (loopMs>70 && logger.warn) logger.warn("ONLINE DIAG loop_ms=%d tick=%d enemies=%d towers=%d",loopMs,tick,state.enemies.length,state.towers.length);
return {state:state};
}
function matchTerminate(ctx,logger,nk,dispatcher,tick,state,graceSeconds) { deletePrivateRoomRecord(nk,state.roomCode); broadcastEvent(dispatcher,"server_shutdown",{grace:graceSeconds}); return {state:state}; }
function matchSignal(ctx,logger,nk,dispatcher,tick,state,data) { return {state:state,data:JSON.stringify(snapshot(state,tick))}; }
function sanitizeDiagnosticRecord(input) {
if (!input || typeof input !== "object") return null;
var allowedEvents = ["window","snapshot_gap","tick_jump","frame_stall","socket_closed","socket_reconnected","socket_reconnect_failed","side","end"];
var event = String(input.event || "");
if (allowedEvents.indexOf(event) < 0) return null;
var result = {event:event};
var utc = String(input.utc || "");
if (/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d/.test(utc)) result.utc = utc.slice(0, 25);
var fields = ["seconds","snapshots","snapshots_per_second","gap_p95_ms","gap_max_ms","gaps_over_500_ms","last_server_tick","tick_jump_max","tick_jumps_over_1","bytes_avg","bytes_max","parse_max_us","apply_max_us","frame_max_ms","frame_stalls_over_200_ms","coalesced","queue_dropped","queue_max","enemies","towers","duration_ms","from_tick","to_tick","steps","attempt"];
for (var i = 0; i < fields.length; i++) {
var value = Number(input[fields[i]]);
if (input[fields[i]] !== undefined && isFinite(value) && value >= 0 && value <= 1000000000) result[fields[i]] = value;
}
if (event === "end") result.reason = ["left_match","cancelled_matchmaking","game_over","victory","new_match"].indexOf(String(input.reason)) >= 0 ? String(input.reason) : "other";
if (event === "side") result.viewing_right = input.viewing_right === true;
return result;
}
function rpcSubmitOnlineDiagnostics(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
if (!payload || payload.length > 65000) return JSON.stringify({accepted:false,error:"too_large"});
var request;
try { request = JSON.parse(payload); } catch (error) { return JSON.stringify({accepted:false,error:"invalid_json"}); }
var matchId = String(request.match_id || "");
if (!matchId || matchId.length > 128 || !Array.isArray(request.records) || request.records.length > 160)
return JSON.stringify({accepted:false,error:"invalid_payload"});
var roundId = nk.sha256Hash(matchId).slice(0, 16);
var participant = readStorageRecord(nk, DIAG_PARTICIPANT_COLLECTION, roundId, SYSTEM_USER_ID);
var slots = participant && participant.value ? participant.value.participants || {} : {};
var participantKey = nk.sha256Hash(matchId + ":" + ctx.userId);
if (slots[participantKey] === undefined || Date.now() - Number(participant.value.created_at || 0) > DIAG_RETENTION_MS)
return JSON.stringify({accepted:false,error:"not_participant"});
var slot = Number(slots[participantKey]);
if (slot !== 0 && slot !== 1) return JSON.stringify({accepted:false,error:"invalid_slot"});
var records = request.records.map(sanitizeDiagnosticRecord).filter(function(record) { return record !== null; });
if (!records.length) return JSON.stringify({accepted:false,error:"no_records"});
var key = roundId + "-" + slot;
nk.storageWrite([{collection:DIAG_REPORT_COLLECTION,key:key,userId:SYSTEM_USER_ID,
value:{round_id:roundId,slot:slot,created_at:Date.now(),version:normalizeMetricLabel(request.version,"unknown"),records:records},
permissionRead:0,permissionWrite:0}]);
try { pruneOnlineDiagnostics(nk); } catch (error) { if (logger.warn) logger.warn("ONLINE DIAG cleanup failed: %s", error); }
if (logger.info) logger.info("ONLINE DIAG received round=%s slot=%d records=%d", roundId, slot, records.length);
return JSON.stringify({accepted:true,round_id:roundId,slot:slot});
}
function pruneOnlineDiagnostics(nk) {
var collections = [DIAG_REPORT_COLLECTION, DIAG_PARTICIPANT_COLLECTION];
var cutoff = Date.now() - DIAG_RETENTION_MS;
for (var c = 0; c < collections.length; c++) {
var cursor = "", pages = 0, stale = [];
do {
var page = nk.storageList(SYSTEM_USER_ID, collections[c], 100, cursor);
var objects = Array.isArray(page) ? page : (page && Array.isArray(page.objects) ? page.objects : []);
for (var i = 0; i < objects.length; i++) {
if (Number(objects[i].value && objects[i].value.created_at || 0) < cutoff && objects[i].key)
stale.push({collection:collections[c],key:objects[i].key,userId:SYSTEM_USER_ID});
}
cursor = page && !Array.isArray(page) ? String(page.cursor || "") : "";
pages++;
} while (cursor && pages < 10);
for (var deleteIndex = 0; deleteIndex < stale.length; deleteIndex += 100)
nk.storageDelete(stale.slice(deleteIndex, deleteIndex + 100));
}
}
function rpcListOnlineDiagnostics(ctx, logger, nk, payload) {
if (ctx.userId) throw new Error("server-to-server access required");
try { pruneOnlineDiagnostics(nk); } catch (error) { if (logger.warn) logger.warn("ONLINE DIAG cleanup failed: %s", error); }
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { request = {}; }
var requestedRound = String(request.round_id || "");
if (requestedRound) {
if (!/^[a-f0-9]{16}$/.test(requestedRound)) return JSON.stringify({reports:[],error:"invalid_round_id"});
var pair = nk.storageRead([0,1].map(function(slot) { return {collection:DIAG_REPORT_COLLECTION,key:requestedRound+"-"+slot,userId:SYSTEM_USER_ID}; }));
return JSON.stringify({reports:pair.map(function(record) { return record.value; })});
}
var reports = [], cursor = "", pages = 0, cutoff = Date.now() - DIAG_RETENTION_MS;
do {
var page = nk.storageList(SYSTEM_USER_ID, DIAG_REPORT_COLLECTION, 100, cursor);
var objects = Array.isArray(page) ? page : (page && Array.isArray(page.objects) ? page.objects : []);
for (var i = 0; i < objects.length; i++) {
var object = objects[i], value = object && object.value ? object.value : {};
if (Number(value.created_at || 0) >= cutoff) reports.push({round_id:value.round_id,slot:value.slot,
created_at:value.created_at,version:value.version,record_count:Array.isArray(value.records)?value.records.length:0});
}
cursor = page && !Array.isArray(page) ? String(page.cursor || "") : "";
pages++;
} while (cursor && pages < 10);
reports.sort(function(a,b) { return b.created_at - a.created_at; });
return JSON.stringify({reports:reports.slice(0,100),truncated:!!cursor||reports.length>100});
}
function InitModule(ctx,logger,nk,initializer) {
for (var mode=0;mode<VALID_MODES.length;mode++) for (var map=0;map<MAP_COUNT;map++) for (var difficulty=0;difficulty<DIFFICULTY_COUNT;difficulty++) nk.leaderboardCreate(leaderboardId(map,difficulty,VALID_MODES[mode]),true,"desc","best",null,{game:"void_bastion"});
initializer.registerRpc("server_info",rpcServerInfo);
initializer.registerRpc("record_activity",rpcRecordActivity);
initializer.registerRpc("private_stats",rpcPrivateStats);
initializer.registerRpc("create_unlock_code",rpcCreateUnlockCode);
initializer.registerRpc("list_unlock_codes",rpcListUnlockCodes);
initializer.registerRpc("delete_unlock_code",rpcDeleteUnlockCode);
initializer.registerRpc("redeem_unlock_code",rpcRedeemUnlockCode);
initializer.registerRpc("get_promo_unlocks",rpcGetPromoUnlocks);
initializer.registerRpc("submit_run",rpcSubmitRun);
initializer.registerRpc("update_display_name",rpcUpdateDisplayName);
initializer.registerRpc("find_player_by_name",rpcFindPlayerByName);
initializer.registerRpc("submit_online_diagnostics",rpcSubmitOnlineDiagnostics);
initializer.registerRpc("list_online_diagnostics",rpcListOnlineDiagnostics);
initializer.registerRpc("delete_account",rpcDeleteAccount);
initializer.registerRpc("create_private_room",rpcCreatePrivateRoom);
initializer.registerRpc("join_private_room",rpcJoinPrivateRoom);
initializer.registerRpc("invite_friend",rpcInviteFriend);
initializer.registerMatchmakerMatched(matchmakerMatched);
initializer.registerMatch(MATCH_MODULE,{matchInit:matchInit,matchJoinAttempt:matchJoinAttempt,matchJoin:matchJoin,matchLeave:matchLeave,matchLoop:matchLoop,matchTerminate:matchTerminate,matchSignal:matchSignal});
logger.info("Void Bastion online runtime %s loaded",GAME_VERSION);
}