Add own-body spectator camera and player names; record Workshop 0.0.139
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user