hayao 0.2.0 → 0.3.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 (69) hide show
  1. package/README.md +113 -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 +33 -2
  5. package/dist/app/game.d.ts +47 -2
  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 +21 -0
  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 +56 -0
  21. package/dist/audio/theory.d.ts +64 -0
  22. package/dist/content/campaign.d.ts +69 -0
  23. package/dist/content/generate.d.ts +78 -0
  24. package/dist/content/level.d.ts +93 -0
  25. package/dist/content/worldgraph.d.ts +73 -0
  26. package/dist/core/dmath.d.ts +2 -0
  27. package/dist/hayao.global.js +15 -0
  28. package/dist/index.d.ts +30 -1
  29. package/dist/index.js +4293 -434
  30. package/dist/index.js.map +4 -4
  31. package/dist/index.min.js +15 -0
  32. package/dist/input/source.d.ts +52 -1
  33. package/dist/mcp.js +31225 -0
  34. package/dist/rasterize-worker-lite.mjs +13 -0
  35. package/dist/render/canvas.d.ts +6 -1
  36. package/dist/render/commands.d.ts +46 -0
  37. package/dist/render/paint.d.ts +33 -0
  38. package/dist/render/renderer.d.ts +22 -0
  39. package/dist/render/svg.d.ts +4 -1
  40. package/dist/render/svgString.d.ts +6 -2
  41. package/dist/scene/cameraController.d.ts +42 -0
  42. package/dist/scene/node.d.ts +17 -4
  43. package/dist/scene/nodes.d.ts +14 -0
  44. package/dist/scene/parallax.d.ts +15 -0
  45. package/dist/studio/mcpMain.d.ts +1 -0
  46. package/dist/studio/mcpServer.d.ts +2 -0
  47. package/dist/studio/record.d.ts +54 -0
  48. package/dist/studio/run.d.ts +78 -0
  49. package/dist/studio/session.d.ts +80 -0
  50. package/dist/studio/timeline.d.ts +35 -0
  51. package/dist/studio/vitePlugin.d.ts +6 -0
  52. package/dist/studio-plugin.js +228 -0
  53. package/dist/ui/overlay.d.ts +6 -0
  54. package/dist/verify/audioFilmstrip.d.ts +39 -0
  55. package/dist/verify/ethnography.d.ts +67 -0
  56. package/dist/verify/gates.d.ts +160 -0
  57. package/dist/verify/layout.d.ts +30 -3
  58. package/dist/verify/ramp.d.ts +40 -0
  59. package/dist/world.d.ts +38 -2
  60. package/dist-studio/assets/index-C7tty_Wo.js +109 -0
  61. package/dist-studio/assets/index-CM3tjRQo.css +1 -0
  62. package/dist-studio/index.html +18 -0
  63. package/docs/API.md +233 -9
  64. package/docs/CONVENTIONS.md +223 -0
  65. package/docs/EMBED.md +85 -0
  66. package/docs/QUICKSTART.md +129 -0
  67. package/docs/STUDIO.md +97 -0
  68. package/docs/VERIFICATION.md +226 -0
  69. package/package.json +39 -6
@@ -0,0 +1,13 @@
1
+ // Lite rasterize worker shipped in the npm package (dist/): SVG file → PNG in
2
+ // an isolated process, no repo-only helpers (no sanitize pass, system fonts).
3
+ // @resvg/resvg-js resolves from the CONSUMER's node_modules — install it as a
4
+ // devDependency to enable inspect_moment PNGs; without it this exits non-zero
5
+ // and the MCP server degrades to probe + hash.
6
+ // node rasterize-worker-lite.mjs <in.svg> <width> <out.png>
7
+ import { readFileSync, writeFileSync } from 'node:fs';
8
+
9
+ const [, , svgPath, width, outPath] = process.argv;
10
+ const { Resvg } = await import('@resvg/resvg-js');
11
+ const svg = readFileSync(svgPath, 'utf8');
12
+ const png = new Resvg(svg, { fitTo: { mode: 'width', value: Number(width) }, font: { loadSystemFonts: true } }).render().asPng();
13
+ writeFileSync(outPath, png);
@@ -1,5 +1,6 @@
1
1
  import { type DrawCommand } from './commands';
2
- import type { Renderer, RendererConfig } from './renderer';
2
+ import { type Renderer, type RendererConfig } from './renderer';
3
+ import type { Vec2 } from '../core/math';
3
4
  export declare class Canvas2DRenderer implements Renderer {
4
5
  readonly width: number;
5
6
  readonly height: number;
@@ -11,9 +12,13 @@ export declare class Canvas2DRenderer implements Renderer {
11
12
  mount(parent: HTMLElement): void;
12
13
  private resize;
13
14
  draw(commands: DrawCommand[]): void;
15
+ /** Resolve a fill: a gradient (mapped to the shape's bbox) or a solid color. */
16
+ private fillFor;
14
17
  private stroke;
15
18
  private paint;
16
19
  private roundRect;
17
20
  get element(): HTMLCanvasElement;
21
+ /** Map a pointer event's clientX/Y into design space (undoes the letterbox). */
22
+ toDesign(clientX: number, clientY: number): Vec2;
18
23
  dispose(): void;
19
24
  }
@@ -1,4 +1,39 @@
1
1
  import type { Transform } from '../core/math';
2
+ /** One color stop of a gradient. `offset` is 0..1 along the gradient axis. */
3
+ export interface GradientStop {
4
+ offset: number;
5
+ color: string;
6
+ }
7
+ /**
8
+ * A gradient fill in OBJECT-BOUNDING-BOX space: all coordinates are 0..1
9
+ * relative to the painted shape's bounds, so the same gradient reads correctly
10
+ * on a shape of any size or position (matches SVG `gradientUnits`).
11
+ */
12
+ export interface LinearGradient {
13
+ type: 'linear';
14
+ x1: number;
15
+ y1: number;
16
+ x2: number;
17
+ y2: number;
18
+ stops: GradientStop[];
19
+ }
20
+ export interface RadialGradient {
21
+ type: 'radial';
22
+ /** Center + radius, all 0..1 in object-bounding-box space. */
23
+ cx: number;
24
+ cy: number;
25
+ r: number;
26
+ stops: GradientStop[];
27
+ }
28
+ export type Gradient = LinearGradient | RadialGradient;
29
+ /** A soft shadow / outer glow. `dx=dy=0` is a symmetric glow; offset = drop. */
30
+ export interface Shadow {
31
+ color: string;
32
+ /** Blur radius in local px. */
33
+ blur: number;
34
+ dx?: number;
35
+ dy?: number;
36
+ }
2
37
  export interface Paint {
3
38
  fill?: string;
4
39
  stroke?: string;
@@ -6,6 +41,10 @@ export interface Paint {
6
41
  opacity?: number;
7
42
  /** Round line joins/caps for organic shapes. */
8
43
  round?: boolean;
44
+ /** A gradient fill; when present it overrides `fill` for the shape body. */
45
+ gradient?: Gradient;
46
+ /** A soft outer glow / drop shadow applied to the shape. */
47
+ shadow?: Shadow;
9
48
  }
10
49
  export type TextAlign = 'left' | 'center' | 'right';
11
50
  interface Base extends Paint {
@@ -13,6 +52,13 @@ interface Base extends Paint {
13
52
  transform: Transform;
14
53
  /** Painter's-order key; ties broken by tree order. Default 0. */
15
54
  z: number;
55
+ /**
56
+ * Transient view chrome — a drifting popup, particle, or tween that lives for
57
+ * a moment and is never something the player reads for meaning. Layout lints
58
+ * (see verify/layout) skip these by default: a "+10" floating over a HUD label
59
+ * is motion, not a collision. Set by cosmetic emitters (FloatingText/Particles).
60
+ */
61
+ transient?: boolean;
16
62
  }
17
63
  export interface RectCommand extends Base {
18
64
  kind: 'rect';
@@ -0,0 +1,33 @@
1
+ import type { DrawCommand, Gradient, GradientStop, LinearGradient, RadialGradient, Shadow } from './commands';
2
+ type StopInput = string | GradientStop;
3
+ /**
4
+ * A linear gradient by angle in degrees (0 = left→right, 90 = top→bottom).
5
+ * Stops are colors (auto-spaced) or explicit `{offset, color}`.
6
+ */
7
+ export declare function linearGradient(stops: readonly StopInput[], angleDeg?: number): LinearGradient;
8
+ /** A radial gradient centered in the shape by default (inner stop first). */
9
+ export declare function radialGradient(stops: readonly StopInput[], opts?: {
10
+ cx?: number;
11
+ cy?: number;
12
+ r?: number;
13
+ }): RadialGradient;
14
+ /** A symmetric soft outer glow. */
15
+ export declare function glow(color: string, blur: number): Shadow;
16
+ /** A soft drop shadow, offset by (dx,dy). */
17
+ export declare function dropShadow(color: string, blur: number, dx?: number, dy?: number): Shadow;
18
+ export declare function gradientDef(g: Gradient, id: string): string;
19
+ export declare function shadowDef(s: Shadow, id: string): string;
20
+ export interface BBox {
21
+ x: number;
22
+ y: number;
23
+ w: number;
24
+ h: number;
25
+ }
26
+ /** Local-space bounding box of a shape command (null for paths/text/image). */
27
+ export declare function shapeBBox(c: DrawCommand): BBox | null;
28
+ /** Build a Canvas gradient mapped from object-bounding-box space into `bbox`. */
29
+ export declare function canvasGradient(ctx: {
30
+ createLinearGradient(x0: number, y0: number, x1: number, y1: number): CanvasGradient;
31
+ createRadialGradient(x0: number, y0: number, r0: number, x1: number, y1: number, r1: number): CanvasGradient;
32
+ }, g: Gradient, bbox: BBox): CanvasGradient;
33
+ export {};
@@ -1,4 +1,5 @@
1
1
  import type { DrawCommand } from './commands';
2
+ import type { Vec2 } from '../core/math';
2
3
  /** A backend that paints a display list into a host (DOM, canvas, or nothing). */
3
4
  export interface Renderer {
4
5
  readonly width: number;
@@ -7,6 +8,16 @@ export interface Renderer {
7
8
  draw(commands: DrawCommand[]): void;
8
9
  /** Attach to a DOM parent (browser backends). */
9
10
  mount?(parent: HTMLElement): void;
11
+ /** The mounted DOM node (canvas or svg), for attaching pointer listeners. */
12
+ readonly element?: HTMLElement | SVGElement;
13
+ /**
14
+ * Invert the display transform: a browser pointer event's clientX/clientY →
15
+ * design-space coordinates. The backend owns the letterbox (`object-fit:
16
+ * contain` / `preserveAspectRatio`) and DPR, so it's the only place that can
17
+ * undo them correctly. Returns design coords even outside the letterbox
18
+ * (values fall outside 0..width/height there).
19
+ */
20
+ toDesign?(clientX: number, clientY: number): Vec2;
10
21
  dispose?(): void;
11
22
  }
12
23
  export interface RendererConfig {
@@ -14,3 +25,14 @@ export interface RendererConfig {
14
25
  height: number;
15
26
  background?: string;
16
27
  }
28
+ /**
29
+ * Undo a centered uniform-fit (`object-fit: contain` / SVG `xMidYMid meet`):
30
+ * map a client pixel inside `rect` back to the design box `width×height`. Shared
31
+ * by every DOM backend so the letterbox math lives in exactly one place.
32
+ */
33
+ export declare function clientToDesign(rect: {
34
+ left: number;
35
+ top: number;
36
+ width: number;
37
+ height: number;
38
+ }, width: number, height: number, clientX: number, clientY: number): Vec2;
@@ -1,5 +1,6 @@
1
1
  import type { DrawCommand } from './commands';
2
- import type { Renderer, RendererConfig } from './renderer';
2
+ import { type Renderer, type RendererConfig } from './renderer';
3
+ import type { Vec2 } from '../core/math';
3
4
  export declare class SvgRenderer implements Renderer {
4
5
  readonly width: number;
5
6
  readonly height: number;
@@ -12,5 +13,7 @@ export declare class SvgRenderer implements Renderer {
12
13
  draw(commands: DrawCommand[]): void;
13
14
  setBackground(color: string): void;
14
15
  get element(): SVGSVGElement;
16
+ /** Map a pointer event's clientX/Y into design space (undoes the letterbox). */
17
+ toDesign(clientX: number, clientY: number): Vec2;
15
18
  dispose(): void;
16
19
  }
@@ -1,5 +1,9 @@
1
1
  import { type DrawCommand } from './commands';
2
- /** Inner SVG markup for a display list (no wrapping <svg>). */
3
- export declare function commandsToSVGInner(commands: DrawCommand[]): string;
2
+ /**
3
+ * Inner SVG markup for a display list (no wrapping <svg>). `idPrefix` salts the
4
+ * ids of any gradient/shadow <defs> so several inner markups can share one SVG
5
+ * document (e.g. a filmstrip) without their `url(#…)` references colliding.
6
+ */
7
+ export declare function commandsToSVGInner(commands: DrawCommand[], idPrefix?: string): string;
4
8
  /** A complete, standalone SVG document string for a frame — a headless screenshot. */
5
9
  export declare function renderToSVGString(commands: DrawCommand[], width: number, height: number, background?: string): string;
@@ -0,0 +1,42 @@
1
+ import { Node, type NodeConfig } from './node';
2
+ import { type Vec2 } from '../core/math';
3
+ import type { Camera2D } from './nodes';
4
+ /** World-space rectangle the camera CENTER is kept inside (before viewport inset). */
5
+ export interface CameraBounds {
6
+ minX: number;
7
+ minY: number;
8
+ maxX: number;
9
+ maxY: number;
10
+ }
11
+ export interface CameraControllerConfig extends NodeConfig {
12
+ /** Node to follow. May be set later via `follow()`. */
13
+ target?: Node;
14
+ /** Camera to drive. Defaults to the world's active camera each step. */
15
+ camera?: Camera2D;
16
+ /** Half-extents of a slack box the target may roam before the camera chases. */
17
+ deadzone?: number | Vec2;
18
+ /** Per-step chase factor in [0,1]; 1 snaps, lower trails. Default 0.15. */
19
+ smooth?: number;
20
+ /** Clamp the view to a world rectangle (the level bounds) so no void shows. */
21
+ bounds?: CameraBounds;
22
+ }
23
+ export declare class CameraController extends Node {
24
+ readonly type = "CameraController";
25
+ cosmetic: boolean;
26
+ target: Node | null;
27
+ private cameraRef;
28
+ deadzone: Vec2;
29
+ smooth: number;
30
+ bounds: CameraBounds | null;
31
+ constructor(config?: CameraControllerConfig);
32
+ /** Point the controller at a node to follow. */
33
+ follow(target: Node): this;
34
+ private host;
35
+ private camera;
36
+ /** Where the camera wants to sit: the target position, clamped to bounds. */
37
+ private desired;
38
+ /** Jump the camera straight to its desired position (no smoothing). */
39
+ snap(): void;
40
+ protected onReady(): void;
41
+ protected onProcess(): void;
42
+ }
@@ -1,12 +1,20 @@
1
1
  import type { Rng } from '../core/rng';
2
2
  import type { Clock } from '../core/clock';
3
+ import type { InputState } from '../input/actions';
3
4
  import { Signal } from '../core/events';
4
5
  import { type Transform, type Vec2 } from '../core/math';
5
6
  import type { DrawCommand } from '../render/commands';
6
- /** What a live node can see of its host world. The World implements this. */
7
+ /**
8
+ * What a live node can see of its host world. The World implements this — it is
9
+ * exactly the read-only context handed to update callbacks, so a behaviour can
10
+ * reach input/rng/clock/time WITHOUT closing over the `world` from build(). That
11
+ * closure-free property is what lets a node be lifted out and reused verbatim.
12
+ */
7
13
  export interface WorldContext {
8
14
  readonly rng: Rng;
9
15
  readonly clock: Clock;
16
+ /** Sampled input for this step — `ctx.input.isDown('jump')`, `ctx.input.axis('pointer.x')`. */
17
+ readonly input: InputState;
10
18
  /** Queue a node to be freed at the end of the current step (safe during iteration). */
11
19
  requestFree(node: Node): void;
12
20
  /** Seconds elapsed in sim time. */
@@ -15,7 +23,8 @@ export interface WorldContext {
15
23
  /** A composable update unit attached to a node (favour over deep inheritance). */
16
24
  export interface Behavior {
17
25
  ready?(node: Node): void;
18
- update?(node: Node, dt: number): void;
26
+ /** `ctx` is the host world (input/rng/clock/time) — self-contained, no closure needed. */
27
+ update?(node: Node, dt: number, ctx: WorldContext): void;
19
28
  exit?(node: Node): void;
20
29
  /** Optional tag for lookup/serialization. */
21
30
  readonly kind?: string;
@@ -50,8 +59,12 @@ export declare class Node {
50
59
  parent: Node | null;
51
60
  readonly children: Node[];
52
61
  world: WorldContext | null;
53
- /** Optional inline update without subclassing: node.onUpdate = (n, dt) => {…}. */
54
- onUpdate?: (node: Node, dt: number) => void;
62
+ /**
63
+ * Optional inline update without subclassing. The third argument is the host
64
+ * world context (input/rng/clock/time), so the callback is self-contained —
65
+ * `node.onUpdate = (n, dt, ctx) => { if (ctx.input.isDown('jump')) … }`.
66
+ */
67
+ onUpdate?: (node: Node, dt: number, ctx: WorldContext) => void;
55
68
  private behaviors;
56
69
  private signals;
57
70
  private _ready;
@@ -1,11 +1,25 @@
1
1
  import type { Transform } from '../core/math';
2
2
  import type { DrawCommand, Paint, TextAlign } from '../render/commands';
3
3
  import { Node, type NodeConfig } from './node';
4
+ /**
5
+ * A Sprite's shape. ORIGIN CONVENTIONS differ per kind — a Sprite's `pos` is the
6
+ * node origin, and each shape places itself relative to it:
7
+ * - `rect` → CENTER-anchored by default: drawn from (-w/2,-h/2), so `pos` is its
8
+ * middle. Pass `anchor: 'topLeft'` to place `pos` at the top-left
9
+ * corner instead (the canvas mental model) — no half-size offset.
10
+ * - `circle` → CENTER-anchored: `pos` is the center.
11
+ * - `glyph` → CENTER-anchored (align 'center').
12
+ * - `poly` → LOCAL coordinates: `points` are relative to `pos` as-is (put your
13
+ * own 0,0 wherever you like).
14
+ * - `path` → LOCAL coordinates: `d` is drawn relative to `pos`.
15
+ * (`Text` is anchored by its `align`, not centered — see the Text node.)
16
+ */
4
17
  export type Shape = {
5
18
  kind: 'rect';
6
19
  w: number;
7
20
  h: number;
8
21
  r?: number;
22
+ anchor?: 'center' | 'topLeft';
9
23
  } | {
10
24
  kind: 'circle';
11
25
  radius: number;
@@ -0,0 +1,15 @@
1
+ import { Node, type NodeConfig } from './node';
2
+ export interface ParallaxLayerConfig extends NodeConfig {
3
+ /** Scroll fraction: 0 = pinned to screen (far), 1 = full world scroll (near). */
4
+ factor: number;
5
+ }
6
+ export declare class ParallaxLayer extends Node {
7
+ readonly type = "ParallaxLayer";
8
+ cosmetic: boolean;
9
+ factor: number;
10
+ constructor(config: ParallaxLayerConfig);
11
+ /** Re-sync to the current camera; called each process tick (and on demand). */
12
+ sync(): void;
13
+ protected onReady(): void;
14
+ protected onProcess(): void;
15
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ /** Start serving over stdio (the entry points call this — never on import). */
2
+ export declare function startStudioMcp(): Promise<void>;
@@ -0,0 +1,54 @@
1
+ import type { EndReason, KnobEvent, PlaytestSession, ScreenEvent, VariantRef, WallClockMark } from './session';
2
+ import type { TuningValues } from '../app/tuning';
3
+ import type { WorldSnapshot } from '../world';
4
+ export interface RecorderInit {
5
+ game: string;
6
+ seed: number;
7
+ tuningValues: TuningValues;
8
+ variant?: VariantRef;
9
+ buildRef?: string;
10
+ startedAt?: string;
11
+ id?: string;
12
+ /** Segment sessions (post hot-swap) replay from this snapshot. */
13
+ startSnapshot?: WorldSnapshot;
14
+ }
15
+ export declare class SessionRecorder {
16
+ private frames;
17
+ private axesLog;
18
+ private lastAxes;
19
+ private knobEvents;
20
+ private screenEvents;
21
+ private wallClockMarks;
22
+ private annotations;
23
+ private readonly init;
24
+ private readonly startedAt;
25
+ readonly id: string;
26
+ /** Set once the dev server reports the git sha (fetched async by runStudio). */
27
+ buildRef: string;
28
+ constructor(init: RecorderInit);
29
+ /** Frames recorded so far — event hooks stamp themselves with this. */
30
+ get frame(): number;
31
+ /** Live view of the recorded input frames (the scrub timeline re-steps these). */
32
+ get liveInputFrames(): readonly string[][];
33
+ /** Live view of the delta-encoded axes log. */
34
+ get liveAxesLog(): ReadonlyArray<readonly [number, string, number]>;
35
+ /** Live view of mid-play knob changes (scrubbing reapplies them exactly). */
36
+ get liveKnobEvents(): readonly KnobEvent[];
37
+ /**
38
+ * Record one sim step: the action set it saw and (optionally) the analog
39
+ * axes. Axes are delta-encoded — an entry lands only when a value changed.
40
+ */
41
+ step(actions: readonly string[], axes?: ReadonlyMap<string, number>): void;
42
+ knob(key: string, value: number | string): void;
43
+ screen(kind: ScreenEvent['kind'], detail?: string): void;
44
+ mark(kind: WallClockMark['kind'], t: number): void;
45
+ annotate(tag: string, note?: string): void;
46
+ /**
47
+ * A rewind-and-resume forked the timeline: everything after `frame` never
48
+ * happened in the final run. Drop those frames plus the events stamped in
49
+ * them, so the artifact stays exactly replayable as what the player kept.
50
+ */
51
+ truncate(frame: number): void;
52
+ /** Finalize into the artifact. The recorder can keep recording afterwards (autosave). */
53
+ toSession(endReason: EndReason): PlaytestSession;
54
+ }
@@ -0,0 +1,78 @@
1
+ import { type GameHandle, type RunOptions } from '../app/browser';
2
+ import { type TuningSpec, type TuningValues, type Variant } from '../app/tuning';
3
+ import { type GameDefinition } from '../app/game';
4
+ import type { EndReason, PlaytestSession, VariantRef } from './session';
5
+ /** The slice of Vite's import.meta.hot the carryover needs (structural, no vite type dep). */
6
+ export interface HotContext {
7
+ data: Record<string, unknown>;
8
+ dispose(cb: (data: Record<string, unknown>) => void): void;
9
+ }
10
+ export interface StudioOptions extends RunOptions {
11
+ /**
12
+ * Named A/B variants of this game. `?variant=<name>` picks one: its `patch`
13
+ * rewrites the definition, its `tuning` seeds the knob values (explicit
14
+ * `?tuning=`/opts overrides still win). Sessions are stamped with the name.
15
+ */
16
+ variants?: Record<string, Variant>;
17
+ /** Variant identity override (worktree builds stamp commit info here). */
18
+ variant?: VariantRef;
19
+ /**
20
+ * Pass `import.meta.hot` (only the game's own entry module has it) to carry
21
+ * the live world ACROSS code hot-swaps: snapshot on dispose, restore into
22
+ * the re-executed module. The old session flushes as 'hot-swap'; the new
23
+ * segment records the restored snapshot so it stays fully replayable.
24
+ *
25
+ * The entry MUST also contain the literal line `import.meta.hot?.accept();`
26
+ * — Vite decides HMR boundaries by statically scanning module source, so an
27
+ * accept() call inside this library cannot mark your entry self-accepting.
28
+ */
29
+ hot?: HotContext;
30
+ }
31
+ export interface StudioHandle extends GameHandle {
32
+ /** Live-change a tuning knob: rebuild-with-carryover, recorded as a knob event. */
33
+ setKnob(key: string, value: number | string): void;
34
+ /** Current resolved tuning values. */
35
+ knobValues(): TuningValues;
36
+ /** The game's declared knob spec (the Studio panel builds its controls from this). */
37
+ tuningSpec(): TuningSpec | undefined;
38
+ /** Declared A/B variants: name → label. */
39
+ variants(): Record<string, string>;
40
+ /** The variant this run is playing (name + kind, worktrees include commit). */
41
+ activeVariant(): VariantRef;
42
+ /** The game title (the Studio page labels panes with it). */
43
+ title(): string;
44
+ /** Drop a human annotation at the current frame ("felt bad here"). */
45
+ annotate(tag: string, note?: string): void;
46
+ /** Freeze/unfreeze the sim (rendering continues; no pause overlay). */
47
+ setFrozen(frozen: boolean): void;
48
+ frozen(): boolean;
49
+ /**
50
+ * Time-travel to a recorded frame (freezes first). Exact: restores the
51
+ * nearest ring snapshot and re-steps the recorded inputs. Returns the frame
52
+ * reached, or null if it's off the ring. Resuming after a rewind FORKS the
53
+ * timeline: the discarded future is truncated from the session.
54
+ */
55
+ scrub(frame: number): number | null;
56
+ /** Scrub bounds + position: min reachable frame, current, recorded max. */
57
+ timeline(): {
58
+ min: number;
59
+ frame: number;
60
+ max: number;
61
+ };
62
+ /**
63
+ * 'live' = playing + recording; 'replay' = watching a past session loaded
64
+ * via ?session=<id> (read-only: scrub/playback over the whole recording,
65
+ * knobs and annotation disabled, nothing records).
66
+ */
67
+ mode(): 'live' | 'replay';
68
+ /** Flush the session artifact to the dev server now. */
69
+ flush(reason?: EndReason): void;
70
+ /** The in-progress session (for inspection/tests). */
71
+ session(): PlaytestSession;
72
+ }
73
+ declare global {
74
+ interface Window {
75
+ __studio?: StudioHandle;
76
+ }
77
+ }
78
+ export declare function runStudio(baseDef: GameDefinition, mount: HTMLElement, opts?: StudioOptions): StudioHandle;
@@ -0,0 +1,80 @@
1
+ import { type GameDefinition } from '../app/game';
2
+ import type { TuningValues } from '../app/tuning';
3
+ import type { InputLog } from '../input/actions';
4
+ import type { World, WorldSnapshot } from '../world';
5
+ /** Which build/variant produced a session — a report is never ambiguous about code. */
6
+ export interface VariantRef {
7
+ name: string;
8
+ kind: 'dev' | 'module' | 'worktree';
9
+ commit?: string;
10
+ }
11
+ export interface ScreenEvent {
12
+ frame: number;
13
+ kind: 'pause' | 'resume' | 'restart' | 'overlay-show' | 'overlay-hide' | 'knob' | 'variant' | 'hot-swap' | 'scrub';
14
+ detail?: string;
15
+ }
16
+ /** Wall-clock context the sim can't see (tab hidden ≠ hesitation). */
17
+ export interface WallClockMark {
18
+ frame: number;
19
+ /** ms since session start (wall clock — context only, never sim input). */
20
+ t: number;
21
+ kind: 'visibility-hidden' | 'visibility-visible' | 'blur' | 'focus';
22
+ }
23
+ export interface Annotation {
24
+ frame: number;
25
+ tag: string;
26
+ note?: string;
27
+ }
28
+ /** A mid-play tuning change (Studio knob): replay applies it AT its frame. */
29
+ export interface KnobEvent {
30
+ frame: number;
31
+ key: string;
32
+ value: number | string;
33
+ }
34
+ export type EndReason = 'goal' | 'quit' | 'restart' | 'navigate' | 'idle' | 'hot-swap';
35
+ export interface PlaytestSession {
36
+ id: string;
37
+ game: string;
38
+ startedAt: string;
39
+ /** Git short sha of the code that ran (from the Studio dev server). */
40
+ buildRef: string;
41
+ seed: number;
42
+ variant: VariantRef;
43
+ /** Tuning at session start (resolved values). */
44
+ tuningValues: TuningValues;
45
+ /**
46
+ * When play continued across a code hot-swap, the new segment records the
47
+ * world it resumed FROM — replay restores it before stepping, so even
48
+ * mid-development sessions stay bit-exactly re-executable.
49
+ */
50
+ startSnapshot?: WorldSnapshot;
51
+ knobEvents: KnobEvent[];
52
+ inputLog: InputLog;
53
+ /** Delta-encoded analog axes: [frame, axis, value] whenever a value changed. */
54
+ axesLog: Array<[number, string, number]>;
55
+ screenEvents: ScreenEvent[];
56
+ wallClockMarks: WallClockMark[];
57
+ annotations: Annotation[];
58
+ endReason: EndReason;
59
+ /** Invited-playtester fields — creator sessions leave them unset. */
60
+ playerId?: string;
61
+ consent?: {
62
+ recorded: boolean;
63
+ at: string;
64
+ };
65
+ }
66
+ export interface ReplayOptions {
67
+ toFrame?: number;
68
+ /** Observer called once with the fresh world BEFORE any step (subscribe to events here). */
69
+ onWorld?: (world: World) => void;
70
+ /** Observer called AFTER each step with the frame index just played. */
71
+ onFrame?: (world: World, frame: number) => void;
72
+ }
73
+ /**
74
+ * Replay a session headlessly to `toFrame` (default: the whole log) and return
75
+ * the live world. Bit-exact with the original run: axes deltas are applied
76
+ * before the step that first saw them; knob events rebuild-with-carryover at
77
+ * their frame exactly as the Studio panel did (including `def.attach`).
78
+ * A bare number as `opts` is `toFrame`.
79
+ */
80
+ export declare function replaySession(def: GameDefinition, session: PlaytestSession, opts?: number | ReplayOptions): World;
@@ -0,0 +1,35 @@
1
+ import type { GameDefinition } from '../app/game';
2
+ import type { World, WorldSnapshot } from '../world';
3
+ import type { KnobEvent } from './session';
4
+ export interface TimelineEntry {
5
+ frame: number;
6
+ snap: WorldSnapshot;
7
+ }
8
+ /**
9
+ * Periodic snapshot keeper. Every `stride` frames costs one snapshot; `cap`
10
+ * bounds memory (oldest evicted), so scrub range ≈ stride × cap frames back.
11
+ */
12
+ export declare class SnapshotRing {
13
+ readonly stride: number;
14
+ readonly cap: number;
15
+ private entries;
16
+ constructor(stride?: number, cap?: number);
17
+ /** Call after every recorded step; keeps frame 0 and every stride-th frame. */
18
+ push(frame: number, world: World): void;
19
+ /** Nearest entry at or before `frame` (null if it fell off the ring). */
20
+ nearest(frame: number): TimelineEntry | null;
21
+ /** Oldest reachable frame (scrubbing earlier than this is off the ring). */
22
+ get minFrame(): number;
23
+ /** Drop entries after `frame` — a rewind-and-resume forked the timeline. */
24
+ truncate(frame: number): void;
25
+ clear(): void;
26
+ }
27
+ /**
28
+ * Scrub a live world to `target`: restore the nearest ring snapshot, then
29
+ * re-step the recorded inputs. `inputFrames[i]` is the input DURING step i, so
30
+ * stepping frames[entry..target-1] lands the world exactly AT `target`.
31
+ * Returns the frame actually reached (clamped to the ring's reachable range),
32
+ * or null if the ring is empty. `def.attach` runs after the restore — the same
33
+ * contract as every other restore path.
34
+ */
35
+ export declare function scrubTo(world: World, def: GameDefinition, ring: SnapshotRing, inputFrames: readonly string[][], axesLog: ReadonlyArray<readonly [number, string, number]>, knobEvents: readonly KnobEvent[], target: number): number | null;
@@ -0,0 +1,6 @@
1
+ import type { Plugin } from 'vite';
2
+ export interface StudioPluginOptions {
3
+ /** Where session/knob/variant files live. Default: `.studio` under the project root. */
4
+ dir?: string;
5
+ }
6
+ export declare function hayaoStudio(opts?: StudioPluginOptions): Plugin;