Track real player sessions and tower builds

This commit is contained in:
Emporex committed 2026-10-11 22:02:51 +02:00
1 parent 8777f28595
commit 1294000348
8 files changed
+213 -8

No files matched your search

+101 -4
View File
@@ -1,4 +1,4 @@
var GAME_VERSION = "0.26";
var GAME_VERSION = "0.27";
var MAP_COUNT = 7;
var DIFFICULTY_COUNT = 3;
var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"];
@@ -6,6 +6,7 @@ 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";
@@ -233,7 +234,19 @@ function rpcRecordActivity(ctx, logger, nk, payload) {
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}]);
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) {
@@ -242,6 +255,75 @@ function rpcPrivateStats(ctx, logger, nk, payload) {
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) builds.push(0);
builds[type]=Math.max(0,Math.floor(Number(builds[type]||0)))+1;
activity.tower_builds=builds;
activity.tower_build_total=Math.max(0,Math.floor(Number(activity.tower_build_total||0)))+1;
}
if (source) { sequences[source]=sequence; activity.event_sequences=sequences; }
try {
nk.storageWrite([{collection:ACTIVITY_COLLECTION,key:ACTIVITY_KEY,userId:userId,value:activity,permissionRead:0,permissionWrite:0,version:record&&record.version?record.version:"*"}]);
return {accepted:true,duplicate:false};
} catch (error) { if (attempt===2) throw error; }
}
}
function rpcRecordPlayerEvent(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 event=String(request.event||""), type=Math.floor(Number(request.tower_type));
var source=String(request.source||""),sequence=Math.floor(Number(request.sequence));
if (["play_start","tower_build"].indexOf(event)<0 || (event==="tower_build"&&(type<0||type>=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<TOWERS.length) builds.push(0);
return JSON.stringify({found:true,player:{user_id:userId,name:String(rows[0].display_name||rows[0].username).substring(0,32),
registered_at:Number(rows[0].registered_at||0),last_seen:Number(activity.last_seen||0),last_played:Number(activity.last_played||0),
platform:String(activity.platform||""),version:String(activity.version||""),tower_builds:builds,
tower_build_total:Number(activity.tower_build_total||0)}});
}
function rpcCreateUnlockCode(ctx, logger, nk, payload) {
if (ctx.userId) throw new Error("server-to-server access required");
var request;
@@ -738,7 +820,13 @@ function updateWave(state,delta,dispatcher) {
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 (message.opCode===OP_BUILD) {
changed=handleBuild(state,slot,payload,message.sender.userId);
if (changed&&Math.floor(Number(payload.tower_type))<TOWERS.length) {
try { updatePlayerActivity(nk,message.sender.userId,"tower_build",Math.floor(Number(payload.tower_type)),"",0,Date.now()); }
catch (telemetryError) { /* Telemetry must never reject an accepted tower build. */ }
}
}
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);
@@ -749,7 +837,13 @@ function handleCommand(nk,dispatcher,state,message) {
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",{}); }
if (allReady) {
state.started=true; state.pausedForReconnect=false; dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"match_started",{});
for (var playerIndex=0;playerIndex<ids.length;playerIndex++) {
try { updatePlayerActivity(nk,ids[playerIndex],"play_start",-1,"",0,Date.now()); }
catch (telemetryError) { /* Telemetry must never stop the match. */ }
}
}
changed=true;
}
else if (message.opCode===OP_DEV_AUTH&&state.started&&!state.gameOver) {
@@ -885,7 +979,10 @@ 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("record_player_event",rpcRecordPlayerEvent);
initializer.registerRpc("private_stats",rpcPrivateStats);
initializer.registerRpc("list_registered_players",rpcListRegisteredPlayers);
initializer.registerRpc("get_registered_player",rpcGetRegisteredPlayer);
initializer.registerRpc("get_game_balance",rpcGetGameBalance);
initializer.registerRpc("set_game_balance",rpcSetGameBalance);
initializer.registerRpc("create_unlock_code",rpcCreateUnlockCode);
+33
View File
@@ -231,6 +231,36 @@ assert.strictEqual(privateStats.platforms.android, 1);
assert.strictEqual(privateStats.versions["0.1.0-alpha.2"], 2);
assert.strictEqual(privateStats.daily_active[context.utcDay(Date.now())], 2);
assert.throws(() => context.rpcPrivateStats({ userId: "pilot-a" }, logger, metricNk, "{}"), /server-to-server/);
const metricSource = "guest_0123456789abcdef0123456789abcdef";
assert.deepStrictEqual(JSON.parse(context.rpcRecordPlayerEvent({ userId: "pilot-metric-a" }, logger, metricNk,
JSON.stringify({ event: "play_start", source: metricSource, sequence: 1, at: Date.now() }))), { accepted: true, duplicate: false });
assert.deepStrictEqual(JSON.parse(context.rpcRecordPlayerEvent({ userId: "pilot-metric-a" }, logger, metricNk,
JSON.stringify({ event: "tower_build", tower_type: 1, source: metricSource, sequence: 2, at: Date.now() }))), { accepted: true, duplicate: false });
assert.deepStrictEqual(JSON.parse(context.rpcRecordPlayerEvent({ userId: "pilot-metric-a" }, logger, metricNk,
JSON.stringify({ event: "tower_build", tower_type: 1, source: metricSource, sequence: 2, at: Date.now() }))), { accepted: true, duplicate: true });
assert.strictEqual(JSON.parse(context.rpcRecordPlayerEvent({ userId: "pilot-metric-a" }, logger, metricNk,
JSON.stringify({ event: "tower_build", tower_type: 7, source: metricSource, sequence: 3 }))).accepted, false, "Admin towers must not enter real tower statistics.");
context.rpcRecordActivity({ userId: "pilot-metric-a" }, logger, metricNk, JSON.stringify({ platform: "Windows", version: "0.1.0-alpha.16" }));
const metricActivity = metricStorage.get("player_activity:pilot-metric-a:summary").value;
assert.strictEqual(metricActivity.tower_builds[1], 1, "Repeated telemetry must not double-count towers.");
assert.ok(metricActivity.last_played > 0, "Game sessions should track when the player last played.");
assert.strictEqual(context.isInternalTestUsername("godot_a_2323883"), true);
assert.strictEqual(context.isInternalTestUsername("social_b_2289087"), true);
assert.strictEqual(context.isInternalTestUsername("Emporex"), false);
const directoryNk = {
sqlQuery(sql) {
if (sql.includes("count(*) AS total")) return [{ total: 7, active_today: 3 }];
if (sql.includes("WHERE id=$1::uuid")) return [{ id: "11111111-1111-1111-1111-111111111111", username: "pilot_real", display_name: "Real Pilot", registered_at: 1000 }];
return [{ id: "11111111-1111-1111-1111-111111111111", username: "pilot_real", display_name: "Real Pilot", registered_at: 1000, last_seen: 2000, last_played: 3000, tower_build_total: 2 }];
},
storageRead() { return [{ value: { tower_builds: [0, 2, 0, 0, 0, 0, 0], tower_build_total: 2, last_seen: 2000, last_played: 3000 } }]; }
};
assert.strictEqual(JSON.parse(context.rpcListRegisteredPlayers({}, logger, directoryNk, "{}")).total, 7);
assert.strictEqual(JSON.parse(context.rpcGetRegisteredPlayer({}, logger, directoryNk,
JSON.stringify({ user_id: "11111111-1111-1111-1111-111111111111" }))).player.tower_builds[1], 2);
assert.throws(() => context.rpcListRegisteredPlayers({ userId: "pilot-a" }, logger, directoryNk, "{}"), /server-to-server/);
nk.storageRead = metricNk.storageRead.bind(metricNk);
nk.storageWrite = metricNk.storageWrite.bind(metricNk);
const promoStorage = new Map();
let promoVersion = 0;
@@ -376,6 +406,8 @@ state = context.matchLoop({}, logger, nk, dispatcher, 5, state, [
assert.strictEqual(state.towers.length, 2);
assert.strictEqual(state.credits[0], 310);
assert.strictEqual(state.credits[1], 250);
assert.strictEqual(metricStorage.get("player_activity:pilot-a:summary").value.tower_builds[0], 1, "Accepted online tower builds should be counted on the server.");
assert.strictEqual(metricStorage.get("player_activity:pilot-b:summary").value.tower_builds[1], 1);
state = context.matchLoop({}, logger, nk, dispatcher, 51, state,
[message(presenceA, 10, { tower_type: 7, x: 2200, y: 600 }),
@@ -402,6 +434,7 @@ assert.strictEqual(state.credits[0], 310, "Authorized test energy removal should
state = context.matchLoop({}, logger, nk, dispatcher, 56, state,
[message(presenceA, 10, { tower_type: 7, x: 2200, y: 600 })]).state;
assert.ok(state.towers.some(tower => tower.type === 7 && tower.owner === 0), "Authorized player may build the admin turret anywhere on the co-op map.");
assert.strictEqual(metricStorage.get("player_activity:pilot-a:summary").value.tower_build_total, 1, "Admin towers are excluded from player statistics.");
const adminCombatState = {
balance: state.balance,
towers: [{ id: 99, type: 7, x: 2200, y: 600, owner: 0, level: 1, cooldown: 0, angle: 0 }],