Add private player metrics and alpha.2 package

This commit is contained in:
Emporex committed 2026-10-04 20:41:31 +02:00
1 parent b4980f0acd
commit 178a6bbc85
12 files changed
+199 -14

No files matched your search

+7
View File
@@ -27,3 +27,10 @@ Für eine öffentliche Testversion wird Port 7350 später über Nginx Proxy Mana
## Betrieb
Der Stack verwendet fest versioniertes Nakama 3.37.0 und PostgreSQL 16 Alpine. Vor einem Versionswechsel werden Datenbank und Nakama-Appdata gesichert und die offiziellen Migrationshinweise geprüft.
## Private Statistik-RPCs
- `record_activity`: nur für angemeldete Spieler; aktualisiert die pseudonyme Aktivität sowie aggregierte Plattform- und Versionswerte.
- `private_stats`: nur als Server-zu-Server-Aufruf mit `NAKAMA_HTTP_KEY`; liefert die zusammengefassten Werte für die interne Homepage.
Die echte Webhosting-Konfiguration mit dem HTTP-Schlüssel darf nicht in Git abgelegt werden.
+98
View File
@@ -4,6 +4,10 @@ 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 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 = 2, GRID = 80, WORLD_WIDTH = 2960, MIRROR_X = 1480;
@@ -33,6 +37,96 @@ function leaderboardId(mapIndex, difficulty, mode) { return mode + "_map" + mapI
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 rpcSubmitRun(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var run;
@@ -79,6 +173,8 @@ function rpcUpdateDisplayName(ctx, logger, nk, payload) {
}
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});
}
@@ -294,6 +390,8 @@ function matchSignal(ctx,logger,nk,dispatcher,tick,state,data) { return {state:s
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("submit_run",rpcSubmitRun);
initializer.registerRpc("update_display_name",rpcUpdateDisplayName);
initializer.registerRpc("delete_account",rpcDeleteAccount);
+29
View File
@@ -27,6 +27,7 @@ const presenceB = { userId: "pilot-b", sessionId: "b", username: "Pilot B" };
let updatedAccount = null;
let deletedAccount = null;
const accountNk = {
storageRead() { return []; },
accountUpdateId(...args) { updatedAccount = args; },
accountDeleteId(...args) { deletedAccount = args; }
};
@@ -38,6 +39,34 @@ assert.strictEqual(JSON.parse(context.rpcUpdateDisplayName({ userId: "pilot-a" }
assert.deepStrictEqual(JSON.parse(context.rpcDeleteAccount({ userId: "pilot-a" }, logger, accountNk, "{}")), { accepted: true });
assert.deepStrictEqual(deletedAccount, ["pilot-a", false]);
const metricStorage = new Map();
let metricVersion = 0;
const metricNk = {
storageRead(ops) {
return ops.map(op => metricStorage.get(`${op.collection}:${op.userId}:${op.key}`)).filter(Boolean);
},
storageWrite(ops) {
for (const op of ops) {
const storageKey = `${op.collection}:${op.userId}:${op.key}`;
const previous = metricStorage.get(storageKey);
if (op.version === "*" && previous) throw new Error("version conflict");
if (op.version && op.version !== "*" && (!previous || previous.version !== op.version)) throw new Error("version conflict");
metricVersion += 1;
metricStorage.set(storageKey, { value: JSON.parse(JSON.stringify(op.value)), version: String(metricVersion) });
}
}
};
assert.deepStrictEqual(JSON.parse(context.rpcRecordActivity({ userId: "pilot-metric-a" }, logger, metricNk, JSON.stringify({ platform: "Windows", version: "0.1.0-alpha.2" }))), { accepted: true });
assert.deepStrictEqual(JSON.parse(context.rpcRecordActivity({ userId: "pilot-metric-a" }, logger, metricNk, JSON.stringify({ platform: "Windows", version: "0.1.0-alpha.2" }))), { accepted: true });
assert.deepStrictEqual(JSON.parse(context.rpcRecordActivity({ userId: "pilot-metric-b" }, logger, metricNk, JSON.stringify({ platform: "Android", version: "0.1.0-alpha.2" }))), { accepted: true });
const privateStats = JSON.parse(context.rpcPrivateStats({}, logger, metricNk, "{}"));
assert.strictEqual(privateStats.total_players, 2);
assert.strictEqual(privateStats.platforms.windows, 1);
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/);
let leaderboardWrite = null;
const leaderboardNk = {
accountGetId() { return { user: { displayName: "Kommandant Ägir" } }; },