Report cumulative player scores alongside balances

This commit is contained in:
2026-10-12 13:57:36 -04:00
parent 158916b664
commit b508b6739b
6 changed files with 121 additions and 18 deletions
+8
View File
@@ -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;
+23 -8
View File
@@ -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.
+4 -1
View File
@@ -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 != "[")
+12 -1
View File
@@ -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.
+4 -1
View File
@@ -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 != "[")
+70 -7
View File
@@ -31,12 +31,15 @@ function functions(source) {
let body = protectedSource.slice(headers.lastIndex, end - 1);
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(x => 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']) {