diff --git a/README.md b/README.md index 7807166..455cc0b 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,14 @@ then deploy the corresponding scripts. The new `telemetryNewMatch`, `telemetryLoad`, `telemetryStore`, `telemetryRemove` and `telemetryResponse` functions are required even if telemetry is disabled by dvar. +Stat reports now include both `score` (spendable balance) and `score_total` +(stock cumulative match score) for each player. Deploy Electric Cherry's +`score_total` receiver support before these scripts; older receivers discard +the extra field. No additional plugin update is needed for this field. +The first stock `end_game` signal is captured without yielding, before +intermission replaces the balance with the total. `game_start` remains an +identity-only roster. + Every captured report includes a `match` key (random native process token plus an increasing match counter, at most 64 characters) and a positive `seq`. `game_start` is sequence 1. Retries and replay send the exact original JSON; diff --git a/t5/scripts/README.md b/t5/scripts/README.md index e49ced5..9f9bc28 100644 --- a/t5/scripts/README.md +++ b/t5/scripts/README.md @@ -336,11 +336,23 @@ The reporter emits exactly four event types: All four events also include `match` and `seq` (see delivery below). Each player in round/heartbeat/end reports has `name`, `guid`, `kills`, `headshots`, -`downs`, `revives`, and `score`. Names and GUIDs remain strings; GUIDs never +`downs`, `revives`, `score`, and `score_total`. Names and GUIDs remain strings; GUIDs never pass through numeric conversion. Round and stats are nonnegative integers, with missing or negative stat values reported as zero. The four counters come from T5's `player.stats`; `score` is the player's current spendable -`player.score`, rather than cumulative points earned. `map` is `mapname`. +`player.score`. `score_total` reads the stock `player.score_total` cumulative +match score used by the final scoreboard; spending points does not reduce it. +It follows stock scoring rules, including grants excluded from the total. +Missing or negative totals report as zero, independently of the balance. +`game_start` includes only player identity. `map` is `mapname`. + +Stock `end_game` yields before `player_intermission` replaces `player.score` +with `player.score_total`. The reporter captures both immediately at the first +end signal without yielding; retries and replay retain that exact snapshot. +Custom scripts that overwrite the score before emitting any supported end +signal must preserve this ordering to retain a spendable final balance. +Deploy receiver support for `score_total` before the reporters. Old payloads +remain valid; the receiver treats their missing totals as unknown, not zero. Every report includes everyone returned by `getPlayers()`, including spectators, downed players, and AFK players. Empty rosters are sent as `[]`. @@ -353,9 +365,9 @@ use `jsonSerialize` for escaping. ```json {"event":"game_start","match":"process-token-1","seq":1,"server":"t5_zombie_prototype_1","map":"zombie_cod5_prototype","players":[{"name":"Josh","guid":"12345678901234567"}]} -{"event":"round_change","match":"process-token-1","seq":2,"server":"t5_zombie_prototype_1","map":"zombie_cod5_prototype","round":17,"players":[{"name":"Josh","guid":"12345678901234567","kills":420,"headshots":160,"downs":1,"revives":3,"score":32000}]} -{"event":"heartbeat","match":"process-token-1","seq":3,"server":"t5_zombie_prototype_1","map":"zombie_cod5_prototype","round":17,"players":[{"name":"Josh","guid":"12345678901234567","kills":430,"headshots":165,"downs":1,"revives":3,"score":32500}]} -{"event":"game_end","match":"process-token-1","seq":4,"server":"t5_zombie_prototype_1","map":"zombie_cod5_prototype","round":18,"players":[{"name":"Josh","guid":"12345678901234567","kills":445,"headshots":170,"downs":2,"revives":3,"score":33500}]} +{"event":"round_change","match":"process-token-1","seq":2,"server":"t5_zombie_prototype_1","map":"zombie_cod5_prototype","round":17,"players":[{"name":"Josh","guid":"12345678901234567","kills":420,"headshots":160,"downs":1,"revives":3,"score":32000,"score_total":62000}]} +{"event":"heartbeat","match":"process-token-1","seq":3,"server":"t5_zombie_prototype_1","map":"zombie_cod5_prototype","round":17,"players":[{"name":"Josh","guid":"12345678901234567","kills":430,"headshots":165,"downs":1,"revives":3,"score":32500,"score_total":62500}]} +{"event":"game_end","match":"process-token-1","seq":4,"server":"t5_zombie_prototype_1","map":"zombie_cod5_prototype","round":18,"players":[{"name":"Josh","guid":"12345678901234567","kills":445,"headshots":170,"downs":2,"revives":3,"score":33500,"score_total":65000}]} ``` Events are captured immediately into an in-memory queue. One HTTP worker @@ -439,11 +451,14 @@ and [score tracking](https://github.com/plutoniummod/t5-scripts/blob/main/ZM/Com 1. Start a match and verify one `game_start`, then an initial `round_change` and one per new round. Check the exact server name, map, string GUIDs, - all player identities, and all five stats against the game. + all player identities, and all six stats against the game. Earn points, then + spend some: the balance should decrease while the total stays unchanged. 2. Join again in the same round, go AFK, become downed, and spectate. Verify the next report includes the full roster without another start event. -3. End a match and verify one `game_end` with final stats after all round - reports, including if more than one stock end notification fires. Check +3. End a match and verify one `game_end` with both scores captured before the + scoreboard overwrite; compare `score_total` to the final scoreboard. Verify + the final stats arrive after all round reports, including if more than one + stock end notification fires. Check a round-0 intro and an empty roster, including last-player disconnect. 4. Delay HTTP completion while rounds advance and the match ends. Verify requests remain ordered and retain the stats captured at each event. diff --git a/t5/scripts/tws_t5_telemetry.gsc b/t5/scripts/tws_t5_telemetry.gsc index 216e287..49ce850 100644 --- a/t5/scripts/tws_t5_telemetry.gsc +++ b/t5/scripts/tws_t5_telemetry.gsc @@ -158,6 +158,8 @@ telemetry_report_round() telemetry_game_end_watcher() { // One waiter consumes the first end signal, even if a map fires all three. + // Stock end_game yields before player_intermission replaces score with + // score_total. Serialize here without waiting so score stays spendable. level waittill_any("end_game", "game_ended", "intermission"); telemetry_log(1, "Game-end signal received; stopping producers and draining queue"); @@ -227,8 +229,9 @@ telemetry_players_json(include_stats) data["headshots"] = telemetry_stat(player, "headshots"); data["downs"] = telemetry_stat(player, "downs"); data["revives"] = telemetry_stat(player, "revives"); - // Current spendable points, not the lifetime points-earned stat. + // Stock match total is independent of the spendable balance. data["score"] = telemetry_nonnegative_int(player.score); + data["score_total"] = telemetry_nonnegative_int(player.score_total); } if (body != "[") diff --git a/t6/scripts/README.md b/t6/scripts/README.md index c0df416..623c318 100644 --- a/t6/scripts/README.md +++ b/t6/scripts/README.md @@ -131,11 +131,22 @@ It sends four event types over the life of a match, mirroring the T5 script: Each event payload includes `event`, `server`, `map`, `match`, `seq`, `round` (except `game_start`) and a `players` array with `name`, `guid`, `kills`, `headshots`, `downs`, `revives` -and `score` per player. `guid` reads Plutonium's `player.guid` engine property +and `score` plus `score_total` per player in stat reports. `game_start` includes +only player identity. `guid` reads Plutonium's `player.guid` engine property (T5 uses the `player getGuid()` method — T6 has no such method, but the field is equivalent for identity purposes). `kills`/`headshots`/`downs`/`revives` read from T6's stock match counters in `player.pers[]`; missing or negative values are reported as `0`. `score` is the player's current spendable `player.score`. +`score_total` reads `player.score_total`, the stock cumulative match score used +by the final scoreboard. Spending does not reduce it; stock grants can opt out +of adding to the total. Missing or negative totals report as `0`, independently +of the balance. Deploy Electric Cherry's receiver support before these scripts; +older payloads without totals remain valid and their totals are unknown. + +Stock `end_game` yields before `player_intermission` replaces the balance with +the total. The reporter serializes both at the first end signal without yielding, +so retries and replay keep the original balance and total. Custom end sequences +must emit a supported end signal before overwriting the balance. Delivery uses one in-flight request and a FIFO queue. Every match receives a native-generated key; `seq` starts at 1 for `game_start` and increases at capture. diff --git a/t6/scripts/tws_t6_telemetry.gsc b/t6/scripts/tws_t6_telemetry.gsc index 2a4da06..54d0ee2 100644 --- a/t6/scripts/tws_t6_telemetry.gsc +++ b/t6/scripts/tws_t6_telemetry.gsc @@ -165,6 +165,8 @@ telemetry_report_round() telemetry_game_end_watcher() { // One waiter consumes the first end signal, even if a map fires all three. + // Stock end_game yields before player_intermission replaces score with + // score_total. Serialize here without waiting so score stays spendable. level waittill_any("end_game", "game_ended", "intermission"); telemetry_log(1, "Game-end signal received; stopping producers and draining queue"); @@ -240,8 +242,9 @@ telemetry_players_json(include_stats) data["headshots"] = telemetry_stat(player, "headshots"); data["downs"] = telemetry_stat(player, "downs"); data["revives"] = telemetry_stat(player, "revives"); - // Current spendable points, not the lifetime points-earned stat. + // Stock match total is independent of the spendable balance. data["score"] = telemetry_nonnegative_int(player.score); + data["score_total"] = telemetry_nonnegative_int(player.score_total); } if (body != "[") diff --git a/tools/check_telemetry.mjs b/tools/check_telemetry.mjs index 4862554..e67ad58 100644 --- a/tools/check_telemetry.mjs +++ b/tools/check_telemetry.mjs @@ -31,12 +31,15 @@ function functions(source) { let body = protectedSource.slice(headers.lastIndex, end - 1); const locals = new Set([...body.matchAll(/(? x[1])); body = body.replace(/\.size\b/g, '.length').replace(/http::request/g, 'httpPost'); + body = body.replace(/player getGuid\(\)/g, 'getGuid(player)'); body = body.replace(/level thread \w+\(\);/g, '').replace(/level notify\(/g, 'notify('); + body = body.replace(/level waittill_any\(([^;]+)\);/g, 'yield [$1];'); body = body.replace(/level waittill\(([^;]+)\);/g, 'yield $1;'); body = body.replace(/wait \(([^;]+)\);/g, 'yield $1;'); body = body.replace(/request waittill\([^;]+\);/g, 'var [response, success, error, diagnostics] = httpComplete(request);'); body = body.replace(/__literal_(\d+)__/g, (_, index) => literals[Number(index)]); - found.set(match[1], `function${match[1] === 'telemetry_sender' ? '*' : ''} ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`); + const generator = ['telemetry_sender', 'telemetry_game_end_watcher'].includes(match[1]); + found.set(match[1], `function${generator ? '*' : ''} ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`); } return found; } @@ -46,11 +49,11 @@ function disk() { } let nextMatch = 0; -function engine(game, storage = disk()) { +function engine(game, storage = disk(), players = []) { const source = readFileSync(new URL(`${game}/scripts/tws_${game}_telemetry.gsc`, root), 'utf8'); const all = functions(source); - const excluded = new Set(['telemetry_players_json', 'telemetry_stat', 'telemetry_heartbeat_watcher', - 'telemetry_round_watcher', 'telemetry_game_end_watcher', 'telemetry_watchdog', 'telemetry_log']); + const excluded = new Set(['telemetry_heartbeat_watcher', + 'telemetry_round_watcher', 'telemetry_watchdog', 'telemetry_log']); const sent = [], responses = [], logs = [], notifications = []; const queue = storage.entries.map(e => ({ ...e, stored: true, captured: 1000 })); const c = vm.createContext({ @@ -63,10 +66,14 @@ function engine(game, storage = disk()) { getTime: () => 1000, isDefined: value => value !== undefined && value !== null, int: Math.trunc, - jsonSerialize: JSON.stringify, + // GSC associative arrays serialize as objects in the native plugin. + jsonSerialize: value => JSON.stringify(Array.isArray(value) && + Object.keys(value).some(key => !/^\d+$/.test(key)) + ? Object.fromEntries(Object.entries(value)) : value), getSubStr: (value, start, end) => value.slice(start, end), strTok: (value, delimiter) => value.split(delimiter), - telemetry_players_json: () => '[]', + getPlayers: () => players, + getGuid: player => player.guid, telemetry_log: (verbosity, text) => logs.push(text), notify: event => notifications.push(event), telemetryNewMatch: () => `boot-${++nextMatch}`, @@ -110,6 +117,62 @@ function engine(game, storage = disk()) { } for (const game of ['t5', 't6']) { + const player = () => ({ name: 'Josh', guid: '12345678901234567', score: 32000, score_total: 62000, + stats: { kills: 420, headshots: 160, downs: 1, revives: 0 }, + pers: { kills: 420, headshots: 160, downs: 1, revives: 0, score: 32000 } }); + + check(`${game}: reports balance and match total separately, with an identity-only start`, () => { + const players = [player()]; + const { c } = engine(game, disk(), players); + c.telemetry_game_start(); + assert.deepEqual(JSON.parse(c.level.telemetry_queue[0].body).players, + [{ name: 'Josh', guid: '12345678901234567' }]); + c.level.round_number = 17; + c.telemetry_report_round(); + const initial = JSON.parse(c.level.telemetry_queue[1].body).players[0]; + assert.deepEqual(initial, { name: 'Josh', guid: '12345678901234567', kills: 420, + headshots: 160, downs: 1, revives: 0, score: 32000, score_total: 62000 }); + // Earn 1,000 and spend 5,000 between snapshots. + players[0].score = 28000; + players[0].score_total = 63000; + c.telemetry_enqueue('heartbeat'); + const later = JSON.parse(c.level.telemetry_queue[2].body).players[0]; + assert.equal(later.score, 28000); + assert.equal(later.score_total, 63000); + for (const total of [undefined, -10, 0, 65000.9]) { + players[0].score_total = total; + const stats = JSON.parse(c.telemetry_players_json(true))[0]; + assert.equal(stats.score_total, total > 0 ? 65000 : 0); + assert.equal(stats.score, 28000); + } + }); + + check(`${game}: end captures before scoreboard overwrite and replays both scores unchanged`, () => { + const players = [player()]; + const first = engine(game, disk(), players); + first.c.telemetry_game_start(); + first.responses.push({}); + first.c.telemetry_sender().next(); + first.c.level.round_number = 18; + const watcher = first.c.telemetry_game_end_watcher(); + assert.deepEqual(Array.from(watcher.next().value), ['end_game', 'game_ended', 'intermission']); + // Resuming at stock end_game must finish capture without another yield. + assert.equal(watcher.next().done, true); + players[0].score = players[0].score_total; + const saved = first.storage.entries[0].body; + const final = JSON.parse(saved); + assert.equal(final.event, 'game_end'); + assert.equal(final.players[0].score, 32000); + assert.equal(final.players[0].score_total, 62000); + // A fresh VM/process cannot re-read the player; replay the saved JSON. + const replay = engine(game, first.storage); + replay.responses.push({ status: 503 }, {}); + const sender = replay.c.telemetry_sender(); + assert.equal(sender.next().value, 1); + assert.equal(sender.next().value, 'telemetry_queued'); + assert.deepEqual(replay.sent.map(request => request.body), [saved, saved]); + }); + check(`${game}: match identity, sequence and capture before send`, () => { const { c, storage, sent, responses } = engine(game); c.telemetry_game_start(); c.telemetry_game_start(); @@ -272,7 +335,7 @@ for (const game of ['t5', 't6']) { check('paired queue and delivery implementations match', () => { const t5 = engine('t5').all, t6 = engine('t6').all; - for (const name of ['telemetry_enqueue', 'telemetry_queue_push', 'telemetry_critical', 'telemetry_persist_pending', + for (const name of ['telemetry_game_end_watcher', 'telemetry_enqueue', 'telemetry_queue_push', 'telemetry_critical', 'telemetry_persist_pending', 'telemetry_sender', 'telemetry_process', 'telemetry_retry_delay', 'telemetry_send', 'telemetry_response_outcome']) assert.equal(t5.get(name), t6.get(name), name); for (const file of ['telemetry.hpp', 'telemetry.cpp']) {