Add own-body spectator camera and player names; record Workshop 0.0.139
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-11-03 21:16:05 +01:00
parent 4f02ad64b4
commit 828bb0a1a7
17 changed files with 1048 additions and 35 deletions
+4 -1
View File
@@ -97,7 +97,10 @@ scripts, fonts or analytics and no server-administration buttons.
- **Missions:** page through history, filter by server/outcome/exact terrain,
and open players, mods, numeric settings, coverage flags, selected snapshot JSON
and paginated raw event history. Download the selected snapshot as JSON.
- **Players:** personal escapes and combat totals, including counter coverage.
- **Players:** names alongside stable account IDs, personal escapes and combat totals, including counter coverage.
Names use the most recent nonblank name in completed gameplay snapshots within
the selected server scope; older queued reports and blank names do not replace it.
Identical names do not merge accounts, and renames do not split their statistics.
Coverage counts are not counts of missions with friendly-fire incidents.
Selecting a server scopes Overview, Missions and Players. The Servers tab returns
@@ -132,9 +132,9 @@ export function dashboardApp() {
return stack(number(p[key]), `${number(p[coverage])}/${number(p.attempts)} missions reported this counter`);
}
function renderPlayers(data) {
const top = toolbar('Player records', 'Authenticated participants in completed gameplay missions. Names are available in individual mission reports.');
const rows = data.players.map(p => [el('span', p.playerId, 'mono'), number(p.attempts), stack(number(p.escapes), `${number(p.didNotEscape)} did not escape`), duration(p.bestEscapeSeconds), counter(p, 'kills', 'missionsWithKills'), counter(p, 'friendlyKills', 'missionsWithFriendlyKills'), counter(p, 'deaths', 'missionsWithDeaths'), counter(p, 'downed', 'missionsWithDowned'), counter(p, 'recoveries', 'missionsWithRecoveries'), `${number(p.partialMissions)} partial / ${number(p.truncatedMissions)} truncated`]);
$('content').replaceChildren(top, table(['Player ID', 'Attempts', 'Escapes', 'Best escape', 'Kills', 'Friendly kills', 'Deaths', 'Downed', 'Recoveries', 'Coverage'], rows), el('p', 'Counter coverage means the field was present, not that a kill or injury happened. A dash means no recorded value.', 'small muted'), pager(data.nextAfter));
const top = toolbar('Player records', 'Authenticated participants in completed gameplay missions. Most recent recorded names; IDs remain the stable identity.');
const rows = data.players.map(p => [stack(p.name || 'Unknown player', p.playerId, true), number(p.attempts), stack(number(p.escapes), `${number(p.didNotEscape)} did not escape`), duration(p.bestEscapeSeconds), counter(p, 'kills', 'missionsWithKills'), counter(p, 'friendlyKills', 'missionsWithFriendlyKills'), counter(p, 'deaths', 'missionsWithDeaths'), counter(p, 'downed', 'missionsWithDowned'), counter(p, 'recoveries', 'missionsWithRecoveries'), `${number(p.partialMissions)} partial / ${number(p.truncatedMissions)} truncated`]);
$('content').replaceChildren(top, table(['Player / ID', 'Attempts', 'Escapes', 'Best escape', 'Kills', 'Friendly kills', 'Deaths', 'Downed', 'Recoveries', 'Coverage'], rows), el('p', 'Counter coverage means the field was present, not that a kill or injury happened. A dash means no recorded value.', 'small muted'), pager(data.nextAfter));
}
async function refresh() {
controller?.abort(); const run = new AbortController(); controller = run; $('refresh').disabled = true;
+10 -4
View File
@@ -226,9 +226,15 @@ async function players(request, env, url) {
const after = url.searchParams.get('after');
if (after !== null) id(after);
const conditions = [], values = [];
if (serverId) { conditions.push('server_id = ?'); values.push(serverId); }
if (after) { conditions.push('player_id > ?'); values.push(after.toLowerCase()); }
const rows = await env.DB.prepare(`SELECT player_id AS playerId, COUNT(*) AS attempts,
if (serverId) { conditions.push('f.server_id = ?'); values.push(serverId); }
if (after) { conditions.push('f.player_id > ?'); values.push(after.toLowerCase()); }
const rows = await env.DB.prepare(`WITH ranked AS (
SELECT f.*, ROW_NUMBER() OVER (PARTITION BY f.player_id
ORDER BY (trim(f.name) != '') DESC, m.occurred_at DESC, m.id DESC) AS nameRank
FROM final_players f JOIN missions m ON m.server_id = f.server_id AND m.mission_id = f.mission_id
${conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''}
) SELECT player_id AS playerId,
MAX(CASE WHEN nameRank = 1 THEN NULLIF(trim(name), '') END) AS name, COUNT(*) AS attempts,
SUM(escaped) AS escapes, SUM(1 - escaped) AS didNotEscape,
SUM(partial) AS partialMissions, SUM(players_truncated) AS truncatedMissions,
MIN(CASE WHEN escaped = 1 THEN elapsed_seconds END) AS bestEscapeSeconds,
@@ -237,7 +243,7 @@ async function players(request, env, url) {
SUM(deaths) AS deaths, COUNT(deaths) AS missionsWithDeaths,
SUM(downed) AS downed, COUNT(downed) AS missionsWithDowned,
SUM(recoveries) AS recoveries, COUNT(recoveries) AS missionsWithRecoveries
FROM final_players ${conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''}
FROM ranked
GROUP BY player_id ORDER BY player_id LIMIT ?`).bind(...values, limit).all();
return json({players: rows.results, nextAfter: rows.results.length === limit ? rows.results.at(-1).playerId : null});
}
@@ -44,7 +44,7 @@ function fixture() {
else if(path.includes('/events?'))data={events:[{receivedAt:1793361000,event}],nextBefore:null};
else if(path.startsWith(`/api/missions/${sid}/`))data={receivedAt:1793361000,event};
else if(path.startsWith('/api/missions?'))data={missions:[{...event,receivedAt:1793361000}],nextBefore:null};
else if(path.startsWith('/api/players'))data={players:[{playerId:sid,attempts:1,escapes:1,didNotEscape:0,bestEscapeSeconds:120,kills:2,missionsWithKills:1,friendlyKills:0,missionsWithFriendlyKills:1}],nextAfter:null};
else if(path.startsWith('/api/players'))data={players:[{playerId:sid,name:malicious,attempts:1,escapes:1,didNotEscape:0,bestEscapeSeconds:120,kills:2,missionsWithKills:1,friendlyKills:0,missionsWithFriendlyKills:1}],nextAfter:null};
else throw new Error('Unexpected path '+path);
return{ok:true,status:200,json:async()=>data};
};
@@ -63,7 +63,7 @@ test('client login, scope, pagination, mission details, safe text and logout',as
await f.click(f.button(f.ids.content,f.malicious));assert.ok(f.requests.some(r=>r.path.startsWith('/api/missions?')&&r.path.includes(`serverId=${f.sid}`)));
await f.click(f.button(f.ids.content,'Everon'));assert.equal(f.ids['mission-dialog'].open,true);assert.match(f.ids['mission-body'].textContent,/Mission settings/);assert.match(f.ids['mission-body'].textContent,/Event history/);assert.ok(f.ids['mission-body'].textContent.includes(f.malicious));
await f.click(f.ids['close-dialog']);assert.equal(f.ids['mission-dialog'].open,false);
await f.tab('players');assert.match(f.ids.content.textContent,/1\/1 missions reported this counter/);
await f.tab('players');assert.ok(f.ids.content.textContent.includes(f.malicious));assert.ok(f.ids.content.textContent.includes(f.sid));assert.equal(descendants(f.ids.content).some(x=>x.tagName==='IMG'),false);assert.match(f.ids.content.textContent,/1\/1 missions reported this counter/);
await f.click(f.ids.logout);assert.equal(f.ids.app.hidden,true);assert.equal(f.ids.content.textContent,'');assert.equal(f.ids['mission-body'].textContent,'');
for(const request of f.requests)assert.ok(!request.path.includes('token'));
});
@@ -398,3 +398,36 @@ test('automatic server names arrive later, follow renames, and survive blanks, r
assert.equal((await ingest(event({serverId: managed, serverName: 'Game name', players: []}), registered.token)).status, 201);
assert.equal((await db.prepare('SELECT name FROM servers WHERE server_id = ?').bind(managed).first()).name, 'Operator label');
});
test('player names follow newest nonblank completed reports without merging IDs or changing counters', async () => {
const sid = crypto.randomUUID(), second = crypto.randomUUID();
const secret = 'player-names-test-credential-0123456789';
const pid = crypto.randomUUID(), otherPid = crypto.randomUUID();
async function named(serverId, playerId, name, occurredAt, extra = {}) {
const e = event({serverId, occurredAt, ...extra});
e.players = [{...e.players[0], id: playerId, name, escaped: e.outcome === 'victory'}];
assert.equal((await ingest(e, secret)).status, 201);
}
await named(sid, pid, 'Old name', 100);
await named(sid, pid, ' Current name ', 300);
await named(sid, pid, 'Queued old name', 200);
await named(sid, pid, ' ', 400);
await named(sid, otherPid, 'Current name', 350);
await named(sid, pid, 'Live-only name', 600, {type:'heartbeat',outcome:'',phase:1});
const scoped = await (await call(`/api/players?serverId=${sid}`, {headers: readAuth})).json();
assert.equal(scoped.players.length, 2);
const player = scoped.players.find(p => p.playerId === pid);
assert.equal(player.name, 'Current name'); assert.equal(player.attempts, 4); assert.equal(player.kills, 8);
assert.equal(scoped.players.find(p => p.playerId === otherPid).attempts, 1);
await named(second, pid, '<img src=x onerror=alert(1)>', 500);
const global = await (await call('/api/players?limit=100', {headers: readAuth})).json();
assert.equal(global.players.find(p => p.playerId === pid).name, '<img src=x onerror=alert(1)>');
const stillScoped = await (await call(`/api/players?serverId=${sid}`, {headers: readAuth})).json();
assert.equal(stillScoped.players.find(p => p.playerId === pid).name, 'Current name');
const first = await (await call(`/api/players?serverId=${sid}&limit=1`, {headers: readAuth})).json();
const next = await (await call(`/api/players?serverId=${sid}&limit=1&after=${first.nextAfter}`, {headers: readAuth})).json();
assert.notEqual(first.players[0].playerId, next.players[0].playerId);
const blankId = crypto.randomUUID(); await named(second, blankId, '', 700);
const blanks = await (await call(`/api/players?serverId=${second}`, {headers: readAuth})).json();
assert.equal(blanks.players.find(p => p.playerId === blankId).name, null);
});