hayao 0.2.0 → 0.4.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.
Files changed (83) hide show
  1. package/README.md +123 -24
  2. package/bin/create-hayao.mjs +380 -0
  3. package/bin/hayao-mcp-cli.mjs +11 -0
  4. package/dist/app/browser.d.ts +65 -4
  5. package/dist/app/game.d.ts +59 -10
  6. package/dist/app/tuning.d.ts +68 -0
  7. package/dist/art/palette.d.ts +41 -0
  8. package/dist/audio/adaptive.d.ts +58 -0
  9. package/dist/audio/album.d.ts +16 -0
  10. package/dist/audio/analysis.d.ts +59 -0
  11. package/dist/audio/audio.d.ts +39 -1
  12. package/dist/audio/chord.d.ts +17 -0
  13. package/dist/audio/genres.d.ts +11 -0
  14. package/dist/audio/lint.d.ts +29 -0
  15. package/dist/audio/match.d.ts +38 -0
  16. package/dist/audio/music.d.ts +88 -0
  17. package/dist/audio/pcm.d.ts +54 -0
  18. package/dist/audio/quality.d.ts +28 -0
  19. package/dist/audio/reverb.d.ts +15 -0
  20. package/dist/audio/synth.d.ts +63 -0
  21. package/dist/audio/theory.d.ts +64 -0
  22. package/dist/audio/zzfx.d.ts +14 -0
  23. package/dist/content/campaign.d.ts +69 -0
  24. package/dist/content/generate.d.ts +78 -0
  25. package/dist/content/level.d.ts +93 -0
  26. package/dist/content/worldgraph.d.ts +73 -0
  27. package/dist/core/clock.d.ts +1 -1
  28. package/dist/core/dmath.d.ts +2 -0
  29. package/dist/core/projection.d.ts +36 -0
  30. package/dist/core/rng.d.ts +9 -0
  31. package/dist/hayao.global.js +200 -0
  32. package/dist/index.d.ts +39 -1
  33. package/dist/index.js +6558 -686
  34. package/dist/index.js.map +4 -4
  35. package/dist/index.min.js +200 -0
  36. package/dist/input/actions.d.ts +60 -6
  37. package/dist/input/gamepad.d.ts +101 -0
  38. package/dist/input/source.d.ts +133 -1
  39. package/dist/logic/coroutine.d.ts +68 -0
  40. package/dist/mcp.js +31225 -0
  41. package/dist/rasterize-worker-lite.mjs +13 -0
  42. package/dist/render/canvas.d.ts +6 -5
  43. package/dist/render/canvas2d-core.d.ts +9 -0
  44. package/dist/render/commands.d.ts +82 -2
  45. package/dist/render/paint.d.ts +41 -0
  46. package/dist/render/renderer.d.ts +47 -0
  47. package/dist/render/svg.d.ts +5 -1
  48. package/dist/render/svgString.d.ts +6 -2
  49. package/dist/render/webgl.d.ts +176 -0
  50. package/dist/scene/cameraController.d.ts +42 -0
  51. package/dist/scene/iso.d.ts +73 -0
  52. package/dist/scene/node.d.ts +98 -6
  53. package/dist/scene/nodes.d.ts +48 -0
  54. package/dist/scene/parallax.d.ts +15 -0
  55. package/dist/scene/particles.d.ts +19 -0
  56. package/dist/scene/verletChain.d.ts +76 -0
  57. package/dist/studio/mcpMain.d.ts +1 -0
  58. package/dist/studio/mcpServer.d.ts +2 -0
  59. package/dist/studio/record.d.ts +54 -0
  60. package/dist/studio/run.d.ts +78 -0
  61. package/dist/studio/session.d.ts +80 -0
  62. package/dist/studio/timeline.d.ts +35 -0
  63. package/dist/studio/vitePlugin.d.ts +6 -0
  64. package/dist/studio-plugin.js +228 -0
  65. package/dist/ui/overlay.d.ts +6 -0
  66. package/dist/ui/touch.d.ts +51 -0
  67. package/dist/verify/audioFilmstrip.d.ts +39 -0
  68. package/dist/verify/dom.d.ts +26 -0
  69. package/dist/verify/ethnography.d.ts +67 -0
  70. package/dist/verify/gates.d.ts +160 -0
  71. package/dist/verify/layout.d.ts +30 -3
  72. package/dist/verify/ramp.d.ts +40 -0
  73. package/dist/world.d.ts +109 -8
  74. package/dist-studio/assets/index-C7tty_Wo.js +109 -0
  75. package/dist-studio/assets/index-CM3tjRQo.css +1 -0
  76. package/dist-studio/index.html +18 -0
  77. package/docs/API.md +300 -23
  78. package/docs/CONVENTIONS.md +511 -0
  79. package/docs/EMBED.md +90 -0
  80. package/docs/QUICKSTART.md +141 -0
  81. package/docs/STUDIO.md +97 -0
  82. package/docs/VERIFICATION.md +256 -0
  83. package/package.json +40 -6
@@ -0,0 +1,228 @@
1
+ // src/studio/vitePlugin.ts
2
+ import { execSync } from "node:child_process";
3
+ import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, statSync, watch, writeFileSync } from "node:fs";
4
+ import { dirname, extname, join, normalize, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ function findPrebuiltStudio() {
7
+ for (const rel of ["../dist-studio", "../../dist-studio"]) {
8
+ try {
9
+ const dir = fileURLToPath(new URL(rel, import.meta.url));
10
+ if (existsSync(join(dir, "index.html"))) return dir;
11
+ } catch {
12
+ }
13
+ }
14
+ return null;
15
+ }
16
+ var MIME = {
17
+ ".html": "text/html; charset=utf-8",
18
+ ".js": "text/javascript",
19
+ ".mjs": "text/javascript",
20
+ ".css": "text/css",
21
+ ".svg": "image/svg+xml",
22
+ ".png": "image/png",
23
+ ".json": "application/json",
24
+ ".woff2": "font/woff2",
25
+ ".wasm": "application/wasm"
26
+ };
27
+ function readBody(req) {
28
+ return new Promise((resolvePromise, reject) => {
29
+ const chunks = [];
30
+ req.on("data", (c) => chunks.push(c));
31
+ req.on("end", () => resolvePromise(Buffer.concat(chunks).toString("utf8")));
32
+ req.on("error", reject);
33
+ });
34
+ }
35
+ function json(res, status, body) {
36
+ res.statusCode = status;
37
+ res.setHeader("content-type", "application/json");
38
+ res.end(JSON.stringify(body));
39
+ }
40
+ var SAFE_ID = /^[a-z0-9][a-z0-9._-]*$/i;
41
+ function hayaoStudio(opts = {}) {
42
+ let studioDir = "";
43
+ let projectRoot = "";
44
+ let buildRef = "unknown";
45
+ return {
46
+ name: "hayao-studio",
47
+ configResolved(config) {
48
+ projectRoot = config.root;
49
+ studioDir = resolve(projectRoot, opts.dir ?? ".studio");
50
+ mkdirSync(join(studioDir, "sessions"), { recursive: true });
51
+ mkdirSync(join(studioDir, "shots"), { recursive: true });
52
+ try {
53
+ buildRef = execSync("git rev-parse --short HEAD", { cwd: projectRoot, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
54
+ } catch {
55
+ }
56
+ },
57
+ configureServer(server) {
58
+ const titleToModule = /* @__PURE__ */ new Map();
59
+ async function loadGameByTitle(title) {
60
+ const engine = await server.ssrLoadModule("@hayao");
61
+ const candidates = [];
62
+ const cached = titleToModule.get(title);
63
+ if (cached) candidates.push(cached);
64
+ for (const [parent] of [["examples"], ["sandboxes"]]) {
65
+ const dir = resolve(projectRoot, parent);
66
+ if (!existsSync(dir)) continue;
67
+ for (const slug of readdirSync(dir)) {
68
+ for (const file of [`${parent}/${slug}/game.ts`, `${parent}/${slug}/${slug}.ts`]) {
69
+ if (existsSync(resolve(projectRoot, file))) candidates.push(`/${file}`);
70
+ }
71
+ }
72
+ }
73
+ if (existsSync(resolve(projectRoot, "game.ts"))) candidates.push("/game.ts");
74
+ for (const path of candidates) {
75
+ try {
76
+ const mod = await server.ssrLoadModule(path);
77
+ for (const v of Object.values(mod)) {
78
+ if (v && typeof v === "object" && "build" in v && "title" in v && v.title === title) {
79
+ titleToModule.set(title, path);
80
+ return { def: v, engine };
81
+ }
82
+ }
83
+ } catch {
84
+ }
85
+ }
86
+ throw new Error(`no game titled "${title}" found`);
87
+ }
88
+ server.middlewares.use((req, res, next) => {
89
+ void (async () => {
90
+ const url = (req.url ?? "").split("?")[0];
91
+ if (req.method === "GET" && url.startsWith("/__studio/report/")) {
92
+ const id = decodeURIComponent(url.slice("/__studio/report/".length));
93
+ if (!SAFE_ID.test(id)) return json(res, 400, { error: "bad session id" });
94
+ const sessionPath = join(studioDir, "sessions", `${id}.json`);
95
+ if (!existsSync(sessionPath)) return json(res, 404, { error: "no such session" });
96
+ const session = JSON.parse(readFileSync(sessionPath, "utf8"));
97
+ const reportsDir = join(studioDir, "reports");
98
+ const cachePath = join(reportsDir, `${id}.json`);
99
+ if (existsSync(cachePath)) {
100
+ const cachedReport = JSON.parse(readFileSync(cachePath, "utf8"));
101
+ if (cachedReport.buildRef === session.buildRef) return json(res, 200, cachedReport);
102
+ }
103
+ const { def, engine } = await loadGameByTitle(session.game);
104
+ const report = engine.analyzePlaytest(def, session);
105
+ mkdirSync(reportsDir, { recursive: true });
106
+ writeFileSync(cachePath, JSON.stringify(report, null, 1));
107
+ return json(res, 200, report);
108
+ }
109
+ if (req.method === "POST" && url === "/__studio/session") {
110
+ const session = JSON.parse(await readBody(req));
111
+ if (typeof session.id !== "string" || !SAFE_ID.test(session.id)) {
112
+ return json(res, 400, { error: "bad session id" });
113
+ }
114
+ writeFileSync(join(studioDir, "sessions", `${session.id}.json`), JSON.stringify(session, null, 1));
115
+ return json(res, 200, { ok: true });
116
+ }
117
+ if (req.method === "POST" && url === "/__shot") {
118
+ const { path, svg } = JSON.parse(await readBody(req));
119
+ if (typeof svg !== "string") return json(res, 400, { error: "missing svg" });
120
+ const rel = normalize(path ?? `shot-${buildRef}.svg`).replace(/^([/\\])+/, "");
121
+ if (rel.split(/[/\\]/).includes("..")) return json(res, 400, { error: "bad path" });
122
+ const target = rel.startsWith("shots/") || rel.startsWith(".studio/") ? resolve(projectRoot, rel) : join(studioDir, "shots", rel);
123
+ mkdirSync(dirname(target), { recursive: true });
124
+ writeFileSync(target, svg);
125
+ return json(res, 200, { ok: true, path: target });
126
+ }
127
+ if (req.method === "POST" && url === "/__studio/knobs") {
128
+ const body = await readBody(req);
129
+ JSON.parse(body);
130
+ writeFileSync(join(studioDir, "knobs.json"), body);
131
+ return json(res, 200, { ok: true });
132
+ }
133
+ if (req.method === "GET" && url === "/__studio/games") {
134
+ const games = [];
135
+ for (const [parent, kind, prefix] of [
136
+ ["examples", "example", "/examples/"],
137
+ ["sandboxes", "gym", "/sandboxes/"]
138
+ ]) {
139
+ const dir = resolve(projectRoot, parent);
140
+ if (!existsSync(dir)) continue;
141
+ for (const slug of readdirSync(dir)) {
142
+ if (existsSync(join(dir, slug, "index.html"))) games.push({ slug, kind, url: `${prefix}${slug}/` });
143
+ }
144
+ }
145
+ if (games.length === 0 && existsSync(join(projectRoot, "index.html")) && existsSync(join(projectRoot, "game.ts"))) {
146
+ games.push({ slug: projectRoot.split("/").pop() ?? "game", kind: "project", url: "/" });
147
+ }
148
+ return json(res, 200, games);
149
+ }
150
+ if (req.method === "GET" && (url === "/studio" || url.startsWith("/studio/")) && !existsSync(join(projectRoot, "studio", "index.html"))) {
151
+ const prebuilt = findPrebuiltStudio();
152
+ if (!prebuilt) return json(res, 404, { error: "no prebuilt Studio UI in this hayao build" });
153
+ const rel = normalize(decodeURIComponent(url.replace(/^\/studio\/?/, "")));
154
+ if (rel.split(/[/\\]/).includes("..")) return json(res, 400, { error: "bad path" });
155
+ let file = join(prebuilt, rel);
156
+ if (!existsSync(file) || statSync(file).isDirectory()) file = join(prebuilt, "index.html");
157
+ res.statusCode = 200;
158
+ res.setHeader("content-type", MIME[extname(file)] ?? "text/html; charset=utf-8");
159
+ createReadStream(file).pipe(res);
160
+ return;
161
+ }
162
+ if (req.method === "GET" && url.startsWith("/__studio/variants/")) {
163
+ const rel = normalize(decodeURIComponent(url.slice("/__studio/variants/".length)));
164
+ if (rel.split(/[/\\]/).includes("..")) return json(res, 400, { error: "bad path" });
165
+ let file = join(studioDir, "variants", rel);
166
+ if (existsSync(file) && statSync(file).isDirectory()) file = join(file, "index.html");
167
+ if (!existsSync(file)) return json(res, 404, { error: "no such variant file" });
168
+ res.statusCode = 200;
169
+ res.setHeader("content-type", MIME[extname(file)] ?? "application/octet-stream");
170
+ createReadStream(file).pipe(res);
171
+ return;
172
+ }
173
+ if (req.method === "GET" && url.startsWith("/__studio/session/")) {
174
+ const id = decodeURIComponent(url.slice("/__studio/session/".length));
175
+ if (!SAFE_ID.test(id)) return json(res, 400, { error: "bad session id" });
176
+ const file = join(studioDir, "sessions", `${id}.json`);
177
+ if (!existsSync(file)) return json(res, 404, { error: "no such session" });
178
+ res.statusCode = 200;
179
+ res.setHeader("content-type", "application/json");
180
+ createReadStream(file).pipe(res);
181
+ return;
182
+ }
183
+ if (req.method === "GET" && url === "/__studio/state") {
184
+ const knobsPath = join(studioDir, "knobs.json");
185
+ const knobs = existsSync(knobsPath) ? JSON.parse(readFileSync(knobsPath, "utf8")) : null;
186
+ const variantsPath = join(studioDir, "variants.json");
187
+ const variants = existsSync(variantsPath) ? JSON.parse(readFileSync(variantsPath, "utf8")) : {};
188
+ const sessions = readdirSync(join(studioDir, "sessions")).filter((f) => f.endsWith(".json")).map((f) => {
189
+ try {
190
+ const s = JSON.parse(readFileSync(join(studioDir, "sessions", f), "utf8"));
191
+ return {
192
+ id: s.id,
193
+ game: s.game,
194
+ startedAt: s.startedAt,
195
+ endReason: s.endReason,
196
+ frames: s.inputLog?.frames?.length ?? 0,
197
+ annotations: Array.isArray(s.annotations) ? s.annotations.length : 0,
198
+ variant: s.variant?.name ?? "dev"
199
+ };
200
+ } catch {
201
+ return null;
202
+ }
203
+ }).filter(Boolean);
204
+ const urls = server.resolvedUrls?.network ?? [];
205
+ return json(res, 200, { buildRef, knobs, variants, sessions, urls });
206
+ }
207
+ if (req.method === "GET" && url === "/__studio/events") {
208
+ res.statusCode = 200;
209
+ res.setHeader("content-type", "text/event-stream");
210
+ res.setHeader("cache-control", "no-cache");
211
+ res.write("retry: 2000\n\n");
212
+ const watcher = watch(studioDir, { recursive: true }, (_event, filename) => {
213
+ res.write(`data: ${JSON.stringify({ file: filename ?? "" })}
214
+
215
+ `);
216
+ });
217
+ req.on("close", () => watcher.close());
218
+ return;
219
+ }
220
+ next();
221
+ })().catch((err) => json(res, 500, { error: String(err) }));
222
+ });
223
+ }
224
+ };
225
+ }
226
+ export {
227
+ hayaoStudio
228
+ };
@@ -20,6 +20,12 @@ export interface ScreenHandle {
20
20
  }
21
21
  /** Set the element overlays mount into (defaults to document.body). */
22
22
  export declare function setOverlayHost(el: HTMLElement): void;
23
+ /**
24
+ * Observe DOM screen chrome (menus/title/game-over) opening and closing —
25
+ * Studio's session recorder uses this to time menu dwell, which the sim can't
26
+ * see. Observer only; it must never mutate.
27
+ */
28
+ export declare function setScreenObserver(cb: ((kind: 'show' | 'hide', title?: string) => void) | null): void;
23
29
  /** Show a screen (replaces any current one). Returns a handle. */
24
30
  export declare function showScreen(spec: ScreenSpec): ScreenHandle;
25
31
  export declare function hideScreen(): void;
@@ -0,0 +1,51 @@
1
+ import type { GameHandle } from '../app/browser';
2
+ /** A virtual stick. Give it 4 directional actions, and/or an analog axis prefix. */
3
+ export interface TouchStick {
4
+ /** Directional actions held while tilted past the deadzone. Array = [up,down,left,right]. */
5
+ dirs?: [string, string, string, string] | {
6
+ up: string;
7
+ down: string;
8
+ left: string;
9
+ right: string;
10
+ };
11
+ /**
12
+ * Emit live analog axes `${prefix}x`, `${prefix}y` (−1..1) and `${prefix}angle`
13
+ * (radians), quantized to `buckets`. NOTE: these are LIVE axes (feel), not part
14
+ * of the input log — for replay-exact analog aim, thread them through
15
+ * `world.step(actions, axes)` (see docs/CONVENTIONS.md §Pointer).
16
+ */
17
+ prefix?: string;
18
+ /** Axis quantization levels (default 32). */
19
+ buckets?: number;
20
+ /** Dead-zone as a fraction of the stick radius, 0..1 (default 0.28). */
21
+ deadzone?: number;
22
+ }
23
+ export interface TouchButton {
24
+ action: string;
25
+ /** Glyph/label drawn on the button (default the action name). */
26
+ label?: string;
27
+ /** Hold the action while pressed (default true); false = one-shot tap via press(). */
28
+ hold?: boolean;
29
+ }
30
+ export interface TouchControlsLayout {
31
+ /** Left-corner stick (movement). Array shorthand → [up,down,left,right]. */
32
+ left?: TouchStick | [string, string, string, string];
33
+ /** Right-corner stick (aim). */
34
+ right?: TouchStick | [string, string, string, string];
35
+ /** Action buttons, stacked at the lower-right. */
36
+ buttons?: TouchButton[];
37
+ /** Only mount when the device has a coarse (touch) pointer (default true). */
38
+ touchOnly?: boolean;
39
+ }
40
+ export declare class TouchControls {
41
+ private handle;
42
+ private root;
43
+ private disposers;
44
+ /** Every action this control set can hold — released together on dispose/reset. */
45
+ private owned;
46
+ constructor(handle: GameHandle, layout: TouchControlsLayout);
47
+ private addStick;
48
+ private addButtons;
49
+ private hold;
50
+ dispose(): void;
51
+ }
@@ -0,0 +1,39 @@
1
+ import { type StereoBuffer } from '../audio/pcm';
2
+ import { type AudioFeatures } from '../audio/analysis';
3
+ export interface AudioFilmstripOptions {
4
+ /** Overall SVG width in px (default 900). */
5
+ width?: number;
6
+ /** Time columns in the spectrogram (default 220). */
7
+ timeBins?: number;
8
+ /** Frequency bands in the spectrogram (default 64). */
9
+ freqBands?: number;
10
+ /** Title shown above the strip. */
11
+ title?: string;
12
+ }
13
+ /**
14
+ * Render an audio filmstrip SVG for a stereo buffer: L/R waveforms, a
15
+ * spectrogram heatmap, and the feature readout. Deterministic and standalone
16
+ * (inline styles, no external refs) so it drops straight into t.artifact().
17
+ */
18
+ export declare function renderAudioFilmstrip(buf: StereoBuffer, opts?: AudioFilmstripOptions): string;
19
+ export interface AudioExpectation {
20
+ minDurationSec?: number;
21
+ maxDurationSec?: number;
22
+ maxPeakDb?: number;
23
+ minRms?: number;
24
+ minCentroidHz?: number;
25
+ maxCentroidHz?: number;
26
+ tempoBpm?: number;
27
+ tempoToleranceBpm?: number;
28
+ }
29
+ export interface AudioAssertion {
30
+ ok: boolean;
31
+ failures: string[];
32
+ features: AudioFeatures;
33
+ }
34
+ /**
35
+ * Assert a rendered buffer's measured features against an expectation. This is
36
+ * the numeric half of the audio channel — what a verify.ts suite calls to prove
37
+ * "this track is a ~120bpm piece that isn't clipping and isn't silent".
38
+ */
39
+ export declare function assertAudio(buf: StereoBuffer, exp: AudioExpectation): AudioAssertion;
@@ -0,0 +1,26 @@
1
+ import { type GameHandle } from '../app/browser';
2
+ import type { GameDefinition } from '../app/game';
3
+ export interface DomHarness {
4
+ /** The live game handle (world, input, pointer, renderer, viewport…). */
5
+ handle: GameHandle;
6
+ /** Press a finger/pointer down at design (x,y) with a stable id (default 1). */
7
+ touchDown(x: number, y: number, id?: number): void;
8
+ /** Move a tracked finger/pointer to design (x,y). */
9
+ touchMove(x: number, y: number, id?: number): void;
10
+ /** Lift a finger/pointer (default id 1). */
11
+ touchUp(id?: number): void;
12
+ /**
13
+ * Sample the live pointer + keyboard input into the sim, then run `n` fixed
14
+ * steps — the same order `runBrowser` uses, minus wall-clock. Held/virtual
15
+ * actions and quantized axes flow through exactly as in production.
16
+ */
17
+ step(n?: number, axes?: Record<string, number>): void;
18
+ dispose(): void;
19
+ }
20
+ /**
21
+ * Boot a game's real host wiring under the DOM for a host-layer test. The loop is
22
+ * frozen (`isHeld`) so stepping is deterministic and test-driven — call `step()`
23
+ * after firing input. Attach a `TouchControls` to `harness.handle` to prove a
24
+ * virtual gamepad end-to-end.
25
+ */
26
+ export declare function bootDom(def: GameDefinition, mount?: HTMLElement): DomHarness;
@@ -0,0 +1,67 @@
1
+ import type { GameDefinition } from '../app/game';
2
+ import { type PlaytestSession } from '../studio/session';
3
+ export interface HesitationSpan {
4
+ startFrame: number;
5
+ frames: number;
6
+ /** Probe sampled at the span's first frame (what was on screen when input went quiet). */
7
+ probe: Record<string, unknown>;
8
+ }
9
+ export interface DeathCluster {
10
+ x: number;
11
+ y: number;
12
+ count: number;
13
+ frames: number[];
14
+ }
15
+ export interface FutileVerb {
16
+ action: string;
17
+ /** Rising-edge presses after which no probe field changed within the window. */
18
+ futilePresses: number;
19
+ totalPresses: number;
20
+ frames: number[];
21
+ }
22
+ export interface QuitContext {
23
+ frame: number;
24
+ probe: Record<string, unknown>;
25
+ /** Deaths / hesitation starts inside the final window before the quit. */
26
+ recentDeaths: number;
27
+ recentHesitations: number;
28
+ endReason: PlaytestSession['endReason'];
29
+ }
30
+ export interface AnnotationContext {
31
+ frame: number;
32
+ tag: string;
33
+ note?: string;
34
+ probe: Record<string, unknown>;
35
+ }
36
+ export interface PlaytestReport {
37
+ sessionId: string;
38
+ game: string;
39
+ buildRef: string;
40
+ seed: number;
41
+ variant: PlaytestSession['variant'];
42
+ frames: number;
43
+ /** Sim seconds (frames × dt); wall time can be longer (pauses, hidden tab). */
44
+ simSeconds: number;
45
+ reachedGoal: boolean;
46
+ deaths: number;
47
+ hesitations: HesitationSpan[];
48
+ deathClusters: DeathCluster[];
49
+ futileVerbs: FutileVerb[];
50
+ /** Present unless the session ended at the goal. */
51
+ quit?: QuitContext;
52
+ annotations: AnnotationContext[];
53
+ knobEvents: PlaytestSession['knobEvents'];
54
+ /** Verbs the player NEVER used (declared in the input map but absent from the log). */
55
+ unusedActions: string[];
56
+ }
57
+ export interface AnalyzeOptions {
58
+ /** Min quiet frames to count as a hesitation (default 45 ≈ 0.75s @ 60fps). */
59
+ hesitationFrames?: number;
60
+ /** Frames after a press in which SOMETHING must change, else it was futile (default 30). */
61
+ futileWindow?: number;
62
+ /** Final-window size for quit context, in frames (default 300 ≈ 5s). */
63
+ quitWindow?: number;
64
+ /** Death-cluster bucket size in world px (default 64). */
65
+ clusterPx?: number;
66
+ }
67
+ export declare function analyzePlaytest(def: GameDefinition, session: PlaytestSession, opts?: AnalyzeOptions): PlaytestReport;
@@ -0,0 +1,160 @@
1
+ import type { DrawCommand } from '../render/commands';
2
+ import type { Vec2 } from '../core/math';
3
+ /** The forgiveness-relevant slice of any character config (PlatformerConfig satisfies it). */
4
+ export interface ForgivenessSpec {
5
+ /** Seconds of coyote time after leaving the ground. */
6
+ coyoteTime: number;
7
+ /** Seconds a jump press is buffered before landing. */
8
+ jumpBuffer: number;
9
+ /** Pixels the body is nudged sideways to slip past a ceiling corner (0 = none). */
10
+ jumpCornerNudge?: number;
11
+ }
12
+ export interface ForgivenessOptions {
13
+ /** Minimum coyote time in seconds (default 0.05 ≈ 3 frames @60Hz). */
14
+ minCoyote?: number;
15
+ /** Minimum jump-buffer in seconds (default 0.05). */
16
+ minBuffer?: number;
17
+ /** Require a non-zero jump corner nudge (default true). */
18
+ requireCornerNudge?: boolean;
19
+ }
20
+ /**
21
+ * Static audit of a controller's grace windows. A platformer that ships without
22
+ * coyote time and input buffering feels like it drops inputs — the single most
23
+ * common reason an otherwise-correct platformer feels amateur.
24
+ */
25
+ export declare function forgivenessIssues(spec: ForgivenessSpec, opts?: ForgivenessOptions): string[];
26
+ /**
27
+ * Behavioural grace-window prover. `accepts(delayFrames)` runs the sim with the
28
+ * grace-triggering input applied `delayFrames` after the enabling event and
29
+ * returns whether the action still took. A correct window accepts every delay in
30
+ * `[0, windowFrames]` and refuses `windowFrames + 1` — the exact edge FUN law 5
31
+ * demands. Use it for coyote time, jump buffering, i-frames, mercy windows.
32
+ */
33
+ export declare function graceWindowIssues(label: string, windowFrames: number, accepts: (delayFrames: number) => boolean): string[];
34
+ export type FeedbackChannel = 'visual' | 'audio' | 'haptic';
35
+ /** What a game promises to fire for one event kind — audited statically. */
36
+ export interface FeedbackResponse {
37
+ /** Channels this event answers on (particles/flash = visual, sfx = audio, shake/rumble = haptic). */
38
+ channels: FeedbackChannel[];
39
+ /** Screen-shake trauma added (0..1), if any — bounded by the envelope. */
40
+ shake?: number;
41
+ /** Hit-stop / freeze frames injected, if any — bounded by the envelope. */
42
+ hitstopFrames?: number;
43
+ }
44
+ /** event kind → its feedback response. The game declares this next to its sim. */
45
+ export type FeedbackContract = Record<string, FeedbackResponse>;
46
+ export interface FeedbackOptions {
47
+ /** Minimum distinct channels per event (default 2). */
48
+ minChannels?: number;
49
+ /** Allowed screen-shake trauma range (default [0, 1]). */
50
+ shake?: [number, number];
51
+ /** Allowed hit-stop range in frames (default [0, 12] — beyond ~0.2s reads as a hitch). */
52
+ hitstop?: [number, number];
53
+ }
54
+ /**
55
+ * Audit a feedback contract: every required event exists, answers on ≥ minChannels
56
+ * distinct channels, and keeps its shake/hit-stop inside the envelope. This is the
57
+ * "does every hit/land/collect/death actually land on the senses?" gate.
58
+ */
59
+ export declare function feedbackIssues(contract: FeedbackContract, requiredEvents: readonly string[], opts?: FeedbackOptions): string[];
60
+ /** Relative luminance (WCAG) of a #rrggbb / #rgb color, in [0,1]. */
61
+ export declare function relLuminance(hex: string): number;
62
+ /** WCAG contrast ratio between two colors, in [1, 21]. */
63
+ export declare function contrastRatio(a: string, b: string): number;
64
+ export interface SalienceOptions {
65
+ /** Minimum contrast the avatar must hold against the background (default 3). */
66
+ minBackgroundContrast?: number;
67
+ /**
68
+ * The avatar's fill must out-contrast the MEDIAN non-avatar fill against the
69
+ * background by at least this factor (default 1.15) — i.e. it visibly pops from
70
+ * the scenery rather than blending into it.
71
+ */
72
+ minSalienceFactor?: number;
73
+ }
74
+ /**
75
+ * Readability gate: the player avatar must be the most salient thing on screen.
76
+ * The game passes the exact fill it painted the avatar with; the gate asserts that
77
+ * color out-contrasts both the background and the median scenery fill in the live
78
+ * display list. "Where's my guy?" is a fun-killer no correctness proof catches —
79
+ * this one does, from pure draw data.
80
+ */
81
+ export declare function salienceIssues(commands: DrawCommand[], avatarFill: string, background: string, opts?: SalienceOptions): string[];
82
+ export interface TelegraphFrame {
83
+ /** True on frames where a threat is warning (flash/wind-up) before it can hurt you. */
84
+ telegraphing: boolean;
85
+ /** True on frames where the threat's hitbox is live (it can deal damage). */
86
+ active: boolean;
87
+ }
88
+ /**
89
+ * Telegraph gate: every activation of a threat must be preceded by at least
90
+ * `minFrames` of contiguous telegraph. Reactive play is only possible if danger
91
+ * announces itself; a hitbox that goes live with no wind-up is an unfair death.
92
+ * Feed a per-frame timeline (one entry per sim frame) for a single threat.
93
+ */
94
+ export declare function telegraphIssues(timeline: readonly TelegraphFrame[], minFrames: number, label?: string): string[];
95
+ export interface CameraOptions {
96
+ /** Fixed timestep between samples in seconds (default 1/60). */
97
+ dt?: number;
98
+ /** Max camera speed in px/s before it reads as a snap (default 1800). */
99
+ maxSpeed?: number;
100
+ /** Max camera acceleration in px/s² before the motion reads as jerky (default 90000). */
101
+ maxAccel?: number;
102
+ }
103
+ /**
104
+ * Camera lawfulness gate over a sampled position series (one Vec2 per frame). A
105
+ * good follow camera never teleports and never jerks: bounded velocity (no snap)
106
+ * and bounded acceleration (smooth, not stuttery). You cannot systematize when a
107
+ * director SHOULD break these for drama — but an unbidden snap is always a bug.
108
+ */
109
+ export declare function cameraIssues(samples: readonly Vec2[], opts?: CameraOptions): string[];
110
+ /**
111
+ * Look-ahead gate: on the axes where the target is moving, the camera should lead
112
+ * in the SAME direction (bias the view toward where you're going), never trail
113
+ * backwards. Compares net camera drift to net target drift across the series.
114
+ */
115
+ export declare function lookAheadIssues(cameraSamples: readonly Vec2[], targetSamples: readonly Vec2[], minLead?: number): string[];
116
+ export interface FeelSpec {
117
+ /** The avatar's fill color — enables the salience gate. */
118
+ avatarFill?: string;
119
+ /** Background color (defaults to the game's `background`). */
120
+ background?: string;
121
+ /** Controller grace config — enables the forgiveness gate (PlatformerConfig fits). */
122
+ forgiveness?: ForgivenessSpec;
123
+ /** Declared feedback contract + the events that must be covered — enables the feedback gate. */
124
+ feedback?: {
125
+ contract: FeedbackContract;
126
+ events: readonly string[];
127
+ };
128
+ /** True for scrolling games — enables the camera gate (needs sampled positions). */
129
+ scrolls?: boolean;
130
+ }
131
+ /** Runtime inputs the audit/verify computes and hands to the aggregator. */
132
+ export interface FeelContext {
133
+ /** A rendered display list (drive a few frames, then `world.render()`). */
134
+ commands?: DrawCommand[];
135
+ /** Sampled camera-follow positions over a run (exclude the pre-start frame). */
136
+ camSamples?: readonly Vec2[];
137
+ /** Matching target positions, for the look-ahead check. */
138
+ targetSamples?: readonly Vec2[];
139
+ /** Fixed timestep for the camera gate (default 1/60). */
140
+ dt?: number;
141
+ /** Background fallback if the spec omits one. */
142
+ background?: string;
143
+ }
144
+ export interface FeelReport {
145
+ ok: boolean;
146
+ /** One entry per gate that actually ran (given the spec + context). */
147
+ sections: {
148
+ gate: string;
149
+ issues: string[];
150
+ }[];
151
+ /** Gates the spec asks for but that lacked the runtime context to run. */
152
+ skipped: string[];
153
+ }
154
+ /**
155
+ * Run every feel gate the spec provides inputs for, returning a structured report.
156
+ * Static gates (forgiveness, feedback) run from the spec alone; salience needs a
157
+ * rendered display list; the camera gate needs sampled positions. Gates whose
158
+ * context is missing are reported as `skipped`, never silently dropped.
159
+ */
160
+ export declare function runFeelGates(spec: FeelSpec, ctx?: FeelContext): FeelReport;
@@ -22,11 +22,38 @@ export interface LayoutOptions {
22
22
  backgroundZ?: number;
23
23
  /** Extra pixels of breathing room demanded around text (default 0 = touch is allowed, overlap is not). */
24
24
  margin?: number;
25
+ /**
26
+ * Lint transient view chrome too (floating popups, particles). Off by default:
27
+ * a "+10" drifting across a HUD label is motion, not a layout bug (see #26).
28
+ */
29
+ includeTransient?: boolean;
30
+ /**
31
+ * The page background these commands paint on. When given, contrast lints run
32
+ * (see `minContrast` / `minTextContrast`): a shape or label that barely differs
33
+ * from what sits under it is invisible even though the sim hashes fine (#30).
34
+ */
35
+ background?: string;
36
+ /**
37
+ * Minimum contrast ratio a solid foreground shape must hold against the
38
+ * background to count as visible at all (default 1.5). Only checked when
39
+ * `background` is set.
40
+ */
41
+ minContrast?: number;
42
+ /**
43
+ * Minimum contrast ratio text must hold against its backing (panel fill, or the
44
+ * background) — the WCAG AA readability bar (default 4.5). Only when `background`
45
+ * is set. Pass 0 to skip the text-contrast check while keeping shape contrast.
46
+ */
47
+ minTextContrast?: number;
25
48
  }
26
49
  /**
27
- * Lint a display list for text readability. Returns human-readable issues
28
- * (empty = clean). Checks: text-vs-shape partial overlaps (rule 1) and
29
- * text-vs-text overlaps.
50
+ * Lint a display list for text readability and visibility. Returns human-readable
51
+ * issues (empty = clean). Checks:
52
+ * 1. text-vs-shape partial overlaps (a label half-under a shape),
53
+ * 2. text fully hidden behind an opaque higher-z shape (silent invisible labels, #31),
54
+ * 3. text-vs-text overlaps,
55
+ * 4. (opt-in via `background`) low-contrast shapes and text (#30).
56
+ * Transient commands (popups/particles) are skipped unless `includeTransient`.
30
57
  */
31
58
  export declare function layoutIssues(commands: DrawCommand[], opts?: LayoutOptions): string[];
32
59
  /** Friendly names a hint text may use to reference a key code. */
@@ -0,0 +1,40 @@
1
+ export interface RampStats {
2
+ count: number;
3
+ min: number;
4
+ max: number;
5
+ mean: number;
6
+ /** Distinct difficulty values — a flat curve has 1. */
7
+ distinct: number;
8
+ /** Largest single step-to-step increase. */
9
+ maxJump: number;
10
+ /** Fraction of steps that rise or hold (0..1). */
11
+ forwardFraction: number;
12
+ }
13
+ /** Summarize a difficulty series — handy for logging the curve next to the gate. */
14
+ export declare function rampStats(difficulty: readonly number[]): RampStats;
15
+ export interface RampOptions {
16
+ /**
17
+ * Largest allowed single-step increase, as a MULTIPLE of the average forward
18
+ * step. A step steeper than this reads as a difficulty cliff (default 3×).
19
+ */
20
+ maxJumpFactor?: number;
21
+ /** Absolute floor for the jump envelope so tiny early steps don't over-constrain (default 2). */
22
+ minJumpAllowance?: number;
23
+ /** Minimum fraction of steps that must rise-or-hold — forward progress (default 0.6). */
24
+ minForwardFraction?: number;
25
+ /** Minimum distinct difficulty values — variety, not a flat line (default 3). */
26
+ minDistinct?: number;
27
+ /** Require the last level to be within this fraction of the peak (default 0.8 = finale ≥ 80% of max). */
28
+ finaleFraction?: number;
29
+ }
30
+ /**
31
+ * Audit a difficulty curve. `difficulty[i]` is the i-th level's difficulty proxy
32
+ * — typically the solver's solution depth, but any monotone measure works. Returns
33
+ * the specific curve sins found (empty array = a well-shaped ramp).
34
+ */
35
+ export declare function rampIssues(difficulty: readonly number[], opts?: RampOptions): string[];
36
+ /**
37
+ * Assert a difficulty curve is well-shaped (throws with the first issue if not).
38
+ * The campaign-level analogue of `assertSolvable` — one call gates the ramp.
39
+ */
40
+ export declare function assertRamp(difficulty: readonly number[], opts?: RampOptions): void;