From 178a6bbc85347c7b743cfbf7ab6e30dd848d5c41 Mon Sep 17 00:00:00 2001 From: Dominic Date: Sun, 4 Oct 2026 20:41:31 +0200 Subject: [PATCH] Add private player metrics and alpha.2 package --- ALPHA_TEST_CHECKLIST.md | 8 +- ONLINE_SETUP.md | 6 ++ RELEASE_NOTES_0.1.0-alpha.2.md | 19 +++++ export_presets.cfg | 4 +- online_manager.gd | 11 +++ packaging/windows/build.ps1 | 20 ++++- packaging/windows/installer.nsi | 4 +- project.godot | 2 +- server/nakama/README.md | 7 ++ server/nakama/runtime/build/index.js | 98 +++++++++++++++++++++++++ server/nakama/tests/runtime_sim_test.js | 29 ++++++++ tests/smoke_test.gd | 5 +- 12 files changed, 199 insertions(+), 14 deletions(-) create mode 100644 RELEASE_NOTES_0.1.0-alpha.2.md diff --git a/ALPHA_TEST_CHECKLIST.md b/ALPHA_TEST_CHECKLIST.md index 965589a..5e925a9 100644 --- a/ALPHA_TEST_CHECKLIST.md +++ b/ALPHA_TEST_CHECKLIST.md @@ -4,10 +4,10 @@ Diese Liste wird vor jeder öffentlichen Windows-Alpha vollständig abgearbeitet ## Build und Installation -- [ ] **A** `tests/smoke_test.gd` läuft ohne Fehler. -- [ ] **A** `tests/online_integration_test.gd` läuft gegen `api.void-bastion.gergames.de` ohne Fehler. -- [ ] **A** Windows-Export und Setup werden mit `packaging/windows/build.ps1` reproduzierbar erstellt. -- [ ] **A** Entwicklerwerkzeuge, Tests, Serverdateien und interne MCP-Konfigurationen sind nicht im Windows-Paket enthalten. +- [x] **A** `tests/smoke_test.gd` läuft ohne Fehler. +- [x] **A** `tests/online_integration_test.gd` läuft gegen `api.void-bastion.gergames.de` ohne Fehler. +- [x] **A** Windows-Export und Setup werden mit `packaging/windows/build.ps1` reproduzierbar erstellt. +- [x] **A** Entwicklerwerkzeuge, Tests, Serverdateien und interne MCP-Konfigurationen sind nicht im Windows-Paket enthalten. - [ ] **M** Setup installiert ohne Administratorrechte, Startmenü- und Desktop-Verknüpfung funktionieren. - [ ] **M** Deinstallation entfernt Programmdateien und Verknüpfungen vollständig. - [ ] **M** Windows-SmartScreen-Hinweis ist in den Testhinweisen genannt, solange der Installer noch nicht signiert ist. diff --git a/ONLINE_SETUP.md b/ONLINE_SETUP.md index 728c76c..57d3cfe 100644 --- a/ONLINE_SETUP.md +++ b/ONLINE_SETUP.md @@ -22,6 +22,12 @@ Die Kennungen folgen diesem Muster: `mission_map0_difficulty0`. Mögliche Spielarten sind `mission`, `endless`, `coop_ai` und `coop_online`. +## Interne Nutzungsstatistik + +Nach jeder erfolgreichen Anmeldung meldet der Client Plattform und App-Version an die Serverfunktion `record_activity`. Pro Konto wird nur ein pseudonymer Aktivitätsdatensatz mit erstem und letztem Aktivitätstag gespeichert. Daraus führt Nakama zusammengefasste Werte für registrierte und täglich aktive Spieler sowie die Verteilung nach Plattform und Version. + +Die Serverfunktion `private_stats` ist ausschließlich über den geheimen Nakama-HTTP-Schlüssel für die geschützte, nicht verlinkte Statistikseite der Homepage erreichbar. Angemeldete Spielclients können diese Werte nicht abrufen. Der geheime Schlüssel bleibt auf Unraid und im privaten Webhosting-Verzeichnis; er wird weder exportiert noch im Repository gespeichert. + Im Hauptmenü ist eine Ranglistenansicht für die aktuell gewählte Karte und Schwierigkeit eingebaut. Sie zeigt Mission und Endlosmodus getrennt, löst aktuelle öffentliche Profilnamen auf und hebt den eigenen Eintrag hervor. ## Multiplayer diff --git a/RELEASE_NOTES_0.1.0-alpha.2.md b/RELEASE_NOTES_0.1.0-alpha.2.md new file mode 100644 index 0000000..42de243 --- /dev/null +++ b/RELEASE_NOTES_0.1.0-alpha.2.md @@ -0,0 +1,19 @@ +# Void Bastion 0.1.0-alpha.2 + +Diese Testversion erweitert die Online-Grundlage und verbessert die Zuverlässigkeit des Windows-Pakets. + +## Neu + +- interne, pseudonyme Zählung registrierter und täglich aktiver Spieler +- Plattform- und Versionsverteilung für die geschützte GerGames-Statistik +- Aktivitätswerte sind nicht öffentlich und für normale Spielclients nicht abrufbar + +## Verbessert + +- Windows-Export enthält keine lokalen Entwicklungswerkzeuge mehr +- automatischer Koop-KI-Test ist unabhängig von den lokal gewählten Einstellungen +- aktualisierte Datenschutzinformation zur internen Nutzungsstatistik + +## Hinweis + +Void Bastion befindet sich weiterhin in einer frühen Alpha-Phase. Rückmeldungen und Fehlerberichte bitte an support@gergames.de senden. diff --git a/export_presets.cfg b/export_presets.cfg index b966c80..6b46012 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -24,8 +24,8 @@ application/modify_resources=true application/icon="res://assets/app_icon.png" application/console_wrapper_icon="" application/icon_interpolation=4 -application/file_version="0.1.0.0" -application/product_version="0.1.0.0" +application/file_version="0.1.0.2" +application/product_version="0.1.0.2" application/company_name="GerGames" application/product_name="Void Bastion" application/file_description="Void Bastion – Alpha-Testversion" diff --git a/online_manager.gd b/online_manager.gd index b12db13..d6b65b3 100644 --- a/online_manager.gd +++ b/online_manager.gd @@ -85,6 +85,7 @@ func _authenticate_on(host: String, port: int, scheme: String) -> bool: func _after_authentication() -> void: + await _record_activity() socket = Nakama.create_socket_from(client) _bind_socket_signals() var result = await socket.connect_async(session, true, 5) @@ -99,6 +100,16 @@ func _after_authentication() -> void: await flush_pending_scores() +func _record_activity() -> void: + if not is_authenticated(): + return + var payload := { + "platform": OS.get_name().to_lower(), + "version": str(ProjectSettings.get_setting("application/config/version", "development")) + } + await client.rpc_async(session, "record_activity", JSON.stringify(payload)) + + func _bind_socket_signals() -> void: socket.received_matchmaker_matched.connect(_on_matchmaker_matched) socket.received_match_state.connect(_on_match_state) diff --git a/packaging/windows/build.ps1 b/packaging/windows/build.ps1 index 58db919..813ff48 100644 --- a/packaging/windows/build.ps1 +++ b/packaging/windows/build.ps1 @@ -11,6 +11,7 @@ $projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path $gameExe = Join-Path $projectRoot "build\windows\Void-Bastion.exe" $installer = Join-Path $projectRoot "dist\Void-Bastion-Setup-x64.exe" $installerScript = Join-Path $PSScriptRoot "installer.nsi" +$projectFile = Join-Path $projectRoot "project.godot" foreach ($requiredFile in @($GodotExe, $MakeNsisExe, $installerScript)) { if (-not (Test-Path -LiteralPath $requiredFile -PathType Leaf)) { @@ -20,9 +21,22 @@ foreach ($requiredFile in @($GodotExe, $MakeNsisExe, $installerScript)) { New-Item -ItemType Directory -Force -Path (Split-Path $gameExe), (Split-Path $installer) | Out-Null -& $GodotExe --headless --path $projectRoot --export-release "Windows x64" $gameExe -if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $gameExe)) { - throw "Der Godot-Windows-Export ist fehlgeschlagen." +$projectConfiguration = Get-Content -LiteralPath $projectFile -Raw +$releaseConfiguration = $projectConfiguration -replace '(?m)^_MCPGameBridge=.*\r?\n?', '' +$releaseConfiguration = $releaseConfiguration -replace '"res://addons/agent_tools/plugin\.cfg",\s*', '' + +try { + # Die lokale MCP-Laufzeit ist ein Entwicklungswerkzeug und wird vom + # Exportfilter ausgeschlossen. Deshalb dürfen weder ihr Autoload-Verweis + # noch das Editor-Plugin in der veröffentlichten project.binary verbleiben. + Set-Content -LiteralPath $projectFile -Value $releaseConfiguration -NoNewline + & $GodotExe --headless --path $projectRoot --export-release "Windows x64" $gameExe + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $gameExe)) { + throw "Der Godot-Windows-Export ist fehlgeschlagen." + } +} +finally { + Set-Content -LiteralPath $projectFile -Value $projectConfiguration -NoNewline } Push-Location $PSScriptRoot diff --git a/packaging/windows/installer.nsi b/packaging/windows/installer.nsi index 205c09e..713a9c7 100644 --- a/packaging/windows/installer.nsi +++ b/packaging/windows/installer.nsi @@ -6,7 +6,7 @@ RequestExecutionLevel user !include "LogicLib.nsh" !define PRODUCT_NAME "Void Bastion" -!define PRODUCT_VERSION "0.1.0-alpha" +!define PRODUCT_VERSION "0.1.0-alpha.2" !define PRODUCT_PUBLISHER "GerGames" !define PRODUCT_WEBSITE "https://void-bastion.gergames.de/" !define PRODUCT_EXE "Void-Bastion.exe" @@ -21,7 +21,7 @@ BrandingText "GerGames" ShowInstDetails show ShowUninstDetails show -VIProductVersion "0.1.0.0" +VIProductVersion "0.1.0.2" VIAddVersionKey /LANG=1031 "ProductName" "${PRODUCT_NAME}" VIAddVersionKey /LANG=1031 "CompanyName" "${PRODUCT_PUBLISHER}" VIAddVersionKey /LANG=1031 "FileDescription" "${PRODUCT_NAME} Setup" diff --git a/project.godot b/project.godot index 142d6b0..2f37aec 100644 --- a/project.godot +++ b/project.godot @@ -15,7 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true [application] config/name="Void Bastion" -config/version="0.1.0-alpha" +config/version="0.1.0-alpha.2" run/main_scene="res://main.tscn" config/features=PackedStringArray("4.7") config/icon="res://assets/app_icon.png" diff --git a/server/nakama/README.md b/server/nakama/README.md index 73bc0e6..c7df357 100644 --- a/server/nakama/README.md +++ b/server/nakama/README.md @@ -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. diff --git a/server/nakama/runtime/build/index.js b/server/nakama/runtime/build/index.js index 345f622..3fca5fd 100644 --- a/server/nakama/runtime/build/index.js +++ b/server/nakama/runtime/build/index.js @@ -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 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" } }; }, diff --git a/tests/smoke_test.gd b/tests/smoke_test.gd index d482e20..9b96c79 100644 --- a/tests/smoke_test.gd +++ b/tests/smoke_test.gd @@ -50,6 +50,7 @@ func _run() -> void: assert(game.get("game_mode") == "multiplayer_menu", "Die Spielersuche muss über die sichtbare Schaltfläche abgebrochen werden können.") game.call("_handle_pointer", Vector2(140, 60), false) assert(game.get("game_mode") == "menu", "Der Multiplayerbereich benötigt einen funktionierenden Zurück-Button.") + game.set("selected_difficulty", 0) game.call("_handle_pointer", Vector2(1100, 730), false) game.call("_handle_pointer", Vector2(1300, 695), false) assert(game.get("game_mode") == "playing" and game.get("coop_mode") and game.get("coop_ai_mode"), "Der KI-Koop-Modus muss direkt spielbar gestartet werden können.") @@ -82,10 +83,10 @@ func _run() -> void: assert(game.get("game_mode") == "menu", "Die Bestenliste benötigt einen funktionierenden Zurück-Button.") assert(not account_manager.player_id.is_empty(), "Beim ersten Start muss automatisch ein stabiles Gastprofil angelegt werden.") assert(not account_manager.display_name.is_empty(), "Das Gastprofil benötigt einen sichtbaren Pilotennamen.") - assert(game.call("_app_version") == "0.1.0-alpha", "Profil, Installer und Online-Übertragung müssen dieselbe Alpha-Version verwenden.") + assert(game.call("_app_version") == "0.1.0-alpha.2", "Profil, Installer und Online-Übertragung müssen dieselbe Alpha-Version verwenden.") var support_url: String = game.call("_support_email_url") assert(support_url.begins_with("mailto:support@gergames.de?"), "Die Feedback-Schaltfläche muss die öffentliche Supportadresse verwenden.") - assert(support_url.contains("0.1.0-alpha") and support_url.contains(account_manager.short_id()), "Die Support-Mail muss Version und gekürzte Spieler-ID zur Fehlerzuordnung enthalten.") + assert(support_url.contains("0.1.0-alpha.2") and support_url.contains(account_manager.short_id()), "Die Support-Mail muss Version und gekürzte Spieler-ID zur Fehlerzuordnung enthalten.") account_manager.storage_enabled = false var original_display_name: String = account_manager.display_name assert(account_manager.validate_display_name("Kommandant Ägir").is_empty(), "Ein normaler internationaler Spielername muss gültig sein.")