Add private player metrics and alpha.2 package
This commit is contained in:
1 parent
b4980f0acd
commit
178a6bbc85
12 files changed
+199
-14
No files matched your search
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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" } }; },
|
||||
|
||||
Reference in new issue
Block a user