Prepare alpha 11 with online diagnostics

This commit is contained in:
Emporex committed 2026-10-10 20:17:39 +02:00
1 parent d53467baf2
commit 4d9e6d9185
14 files changed
+550 -77

No files matched your search

+8
View File
@@ -32,6 +32,14 @@ Der Stack verwendet fest versioniertes Nakama 3.37.0 und PostgreSQL 16 Alpine. V
Freundschaftsanfragen, bestätigte Freundschaften und Online-Präsenz verwenden die eingebauten Nakama-Funktionen. Die app-eigene RPC `invite_friend` nimmt ausschließlich die Freundes-ID und einen sechsstelligen privaten Raumcode entgegen. Sie prüft, dass der Absender den Raum erstellt hat und eine gegenseitige Freundschaft besteht. Je Empfänger gilt ein Mindestabstand von 15 Sekunden. Die Benachrichtigung ist nicht persistent und enthält nur Absendername und Raumcode; ein Chat oder frei eingebbarer Einladungstext existiert nicht. Die Funktion muss zusammen mit dem aktualisierten Godot-Client ausgerollt werden.
`find_player_by_name` löst einen sichtbaren Spielernamen per exakter, nicht groß-/kleinschreibungsabhängiger Suche in eine interne Benutzer-ID auf. Wenn mehrere Konten denselben Anzeigenamen haben, wird die Anfrage als `ambiguous` abgewiesen, statt versehentlich das falsche Konto hinzuzufügen. Der Client zeigt und kopiert im Freundemenü nur den sichtbaren Namen. Im privaten Warteraum ist für bestätigte Freunde `EINLADEN` verfügbar.
## Online-Diagnose
`submit_online_diagnostics` nimmt am Ende einer Online-Partie automatisch technische Berichte der beiden Clients entgegen. Der Server prüft anhand der beim Matchbeitritt gespeicherten Teilnehmerzuordnung, ob das sendende Konto zur Runde gehört. Beide Berichte werden mit derselben 16-stelligen Rundenkennung und unterschiedlichen Seitenzahlen gespeichert. Es werden nur erlaubte numerische Messwerte und vordefinierte Ereignisse übernommen; keine Namen, Nachrichten, IP-Adressen oder Raumcodes. Die Speicherung ist serverprivat und wird nach 14 Tagen beim nächsten Diagnoseabruf oder -upload bereinigt.
Die Server-zu-Server-RPC `list_online_diagnostics` liefert mit `{}` eine kompakte Übersicht. Mit `{"round_id":"<Kennung>"}` liefert sie beide vollständigen Seitenberichte. Der private HTTP-Schlüssel gehört ausschließlich in die Server-/Webhosting-Konfiguration, niemals in den Spielclient. Weitere Auswertungshinweise stehen in `ONLINE_DIAGNOSE.md`.
## Private Statistik-RPCs
- `record_activity`: nur für angemeldete Spieler; aktualisiert die pseudonyme Aktivität sowie aggregierte Plattform- und Versionswerte.
+118
View File
@@ -12,6 +12,9 @@ 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;
@@ -320,6 +323,18 @@ function rpcUpdateDisplayName(ctx, logger, nk, payload) {
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);
@@ -449,6 +464,16 @@ function matchJoin(ctx,logger,nk,dispatcher,tick,state,presences) {
}
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);
@@ -535,6 +560,7 @@ function spawnEnemy(state) {
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) {
@@ -642,6 +668,95 @@ function matchLoop(ctx,logger,nk,dispatcher,tick,state,messages) {
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);
@@ -654,6 +769,9 @@ function InitModule(ctx,logger,nk,initializer) {
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);
+68
View File
@@ -49,6 +49,20 @@ assert.strictEqual(JSON.parse(context.rpcInviteFriend(inviteCtx, {}, inviteNk, J
assert.strictEqual(sentInvites.length, 1, "A rate-limited invitation must not send another notification.");
assert.strictEqual(JSON.parse(context.rpcInviteFriend({ userId: friendId }, {}, inviteNk, JSON.stringify({ friend_id: hostId, room_code: "ABC123" }))).accepted, false, "Only the private room host may invite.");
assert.strictEqual(JSON.parse(context.rpcInviteFriend(inviteCtx, {}, { ...inviteNk, friendsList: () => ({ friends: [] }) }, JSON.stringify({ friend_id: friendId, room_code: "ABC123" }))).accepted, false, "Only mutual friends may be invited.");
const nameLookupNk = {
sqlQuery(_query, params) {
assert.strictEqual(params[0], "Emporex2", "Player search must use parameter binding.");
return [{ id: friendId, display_name: "Emporex2" }];
}
};
assert.deepStrictEqual(JSON.parse(context.rpcFindPlayerByName(inviteCtx, {}, nameLookupNk, JSON.stringify({ display_name: "Emporex2" }))),
{ found: true, user_id: friendId, display_name: "Emporex2" });
assert.strictEqual(JSON.parse(context.rpcFindPlayerByName(inviteCtx, {},
{ sqlQuery: () => [{ id: friendId, display_name: "Same" }, { id: "third", display_name: "Same" }] },
JSON.stringify({ display_name: "Same" }))).error, "ambiguous", "Duplicate display names must not invite the wrong account.");
assert.strictEqual(JSON.parse(context.rpcFindPlayerByName(inviteCtx, {},
{ sqlQuery: () => [{ id: hostId, display_name: "Self" }] },
JSON.stringify({ display_name: "Self" }))).error, "not_found");
const flakState = {
towers: [{ id: 20, type: 6, x: 800, y: 200, owner: 0, level: 1, cooldown: 0 }],
enemies: [
@@ -344,4 +358,58 @@ state = context.matchJoin({}, logger, nk, dispatcher, 33, state, [presenceB]).st
assert.strictEqual(state.pausedForReconnect, false);
assert.strictEqual(state.slots["pilot-b"], 1);
const bossState = context.initialState({ difficulty: 1 });
bossState.wave = 5;
bossState.waveSchedule = [{ type: 5, boss: true }];
const originalRandom = context.Math.random;
context.Math.random = () => 0;
context.spawnEnemy(bossState);
context.Math.random = originalRandom;
assert.ok(bossState.enemies[0].shield, "The first boss shield test needs a shielded ship.");
assert.strictEqual(bossState.enemies[0].max_shield_hp, bossState.enemies[0].max_hp * 0.275,
"Wave-five mothership shields must be half their former strength.");
const laterBossState = context.initialState({ difficulty: 1 });
laterBossState.wave = 10;
laterBossState.waveSchedule = [{ type: 8, boss: true }];
context.Math.random = () => 0;
context.spawnEnemy(laterBossState);
context.Math.random = originalRandom;
assert.strictEqual(laterBossState.enemies[0].max_shield_hp, laterBossState.enemies[0].max_hp * 0.55,
"Later bosses retain their original shield balance.");
const diagnosticStore = new Map();
const diagnosticNk = {
sha256Hash(value) { return crypto.createHash("sha256").update(value).digest("hex"); },
storageRead(ops) { return ops.map(op => diagnosticStore.get(`${op.collection}:${op.userId}:${op.key}`)).filter(Boolean); },
storageWrite(ops) { for (const op of ops) diagnosticStore.set(`${op.collection}:${op.userId}:${op.key}`, { key: op.key, value: op.value }); },
storageList(userId, collection) {
return { objects: [...diagnosticStore.entries()].filter(([key]) => key.startsWith(`${collection}:${userId}:`)).map(([, object]) => object), cursor: "" };
},
storageDelete(ops) { for (const op of ops) diagnosticStore.delete(`${op.collection}:${op.userId}:${op.key}`); }
};
const diagnosticMatchId = "sample-match.123";
let diagState = context.initialState({ difficulty: 1 });
diagState = context.matchJoin({ matchId: diagnosticMatchId }, logger, diagnosticNk, dispatcher, 1, diagState,
[{ userId: hostId, username: "Host" }, { userId: friendId, username: "Guest" }]).state;
const report = { match_id: diagnosticMatchId, version: "0.26", records: [
{ event: "window", utc: "2026-10-10T13:00:00", snapshots: 90, gap_p95_ms: 103, player_name: "must_not_store" },
{ event: "end", utc: "2026-10-10T13:00:01", reason: "left_match" }
] };
const firstReport = JSON.parse(context.rpcSubmitOnlineDiagnostics({ userId: hostId }, logger, diagnosticNk, JSON.stringify(report)));
const secondReport = JSON.parse(context.rpcSubmitOnlineDiagnostics({ userId: friendId }, logger, diagnosticNk, JSON.stringify(report)));
assert.strictEqual(firstReport.round_id, secondReport.round_id, "Both players need one shared round ID.");
assert.strictEqual(firstReport.slot, 0);
assert.strictEqual(secondReport.slot, 1);
assert.strictEqual(JSON.parse(context.rpcSubmitOnlineDiagnostics({ userId: "stranger" }, logger, diagnosticNk, JSON.stringify(report))).error,
"not_participant", "A third account cannot attach data to the round.");
const listedReports = JSON.parse(context.rpcListOnlineDiagnostics({}, logger, diagnosticNk, "{}")).reports;
assert.strictEqual(listedReports.length, 2);
assert.ok(!JSON.stringify(listedReports).includes("must_not_store"), "The server must discard arbitrary client strings.");
const pairReports = JSON.parse(context.rpcListOnlineDiagnostics({}, logger, diagnosticNk,
JSON.stringify({ round_id: firstReport.round_id }))).reports;
assert.strictEqual(pairReports.length, 2);
assert.strictEqual(pairReports[0].round_id, pairReports[1].round_id);
assert.ok(!JSON.stringify(pairReports).includes("must_not_store"), "Detailed reports must also omit unapproved strings.");
assert.throws(() => context.rpcListOnlineDiagnostics({ userId: hostId }, logger, diagnosticNk, "{}"), /server-to-server/);
console.log("VOID_BASTION_SERVER_SIM_OK");