var GAME_VERSION = "0.27"; 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 INTERNAL_TEST_USERNAME_PATTERN = "^(godot_[ab]_[0-9]+|social_[ab]_[0-9]+|GODOT-[AB]|SYSTEMTEST)$"; 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 BALANCE_COLLECTION = "game_balance"; var BALANCE_KEY = "global"; 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 OP_DEV_AUTH = 15, OP_DEV_ENERGY = 16, ADMIN_TOWER_TYPE = 7, DEV_MODE_PASSWORD = "Spring.3"; var DEV_ADMIN_USER_IDS = { "6605c44c-17a2-4cc4-bf3e-e772fd6229b0": true, "6f32e90b-1ed8-4baf-bfa2-17646600da79": true }; 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:22,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:620,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 defaultBalanceConfig() { return {schema:3,towers:JSON.parse(JSON.stringify(TOWERS)),enemies:JSON.parse(JSON.stringify(ENEMIES)),admin_tower:{range:650,cooldown:0.08}}; } function boundedNumber(value, fallback, minimum, maximum, integer) { var number = Number(value); if (!isFinite(number)) number = fallback; number = Math.max(minimum, Math.min(maximum, number)); return integer ? Math.floor(number) : number; } function sanitizeBalanceConfig(input) { var defaults = defaultBalanceConfig(); if (!input || !Array.isArray(input.towers) || input.towers.length !== defaults.towers.length || !Array.isArray(input.enemies) || input.enemies.length !== defaults.enemies.length) throw new Error("invalid balance schema"); var towers = defaults.towers.map(function(base, index) { var candidate = input.towers[index] || {}; var targetClass = String(candidate.targetClass || base.targetClass); if (["air","ground","both"].indexOf(targetClass) < 0) targetClass = base.targetClass; return { cost:boundedNumber(candidate.cost,base.cost,10,50000,true), damage:boundedNumber(candidate.damage,base.damage,0.1,10000,false), range:boundedNumber(candidate.range,base.range,20,1200,false), cooldown:boundedNumber(candidate.cooldown,base.cooldown,0.04,20,false), targetClass:targetClass, bothScale:boundedNumber(candidate.bothScale,base.bothScale===undefined?1:base.bothScale,0.05,2,false) }; }); var enemies = defaults.enemies.map(function(base, index) { var candidate = input.enemies[index] || {}; return { category:base.category, hp:boundedNumber(candidate.hp,base.hp,1,1000000,false), speed:boundedNumber(candidate.speed,base.speed,1,1500,false), reward:boundedNumber(candidate.reward,base.reward,0,50000,true), breachDamage:boundedNumber(candidate.breachDamage,base.breachDamage,1,100,true), shieldResistance:boundedNumber(candidate.shieldResistance,base.shieldResistance,0.01,1,false) }; }); var adminInput=input.admin_tower||{}; var adminTower={ range:boundedNumber(adminInput.range,defaults.admin_tower.range,80,1200,false), cooldown:boundedNumber(adminInput.cooldown,defaults.admin_tower.cooldown,0.04,20,false) }; return {schema:3,towers:towers,enemies:enemies,admin_tower:adminTower}; } function readBalanceConfig(nk) { var record = nk && typeof nk.storageRead === "function" ? readStorageRecord(nk,BALANCE_COLLECTION,BALANCE_KEY,SYSTEM_USER_ID) : null; var config = defaultBalanceConfig(), updatedAt = 0; if (record && record.value && record.value.config) { try { var storedConfig = record.value.config; config = sanitizeBalanceConfig(storedConfig); updatedAt = Number(record.value.updated_at||0); if (Number(storedConfig.schema || 1) < 2) { var legacyTower = storedConfig.towers[6] || {}; var legacyBoss = storedConfig.enemies[5] || {}; if (Number(legacyTower.damage) === 20) config.towers[6].damage = 22; if (Number(legacyBoss.hp) === 740) config.enemies[5].hp = 620; } if (Number(storedConfig.schema || 1) < 3) { updatedAt = Date.now(); if (nk && typeof nk.storageWrite === "function") { try { nk.storageWrite([{collection:BALANCE_COLLECTION,key:BALANCE_KEY,userId:SYSTEM_USER_ID,value:{config:config,updated_at:updatedAt},permissionRead:0,permissionWrite:0,version:record.version||"*"}]); } catch (writeError) { updatedAt = Number(record.value.updated_at||0); } } } } catch (error) { config = defaultBalanceConfig(); } } return {config:config,updated_at:updatedAt}; } function rpcGetGameBalance(ctx, logger, nk, payload) { // Nakama server-to-server HTTP-key calls intentionally have no player userId. // The internal statistics page uses that path; game clients call with a session. return JSON.stringify(readBalanceConfig(nk)); } function rpcSetGameBalance(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 config = sanitizeBalanceConfig(request.config); var record = readStorageRecord(nk,BALANCE_COLLECTION,BALANCE_KEY,SYSTEM_USER_ID); var updatedAt = Date.now(); var write = {collection:BALANCE_COLLECTION,key:BALANCE_KEY,userId:SYSTEM_USER_ID,value:{config:config,updated_at:updatedAt},permissionRead:0,permissionWrite:0,version:record&&record.version?record.version:"*"}; nk.storageWrite([write]); return JSON.stringify({accepted:true,updated_at:updatedAt,config:config}); } 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); for (var attempt=0;attempt<3;attempt++) { playerRecord=readStorageRecord(nk,ACTIVITY_COLLECTION,ACTIVITY_KEY,ctx.userId); var activity=playerRecord&&playerRecord.value?JSON.parse(JSON.stringify(playerRecord.value)):{}; activity.first_seen=Number(activity.first_seen||now); activity.last_seen=now; activity.last_seen_day=day; activity.platform=platform; activity.version=version; try { nk.storageWrite([{collection:ACTIVITY_COLLECTION,key:ACTIVITY_KEY,userId:ctx.userId,value:activity,permissionRead:0,permissionWrite:0,version:playerRecord&&playerRecord.version?playerRecord.version:"*"}]); return JSON.stringify({accepted:true}); } catch (error) { if (attempt===2) throw error; } } 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 updatePlayerActivity(nk,userId,event,type,source,sequence,eventAt) { var now=Date.now(); for (var attempt=0;attempt<3;attempt++) { var record=readStorageRecord(nk,ACTIVITY_COLLECTION,ACTIVITY_KEY,userId); var activity=record&&record.value?JSON.parse(JSON.stringify(record.value)):{}; var sequences=activity.event_sequences&&typeof activity.event_sequences==="object"?activity.event_sequences:{}; if (source && sequence<=Number(sequences[source]||0)) return {accepted:true,duplicate:true}; activity.first_seen=Number(activity.first_seen||now); activity.last_seen=now; activity.last_seen_day=utcDay(now); activity.last_played=Math.max(Number(activity.last_played||0),Math.min(now,Math.max(0,Number(eventAt||now)))); if (event==="tower_build") { var builds=Array.isArray(activity.tower_builds)?activity.tower_builds.slice(0,TOWERS.length):[]; while (builds.length=TOWERS.length)) || !/^[A-Za-z0-9_-]{8,64}$/.test(source) || !isFinite(sequence) || sequence<1 || sequence>1000000000) return JSON.stringify({accepted:false,error:"invalid_event"}); var eventAt=Number(request.at||Date.now()); return JSON.stringify(updatePlayerActivity(nk,ctx.userId,event,type,source,sequence,eventAt)); } function isInternalTestUsername(username) { return new RegExp(INTERNAL_TEST_USERNAME_PATTERN,"i").test(String(username||"")); } function playerDirectoryRow(row) { return {user_id:String(row.id||""),name:String(row.display_name||row.username||"PILOT").substring(0,32), registered_at:Number(row.registered_at||0),last_seen:Number(row.last_seen||0),last_played:Number(row.last_played||0), tower_build_total:Number(row.tower_build_total||0)}; } function rpcListRegisteredPlayers(ctx,logger,nk,payload) { if (ctx.userId) throw new Error("server-to-server access required"); var request; try { request=JSON.parse(payload||"{}"); } catch (error) { request={}; } var page=Math.max(0,Math.min(10000,Math.floor(Number(request.page||0))||0)); var countRows=nk.sqlQuery("SELECT count(*) AS total, count(*) FILTER (WHERE a.value->>'last_seen_day'=$1) AS active_today FROM users u LEFT JOIN storage a ON a.user_id=u.id AND a.collection='player_activity' AND a.key='summary' WHERE u.username<>'' AND u.username !~* $2",[utcDay(Date.now()),INTERNAL_TEST_USERNAME_PATTERN]); var rows=nk.sqlQuery("SELECT u.id,u.username,u.display_name,extract(epoch from u.create_time)*1000 AS registered_at,coalesce((a.value->>'last_seen')::bigint,0) AS last_seen,coalesce((a.value->>'last_played')::bigint,0) AS last_played,coalesce((a.value->>'tower_build_total')::bigint,0) AS tower_build_total FROM users u LEFT JOIN storage a ON a.user_id=u.id AND a.collection='player_activity' AND a.key='summary' WHERE u.username<>'' AND u.username !~* $1 ORDER BY last_seen DESC,u.create_time DESC LIMIT 50 OFFSET $2",[INTERNAL_TEST_USERNAME_PATTERN,page*50]); return JSON.stringify({page:page,page_size:50,total:Number(countRows[0]&&countRows[0].total||0),active_today:Number(countRows[0]&&countRows[0].active_today||0),players:rows.map(playerDirectoryRow)}); } function rpcGetRegisteredPlayer(ctx,logger,nk,payload) { if (ctx.userId) throw new Error("server-to-server access required"); var request; try { request=JSON.parse(payload||"{}"); } catch (error) { request={}; } var userId=String(request.user_id||""); if (!/^[0-9a-f-]{36}$/i.test(userId)) return JSON.stringify({found:false}); var rows=nk.sqlQuery("SELECT id,username,display_name,extract(epoch from create_time)*1000 AS registered_at FROM users WHERE id=$1::uuid LIMIT 1",[userId]); if (!rows.length || isInternalTestUsername(rows[0].username) || !rows[0].username) return JSON.stringify({found:false}); var activityRecord=readStorageRecord(nk,ACTIVITY_COLLECTION,ACTIVITY_KEY,userId); var activity=activityRecord&&activityRecord.value?activityRecord.value:{}; var builds=Array.isArray(activity.tower_builds)?activity.tower_builds.slice(0,TOWERS.length):[]; while (builds.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 requestedDifficulty = params.difficulty === undefined || params.difficulty === null ? 1 : Number(params.difficulty); var difficulty = Math.max(0,Math.min(2,Math.floor(isFinite(requestedDifficulty) ? requestedDifficulty : 1))); var credits = DIFFICULTIES[difficulty].startCredits; return {presences:{},slots:{},players:{},ready:{},devAuthorized:{},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,balance:defaultBalanceConfig()}; } 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||{}); state.balance=readBalanceConfig(nk).config; 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;i0?((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,allowAnySide) { 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||x<120||x>WORLD_WIDTH-120||yPLAYFIELD_BOTTOM) return false; if (!allowAnySide&&slot===0&&(x<120||x>=MIRROR_X-GRID*0.5)) return false; if (!allowAnySide&&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;rTOWERS.length||(isAdmin&&!state.devAuthorized[userId])||!validBuildPosition(state,slot,x,y,isAdmin)||state.credits[slot]=3) return false; var cost=upgradeCost(state,state.towers[i]); if (state.credits[slot]=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-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=enemyInfo(state,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 hullScale=scale*(boss&&state.wave===5?0.5:1); var routeId=state.nextSpawnRoute; state.nextSpawnRoute=(state.nextSpawnRoute+1)%2; var route=PATHS[routeId]; var hasShield=type===4||(state.wave>=4&&Math.random()=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-(enemyInfo(state,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;ibest)) { 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,6].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(state,t)*(tower.targetClass==="both"?tower.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 ((tower.targetClass==="both"||tower.targetClass===enemyInfo(state,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(state,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,message.sender.userId); if (changed&&Math.floor(Number(payload.tower_type))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=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