ugly-game 0.1.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 (47) hide show
  1. package/dist/capture.d.ts +27 -0
  2. package/dist/capture.js +35 -0
  3. package/dist/determinismProbe.d.ts +21 -0
  4. package/dist/determinismProbe.js +61 -0
  5. package/dist/ecs.d.ts +80 -0
  6. package/dist/ecs.js +338 -0
  7. package/dist/frameProbe.d.ts +22 -0
  8. package/dist/frameProbe.js +56 -0
  9. package/dist/gpuDriven.d.ts +39 -0
  10. package/dist/gpuDriven.js +197 -0
  11. package/dist/gpuGI.d.ts +39 -0
  12. package/dist/gpuGI.js +253 -0
  13. package/dist/gpuGraph.d.ts +28 -0
  14. package/dist/gpuGraph.js +128 -0
  15. package/dist/gpuLit.d.ts +40 -0
  16. package/dist/gpuLit.js +193 -0
  17. package/dist/gpuPost.d.ts +33 -0
  18. package/dist/gpuPost.js +162 -0
  19. package/dist/gpuShadowMap.d.ts +11 -0
  20. package/dist/gpuShadowMap.js +41 -0
  21. package/dist/gpuSpot.d.ts +39 -0
  22. package/dist/gpuSpot.js +213 -0
  23. package/dist/gpuTaa.d.ts +34 -0
  24. package/dist/gpuTaa.js +247 -0
  25. package/dist/gpuTerrain.d.ts +25 -0
  26. package/dist/gpuTerrain.js +154 -0
  27. package/dist/gpuTiled.d.ts +55 -0
  28. package/dist/gpuTiled.js +247 -0
  29. package/dist/gpuWorld.d.ts +49 -0
  30. package/dist/gpuWorld.js +309 -0
  31. package/dist/ir.d.ts +152 -0
  32. package/dist/ir.js +287 -0
  33. package/dist/kcc.d.ts +47 -0
  34. package/dist/kcc.js +191 -0
  35. package/dist/physics.d.ts +35 -0
  36. package/dist/physics.js +131 -0
  37. package/dist/playSim.d.ts +75 -0
  38. package/dist/playSim.js +205 -0
  39. package/dist/renderIR.d.ts +60 -0
  40. package/dist/renderIR.js +96 -0
  41. package/dist/scheduler.d.ts +41 -0
  42. package/dist/scheduler.js +304 -0
  43. package/dist/shaderGraph.d.ts +84 -0
  44. package/dist/shaderGraph.js +231 -0
  45. package/dist/stateTree.d.ts +53 -0
  46. package/dist/stateTree.js +108 -0
  47. package/package.json +5 -2
@@ -0,0 +1,27 @@
1
+ export interface CaptureRow {
2
+ [k: string]: number | string;
3
+ }
4
+ export interface CaptureFrame {
5
+ tick: number;
6
+ rows: CaptureRow[];
7
+ }
8
+ export interface CaptureResult {
9
+ label: string;
10
+ level: string;
11
+ from: number;
12
+ to: number;
13
+ ticks: number;
14
+ rows: number;
15
+ frames: CaptureFrame[];
16
+ }
17
+ /** Emit detail rows for `state` at `tick`, honoring the probe's own level + filter. Return [] to skip. */
18
+ export type Probe<S> = (state: S, tick: number) => CaptureRow[];
19
+ /** Replay a deterministic sim from `init` and capture probe output over the window [from,to). Warms up to
20
+ * `from` without probing (cheap), then probes each tick. `step(s, tick)` advances one deterministic tick
21
+ * (it may pull `inputs[tick]` from a closure). `clone` protects the caller's state. */
22
+ export declare function captureWindow<S>(init: S, clone: (s: S) => S, step: (s: S, tick: number) => void, from: number, to: number, probe: Probe<S>, opts?: {
23
+ label?: string;
24
+ level?: string;
25
+ }): CaptureResult;
26
+ /** Flatten a capture to one row per (tick, entity) — handy for a table / CSV / assertion. */
27
+ export declare function captureRows(r: CaptureResult): CaptureRow[];
@@ -0,0 +1,35 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // LEVELED, FILTERABLE CAPTURE via REPLAY — the debugging counterpart to the moat. Because a sim is a
3
+ // DETERMINISTIC function of (init, inputs), you record only the cheap stuff LIVE (the input stream + a
4
+ // coarse per-tick hash) and reconstruct ANY level of detail ON DEMAND by replaying a bounded WINDOW and
5
+ // running a PROBE at the chosen level-of-detail + filter. So live capture never overloads the system,
6
+ // yet you can later ask "replay 1 second of belt movement, give me the coords of every belt item" and
7
+ // get frame-exact fine detail — for exactly the ticks / entities / fields you filter to.
8
+ //
9
+ // The three knobs:
10
+ // • WINDOW [from,to) — fast-forward to `from` (not captured), probe each tick to `to`. Bounds cost.
11
+ // • LEVEL — how much detail the probe emits (coarse counters → per-entity → per-item). Game-defined.
12
+ // • FILTER — which entities/region/fields the probe emits. Game-defined, applied inside the probe.
13
+ // The engine owns the replay-window machinery; the game supplies a level+filter-aware probe.
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+ /** Replay a deterministic sim from `init` and capture probe output over the window [from,to). Warms up to
16
+ * `from` without probing (cheap), then probes each tick. `step(s, tick)` advances one deterministic tick
17
+ * (it may pull `inputs[tick]` from a closure). `clone` protects the caller's state. */
18
+ export function captureWindow(init, clone, step, from, to, probe, opts) {
19
+ const s = clone(init);
20
+ for (let t = 0; t < from; t++)
21
+ step(s, t); // fast-forward to the window — not captured
22
+ const frames = [];
23
+ let rows = 0;
24
+ for (let t = from; t < to; t++) {
25
+ step(s, t);
26
+ const r = probe(s, t);
27
+ if (r.length) {
28
+ frames.push({ tick: t, rows: r });
29
+ rows += r.length;
30
+ }
31
+ }
32
+ return { label: opts?.label ?? 'capture', level: opts?.level ?? 'custom', from, to, ticks: Math.max(0, to - from), rows, frames };
33
+ }
34
+ /** Flatten a capture to one row per (tick, entity) — handy for a table / CSV / assertion. */
35
+ export function captureRows(r) { return r.frames.flatMap((fr) => fr.rows); }
@@ -0,0 +1,21 @@
1
+ /** Transcendental probe over the exact math surface the ENGINE uses — Math.sin/cos/hypot
2
+ * /sqrt. (We deliberately exclude tan/atan2: the engine doesn't use them, and they differ
3
+ * even between V8 *versions*, which would be false-alarm noise.) Isolates whether these
4
+ * specific functions agree bit-for-bit across the device's JS engine and the reference. */
5
+ export declare function mathProbe(): string;
6
+ /** Serial ECS sim (Gauss-Seidel) hash over the fixed scenario. */
7
+ export declare function serialProbe(): string;
8
+ /** Parallel-grid scheduler (Jacobi) hash over the fixed scenario. */
9
+ export declare function parallelProbe(): string;
10
+ export declare function runProbes(): {
11
+ math: string;
12
+ serial: string;
13
+ parallel: string;
14
+ };
15
+ /** The Mac / Node (V8, arm64) reference — the baseline every device is compared against.
16
+ * Computed by `npx tsx cli/determinismRef.mts`. */
17
+ export declare const REFERENCE: {
18
+ readonly math: "6502c8d3";
19
+ readonly serial: "4bffcd3";
20
+ readonly parallel: "9d2a9612";
21
+ };
@@ -0,0 +1,61 @@
1
+ // Cross-machine / cross-engine determinism probe. The engine's moat (replay, savestate,
2
+ // lockstep) needs bit-exact determinism ACROSS hardware + JS engines. Pure-JS Math.sin/cos
3
+ // etc. are NOT guaranteed identical across V8 (Chrome/Node) vs JavaScriptCore (iOS Safari)
4
+ // vs the x86 vs ARM microcode — so this probe hashes: (1) a transcendental-heavy loop that
5
+ // ISOLATES Math.* divergence, (2) the serial ECS sim, (3) the parallel-grid scheduler.
6
+ // Run the same probe on iOS / Android / Windows-Intel and compare the hashes to the Mac/Node
7
+ // REFERENCE below. Any mismatch localises exactly where determinism breaks (and tells us
8
+ // whether we need fixed-point / a fixed-transcendental library, per GAME.md §5.1).
9
+ import { fnv1a } from './stateTree';
10
+ import { rngFromState, NO_INPUT } from './playSim';
11
+ import { initEcs, ecsStep, ecsHash } from './ecs';
12
+ import { stepScheduledGrid } from './scheduler';
13
+ /** Transcendental probe over the exact math surface the ENGINE uses — Math.sin/cos/hypot
14
+ * /sqrt. (We deliberately exclude tan/atan2: the engine doesn't use them, and they differ
15
+ * even between V8 *versions*, which would be false-alarm noise.) Isolates whether these
16
+ * specific functions agree bit-for-bit across the device's JS engine and the reference. */
17
+ export function mathProbe() {
18
+ let x = 0.123456789, acc = 0;
19
+ for (let i = 0; i < 40000; i++) {
20
+ x = Math.sin(x * 1.30017 + i * 1e-4) * 0.9 + Math.cos(x * 0.7) * 0.3;
21
+ acc += Math.hypot(x, i * 1e-3) + Math.sqrt(Math.abs(x) + 1);
22
+ }
23
+ return fnv1a([x, acc].join(','));
24
+ }
25
+ /** A fixed, deterministic input stream that exercises movement / collision / shoot / spawn / despawn. */
26
+ function scenario() {
27
+ const out = [];
28
+ for (let f = 0; f < 400; f++) {
29
+ const inp = { ...NO_INPUT, mv: f % 4 !== 0 ? 1 : 0, turn: f % 50 < 18 ? 1 : f % 50 < 36 ? -1 : 0 };
30
+ if (f % 17 === 0)
31
+ inp.shoot = 1;
32
+ if (f % 23 === 0)
33
+ inp.shove = 1;
34
+ out.push(inp);
35
+ }
36
+ return out;
37
+ }
38
+ /** Serial ECS sim (Gauss-Seidel) hash over the fixed scenario. */
39
+ export function serialProbe() {
40
+ const w = initEcs();
41
+ const rng = rngFromState(0);
42
+ const inp = scenario();
43
+ for (let f = 0; f < inp.length; f++)
44
+ ecsStep(w, inp[f], rng, f);
45
+ return ecsHash(w, rng.state);
46
+ }
47
+ /** Parallel-grid scheduler (Jacobi) hash over the fixed scenario. */
48
+ export function parallelProbe() {
49
+ const w = initEcs();
50
+ const rng = rngFromState(0);
51
+ const inp = scenario();
52
+ for (let f = 0; f < inp.length; f++)
53
+ stepScheduledGrid(w, inp[f], rng, f, 4);
54
+ return ecsHash(w, rng.state);
55
+ }
56
+ export function runProbes() {
57
+ return { math: mathProbe(), serial: serialProbe(), parallel: parallelProbe() };
58
+ }
59
+ /** The Mac / Node (V8, arm64) reference — the baseline every device is compared against.
60
+ * Computed by `npx tsx cli/determinismRef.mts`. */
61
+ export const REFERENCE = { math: '6502c8d3', serial: '4bffcd3', parallel: '9d2a9612' };
package/dist/ecs.d.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { type Input, type RNG } from './playSim';
2
+ export declare const KIND_OBJ = 0, KIND_PROJ = 1, KIND_FRAG = 2;
3
+ export declare const CAP = 512;
4
+ /** A deferred structural mutation — applied at flush() in insertion order (deterministic). */
5
+ type Cmd = {
6
+ op: 'spawn';
7
+ kind: number;
8
+ x: number;
9
+ y: number;
10
+ z: number;
11
+ vx: number;
12
+ vy: number;
13
+ vz: number;
14
+ r: number;
15
+ m: number;
16
+ hp: number;
17
+ life: number;
18
+ } | {
19
+ op: 'despawn';
20
+ slot: number;
21
+ };
22
+ export interface Character {
23
+ x: number;
24
+ z: number;
25
+ heading: number;
26
+ y: number;
27
+ vy: number;
28
+ gait: 0 | 1 | 2;
29
+ jumpF: number;
30
+ animRate: number;
31
+ carrying: number;
32
+ shootCd: number;
33
+ }
34
+ /** The ECS world: SoA body columns + a character singleton + the allocator + queue. */
35
+ export interface EcsWorld {
36
+ alive: Uint8Array;
37
+ id: Float64Array;
38
+ kind: Float64Array;
39
+ x: Float64Array;
40
+ y: Float64Array;
41
+ z: Float64Array;
42
+ vx: Float64Array;
43
+ vy: Float64Array;
44
+ vz: Float64Array;
45
+ r: Float64Array;
46
+ m: Float64Array;
47
+ hp: Float64Array;
48
+ life: Float64Array;
49
+ held: Float64Array;
50
+ free: Int32Array;
51
+ freeTop: number;
52
+ nextId: number;
53
+ ch: Character;
54
+ cmds: Cmd[];
55
+ }
56
+ /** Initial world: character + `n` crates on a ring (a representative slice of the sim). */
57
+ export declare function initEcs(): EcsWorld;
58
+ /** Seed a world with specific crates (for tests): each [x, z, r]. */
59
+ export declare function initEcsAt(bodies: [number, number, number][]): EcsWorld;
60
+ /** Seed `n` crates at deterministic pseudo-random positions (dense-scene scale tests). */
61
+ export declare function initEcsDense(n: number, seed?: number): EcsWorld;
62
+ /** Nearest grabbable/shovable body the character is FACING — aim-aware, so grab/shove target
63
+ * what you point at, not merely the closest crate. Considers only bodies within a forward cone
64
+ * (~78° of the heading); a coincident/touching body counts as in-front. Deterministic (dmath). */
65
+ export declare function nearestBody(w: EcsWorld, cx: number, cz: number, reach: number): number;
66
+ export declare function sysCharacter(w: EcsWorld, inp: Input, frame: number): void;
67
+ export declare function sysInteract(w: EcsWorld, inp: Input): void;
68
+ export declare function flush(w: EcsWorld): void;
69
+ /** Advance the ECS world ONE fixed tick. Pure + deterministic. Matches StepFn<EcsWorld>. */
70
+ export declare function ecsStep(w: EcsWorld, inp: Input, _rng: RNG, frame: number): void;
71
+ export declare function ecsClone(w: EcsWorld): EcsWorld;
72
+ export declare function ecsHash(w: EcsWorld, rngState: number): string;
73
+ export declare const ECS_OPS: {
74
+ clone: typeof ecsClone;
75
+ hash: typeof ecsHash;
76
+ };
77
+ /** Count alive bodies of a kind (helpers for tests + detectors). */
78
+ export declare function countKind(w: EcsWorld, kind: number): number;
79
+ export declare function aliveCount(w: EcsWorld): number;
80
+ export {};
package/dist/ecs.js ADDED
@@ -0,0 +1,338 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // A minimal DETERMINISTIC SoA ECS — the real engine's simulation core, in embryo.
3
+ // This is the engine-build-plan.md "next step": move the moat (deterministic
4
+ // replay + savestate + AI exploration) off the hand-written demo struct and onto a
5
+ // real Structure-of-Arrays ECS, WITHOUT losing bit-exact determinism.
6
+ //
7
+ // Design that makes the moat survive here:
8
+ // • Components are numeric COLUMNS (typed arrays) indexed by a body slot — SoA, the
9
+ // layout the real engine needs (GAME.md §4.1: "the enemy is boundary crossings").
10
+ // • Entities are slots with a DETERMINISTIC free-slot allocator + monotonic stable
11
+ // ids — allocation order is part of the determinism contract.
12
+ // • Structural mutations (spawn/despawn/fragment) go through an ORDERED COMMAND
13
+ // QUEUE applied at a barrier (flush), so mid-tick iteration never mutates the set
14
+ // and the apply order is deterministic (GAME.md §9's command-queue requirement).
15
+ // • The whole world snapshots to owned copies → the StateTree/replay/exploration run
16
+ // on it UNCHANGED (StateTree is generic; ecsClone/ecsHash are the injected ops).
17
+ //
18
+ // The character is a singleton (physics-vs-logic split); bodies are the ECS entities.
19
+ // Constants are owned here (this is the engine core) and match physics.ts/playSim.ts.
20
+ // See design/engine-build-plan.md §4, design/search-based-testing.md.
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+ import { FIXED_DT, SPEED, CLIP_BASE, mulberry32 } from './playSim';
23
+ import { fnv1a } from './stateTree';
24
+ import { dsin, dcos, dhypot, dhypot3 } from './dmath';
25
+ export const KIND_OBJ = 0, KIND_PROJ = 1, KIND_FRAG = 2;
26
+ export const CAP = 512; // max concurrent body entities
27
+ // character-motion constants (match playSim)
28
+ const TURN = 1.8, GRAV_CH = 26, JUMP_V0 = 8.5, RIM = 14;
29
+ // body-physics constants (match physics.ts)
30
+ const GRAV = 20, PROJ_SPEED = 22, PROJ_DMG = 1, HARD_IMPACT = 9, REST_FRICTION = 0.82, AIR_DAMP = 0.992;
31
+ function blankWorld() {
32
+ const free = new Int32Array(CAP);
33
+ for (let i = 0; i < CAP; i++)
34
+ free[i] = CAP - 1 - i; // pop order: slot 0,1,2,… (deterministic)
35
+ return {
36
+ alive: new Uint8Array(CAP), id: new Float64Array(CAP), kind: new Float64Array(CAP),
37
+ x: new Float64Array(CAP), y: new Float64Array(CAP), z: new Float64Array(CAP),
38
+ vx: new Float64Array(CAP), vy: new Float64Array(CAP), vz: new Float64Array(CAP),
39
+ r: new Float64Array(CAP), m: new Float64Array(CAP), hp: new Float64Array(CAP), life: new Float64Array(CAP), held: new Float64Array(CAP),
40
+ free, freeTop: CAP, nextId: 1,
41
+ ch: { x: 0, z: 0, heading: 0, y: 0, vy: 0, gait: 0, jumpF: -1, animRate: 1, carrying: -1, shootCd: 0 },
42
+ cmds: [],
43
+ };
44
+ }
45
+ /** Allocate a body immediately (used by init + by flush). Deterministic slot + id. */
46
+ function alloc(w, kind, x, y, z, vx, vy, vz, r, m, hp, life) {
47
+ if (w.freeTop === 0)
48
+ return -1; // at capacity — deterministic drop
49
+ const slot = w.free[--w.freeTop];
50
+ w.alive[slot] = 1;
51
+ w.id[slot] = w.nextId++;
52
+ w.kind[slot] = kind;
53
+ w.x[slot] = x;
54
+ w.y[slot] = y;
55
+ w.z[slot] = z;
56
+ w.vx[slot] = vx;
57
+ w.vy[slot] = vy;
58
+ w.vz[slot] = vz;
59
+ w.r[slot] = r;
60
+ w.m[slot] = m;
61
+ w.hp[slot] = hp;
62
+ w.life[slot] = life;
63
+ w.held[slot] = 0;
64
+ return slot;
65
+ }
66
+ function freeSlot(w, slot) { if (!w.alive[slot])
67
+ return; w.alive[slot] = 0; w.free[w.freeTop++] = slot; }
68
+ /** Initial world: character + `n` crates on a ring (a representative slice of the sim). */
69
+ export function initEcs() {
70
+ const w = blankWorld();
71
+ // crates at r≈0.9 so a muzzle-height projectile (y≈1.4) actually hits them
72
+ const seeds = [[3, 3], [-3.5, 4], [4.5, -1], [-2, 5], [1.5, 6]];
73
+ for (const [cx, cz] of seeds)
74
+ alloc(w, KIND_OBJ, cx, 0.9, cz, 0, 0, 0, 0.9, 1, 3, -1);
75
+ return w;
76
+ }
77
+ /** Seed a world with specific crates (for tests): each [x, z, r]. */
78
+ export function initEcsAt(bodies) {
79
+ const w = blankWorld();
80
+ for (const [x, z, r] of bodies)
81
+ alloc(w, KIND_OBJ, x, r, z, 0, 0, 0, r, 1, 3, -1);
82
+ return w;
83
+ }
84
+ /** Seed `n` crates at deterministic pseudo-random positions (dense-scene scale tests). */
85
+ export function initEcsDense(n, seed = 0x1234) {
86
+ const rng = mulberry32(seed);
87
+ const b = [];
88
+ for (let k = 0; k < n; k++)
89
+ b.push([(rng() - 0.5) * 24, (rng() - 0.5) * 24, 0.3 + rng() * 0.6]);
90
+ return initEcsAt(b);
91
+ }
92
+ /** Nearest grabbable/shovable body the character is FACING — aim-aware, so grab/shove target
93
+ * what you point at, not merely the closest crate. Considers only bodies within a forward cone
94
+ * (~78° of the heading); a coincident/touching body counts as in-front. Deterministic (dmath). */
95
+ export function nearestBody(w, cx, cz, reach) {
96
+ const fx = dsin(w.ch.heading), fz = dcos(w.ch.heading);
97
+ let best = -1, bestD = reach;
98
+ for (let s = 0; s < CAP; s++) {
99
+ if (!w.alive[s] || w.kind[s] !== KIND_OBJ || w.held[s])
100
+ continue;
101
+ const dx = w.x[s] - cx, dz = w.z[s] - cz, d = dhypot(dx, dz);
102
+ if (d >= bestD)
103
+ continue; // only closer-than-best candidates
104
+ const fwd = d > 1e-6 ? (dx * fx + dz * fz) / d : 1; // cos(angle to facing); coincident ⇒ in front
105
+ if (fwd < 0.2)
106
+ continue; // must be within ~78° of where you face
107
+ bestD = d;
108
+ best = s;
109
+ }
110
+ return best;
111
+ }
112
+ // ── systems (fixed order over the columns) ── (exported for the parallel scheduler)
113
+ export function sysCharacter(w, inp, frame) {
114
+ const c = w.ch;
115
+ c.heading += inp.turn * TURN * FIXED_DT;
116
+ c.gait = inp.mv !== 0 ? (inp.run ? 2 : 1) : 0;
117
+ const sp = SPEED[c.gait] * (inp.mv < 0 ? -1 : 1) * (inp.mv !== 0 ? 1 : 0);
118
+ c.x += dsin(c.heading) * sp * FIXED_DT;
119
+ c.z += dcos(c.heading) * sp * FIXED_DT;
120
+ const rr = dhypot(c.x, c.z);
121
+ if (rr > RIM) {
122
+ c.x *= RIM / rr;
123
+ c.z *= RIM / rr;
124
+ }
125
+ c.animRate = c.gait > 0 ? SPEED[c.gait] / CLIP_BASE[c.gait] : 1;
126
+ if (inp.jump && c.jumpF < 0 && c.y <= 0.001) {
127
+ c.jumpF = frame;
128
+ c.vy = JUMP_V0;
129
+ }
130
+ c.vy -= GRAV_CH * FIXED_DT;
131
+ c.y += c.vy * FIXED_DT;
132
+ if (c.y <= 0) {
133
+ c.y = 0;
134
+ c.vy = 0;
135
+ if (c.jumpF >= 0)
136
+ c.jumpF = -1;
137
+ }
138
+ }
139
+ export function sysInteract(w, inp) {
140
+ const c = w.ch;
141
+ if (inp.shove) {
142
+ const n = nearestBody(w, c.x, c.z, 1.9);
143
+ if (n >= 0) {
144
+ w.vx[n] += dsin(c.heading) * 11;
145
+ w.vz[n] += dcos(c.heading) * 11;
146
+ }
147
+ }
148
+ if (inp.grab) {
149
+ if (c.carrying < 0) {
150
+ const n = nearestBody(w, c.x, c.z, 1.6);
151
+ if (n >= 0) {
152
+ w.held[n] = 1;
153
+ c.carrying = w.id[n];
154
+ }
155
+ }
156
+ else {
157
+ for (let s = 0; s < CAP; s++)
158
+ if (w.alive[s] && w.id[s] === c.carrying) {
159
+ w.held[s] = 0;
160
+ w.vx[s] = dsin(c.heading) * 8;
161
+ w.vz[s] = dcos(c.heading) * 8;
162
+ w.vy[s] = 3;
163
+ }
164
+ c.carrying = -1;
165
+ }
166
+ }
167
+ if (c.shootCd > 0)
168
+ c.shootCd -= 1;
169
+ if (inp.shoot && c.shootCd <= 0) {
170
+ const fx = dsin(c.heading), fz = dcos(c.heading);
171
+ w.cmds.push({ op: 'spawn', kind: KIND_PROJ, x: c.x + fx * 1.15, y: 1.4, z: c.z + fz * 1.15, vx: fx * PROJ_SPEED, vy: 0.5, vz: fz * PROJ_SPEED, r: 0.16, m: 0.3, hp: 1, life: 90 });
172
+ c.shootCd = 10;
173
+ }
174
+ }
175
+ function sysPhysics(w, dt) {
176
+ const c = w.ch;
177
+ for (let s = 0; s < CAP; s++) {
178
+ if (!w.alive[s])
179
+ continue;
180
+ if (w.life[s] > 0)
181
+ w.life[s] -= 1;
182
+ if (w.held[s]) {
183
+ w.x[s] = c.x + dsin(c.heading) * (0.6 + w.r[s] + 0.1);
184
+ w.z[s] = c.z + dcos(c.heading) * (0.6 + w.r[s] + 0.1);
185
+ w.y[s] = 1.7;
186
+ w.vx[s] = 0;
187
+ w.vy[s] = 0;
188
+ w.vz[s] = 0;
189
+ continue;
190
+ }
191
+ w.vy[s] -= GRAV * dt;
192
+ w.x[s] += w.vx[s] * dt;
193
+ w.y[s] += w.vy[s] * dt;
194
+ w.z[s] += w.vz[s] * dt;
195
+ if (w.y[s] < w.r[s]) {
196
+ w.y[s] = w.r[s];
197
+ if (w.vy[s] < 0)
198
+ w.vy[s] = -w.vy[s] * 0.2;
199
+ w.vx[s] *= REST_FRICTION;
200
+ w.vz[s] *= REST_FRICTION;
201
+ }
202
+ const rr = dhypot(w.x[s], w.z[s]);
203
+ if (rr > RIM) {
204
+ w.x[s] *= RIM / rr;
205
+ w.z[s] *= RIM / rr;
206
+ w.vx[s] *= -0.3;
207
+ w.vz[s] *= -0.3;
208
+ }
209
+ w.vx[s] *= AIR_DAMP;
210
+ w.vz[s] *= AIR_DAMP;
211
+ }
212
+ // body↔body (alive slots, i<j) — projectile damage + hard-impact hp + separation
213
+ for (let i = 0; i < CAP; i++) {
214
+ if (!w.alive[i] || w.held[i])
215
+ continue;
216
+ for (let j = i + 1; j < CAP; j++) {
217
+ if (!w.alive[j] || w.held[j])
218
+ continue;
219
+ const dx = w.x[j] - w.x[i], dy = w.y[j] - w.y[i], dz = w.z[j] - w.z[i];
220
+ const d = dhypot3(dx, dy, dz) || 1e-6;
221
+ const ov = w.r[i] + w.r[j] - d;
222
+ if (ov <= 0)
223
+ continue;
224
+ const nx = dx / d, ny = dy / d, nz = dz / d;
225
+ const iProj = w.kind[i] === KIND_PROJ, jProj = w.kind[j] === KIND_PROJ;
226
+ if (iProj !== jProj) {
227
+ const p = iProj ? i : j, o = iProj ? j : i;
228
+ if (w.kind[o] === KIND_OBJ) {
229
+ w.hp[o] -= PROJ_DMG;
230
+ w.vx[o] += w.vx[p] * 0.06;
231
+ w.vz[o] += w.vz[p] * 0.06;
232
+ w.life[p] = 0;
233
+ continue;
234
+ }
235
+ }
236
+ const push = ov * 0.5;
237
+ w.x[i] -= nx * push;
238
+ w.y[i] -= ny * push;
239
+ w.z[i] -= nz * push;
240
+ w.x[j] += nx * push;
241
+ w.y[j] += ny * push;
242
+ w.z[j] += nz * push;
243
+ const rvn = (w.vx[j] - w.vx[i]) * nx + (w.vy[j] - w.vy[i]) * ny + (w.vz[j] - w.vz[i]) * nz;
244
+ if (rvn < 0) {
245
+ const imp = rvn * 0.5;
246
+ w.vx[i] += imp * nx;
247
+ w.vy[i] += imp * ny;
248
+ w.vz[i] += imp * nz;
249
+ w.vx[j] -= imp * nx;
250
+ w.vy[j] -= imp * ny;
251
+ w.vz[j] -= imp * nz;
252
+ if (-rvn > HARD_IMPACT) {
253
+ if (w.kind[i] === KIND_OBJ)
254
+ w.hp[i] -= 1;
255
+ if (w.kind[j] === KIND_OBJ)
256
+ w.hp[j] -= 1;
257
+ }
258
+ }
259
+ }
260
+ }
261
+ // character cylinder push (planar)
262
+ for (let s = 0; s < CAP; s++) {
263
+ if (!w.alive[s] || w.held[s] || w.kind[s] !== KIND_OBJ || w.y[s] > 2.2)
264
+ continue;
265
+ const dx = w.x[s] - c.x, dz = w.z[s] - c.z;
266
+ const d = dhypot(dx, dz) || 1e-6;
267
+ const ov = 0.6 + w.r[s] - d;
268
+ if (ov > 0) {
269
+ const nx = dx / d, nz = dz / d;
270
+ w.x[s] += nx * ov;
271
+ w.z[s] += nz * ov;
272
+ w.vx[s] += nx * ov * 9;
273
+ w.vz[s] += nz * ov * 9;
274
+ }
275
+ }
276
+ }
277
+ function sysLifecycle(w) {
278
+ for (let s = 0; s < CAP; s++) {
279
+ if (!w.alive[s])
280
+ continue;
281
+ const expired = (w.kind[s] === KIND_PROJ || w.kind[s] === KIND_FRAG) && w.life[s] <= 0;
282
+ const broken = w.kind[s] === KIND_OBJ && w.hp[s] <= 0 && !w.held[s];
283
+ if (broken)
284
+ for (let k = 0; k < 5; k++) {
285
+ const a = (Math.PI * 2 * k) / 5, off = w.r[s] * 0.6;
286
+ w.cmds.push({ op: 'spawn', kind: KIND_FRAG, x: w.x[s] + dcos(a) * off, y: w.y[s] + 0.1, z: w.z[s] + dsin(a) * off, vx: dcos(a) * 4, vy: 4, vz: dsin(a) * 4, r: w.r[s] * 0.25, m: 0.2, hp: 1, life: 48 });
287
+ }
288
+ if (expired || broken) {
289
+ if (w.ch.carrying === w.id[s])
290
+ w.ch.carrying = -1;
291
+ w.cmds.push({ op: 'despawn', slot: s });
292
+ }
293
+ }
294
+ }
295
+ export function flush(w) {
296
+ for (const cmd of w.cmds) {
297
+ if (cmd.op === 'despawn')
298
+ freeSlot(w, cmd.slot);
299
+ else
300
+ alloc(w, cmd.kind, cmd.x, cmd.y, cmd.z, cmd.vx, cmd.vy, cmd.vz, cmd.r, cmd.m, cmd.hp, cmd.life);
301
+ }
302
+ w.cmds.length = 0;
303
+ }
304
+ /** Advance the ECS world ONE fixed tick. Pure + deterministic. Matches StepFn<EcsWorld>. */
305
+ export function ecsStep(w, inp, _rng, frame) {
306
+ sysCharacter(w, inp, frame);
307
+ sysInteract(w, inp);
308
+ sysPhysics(w, FIXED_DT);
309
+ sysLifecycle(w);
310
+ flush(w); // deterministic structural barrier — cmds empty at the tick boundary
311
+ }
312
+ // ── the moat's injected ops (clone + content-hash) — so StateTree runs on the ECS ──
313
+ export function ecsClone(w) {
314
+ return {
315
+ alive: w.alive.slice(), id: w.id.slice(), kind: w.kind.slice(),
316
+ x: w.x.slice(), y: w.y.slice(), z: w.z.slice(), vx: w.vx.slice(), vy: w.vy.slice(), vz: w.vz.slice(),
317
+ r: w.r.slice(), m: w.m.slice(), hp: w.hp.slice(), life: w.life.slice(), held: w.held.slice(),
318
+ free: w.free.slice(), freeTop: w.freeTop, nextId: w.nextId,
319
+ ch: { ...w.ch }, cmds: w.cmds.map((c) => ({ ...c })),
320
+ };
321
+ }
322
+ export function ecsHash(w, rngState) {
323
+ const p = [w.freeTop, w.nextId, rngState, w.ch.x, w.ch.z, w.ch.heading, w.ch.y, w.ch.vy, w.ch.gait, w.ch.jumpF, w.ch.carrying, w.ch.shootCd];
324
+ for (let s = 0; s < CAP; s++) {
325
+ if (!w.alive[s])
326
+ continue;
327
+ p.push(s, w.id[s], w.kind[s], w.x[s], w.y[s], w.z[s], w.vx[s], w.vy[s], w.vz[s], w.r[s], w.hp[s], w.life[s], w.held[s]);
328
+ }
329
+ return fnv1a(p.join(','));
330
+ }
331
+ export const ECS_OPS = { clone: ecsClone, hash: ecsHash };
332
+ /** Count alive bodies of a kind (helpers for tests + detectors). */
333
+ export function countKind(w, kind) { let n = 0; for (let s = 0; s < CAP; s++)
334
+ if (w.alive[s] && w.kind[s] === kind)
335
+ n++; return n; }
336
+ export function aliveCount(w) { let n = 0; for (let s = 0; s < CAP; s++)
337
+ if (w.alive[s])
338
+ n++; return n; }
@@ -0,0 +1,22 @@
1
+ export interface FrameStats {
2
+ meanLum: number;
3
+ minLum: number;
4
+ maxLum: number;
5
+ contrast: number;
6
+ }
7
+ export interface FrameResult {
8
+ stats: FrameStats;
9
+ sig: number[];
10
+ }
11
+ export declare class FrameProbe {
12
+ private device;
13
+ constructor(device: GPUDevice);
14
+ /** Render via `render(enc, colorView, depthView)` to a 256² rgba8 target, read it back, and
15
+ * return sanity stats + an 8×8 luminance signature. Deterministic for a pinned scene. */
16
+ capture(render: (enc: GPUCommandEncoder, colorView: GPUTextureView, depthView: GPUTextureView) => void): Promise<FrameResult>;
17
+ }
18
+ /** Compare a signature to a golden: max per-cell diff + count of cells over a small threshold. */
19
+ export declare function sigDiff(a: number[], b: number[]): {
20
+ maxCell: number;
21
+ overCount: number;
22
+ };
@@ -0,0 +1,56 @@
1
+ // FULL-FRAME visual-regression harness: render a pinned deterministic scene to an offscreen
2
+ // target, READ THE WHOLE FRAMEBUFFER BACK, and reduce it to (a) sanity stats and (b) a small
3
+ // perceptual SIGNATURE that can be compared to a committed golden. This is how the eyeballed
4
+ // render demos become actual regression tests — a broken render (all-black, blown-out, changed)
5
+ // fails automatically instead of needing a human to notice. Pairs with shaderProbe.ts (which
6
+ // checks material values); this checks a whole rendered frame.
7
+ const SIZE = 256, GRID = 8; // 256×256 render (bytesPerRow = 1024, 256-aligned); 8×8 signature
8
+ export class FrameProbe {
9
+ device;
10
+ constructor(device) { this.device = device; }
11
+ /** Render via `render(enc, colorView, depthView)` to a 256² rgba8 target, read it back, and
12
+ * return sanity stats + an 8×8 luminance signature. Deterministic for a pinned scene. */
13
+ async capture(render) {
14
+ const d = this.device, w = SIZE, h = SIZE, bpr = w * 4;
15
+ const color = d.createTexture({ size: [w, h], format: 'rgba8unorm', usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC | GPUTextureUsage.TEXTURE_BINDING });
16
+ const depth = d.createTexture({ size: [w, h], format: 'depth24plus', usage: GPUTextureUsage.RENDER_ATTACHMENT });
17
+ const readBuf = d.createBuffer({ size: bpr * h, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
18
+ const enc = d.createCommandEncoder();
19
+ render(enc, color.createView(), depth.createView());
20
+ enc.copyTextureToBuffer({ texture: color }, { buffer: readBuf, bytesPerRow: bpr, rowsPerImage: h }, [w, h]);
21
+ d.queue.submit([enc.finish()]);
22
+ await readBuf.mapAsync(GPUMapMode.READ);
23
+ const px = new Uint8Array(readBuf.getMappedRange().slice(0));
24
+ readBuf.unmap();
25
+ color.destroy();
26
+ depth.destroy();
27
+ readBuf.destroy();
28
+ // stats over every pixel
29
+ let sum = 0, mn = 255, mx = 0;
30
+ const cell = new Float64Array(GRID * GRID), cellN = w / GRID;
31
+ for (let y = 0; y < h; y++)
32
+ for (let x = 0; x < w; x++) {
33
+ const o = (y * w + x) * 4, lum = 0.299 * px[o] + 0.587 * px[o + 1] + 0.114 * px[o + 2];
34
+ sum += lum;
35
+ if (lum < mn)
36
+ mn = lum;
37
+ if (lum > mx)
38
+ mx = lum;
39
+ cell[Math.floor(y / cellN) * GRID + Math.floor(x / cellN)] += lum;
40
+ }
41
+ const n = w * h, sig = Array.from(cell, (c) => Math.round(c / (cellN * cellN)));
42
+ return { stats: { meanLum: +(sum / n).toFixed(2), minLum: mn, maxLum: mx, contrast: mx - mn }, sig };
43
+ }
44
+ }
45
+ /** Compare a signature to a golden: max per-cell diff + count of cells over a small threshold. */
46
+ export function sigDiff(a, b) {
47
+ let maxCell = 0, overCount = 0;
48
+ for (let i = 0; i < a.length; i++) {
49
+ const dd = Math.abs(a[i] - (b[i] ?? 0));
50
+ if (dd > maxCell)
51
+ maxCell = dd;
52
+ if (dd > 8)
53
+ overCount++;
54
+ }
55
+ return { maxCell, overCount };
56
+ }