@homie-rocks/studio 0.48.4 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/app-starters/gathering/README.md +4 -0
- package/app-starters/gathering/app.json +162 -0
- package/app-starters/gathering/index.html +3 -0
- package/app-starters/gathering/map/main.json +1 -0
- package/app-starters/gathering/src/move.ts +3 -0
- package/app-starters/gathering/src/rules.ts +21 -0
- package/app-starters/gathering/src/view.ts +74 -0
- package/app-starters/giving/README.md +5 -0
- package/app-starters/giving/app.json +162 -0
- package/app-starters/giving/index.html +3 -0
- package/app-starters/giving/map/main.json +1 -0
- package/app-starters/giving/src/move.ts +3 -0
- package/app-starters/giving/src/rules.ts +21 -0
- package/app-starters/giving/src/view.ts +74 -0
- package/app-starters/jobs/README.md +4 -0
- package/app-starters/jobs/app.json +162 -0
- package/app-starters/jobs/index.html +3 -0
- package/app-starters/jobs/map/main.json +1 -0
- package/app-starters/jobs/src/move.ts +3 -0
- package/app-starters/jobs/src/rules.ts +21 -0
- package/app-starters/jobs/src/view.ts +74 -0
- package/app-starters/quiz/README.md +4 -0
- package/app-starters/quiz/app.json +162 -0
- package/app-starters/quiz/index.html +3 -0
- package/app-starters/quiz/map/main.json +1 -0
- package/app-starters/quiz/src/move.ts +3 -0
- package/app-starters/quiz/src/rules.ts +21 -0
- package/app-starters/quiz/src/view.ts +74 -0
- package/app-starters/welcome/app.json +25 -2
- package/app-starters/welcome/index.html +1 -1
- package/app-starters/welcome/src/view.ts +20 -8
- package/bin/homie-studio.mjs +20 -16
- package/functions/FUNCTIONS.md +1 -1
- package/guides/app/SKILL.md +178 -0
- package/guides/art/SKILL.md +154 -0
- package/guides/art/references/RIGHTS.md +20 -0
- package/guides/art/scripts/art.mjs +308 -0
- package/guides/models/SKILL.md +198 -0
- package/guides/models/references/RIGHTS.md +46 -0
- package/guides/models/references/models.json +175 -0
- package/guides/models/scripts/models.mjs +483 -0
- package/guides/music/SKILL.md +197 -0
- package/guides/music/references/COMPOSITION.md +59 -0
- package/guides/music/references/GAME-AUDIO.md +50 -0
- package/guides/music/references/RIGHTS.md +43 -0
- package/guides/music/scripts/lib/audio.mjs +258 -0
- package/guides/music/scripts/lib/eleven.mjs +260 -0
- package/guides/music/scripts/lib/studio.mjs +147 -0
- package/guides/music/scripts/music.mjs +644 -0
- package/guides/parts/SKILL.md +158 -0
- package/guides/plan/SKILL.md +126 -0
- package/guides/plan/references/CODEX.md +105 -0
- package/guides/plan/references/INTERVIEW.md +86 -0
- package/guides/playtest/SKILL.md +205 -0
- package/guides/playtest/references/METHOD.md +124 -0
- package/guides/playtest/references/REVIEWER.md +78 -0
- package/guides/playtest/scripts/lib/exit.mjs +31 -0
- package/guides/playtest/scripts/lib/judge.mjs +502 -0
- package/guides/playtest/scripts/lib/pixels.mjs +128 -0
- package/guides/playtest/scripts/lib/preflight.mjs +69 -0
- package/guides/playtest/scripts/playtest.mjs +870 -0
- package/guides/shop/SKILL.md +153 -0
- package/guides/sound/SKILL.md +156 -0
- package/guides/sound/references/GAME-SOUND.md +100 -0
- package/guides/sound/references/SCORE.md +131 -0
- package/guides/sound/references/TRAPS.md +93 -0
- package/guides/sound/scripts/lib/measure.mjs +252 -0
- package/guides/sound/scripts/lib/score.mjs +415 -0
- package/guides/sound/scripts/lib/sfx.mjs +222 -0
- package/guides/sound/scripts/lib/synth.mjs +509 -0
- package/guides/sound/scripts/sound.mjs +520 -0
- package/guides/studio-setup/SKILL.md +419 -0
- package/guides/style/SKILL.md +105 -0
- package/lib/app-needs.mjs +63 -0
- package/lib/authoring-guides.mjs +5 -4
- package/lib/build.mjs +5 -4
- package/lib/check-site.mjs +6 -6
- package/lib/check.mjs +18 -5
- package/lib/codex.mjs +95 -18
- package/lib/decisions.mjs +9 -7
- package/lib/dev.mjs +4 -2
- package/lib/doctor.mjs +1 -1
- package/lib/handoff.mjs +13 -8
- package/lib/mcp-tools.mjs +19 -17
- package/lib/mcp.mjs +1 -1
- package/lib/parts.mjs +6 -6
- package/lib/scaffold.mjs +45 -11
- package/lib/shop-links.mjs +2 -2
- package/lib/shop.mjs +5 -3
- package/lib/studio.mjs +7 -6
- package/lib/template-history.json +84 -0
- package/package.json +4 -4
- package/shop/SHOP.md +4 -0
- package/shop/shop.ts +4 -4
- package/worker/app-format.mjs +1 -1
- package/worker/build-id.mjs +1 -1
- package/worker/functions-schema.mjs +15 -0
- package/worker/index.mjs +2 -1
- package/worker/parts.mjs +4 -4
- package/worker/shop-page.mjs +22 -16
- package/worker/shop-rules.mjs +4 -3
- package/worker/shop.mjs +6 -2
- package/worker/site.mjs +4 -2
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a playtest's readings mean. Everything here is plain arithmetic on what the browsers reported: no browser, no
|
|
3
|
+
* clock, no file. The script (playtest.mjs) gathers; this judges; a test feeds it readings whose answers are known.
|
|
4
|
+
*
|
|
5
|
+
* The rule every function keeps: "could not measure" and "measured, and fine" never come out the same. A row whose
|
|
6
|
+
* precondition did not hold (the round was on its results screen, the body was not the player's to steer, the loading
|
|
7
|
+
* cover was still up) is BLOCKED or N/A with the state it was in, never PASS and never FAIL; and one scripted run is
|
|
8
|
+
* reported as one run, never as what always happens.
|
|
9
|
+
*
|
|
10
|
+
* WHERE THE GAME'S STATE COMES FROM (all optional; a reading that is missing stays null and is said to be unknown):
|
|
11
|
+
*
|
|
12
|
+
* the port probe, `exposePort(net, { ... })` → window.__homiePort in the game's frame
|
|
13
|
+
* info().round { n, phase: 'live' | 'over', leftMs } the round and the time left
|
|
14
|
+
* rows()[..][8] (from `busy: () => boolean`) the body is not the player's to steer right now
|
|
15
|
+
* rows()[..][1..2] where the body is
|
|
16
|
+
* info().extra.alive boolean the local body is alive (false: spectating until the next round)
|
|
17
|
+
* info().extra.mode string the control mode on screen ("manual fire", "auto", "driving")
|
|
18
|
+
* info().extra.loadout string what the player holds (a weapon, a tool): it changes which controls show
|
|
19
|
+
* info().extra.touchHeld boolean a thumb is down on the game's stick
|
|
20
|
+
* info().extra.drawCalls / .triangles numbers the renderer's last frame (three.js: renderer.info.render)
|
|
21
|
+
* the play page's shell → window.__shell
|
|
22
|
+
* round { n, phase, endsAt } arrival { phase ('done' = the loading cover lifted), liftedMs, by }
|
|
23
|
+
* link { state } where the browser stands with its room, as the helper told the page
|
|
24
|
+
* rects { width, height, rects: [{ id, x, y, w, h, fades? }] } where the page's own controls sit over the game
|
|
25
|
+
* the netplay helper → window.__homieNet in the game's frame (revision 9)
|
|
26
|
+
* link 'connecting' | 'online' | 'reconnecting' | 'alone' | 'offline' | 'closed'
|
|
27
|
+
* reconnects how often the link was interrupted
|
|
28
|
+
* stale the revision of a newer build that is live (null: this tab runs the live build)
|
|
29
|
+
*
|
|
30
|
+
* A game sets the `extra` ones in its own exposePort call: `extra: { alive: () => me.hp > 0, mode: () => hud.mode }`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const num = (v) => (Number.isFinite(v) ? v : null);
|
|
34
|
+
const str = (v) => (typeof v === 'string' && v ? v.slice(0, 60) : typeof v === 'number' && Number.isFinite(v) ? String(v) : null);
|
|
35
|
+
const bool = (v) => (v === true || v === false ? v : null);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The `extra` names read by name from the port probe: the same list as @homie-rocks/studio's port/probe.ts
|
|
39
|
+
* (`PortExtra`, `PORT_EXTRA_NAMES`), where a game's author finds them typed and documented. A test holds the two
|
|
40
|
+
* lists equal and runs this reader against the real probe.
|
|
41
|
+
*/
|
|
42
|
+
export const EXTRA_NAMES = ['alive', 'mode', 'loadout', 'touchHeld', 'drawCalls', 'triangles'];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What the port probe says right now. It runs INSIDE the game's frame (the script hands it to the browser), so it
|
|
46
|
+
* uses nothing but its argument and `window`: the round, the named extras, and the newest row's busy flag and place.
|
|
47
|
+
*/
|
|
48
|
+
export function readPort(names) {
|
|
49
|
+
const p = window.__homiePort; if (!p) return null;
|
|
50
|
+
let i = null; try { i = p.info(); } catch { /* a probe that throws reports nothing */ }
|
|
51
|
+
const r = p.rows(p.now() - 400); const last = r.length ? r[r.length - 1] : null;
|
|
52
|
+
const extra = {};
|
|
53
|
+
for (const k of names) { const v = i?.extra?.[k]; if (['boolean', 'number', 'string'].includes(typeof v)) extra[k] = v; }
|
|
54
|
+
return { round: i?.round ?? null, extra, frames: i?.frames ?? null, busy: last ? last[8] : null, x: last ? last[1] : null, y: last ? last[2] : null };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/*
|
|
58
|
+
* THE LINK. A browser that is `reconnecting`, `alone` (the room never answered: it hosts by itself), `offline` or
|
|
59
|
+
* `closed` is not in its room. What is sampled there is not the game as two people play it: the body may be frozen
|
|
60
|
+
* between snapshots, the other players gone, the scores this browser's alone. Such a reading is labelled `cut-off`
|
|
61
|
+
* and never judged as active play. The same six words and the same four "cut off" ones as the studio's own check
|
|
62
|
+
* (lib/check.mjs LINK_STATES / CUT_OFF), held equal by a test.
|
|
63
|
+
*/
|
|
64
|
+
export const LINK_STATES = ['connecting', 'online', 'reconnecting', 'alone', 'offline', 'closed'];
|
|
65
|
+
export const CUT_OFF = ['reconnecting', 'alone', 'offline', 'closed'];
|
|
66
|
+
/** The link's state from the helper's word or the shell's { state } (the shell's `link` is the invite address, a string, until the first change), or null. */
|
|
67
|
+
export function linkOf(v) {
|
|
68
|
+
const state = typeof v === 'string' ? v : v && typeof v === 'object' ? v.state : null;
|
|
69
|
+
return LINK_STATES.includes(state) ? state : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* One reading of the game's state, from what the probe and the shell said at one moment. `raw`:
|
|
74
|
+
* { at, port: { round, busy, x, y, extra, frames } | null, shell: { round, arrival, seat, link } | null,
|
|
75
|
+
* net: { link, reconnects } | null } (`net`: the helper's own word, read in the game's frame; it wins over the shell's).
|
|
76
|
+
*/
|
|
77
|
+
export function stateOf(raw) {
|
|
78
|
+
const p = raw?.port ?? null; const s = raw?.shell ?? null;
|
|
79
|
+
const pr = p?.round ?? null; const sr = s?.round ?? null;
|
|
80
|
+
const phase = pr?.phase === 'live' || pr?.phase === 'over' ? pr.phase : sr?.phase === 'live' || sr?.phase === 'over' ? sr.phase : null;
|
|
81
|
+
// The probe's leftMs is on the room's own clock. The shell only has the end on the server's clock: without the
|
|
82
|
+
// probe the time left is not known (this computer's clock is not the room's), so it stays null rather than a guess.
|
|
83
|
+
const ex = p?.extra && typeof p.extra === 'object' ? p.extra : {};
|
|
84
|
+
const arrival = s?.arrival ?? null;
|
|
85
|
+
return {
|
|
86
|
+
at: num(raw?.at),
|
|
87
|
+
phase,
|
|
88
|
+
round: num(pr?.n) ?? num(sr?.n),
|
|
89
|
+
leftMs: pr ? num(pr.leftMs) : null,
|
|
90
|
+
busy: p && (p.busy === 0 || p.busy === 1 || typeof p.busy === 'boolean') ? Boolean(p.busy) : null,
|
|
91
|
+
alive: bool(ex.alive),
|
|
92
|
+
pose: p && Number.isFinite(p.x) && Number.isFinite(p.y) ? { x: +p.x.toFixed(2), y: +p.y.toFixed(2) } : null,
|
|
93
|
+
mode: str(ex.mode), loadout: str(ex.loadout), touchHeld: bool(ex.touchHeld),
|
|
94
|
+
cover: arrival ? (arrival.phase === 'done' ? 'lifted' : 'up') : null,
|
|
95
|
+
link: linkOf(raw?.net?.link) ?? linkOf(s?.link),
|
|
96
|
+
// Said beside the state, never judged: how often the link was interrupted so far, and that a newer build of
|
|
97
|
+
// the game is live than the one this tab runs (it loads again at the round's break).
|
|
98
|
+
reconnects: num(raw?.net?.reconnects), stale: raw?.net?.stale ? String(raw.net.stale).slice(0, 40) : s?.stale ? String(s.stale.ver ?? 'a newer build').slice(0, 40) : null,
|
|
99
|
+
source: pr ? 'probe' : sr ? 'shell' : null,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What kind of moment a reading is, for anything that needs active play:
|
|
105
|
+
* cut-off the browser is not in its room (the helper's link: reconnecting, alone, offline, closed)
|
|
106
|
+
* loading the arrival cover is still over the game
|
|
107
|
+
* results the round is over (its results card, the break before the next)
|
|
108
|
+
* spectating the game says the local body is dead
|
|
109
|
+
* busy the body is not the player's to steer (knocked back, stunned, respawning; or dead in a game
|
|
110
|
+
* that reports only `busy`)
|
|
111
|
+
* live a live round and nothing above
|
|
112
|
+
* unknown the game exposes no round: nothing can be gated on it, and the row says so
|
|
113
|
+
*/
|
|
114
|
+
export function contextOf(state) {
|
|
115
|
+
if (!state) return { kind: 'unknown', why: 'no reading of the game' };
|
|
116
|
+
if (CUT_OFF.includes(state.link)) return { kind: 'cut-off', why: `the browser was not in its room (link: ${state.link}${state.link === 'alone' ? ', playing by itself because the room never answered' : state.link === 'reconnecting' ? ', its connection lost and knocking again' : ''}): this is not the game as people play it together` };
|
|
117
|
+
if (state.cover === 'up') return { kind: 'loading', why: 'the loading cover was still over the game' };
|
|
118
|
+
if (state.phase === 'over') return { kind: 'results', why: `round ${state.round ?? '?'} was over (its results and the break before the next one)` };
|
|
119
|
+
if (state.alive === false) return { kind: 'spectating', why: 'the local body was dead (spectating until the next round)' };
|
|
120
|
+
if (state.busy === true) return { kind: 'busy', why: 'the body was not the player\'s to steer (knocked back, stunned, dead or respawning: the probe\'s `busy`)' };
|
|
121
|
+
if (state.phase === 'live') return { kind: 'live', why: `round ${state.round ?? '?'} was live` };
|
|
122
|
+
return { kind: 'unknown', why: 'the game exposes no round phase (no port probe, no round on the shell)' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A reading in a few words, for a report line: "round 3 live, 31 s left, body free at (878, 120), mode manual". */
|
|
126
|
+
export function describeState(state) {
|
|
127
|
+
if (!state) return 'state not read';
|
|
128
|
+
const bits = [];
|
|
129
|
+
bits.push(state.phase ? `round ${state.round ?? '?'} ${state.phase === 'over' ? 'over (results)' : 'live'}` : 'round phase unknown');
|
|
130
|
+
if (state.leftMs !== null) bits.push(`${Math.max(0, Math.round(state.leftMs / 1000))} s left`);
|
|
131
|
+
bits.push(state.alive === false ? 'body dead' : state.busy === true ? 'body busy' : state.busy === false ? `body free${state.pose ? ` at (${state.pose.x}, ${state.pose.y})` : ''}` : 'body state unknown');
|
|
132
|
+
if (state.mode) bits.push(`mode ${state.mode}`);
|
|
133
|
+
if (state.loadout) bits.push(`loadout ${state.loadout}`);
|
|
134
|
+
if (state.touchHeld !== null) bits.push(state.touchHeld ? 'thumb held' : 'thumb up');
|
|
135
|
+
if (state.cover === 'up') bits.push('loading cover up');
|
|
136
|
+
if (CUT_OFF.includes(state.link)) bits.push(`CUT OFF from its room (link ${state.link})`);
|
|
137
|
+
if (state.reconnects > 0) bits.push(`${state.reconnects} reconnect${state.reconnects === 1 ? '' : 's'} so far`);
|
|
138
|
+
if (state.stale) bits.push(`this tab runs an older build (${state.stale} is live)`);
|
|
139
|
+
return bits.join(', ');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** May a row that needs active play be judged at this reading? true, false, or null when the game does not say. */
|
|
143
|
+
export const activePlay = (state) => { const k = contextOf(state).kind; return k === 'live' ? true : k === 'unknown' ? null : false; };
|
|
144
|
+
|
|
145
|
+
/* ---------------------------------------------------------------- the first seconds */
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* WHEN WAS THERE GAMEPLAY ON THE SCREEN. `samples`, in time order: { ms, seated, picture, cover, heartbeat } where
|
|
149
|
+
* `picture` is "this screenshot is not black and not one colour", `cover` is 'up' | 'lifted' | null (a page with no
|
|
150
|
+
* arrival state) and `heartbeat` is "the game's own frame counter moved since the last sample".
|
|
151
|
+
*
|
|
152
|
+
* Four separate times, because they are four different things:
|
|
153
|
+
* seatedMs the shell had a room, a seat and a role
|
|
154
|
+
* firstPaintMs the page showed anything that is a picture. The loading card is a picture: this is NOT the game.
|
|
155
|
+
* coverLiftedMs the loading cover was gone
|
|
156
|
+
* firstGameplayMs a picture, with the cover gone and the game's frames advancing: the first meaningful gameplay
|
|
157
|
+
* `readiness` says how sure the last one is: 'arrival' (the page reported its cover lifting), or 'unverified' (the
|
|
158
|
+
* page has no arrival state, so a loading screen the game draws itself cannot be told from play).
|
|
159
|
+
*/
|
|
160
|
+
export function readinessOf(samples) {
|
|
161
|
+
const first = (f) => { const s = samples.find(f); return s ? s.ms : null; };
|
|
162
|
+
const known = samples.some((s) => s.cover === 'up' || s.cover === 'lifted');
|
|
163
|
+
const gameplay = samples.find((s) => s.picture && s.heartbeat && (known ? s.cover === 'lifted' : true)) ?? null;
|
|
164
|
+
return {
|
|
165
|
+
seatedMs: first((s) => s.seated),
|
|
166
|
+
firstPaintMs: first((s) => s.picture),
|
|
167
|
+
coverLiftedMs: known ? first((s) => s.cover === 'lifted') : null,
|
|
168
|
+
heartbeatMs: first((s) => s.heartbeat),
|
|
169
|
+
firstGameplayMs: gameplay ? gameplay.ms : null,
|
|
170
|
+
readiness: known ? 'arrival' : 'unverified',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The `first <device>` row from those times. `budgetMs` is how long the watch lasted (30 s). */
|
|
175
|
+
export function judgeFirst(r, { budgetMs = 30_000, slowMs = 10_000 } = {}) {
|
|
176
|
+
const s = (ms) => `${(ms / 1000).toFixed(1)} s`;
|
|
177
|
+
const why = r.seatedMs === null ? `never got a seat in ${s(budgetMs)}`
|
|
178
|
+
: r.firstPaintMs === null ? `no real picture (black or one colour) in ${s(budgetMs)}`
|
|
179
|
+
: r.readiness === 'arrival' && r.coverLiftedMs === null ? `the loading cover never lifted in ${s(budgetMs)}: the page painted at ${s(r.firstPaintMs)} but that picture was the loading card, not the game`
|
|
180
|
+
: r.heartbeatMs === null ? `the game's frame never drew a frame in ${s(budgetMs)} (its frame counter did not move)`
|
|
181
|
+
: r.firstGameplayMs === null ? `no picture of the game itself in ${s(budgetMs)} (the cover lifted${r.coverLiftedMs !== null ? ` at ${s(r.coverLiftedMs)}` : ''}, and every frame after it was black or one colour)`
|
|
182
|
+
: r.seatedMs > slowMs ? `seated only after ${s(r.seatedMs)}`
|
|
183
|
+
: r.firstGameplayMs > slowMs ? `the first picture of the game came at ${s(r.firstGameplayMs)} (the page first painted at ${s(r.firstPaintMs)}: that was the loading card)`
|
|
184
|
+
: undefined;
|
|
185
|
+
const note = r.readiness === 'unverified' && r.firstGameplayMs !== null ? 'this page reports no arrival state, so "first gameplay" is the first picture with the game\'s frames advancing: a loading screen the game draws itself would count. Look at the 1 s / 3 s / 5 s frames.' : undefined;
|
|
186
|
+
return { verdict: why ? 'FAIL' : 'PASS', why, note };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* ---------------------------------------------------------------- the first move */
|
|
190
|
+
|
|
191
|
+
const OPPOSITE = { right: 'left', left: 'right', up: 'down', down: 'up' };
|
|
192
|
+
export const oppositeOf = (dir) => OPPOSITE[dir] ?? 'left';
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* THE FIRST MOVE. `attempts`, in order: { dir, moved, ms, before, after } with `before`/`after` the game's state at
|
|
196
|
+
* the press and when the watch ended (stateOf readings). `waited`: what the wait for a live round came to
|
|
197
|
+
* ({ ok, ms, state }) when no press was made at all.
|
|
198
|
+
*
|
|
199
|
+
* PASS the first press moved the body in time
|
|
200
|
+
* WARN the first way moved nothing and the opposite way did: what a wall on that side looks like. Both are
|
|
201
|
+
* reported; it is not called broken controls, and it is not proof the first way works either.
|
|
202
|
+
* FAIL the body answered too slowly, or neither way moved it in a live round with a free body
|
|
203
|
+
* BLOCKED no press was made wholly inside a live round with a free body (a round break, a dead body, the cover):
|
|
204
|
+
* the latency of active play was not measured
|
|
205
|
+
*/
|
|
206
|
+
export function judgeMove(attempts, { maxMs = 1500, waited = null, holdMs = 3000 } = {}) {
|
|
207
|
+
const at = (a) => (a.before?.pose ? ` from (${a.before.pose.x}, ${a.before.pose.y})` : '');
|
|
208
|
+
const line = (a) => `${a.dir}${at(a)}: ${a.moved ? `moved in ${a.ms} ms` : `no movement in ${Math.round(holdMs / 1000)} s`} [${describeState(a.before)}]`;
|
|
209
|
+
const detail = { attempts: attempts.map((a) => ({ dir: a.dir, moved: Boolean(a.moved), ms: a.ms ?? null, at: a.before?.pose ?? null, before: describeState(a.before), after: describeState(a.after) })) };
|
|
210
|
+
if (!attempts.length) {
|
|
211
|
+
return { verdict: 'BLOCKED', controlMs: null, why: `no press was made: ${waited?.state ? `${contextOf(waited.state).why} for the whole ${Math.round((waited.ms ?? 0) / 1000)} s wait` : 'the game never reached a state to press in'}. First-move latency in active play was not measured (the result screen and a spectator are not movement tests)`, ...detail };
|
|
212
|
+
}
|
|
213
|
+
const clean = (a) => activePlay(a.before) !== false && activePlay(a.after) !== false;
|
|
214
|
+
const hit = attempts.find((a) => a.moved);
|
|
215
|
+
if (hit) {
|
|
216
|
+
const slow = hit.ms > maxMs;
|
|
217
|
+
if (hit === attempts[0]) return { verdict: slow ? 'FAIL' : 'PASS', controlMs: hit.ms, why: slow ? `the body answered a press after ${hit.ms} ms (${line(hit)})` : undefined, ...detail };
|
|
218
|
+
const firstTry = attempts[0];
|
|
219
|
+
return {
|
|
220
|
+
verdict: slow ? 'FAIL' : 'WARN', controlMs: hit.ms,
|
|
221
|
+
why: `${slow ? `the body answered after ${hit.ms} ms. ` : ''}pressing ${firstTry.dir}${at(firstTry)} moved nothing, pressing ${hit.dir} moved the body in ${hit.ms} ms: that is what a wall on the ${firstTry.dir} looks like, so this is not called unresponsive controls. It does not prove ${firstTry.dir} works either: the controls row presses every way`,
|
|
222
|
+
...detail,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const dirty = attempts.filter((a) => !clean(a));
|
|
226
|
+
if (dirty.length) {
|
|
227
|
+
return { verdict: 'BLOCKED', controlMs: null, why: `no press stayed inside a live round with a free body, so first-move latency was not measured: ${attempts.map(line).join('; ')}${dirty[0].after && contextOf(dirty[0].after).kind !== contextOf(dirty[0].before).kind ? `; by the end of the press ${contextOf(dirty[0].after).why}` : ''}`, ...detail };
|
|
228
|
+
}
|
|
229
|
+
const unknown = attempts.every((a) => activePlay(a.before) === null);
|
|
230
|
+
return { verdict: 'FAIL', controlMs: null, why: `no press moved the body: ${attempts.map(line).join('; ')}${unknown ? '. The game exposes no round phase, so a round break cannot be ruled out' : ''}`, ...detail };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/* ---------------------------------------------------------------- UI coverage */
|
|
234
|
+
|
|
235
|
+
/** The limits of the UI row, said beside its result wherever a person reads it (the report, the row). */
|
|
236
|
+
export const UI_CAVEAT = 'DOM coverage only: the game\'s canvas is hidden for this measurement, so a HUD, labels or hints drawn inside the canvas were NOT checked for coverage or overlap. A pass here does not show a clear play area in a canvas game: look at the pictures.';
|
|
237
|
+
|
|
238
|
+
/** Why two isolation frames are not a pair (the game changed state between them), or null when they are. */
|
|
239
|
+
export function pairMismatch(a, b) {
|
|
240
|
+
if (!a || !b) return null;
|
|
241
|
+
const diff = [];
|
|
242
|
+
for (const [k, label] of [['phase', 'round phase'], ['round', 'round'], ['alive', 'alive'], ['busy', 'busy'], ['mode', 'control mode'], ['loadout', 'loadout'], ['cover', 'loading cover'], ['link', 'link']]) {
|
|
243
|
+
if (a[k] !== null && b[k] !== null && a[k] !== b[k]) diff.push(`${label} ${a[k]} → ${b[k]}`);
|
|
244
|
+
}
|
|
245
|
+
return diff.length ? diff.join(', ') : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* THE UI ROW. `cov` { cover, opaque, centreOpaque } from the two isolation frames; `before`/`after` the game's state
|
|
250
|
+
* at the black and at the white frame; `thumb` 'held' | 'up' | 'none' (what the instrument's own thumb was doing).
|
|
251
|
+
*
|
|
252
|
+
* BLOCKED the two frames are not the same moment (the round ended between them): nothing is counted
|
|
253
|
+
* N/A the frames show a results screen, a spectator or the loading cover: measured and labelled, and never
|
|
254
|
+
* judged against the active-play bar
|
|
255
|
+
* PASS/FAIL active play (or a game that exposes no state, said so): the 12% bar and the clear middle
|
|
256
|
+
* WARN it would pass, and a HUD element sits under one of the play page's own controls (`overlaps`)
|
|
257
|
+
*/
|
|
258
|
+
export function judgeUi(cov, { before = null, after = null, thumb = 'none', bar = 0.12, centreBar = 0.02, overlaps = null } = {}) {
|
|
259
|
+
const mismatch = pairMismatch(before, after);
|
|
260
|
+
const state = describeState(before);
|
|
261
|
+
if (mismatch) return { verdict: 'BLOCKED', screen: 'mixed', why: `the game changed between the black frame and the white frame (${mismatch}), so they are not one screen and nothing was counted. Both frames are saved`, state };
|
|
262
|
+
const ctx = contextOf(before);
|
|
263
|
+
const pct = (v) => `${Math.round(v * 100)}%`;
|
|
264
|
+
if (!['live', 'unknown'].includes(ctx.kind)) {
|
|
265
|
+
const screen = { results: 'results screen', spectating: 'spectator view', busy: 'body not steerable', loading: 'loading cover', 'cut-off': 'screen of a browser cut off from its room' }[ctx.kind];
|
|
266
|
+
return { verdict: 'N/A', screen, why: `this is the ${screen}, not active play (${ctx.why}): its DOM UI covers ${pct(cov.cover)} of the screen and ${pct(cov.centreOpaque)} of the middle third, recorded and not judged against the ${pct(bar)} active-play bar. Active-play coverage was not measured in this run`, state };
|
|
267
|
+
}
|
|
268
|
+
const why = cov.cover > bar ? `the DOM UI covers ${pct(cov.cover)} of the screen during play (the bar is ${pct(bar)})` : cov.centreOpaque > centreBar ? `something opaque covers ${pct(cov.centreOpaque)} of the middle third` : undefined;
|
|
269
|
+
const unsure = ctx.kind === 'unknown' ? 'the game exposes no round phase, so this may be a results screen rather than active play: check the saved frames' : undefined;
|
|
270
|
+
const held = thumb === 'up' ? 'measured with the thumb lifted: a held stick can add visible UI' : undefined;
|
|
271
|
+
// The page's own controls over the HUD (shellOverlaps): a HUD element under one that does not fade is a WARN on a
|
|
272
|
+
// row that would otherwise pass (measured, worth a look: the page's control is drawn on top of it). Under one
|
|
273
|
+
// that fades it is a note. A page that reports no layout is said to be unchecked, never clear.
|
|
274
|
+
const under = describeOverlaps(overlaps);
|
|
275
|
+
const hides = Boolean(overlaps?.hidden?.length);
|
|
276
|
+
const unchecked = overlaps && !overlaps.known ? 'the play page reported no layout of its own controls (window.__shell.rects), so the HUD was not checked against them' : undefined;
|
|
277
|
+
return { verdict: why ? 'FAIL' : hides ? 'WARN' : 'PASS', screen: ctx.kind === 'live' ? 'active play' : 'state unknown', why: why ?? (hides ? under : undefined), qualifier: [unsure, held, unchecked, (why || !hides) && under ? under : undefined].filter(Boolean).join('; ') || undefined, state };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* ---------------------------------------------------------------- the page's controls over the game's HUD */
|
|
281
|
+
|
|
282
|
+
/** The page's controls that stay over the game while it is played (a sheet, the results card and a vote are moments, not layout). */
|
|
283
|
+
const STANDING = new Set(['room', 'server', 'chat', 'chip', 'join', 'banner', 'ticker']);
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* A GAME'S HUD UNDER THE PLAY PAGE'S OWN CONTROLS. The page draws its controls over the game's frame (the room
|
|
287
|
+
* button, the server and chat pills, the "3 playing" chip, the banner). A HUD check that only measures inside the
|
|
288
|
+
* frame cannot see them, so a score or a button can sit right under the room button and pass every check.
|
|
289
|
+
*
|
|
290
|
+
* `layout` the page's `window.__shell.rects` (or the helper's `net.shell`): { width, height, rects: [{ id, x, y, w, h, fades? }] }
|
|
291
|
+
* in the frame's own CSS pixels (the frame fills the page)
|
|
292
|
+
* `hud` the game's visible DOM HUD elements, in the same pixels: [{ label, x, y, w, h, interactive? }]
|
|
293
|
+
*
|
|
294
|
+
* Returns { known, overlaps: [{ hud, shell, fades, interactive, px, share }], hidden: overlaps under a control that
|
|
295
|
+
* does not fade }. `share` is how much of the HUD element is covered. A sliver (under 4 px either way) is not an
|
|
296
|
+
* overlap. `known: false` when the page said no layout (an older shell): not checked, and said so. A HUD drawn
|
|
297
|
+
* inside the canvas has no DOM element and is not seen here.
|
|
298
|
+
*/
|
|
299
|
+
export function shellOverlaps(layout, hud, { minPx = 4 } = {}) {
|
|
300
|
+
const rects = Array.isArray(layout?.rects) ? layout.rects.filter((r) => r && STANDING.has(r.id) && r.w > 0 && r.h > 0) : null;
|
|
301
|
+
if (!rects) return { known: false, overlaps: [], hidden: [] };
|
|
302
|
+
const overlaps = [];
|
|
303
|
+
for (const e of Array.isArray(hud) ? hud : []) {
|
|
304
|
+
if (!(e?.w > 0 && e?.h > 0)) continue;
|
|
305
|
+
for (const r of rects) {
|
|
306
|
+
const w = Math.min(e.x + e.w, r.x + r.w) - Math.max(e.x, r.x);
|
|
307
|
+
const h = Math.min(e.y + e.h, r.y + r.h) - Math.max(e.y, r.y);
|
|
308
|
+
if (w < minPx || h < minPx) continue;
|
|
309
|
+
overlaps.push({ hud: String(e.label ?? 'element').slice(0, 80), shell: r.id, fades: r.fades === true, interactive: e.interactive === true, px: Math.round(w * h), share: +((w * h) / (e.w * e.h)).toFixed(2) });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
overlaps.sort((a, b) => Number(a.fades) - Number(b.fades) || b.px - a.px);
|
|
313
|
+
return { known: true, overlaps, hidden: overlaps.filter((o) => !o.fades) };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Those overlaps in a sentence for a row, or undefined when there are none. */
|
|
317
|
+
export function describeOverlaps(o) {
|
|
318
|
+
if (!o?.known || !o.overlaps.length) return undefined;
|
|
319
|
+
const one = (x) => `${x.hud} is under the page's ${x.shell} control (${Math.round(x.share * 100)}% of it covered${x.fades ? '; that control fades after a few seconds' : ''}${x.interactive ? '; it is something to press' : ''})`;
|
|
320
|
+
return `the game's HUD sits under the play page's own controls: ${o.overlaps.slice(0, 4).map(one).join('; ')}${o.overlaps.length > 4 ? `; and ${o.overlaps.length - 4} more` : ''}. Lay the HUD out around them (net.shell / on('shell') says where they are, per device and orientation) or move the page's controls (game.json screen.share, screen.chat)`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/* ---------------------------------------------------------------- the primary action */
|
|
324
|
+
|
|
325
|
+
const KEY = /^[A-Za-z0-9]{1,24}$/;
|
|
326
|
+
const frac = (v) => Number.isFinite(v) && v >= 0 && v <= 1;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* THE GAME'S PRIMARY ACTION, as game.json declares it (a shooter fires with the mouse; a default of "space, or a
|
|
330
|
+
* tap at a fixed spot" never presses it):
|
|
331
|
+
*
|
|
332
|
+
* "playtest": { "primary": { "label": "fire",
|
|
333
|
+
* "computer": { "key": "KeyF" } a KeyboardEvent.code
|
|
334
|
+
* | { "mouse": "left", "at": [0.5, 0.5] } a mouse button, at a fraction of the screen (default the middle)
|
|
335
|
+
* "phone": { "selector": "[data-action=fire]" } the game's own control, found in the game's frame
|
|
336
|
+
* | { "region": [0.7, 0.7, 0.25, 0.2] } x, y, w, h as fractions of the screen; the tap lands in its middle
|
|
337
|
+
* } }
|
|
338
|
+
*
|
|
339
|
+
* Returns { declared, kind: 'key' | 'mouse' | 'selector' | 'region', label, how, ... }. Undeclared (or malformed, with
|
|
340
|
+
* `problem`): the old guess, marked `declared: false` so a score conclusion can say the action was a guess.
|
|
341
|
+
*/
|
|
342
|
+
export function actionProfile(gameJson, device) {
|
|
343
|
+
const touch = device !== 'desk' && device !== 'computer';
|
|
344
|
+
const fallback = touch
|
|
345
|
+
? { declared: false, kind: 'region', label: 'action', region: [0.82, 0.78, 0, 0], how: 'a tap at 82% across, 78% down (a guess: game.json declares no playtest.primary)' }
|
|
346
|
+
: { declared: false, kind: 'key', label: 'action', key: 'Space', how: 'the space bar (a guess: game.json declares no playtest.primary)' };
|
|
347
|
+
const p = gameJson?.playtest?.primary;
|
|
348
|
+
if (!p || typeof p !== 'object') return fallback;
|
|
349
|
+
const label = str(p.label) ?? 'primary action';
|
|
350
|
+
const d = touch ? p.phone : p.computer;
|
|
351
|
+
if (!d || typeof d !== 'object') return { ...fallback, how: `${fallback.how.replace(/ \(a guess.*$/, '')} (a guess: playtest.primary has no "${touch ? 'phone' : 'computer'}")` };
|
|
352
|
+
if (!touch && typeof d.key === 'string' && KEY.test(d.key)) return { declared: true, kind: 'key', label, key: d.key, how: `${label}: the ${d.key} key` };
|
|
353
|
+
if (!touch && ['left', 'right', 'middle'].includes(d.mouse)) {
|
|
354
|
+
const at = Array.isArray(d.at) && d.at.length === 2 && d.at.every(frac) ? d.at : [0.5, 0.5];
|
|
355
|
+
return { declared: true, kind: 'mouse', label, button: d.mouse, at, how: `${label}: the ${d.mouse} mouse button at ${Math.round(at[0] * 100)}% across, ${Math.round(at[1] * 100)}% down` };
|
|
356
|
+
}
|
|
357
|
+
if (touch && typeof d.selector === 'string' && d.selector.length <= 200 && d.selector.trim()) return { declared: true, kind: 'selector', label, selector: d.selector.trim(), how: `${label}: a tap on the game's control ${d.selector.trim()}` };
|
|
358
|
+
if (touch && Array.isArray(d.region) && d.region.length === 4 && d.region.every(frac)) return { declared: true, kind: 'region', label, region: d.region, how: `${label}: a tap in the declared region (${d.region.map((v) => Math.round(v * 100)).join('%, ')}%)` };
|
|
359
|
+
return { ...fallback, problem: `game.json playtest.primary.${touch ? 'phone' : 'computer'} is not one of ${touch ? '{ "selector": "<css>" } or { "region": [x, y, w, h] }' : '{ "key": "<KeyboardEvent.code>" } or { "mouse": "left" | "right" | "middle" }'}` };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** What the script really pressed, for the report: "moves 31; fire (the left mouse button …) 9 pressed, 2 missed". */
|
|
363
|
+
export function describeActions(a) {
|
|
364
|
+
if (!a) return 'not recorded';
|
|
365
|
+
const p = a.primary ?? {};
|
|
366
|
+
return `direction holds ${a.moves ?? 0}; ${p.how ?? 'primary action'}: ${p.pressed ?? 0} pressed${p.missed ? `, ${p.missed} missed (the control was not on screen)` : ''}`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/* ---------------------------------------------------------------- the round */
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* WHAT A PLAYED ROUND'S SCORES SAY. `me` the row of the browser that played, `them` the one that did nothing,
|
|
373
|
+
* `rows` every result row, `scoring` game.json's ("together": one shared total on every row), `actions` what the
|
|
374
|
+
* script exercised ({ moves, primary: { declared, how, pressed, missed } }).
|
|
375
|
+
*
|
|
376
|
+
* Returns { notes, comparison }. A cooperative game's rows carry one shared total, so comparing them as rivals is
|
|
377
|
+
* `comparison: 'not applicable'`. Nothing here claims more than one scripted round can show.
|
|
378
|
+
*/
|
|
379
|
+
export function judgeScores({ me, them, rows = [], scoring = null, planned = null, roundSeconds = null, actions = null }) {
|
|
380
|
+
const notes = [];
|
|
381
|
+
const together = scoring === 'together';
|
|
382
|
+
const primary = actions?.primary ?? null;
|
|
383
|
+
const exercised = Boolean(primary?.declared && primary.pressed > 0);
|
|
384
|
+
// What the script did NOT do, said with any conclusion drawn from the scores.
|
|
385
|
+
const scope = !primary ? ''
|
|
386
|
+
: !primary.declared ? ` The script only held directions and pressed ${primary.how}; game.json declares no primary action (playtest.primary), so if the game scores through another input (a mouse button, a fire control) it was never used, and this is not evidence that input does not matter`
|
|
387
|
+
: !exercised ? ` The declared primary action (${primary.how}) was never pressed in this round${primary.missed ? ` (${primary.missed} tries found no control on screen)` : ''}, so this is not evidence that input does not matter`
|
|
388
|
+
: '';
|
|
389
|
+
let comparison;
|
|
390
|
+
if (together) {
|
|
391
|
+
comparison = 'not applicable: game.json says "scoring": "together", so every row carries the room\'s one shared total and an idle player level with an active one is how the game scores. Whether input matters to a cooperative game needs each player\'s contribution, which this row does not measure';
|
|
392
|
+
const total = Math.max(-Infinity, ...rows.map((x) => x.score).filter(Number.isFinite));
|
|
393
|
+
if (Number.isFinite(total) && total === 0) notes.push(`the room's shared total was 0 after a whole round with one person playing.${scope}`);
|
|
394
|
+
} else {
|
|
395
|
+
comparison = 'the player who played against the one who pressed nothing, in one scripted round';
|
|
396
|
+
if (me && them && them.score >= me.score) notes.push(`in this one round the player who pressed nothing scored ${them.score} and the one who played scored ${me.score}.${scope || ' The declared primary action was pressed, so input may matter little to the score: play it again before concluding'}`);
|
|
397
|
+
if (me && me.score === 0 && !(them && them.score >= me.score)) notes.push(`the playing browser scored 0 for a whole round.${scope}`);
|
|
398
|
+
if (rows.length > 1 && rows.every((x) => x.score === rows[0].score)) notes.push('every player finished on the same score');
|
|
399
|
+
const bestBot = Math.max(-Infinity, ...rows.filter((x) => x.bot).map((x) => x.score));
|
|
400
|
+
const bestHuman = Math.max(-Infinity, ...rows.filter((x) => !x.bot).map((x) => x.score));
|
|
401
|
+
if (Number.isFinite(bestBot) && Number.isFinite(bestHuman) && bestBot > Math.max(3 * bestHuman, bestHuman + 10)) {
|
|
402
|
+
notes.push(`a bot outscored every person ${bestBot} to ${bestHuman} in this one scripted round (a gap of ${bestBot - bestHuman}). That is a measured gap, not proof that people cannot win: the script is not a person${exercised ? '' : ' and did not use the game\'s primary action'}. Run the play row a few more times and compare with rounds people played before tuning the bots`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (planned && roundSeconds && Math.abs(planned - roundSeconds) > roundSeconds * 0.2) notes.push(`the round was set to ${planned} s but game.json says ${roundSeconds} s`);
|
|
406
|
+
return { notes, comparison, scoring: together ? 'together' : 'rivals', primaryExercised: exercised };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* A finished round is not an uninterrupted one. `connection`: homie-studio check's
|
|
411
|
+
* [{ browser, reconnects (a number, or null when the page did not report it), cutOff? (link states seen) }].
|
|
412
|
+
* Returns { reconnects, uninterrupted: true | false | null, note }.
|
|
413
|
+
*/
|
|
414
|
+
export function connectionNote(connection) {
|
|
415
|
+
if (!Array.isArray(connection) || !connection.length) return { reconnects: null, uninterrupted: null, note: 'reconnects were not reported (an older homie-studio): completion says nothing about whether the connection held' };
|
|
416
|
+
const unknown = connection.filter((c) => !Number.isFinite(c.reconnects)).map((c) => c.browser);
|
|
417
|
+
const dropped = connection.filter((c) => c.reconnects > 0);
|
|
418
|
+
const total = dropped.reduce((s, c) => s + c.reconnects, 0);
|
|
419
|
+
// `cutOff`: the link states homie-studio check saw a browser in besides `online` (reconnecting, alone, offline,
|
|
420
|
+
// closed). A browser seen cut off was interrupted whether or not its counter moved.
|
|
421
|
+
const cut = connection.filter((c) => Array.isArray(c.cutOff) && c.cutOff.length && !(c.reconnects > 0));
|
|
422
|
+
if (dropped.length || cut.length) return { reconnects: dropped.length ? total : null, uninterrupted: false, note: `the round finished, and the connection did not hold throughout: ${[...dropped.map((c) => `the ${c.browser} reconnected ${c.reconnects} time${c.reconnects === 1 ? '' : 's'}${Array.isArray(c.cutOff) && c.cutOff.length ? ` (seen ${c.cutOff.join(', ')})` : ''}`), ...cut.map((c) => `the ${c.browser} was seen cut off from the room (${c.cutOff.join(', ')})`)].join(', ')}. No cause is established by one run: run it again and compare` };
|
|
423
|
+
if (unknown.length) return { reconnects: null, uninterrupted: null, note: `reconnects were not reported by the ${unknown.join(' and the ')}: whether the connection held is unknown` };
|
|
424
|
+
return { reconnects: 0, uninterrupted: true, note: 'no reconnects: both connections held from the seat to the results' };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/* ---------------------------------------------------------------- measured runtime cost */
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The renderer's own numbers while the game was played, from samples of `extra.drawCalls` / `extra.triangles` (null
|
|
431
|
+
* entries: the game did not expose them at that moment). MEASURED runtime cost, which the asset inventory's estimate
|
|
432
|
+
* is not: it includes procedural geometry, repeated characters, effects and shadow passes.
|
|
433
|
+
* Returns { samples, drawCalls: { median, max }, triangles: { median, max } }, or null when nothing was exposed.
|
|
434
|
+
*/
|
|
435
|
+
export function renderCost(samples) {
|
|
436
|
+
const pick = (k) => samples.map((s) => s?.[k]).filter((v) => Number.isFinite(v) && v >= 0);
|
|
437
|
+
const calls = pick('drawCalls'); const tris = pick('triangles');
|
|
438
|
+
if (!calls.length && !tris.length) return null;
|
|
439
|
+
const sum = (xs) => { if (!xs.length) return null; const s = [...xs].sort((a, b) => a - b); return { median: s[Math.floor((s.length - 1) / 2)], max: s[s.length - 1] }; };
|
|
440
|
+
return { samples: Math.max(calls.length, tris.length), drawCalls: sum(calls), triangles: sum(tris) };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/* ---------------------------------------------------------------- the review */
|
|
444
|
+
|
|
445
|
+
/** The kinds of review a run can have had. Only `independent` is a blind review. */
|
|
446
|
+
export const REVIEW_KINDS = ['independent', 'local', 'none'];
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The line a report carries about its review, from review.json in the run folder (or its absence):
|
|
450
|
+
* independent a fresh reviewer that never saw the code scored it
|
|
451
|
+
* local the session that built the game scored the pictures with the same rubric: NOT independent
|
|
452
|
+
* none no review ran: BLOCKED with the reason, never a pass
|
|
453
|
+
*/
|
|
454
|
+
export function reviewLine(record) {
|
|
455
|
+
if (!record || !REVIEW_KINDS.includes(record.kind)) return { verdict: 'BLOCKED', kind: 'none', line: 'BLOCKED review: no review has been recorded for this run. The instruments are not a review; a run without one has not been reviewed.' };
|
|
456
|
+
const score = Number.isFinite(record.score) ? ` Overall ${record.score}/100.` : '';
|
|
457
|
+
if (record.kind === 'independent') return { verdict: 'DONE', kind: 'independent', line: `Review: independent (a fresh reviewer that never saw the code${record.by ? `: ${record.by}` : ''}).${score}` };
|
|
458
|
+
if (record.kind === 'local') return { verdict: 'WARN', kind: 'local', line: `WARN review: local fallback, NOT independent (the session that made the game scored the contact sheets with the same rubric${record.reason ? `, because ${record.reason}` : ''}).${score} A builder grading its own build is weaker evidence: get an independent review before calling it reviewed.` };
|
|
459
|
+
return { verdict: 'BLOCKED', kind: 'none', line: `BLOCKED review: no review ran${record.reason ? ` (${record.reason})` : ''}. A missing review is not a passed one.` };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/* ---------------------------------------------------------------- the report */
|
|
463
|
+
|
|
464
|
+
/** What is weakest, most important first: failures, then what could not be measured, then warnings. */
|
|
465
|
+
export function weakest(rs) {
|
|
466
|
+
const order = { FAIL: 0, BLOCKED: 1, WARN: 2 };
|
|
467
|
+
const weight = { round: 0, play: 1, controls: 2, first: 3, move: 3, sound: 4, look: 5, ui: 6, errors: 7 };
|
|
468
|
+
// N/A is not weak: the row did not apply (a QR on a loopback preview, a results screen under the active-play bar).
|
|
469
|
+
return rs.filter((r) => r.verdict in order).sort((a, b) => order[a.verdict] - order[b.verdict] || (weight[a.name.split(' ')[0]] ?? 9) - (weight[b.name.split(' ')[0]] ?? 9)).map((r) => `${r.verdict} ${r.name}: ${r.why ?? 'see the report'}`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const HIDE = new Set(['shellOverlaps', 'name', 'verdict', 'why', 'shots', 'rows', 'results', 'scoreSamples', 'errors', 'requests', 'note', 'soundJs', 'gaps', 'seats', 'state', 'qualifier', 'comparison', 'actions', 'attempts', 'connection', 'instrument', 'screen', 'frames', 'render', 'stateBlack', 'stateWhite', 'heartbeatFrom', 'action']);
|
|
473
|
+
|
|
474
|
+
/** REPORT.md. `review` is review.json's content, or null. */
|
|
475
|
+
export function reportMd(rep, review = null) {
|
|
476
|
+
const L = [`# Playtest: ${rep.game}`, '', `${rep.url} · ${rep.at} · ${rep.seconds} s · port probe: ${rep.probe ? 'yes' : 'no (owner tests cannot see the body, and no row can tell a live round from its results screen; see the port skill)'}`, ''];
|
|
477
|
+
L.push('## What is weak', '', ...(rep.weak.length ? rep.weak.map((w) => `- ${w}`) : ['- Nothing the instruments can see. That is not the same as fun: read the review.']), '');
|
|
478
|
+
L.push('## Review', '', reviewLine(review).line, '');
|
|
479
|
+
L.push('## Rows', '', 'PASS: measured, and fine. FAIL: measured, and not. WARN: measured, worth a look. BLOCKED: could not be measured (never a pass). N/A: the row did not apply to what was on screen.', '', '| row | verdict | numbers |', '| --- | --- | --- |');
|
|
480
|
+
for (const r of rep.rows) {
|
|
481
|
+
const nums = Object.entries(r).filter(([k, v]) => !HIDE.has(k) && v !== null && v !== undefined && typeof v !== 'object').map(([k, v]) => `${k} ${v}`).join(', ');
|
|
482
|
+
L.push(`| ${r.name} | ${r.verdict} | ${nums.replace(/\|/g, '/')} |`);
|
|
483
|
+
}
|
|
484
|
+
L.push('');
|
|
485
|
+
// What a number alone would overstate, said beside it in words a person reads (not only in report.json).
|
|
486
|
+
const said = [];
|
|
487
|
+
for (const r of rep.rows) {
|
|
488
|
+
const kind = r.name.split(' ')[0];
|
|
489
|
+
const bits = [];
|
|
490
|
+
if (kind === 'ui') bits.push(`${r.screen ? `screen measured: ${r.screen}. ` : ''}${r.stateBlack ? `State at the black frame: ${r.stateBlack}; at the white frame: ${r.stateWhite ?? 'not read'}${r.thumb ? `; thumb ${r.thumb}` : ''}. ` : r.state ? `State at the capture: ${r.state}. ` : ''}${UI_CAVEAT}`);
|
|
491
|
+
if (kind === 'move' && r.attempts?.length) bits.push(r.attempts.map((a) => `pressed ${a.dir}${a.at ? ` from (${a.at.x}, ${a.at.y})` : ''}: ${a.moved ? `moved in ${a.ms} ms` : 'no movement'} [${a.before}]`).join('; '));
|
|
492
|
+
if (kind === 'play') { if (r.comparison) bits.push(`Score comparison: ${r.comparison}.`); if (r.actions) bits.push(`What the script pressed: ${r.actions}.`); bits.push('One scripted round: a gap measured once, not what always happens.'); }
|
|
493
|
+
if (kind === 'round' && r.connection) bits.push(`Completion and connection are separate: ${r.connection}.`);
|
|
494
|
+
if (kind === 'look' && r.render) bits.push(`Measured runtime cost (the renderer's own counters while playing, not the asset inventory's estimate): ${r.render}.`);
|
|
495
|
+
if (r.qualifier) bits.push(r.qualifier);
|
|
496
|
+
if (r.note && kind !== 'ui') bits.push(r.note);
|
|
497
|
+
if (bits.length) said.push(`- **${r.name}** (${r.verdict}): ${bits.join(' ')}`);
|
|
498
|
+
}
|
|
499
|
+
if (said.length) L.push('## What each row does and does not show', '', ...said, '');
|
|
500
|
+
L.push('Pictures: `sheet-*.png` (contact sheets), `*-first-*.png`, `*-look-*.png`, `*-ui-on-black.png` and `*-ui-on-white.png` (the pair the UI row was counted from), `sound-capture.png`, `play-round-over.png`. Look at them before believing any number here.', '');
|
|
501
|
+
return `${L.join('\n')}\n`;
|
|
502
|
+
}
|