From 1294000348c1b0907d56eaea88933a1d27e19214 Mon Sep 17 00:00:00 2001 From: Dominic Date: Sun, 11 Oct 2026 22:02:51 +0200 Subject: [PATCH] Track real player sessions and tower builds --- account_manager.gd | 31 +++++++ main.gd | 3 + online_manager.gd | 41 +++++++++ packaging/windows/installer.nsi | 4 +- project.godot | 2 +- server/nakama/runtime/build/index.js | 105 +++++++++++++++++++++++- server/nakama/tests/runtime_sim_test.js | 33 ++++++++ tests/online_integration_test.gd | 2 +- 8 files changed, 213 insertions(+), 8 deletions(-) diff --git a/account_manager.gd b/account_manager.gd index af3374f..9c9af73 100644 --- a/account_manager.gd +++ b/account_manager.gd @@ -14,6 +14,8 @@ var primary_provider := "guest" var linked_identities: Array = [] var highscores: Dictionary = {} var pending_submissions: Array = [] +var pending_player_events: Array = [] +var next_player_event_sequence := 0 var created_at := 0 var storage_enabled := true @@ -34,6 +36,8 @@ func _load_or_create_profile() -> void: linked_identities = data.get("linked_identities", []) highscores = data.get("highscores", {}) pending_submissions = data.get("pending_submissions", []) + pending_player_events = data.get("pending_player_events", []) + next_player_event_sequence = int(data.get("next_player_event_sequence", 0)) created_at = int(data.get("created_at", 0)) if player_id.is_empty(): _create_guest_profile() @@ -50,6 +54,8 @@ func _create_guest_profile() -> void: linked_identities = [] highscores = {} pending_submissions = [] + pending_player_events = [] + next_player_event_sequence = 0 created_at = int(Time.get_unix_time_from_system()) @@ -66,6 +72,8 @@ func _save_profile() -> void: "linked_identities": linked_identities, "highscores": highscores, "pending_submissions": pending_submissions, + "pending_player_events": pending_player_events, + "next_player_event_sequence": next_player_event_sequence, "created_at": created_at })) @@ -91,6 +99,29 @@ func record_run(map_index: int, difficulty_index: int, endless: bool, reached_wa return true +func queue_player_event(event_name: String, tower_type := -1) -> void: + if event_name != "play_start" and event_name != "tower_build": + return + next_player_event_sequence += 1 + pending_player_events.append({ + "event": event_name, + "tower_type": tower_type, + "source": player_id, + "sequence": next_player_event_sequence, + "at": int(Time.get_unix_time_from_system() * 1000.0) + }) + _save_profile() + + +func mark_player_event_complete(sequence: int) -> void: + for index in range(pending_player_events.size() - 1, -1, -1): + var event = pending_player_events[index] + if event is Dictionary and int(event.get("sequence", -1)) == sequence: + pending_player_events.remove_at(index) + _save_profile() + return + + func attach_online_identity(online_user_id: String, online_username: String) -> void: if online_user_id.is_empty(): return diff --git a/main.gd b/main.gd index d11d185..7709873 100644 --- a/main.gd +++ b/main.gd @@ -1645,6 +1645,7 @@ func _toggle_coop_side() -> void: func _start_coop_ai_game() -> void: online_manager.set_presence_status("playing") + online_manager.record_local_play_start() _reset_developer_access() coop_mode = true coop_ai_mode = true @@ -1849,6 +1850,7 @@ func _refresh_leaderboard() -> void: func _start_game() -> void: online_manager.set_presence_status("playing") + online_manager.record_local_play_start() _reset_developer_access() coop_online_mode = false credits = int(_difficulty().start_credits) @@ -2149,6 +2151,7 @@ func _place_tower(position: Vector2) -> void: if not is_admin_tower: _spend_credits(0, int(data.cost)) towers_built_this_run += 1 + online_manager.record_local_tower_build(selected_tower_type) towers.append({"pos": position, "type": selected_tower_type, "level": 1, "cooldown": 0.15, "angle": 0.0, "owner": 0, "target_priority": "shield" if selected_tower_type == 1 else "first"}) selected_tower_index = towers.size() - 1 selected_tower_type = -1 diff --git a/online_manager.gd b/online_manager.gd index 3c484f5..083c3ba 100644 --- a/online_manager.gd +++ b/online_manager.gd @@ -73,6 +73,8 @@ var pending_snapshot_payloads: Array[String] = [] var friends: Array[Dictionary] = [] var friend_statuses: Dictionary = {} var presence_status := "" +var player_event_flush_busy := false +var player_event_flush_again := false func _ready() -> void: @@ -349,6 +351,7 @@ func _after_authentication() -> void: var parent_game = get_parent() await set_presence_status("playing" if parent_game != null and parent_game.game_mode in ["playing", "paused", "victory", "game_over", "endless_offer", "endless_result"] else "menu") authenticated.emit(session.user_id, session.username) + await flush_pending_player_events() await flush_pending_scores() @@ -362,6 +365,44 @@ func _record_activity() -> void: await client.rpc_async(session, "record_activity", JSON.stringify(payload)) +func record_local_play_start() -> void: + _queue_player_event("play_start") + + +func record_local_tower_build(tower_type: int) -> void: + if tower_type < 0 or tower_type >= 7: + return + _queue_player_event("tower_build", tower_type) + + +func _queue_player_event(event_name: String, tower_type := -1) -> void: + if not account_manager: + return + account_manager.queue_player_event(event_name, tower_type) + if is_authenticated(): + flush_pending_player_events() + + +func flush_pending_player_events() -> void: + if player_event_flush_busy: + player_event_flush_again = true + return + if not account_manager or not is_authenticated(): + return + player_event_flush_busy = true + while is_authenticated() and not account_manager.pending_player_events.is_empty(): + var event: Dictionary = account_manager.pending_player_events[0] + var result = await client.rpc_async(session, "record_player_event", JSON.stringify(event)) + var parsed := _parse_rpc_result(result) + if not bool(parsed.get("accepted", false)): + break + account_manager.mark_player_event_complete(int(event.get("sequence", -1))) + player_event_flush_busy = false + if player_event_flush_again: + player_event_flush_again = false + call_deferred("flush_pending_player_events") + + 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/installer.nsi b/packaging/windows/installer.nsi index 9fd98e7..45b3d1a 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.15" +!define PRODUCT_VERSION "0.1.0-alpha.16" !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.15" +VIProductVersion "0.1.0.16" 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 cb63998..0550ae6 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.15" +config/version="0.1.0-alpha.16" run/main_scene="res://main.tscn" config/features=PackedStringArray("4.7") config/icon="res://assets/app_icon.png" diff --git a/server/nakama/runtime/build/index.js b/server/nakama/runtime/build/index.js index f8bf4d0..885a7f1 100644 --- a/server/nakama/runtime/build/index.js +++ b/server/nakama/runtime/build/index.js @@ -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)) || + !/^[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 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 }], diff --git a/tests/online_integration_test.gd b/tests/online_integration_test.gd index 11cd761..d734a78 100644 --- a/tests/online_integration_test.gd +++ b/tests/online_integration_test.gd @@ -32,7 +32,7 @@ func _run() -> void: assert(not info.is_exception(), "Die Godot-Anbindung muss Server-RPCs erreichen.") var info_data = JSON.parse_string(info.payload) assert(info_data is Dictionary and info_data.get("status") == "online", "Der Server muss seinen Onlinestatus melden.") - assert(info_data.get("version") == "0.25", "Der Server muss die aktuelle Runtime 0.25 geladen haben: %s" % info_data) + assert(info_data.get("version") == "0.27", "Der Server muss die aktuelle Runtime 0.27 geladen haben: %s" % info_data) var deletion_client: NakamaClient = nakama.create_client("voidbastion-public-v1", test_host, test_port, test_scheme, 5, NakamaLogger.LOG_LEVEL.ERROR) var deletion_session: NakamaSession = await deletion_client.authenticate_device_async("voidbastion_delete_test_" + suffix, "delete_test_" + suffix, true)