ugly-game 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ir.d.ts ADDED
@@ -0,0 +1,152 @@
1
+ import { type Input, type SimState } from './playSim';
2
+ import { type Probe, type CaptureResult } from './capture';
3
+ export declare const IR_VERSION = 2;
4
+ export interface IRMeta {
5
+ irVersion: number;
6
+ app: string;
7
+ page: string;
8
+ buildId: string;
9
+ sessionId: string;
10
+ startedAtMs: number;
11
+ seed: number;
12
+ ua: string;
13
+ }
14
+ export interface IREntity {
15
+ id: string;
16
+ kind: string;
17
+ role?: string;
18
+ materialId?: string;
19
+ meshRef?: string;
20
+ attachedTo?: string;
21
+ }
22
+ export interface IRMaterial {
23
+ id: string;
24
+ color?: string;
25
+ roughness?: number;
26
+ metalness?: number;
27
+ emissive?: string;
28
+ emissiveIntensity?: number;
29
+ maps?: {
30
+ slot: string;
31
+ name: string;
32
+ wrapS?: string;
33
+ wrapT?: string;
34
+ repeat?: [number, number];
35
+ size?: [number, number];
36
+ }[];
37
+ }
38
+ export interface IRLight {
39
+ id: string;
40
+ type: string;
41
+ color: string;
42
+ intensity: number;
43
+ pos?: [number, number, number];
44
+ }
45
+ export interface IRSceneManifest {
46
+ entities: IREntity[];
47
+ materials: IRMaterial[];
48
+ lights: IRLight[];
49
+ }
50
+ export interface IRSnapshot {
51
+ meta: IRMeta;
52
+ scene: IRSceneManifest;
53
+ startFrame: number;
54
+ startRngState: number;
55
+ startState: SimState;
56
+ inputs: Input[];
57
+ keyframes: {
58
+ f: number;
59
+ s: SimState;
60
+ rngState: number;
61
+ }[];
62
+ renderProbes?: RenderProbe[];
63
+ reason: 'error' | 'feedback' | 'manual';
64
+ note?: string;
65
+ capturedAtFrame: number;
66
+ }
67
+ /** A render/audio-layer measurement sampled from the LIVE scene (mesh + mixer + audio
68
+ * context) — the things deterministic replay can't reconstruct because they live in the
69
+ * GLB/mixer/WebAudio, not the sim. Captured sparsely (keyframe-like) by the client/harness;
70
+ * `analyzeTrajectory` reads them to catch bugs the sim is structurally blind to (a
71
+ * character that visibly shrinks when an animation plays, TTS that "speaks" with no audio). */
72
+ export interface RenderProbe {
73
+ frame: number;
74
+ charScale?: number;
75
+ headY?: number;
76
+ gait?: 0 | 1 | 2;
77
+ ttsSpeaking?: boolean;
78
+ audioPlaying?: boolean;
79
+ }
80
+ export interface IRAnomaly {
81
+ severity: 'warn' | 'error';
82
+ code: string;
83
+ msg: string;
84
+ frames?: [number, number];
85
+ detail?: unknown;
86
+ }
87
+ export declare class IRRecorder {
88
+ meta: IRMeta;
89
+ scene: IRSceneManifest;
90
+ startState: SimState;
91
+ startFrame: number;
92
+ startRngState: number;
93
+ inputs: Input[];
94
+ keyframes: {
95
+ f: number;
96
+ s: SimState;
97
+ rngState: number;
98
+ }[];
99
+ renderProbes: RenderProbe[];
100
+ private cap;
101
+ private keyEvery;
102
+ constructor(meta: Omit<IRMeta, 'irVersion'>, startState: SimState, capFrames?: number, keyEvery?: number);
103
+ registerEntity(e: IREntity): void;
104
+ registerMaterial(m: IRMaterial): void;
105
+ registerLight(l: IRLight): void;
106
+ /** Record a render/audio-layer probe (see RenderProbe). Kept bounded like the input
107
+ * window; dropped oldest-first when it exceeds the frame cap. */
108
+ recordRenderProbe(p: RenderProbe): void;
109
+ /** Record one tick. `state`/`rngState` are the sim + RNG state AFTER this step
110
+ * (== the start of the next frame), stored periodically as a replay/seek anchor. */
111
+ recordTick(inp: Input, state: SimState, rngState: number): void;
112
+ /** Keep the LAST ~cap frames (a debug capture wants the run leading up to the issue).
113
+ * Re-anchor the window to the latest keyframe ≤ the new start — carrying its sim +
114
+ * RNG state — so the trimmed window still replays exactly. */
115
+ private reanchor;
116
+ snapshot(reason: IRSnapshot['reason'], note?: string): IRSnapshot;
117
+ }
118
+ /** Reconstruct the full state trajectory from the anchor (startState + rng) + input
119
+ * stream, in a single O(n) pass. Same in the browser and Node — the CLI/agent calls
120
+ * this to inspect any frame, and re-runs it after a fix to confirm. */
121
+ export declare function replayTrajectory(snap: IRSnapshot): SimState[];
122
+ /** Trim a snapshot to fit `maxBytes` (the D1 row ceiling a report rides in) by
123
+ * re-anchoring to the latest keyframe that fits — dropping OLDEST frames first, and
124
+ * carrying that keyframe's sim + RNG state so the result still replays exactly. */
125
+ export declare function capIRSnapshot(snap: IRSnapshot, maxBytes?: number): IRSnapshot;
126
+ export type CaptureLevel = 'hash' | 'counters' | 'entity' | 'full';
127
+ export interface CaptureFilter {
128
+ region?: [number, number, number, number];
129
+ kinds?: number[];
130
+ }
131
+ /** A leveled + filtered probe over a reconstructed playSim state: hash (a per-tick fingerprint) →
132
+ * counters (aggregate) → entity (per-body pose) → full (per-body pose + velocity). Reusable pattern:
133
+ * a game supplies one of these to captureWindow/captureIR for its own state. */
134
+ export declare function playProbe(level: CaptureLevel, filter?: CaptureFilter): Probe<SimState>;
135
+ /** Replay the IR and CAPTURE detail at a chosen level + filter over an absolute-frame window — the IR's
136
+ * leveled, filterable extraction, riding the shared capture.ts engine. `from`/`to` are ABSOLUTE frames
137
+ * (default = the whole recorded window), clamped to what's recorded. Offline; zero live cost. */
138
+ export declare function captureIR(snap: IRSnapshot, opts: {
139
+ level: CaptureLevel;
140
+ from?: number;
141
+ to?: number;
142
+ filter?: CaptureFilter;
143
+ probe?: Probe<SimState>;
144
+ }): CaptureResult;
145
+ /** A few built-in INVARIANT checks (the two live bugs are the first test cases).
146
+ * The real value is agent-queryable data + replay; these are sanity assertions, not
147
+ * the product. Runs the deterministic replay, then inspects the reconstructed states. */
148
+ export declare function analyzeIR(snap: IRSnapshot): IRAnomaly[];
149
+ /** The invariant checks, over any reconstructed trajectory. Exported so the ugly-game
150
+ * CLI (and tests) can run them on a replayed run — and so they act as REGRESSION
151
+ * GUARDS: feed a buggy trajectory (drifting camera / flat mouth) and they fire. */
152
+ export declare function analyzeTrajectory(T: SimState[], inputs: Input[], scene: IRSceneManifest, probes?: RenderProbe[]): IRAnomaly[];
package/dist/ir.js ADDED
@@ -0,0 +1,287 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // ugly-game IR (intermediate representation) — v2, replay-based
3
+ //
4
+ // The debuggability moat: a structured, AI-parseable record of a run so an agent
5
+ // can diagnose from DATA (and re-run it), not screenshots. Because the sim is
6
+ // deterministic (playSim), the IR is TINY and truly replayable: a scene *manifest*
7
+ // (entities/materials/lights) + the RNG *seed* + the per-tick *input stream* +
8
+ // periodic state *keyframes* (seek anchors). The dynamic state is not stored — it's
9
+ // RECONSTRUCTED by re-running playSim.step over the input stream, identically in the
10
+ // browser or in Node (the ugly-game CLI). Re-running after a code fix proves the fix.
11
+ //
12
+ // v2 lives in the demo (the sim is /play-specific). It graduates to the ugly-game
13
+ // package + the owned ECS once the theory holds; the manifest is the shim that
14
+ // stands in for the ECS component snapshot until the ECS exists.
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+ import { step, rngFromState, NO_INPUT, cloneState, SPEED, CLIP_BASE } from './playSim';
17
+ import { captureWindow } from './capture';
18
+ import { fnv1a } from './stateTree';
19
+ export const IR_VERSION = 2;
20
+ // ─── Recorder (client-side) ──────────────────────────────────────────────────
21
+ export class IRRecorder {
22
+ meta;
23
+ scene = { entities: [], materials: [], lights: [] };
24
+ startState;
25
+ startFrame = 0;
26
+ startRngState;
27
+ inputs = [];
28
+ keyframes = [];
29
+ renderProbes = [];
30
+ cap;
31
+ keyEvery;
32
+ constructor(meta, startState, capFrames = 3600, keyEvery = 300) {
33
+ this.meta = { irVersion: IR_VERSION, ...meta };
34
+ this.startState = cloneState(startState);
35
+ this.startRngState = meta.seed >>> 0; // RNG state at frame 0 == the seed
36
+ this.cap = capFrames;
37
+ this.keyEvery = keyEvery;
38
+ }
39
+ registerEntity(e) { if (!this.scene.entities.some((x) => x.id === e.id))
40
+ this.scene.entities.push(e); }
41
+ registerMaterial(m) { if (!this.scene.materials.some((x) => x.id === m.id))
42
+ this.scene.materials.push(m); }
43
+ registerLight(l) { if (!this.scene.lights.some((x) => x.id === l.id))
44
+ this.scene.lights.push(l); }
45
+ /** Record a render/audio-layer probe (see RenderProbe). Kept bounded like the input
46
+ * window; dropped oldest-first when it exceeds the frame cap. */
47
+ recordRenderProbe(p) {
48
+ this.renderProbes.push(p);
49
+ const floor = this.startFrame;
50
+ while (this.renderProbes.length && this.renderProbes[0].frame < floor)
51
+ this.renderProbes.shift();
52
+ }
53
+ /** Record one tick. `state`/`rngState` are the sim + RNG state AFTER this step
54
+ * (== the start of the next frame), stored periodically as a replay/seek anchor. */
55
+ recordTick(inp, state, rngState) {
56
+ this.inputs.push(inp);
57
+ const abs = this.startFrame + this.inputs.length; // absolute frame count after this tick
58
+ if (abs % this.keyEvery === 0)
59
+ this.keyframes.push({ f: abs, s: cloneState(state), rngState });
60
+ if (this.inputs.length > this.cap)
61
+ this.reanchor();
62
+ }
63
+ /** Keep the LAST ~cap frames (a debug capture wants the run leading up to the issue).
64
+ * Re-anchor the window to the latest keyframe ≤ the new start — carrying its sim +
65
+ * RNG state — so the trimmed window still replays exactly. */
66
+ reanchor() {
67
+ const abs = this.startFrame + this.inputs.length;
68
+ const windowStart = abs - this.cap;
69
+ let kf = null;
70
+ for (const k of this.keyframes)
71
+ if (k.f <= windowStart && k.f > this.startFrame)
72
+ kf = k;
73
+ if (!kf)
74
+ return; // no interior keyframe yet — keep the full window
75
+ this.inputs = this.inputs.slice(kf.f - this.startFrame);
76
+ this.startState = cloneState(kf.s);
77
+ this.startRngState = kf.rngState;
78
+ this.startFrame = kf.f;
79
+ this.keyframes = this.keyframes.filter((k) => k.f >= kf.f);
80
+ }
81
+ snapshot(reason, note) {
82
+ const snap = { meta: this.meta, scene: this.scene, startFrame: this.startFrame, startRngState: this.startRngState, startState: this.startState, inputs: this.inputs.slice(), keyframes: this.keyframes.slice(), reason, capturedAtFrame: this.startFrame + this.inputs.length };
83
+ if (this.renderProbes.length)
84
+ snap.renderProbes = this.renderProbes.slice();
85
+ if (note !== undefined)
86
+ snap.note = note;
87
+ return snap;
88
+ }
89
+ }
90
+ // ─── Replay + analysis (shared by client & CLI; the moat) ────────────────────
91
+ /** Reconstruct the full state trajectory from the anchor (startState + rng) + input
92
+ * stream, in a single O(n) pass. Same in the browser and Node — the CLI/agent calls
93
+ * this to inspect any frame, and re-runs it after a fix to confirm. */
94
+ export function replayTrajectory(snap) {
95
+ const s = cloneState(snap.startState);
96
+ const rng = rngFromState(snap.startRngState);
97
+ const out = [];
98
+ for (let i = 0; i < snap.inputs.length; i++) {
99
+ step(s, snap.inputs[i] ?? NO_INPUT, rng, snap.startFrame + i);
100
+ out.push(cloneState(s));
101
+ }
102
+ return out;
103
+ }
104
+ /** Trim a snapshot to fit `maxBytes` (the D1 row ceiling a report rides in) by
105
+ * re-anchoring to the latest keyframe that fits — dropping OLDEST frames first, and
106
+ * carrying that keyframe's sim + RNG state so the result still replays exactly. */
107
+ export function capIRSnapshot(snap, maxBytes = 600_000) {
108
+ if (JSON.stringify(snap).length <= maxBytes)
109
+ return snap;
110
+ for (let i = snap.keyframes.length - 1; i >= 0; i--) {
111
+ const kf = snap.keyframes[i];
112
+ const drop = kf.f - snap.startFrame;
113
+ if (drop <= 0)
114
+ continue;
115
+ const trimmed = { ...snap, startFrame: kf.f, startRngState: kf.rngState, startState: kf.s, inputs: snap.inputs.slice(drop), keyframes: snap.keyframes.filter((k) => k.f >= kf.f) };
116
+ if (snap.renderProbes)
117
+ trimmed.renderProbes = snap.renderProbes.filter((p) => p.frame >= kf.f);
118
+ if (JSON.stringify(trimmed).length <= maxBytes)
119
+ return trimmed;
120
+ }
121
+ return snap; // no keyframe to anchor to — send as-is (best effort)
122
+ }
123
+ const rnd = (v) => Math.round(v * 1000) / 1000;
124
+ /** A leveled + filtered probe over a reconstructed playSim state: hash (a per-tick fingerprint) →
125
+ * counters (aggregate) → entity (per-body pose) → full (per-body pose + velocity). Reusable pattern:
126
+ * a game supplies one of these to captureWindow/captureIR for its own state. */
127
+ export function playProbe(level, filter = {}) {
128
+ return (s, tick) => {
129
+ if (level === 'hash')
130
+ return [{ tick, hash: fnv1a(`${rnd(s.x)},${rnd(s.z)},${rnd(s.y)},${s.gait},${s.bodies.length}`) }];
131
+ if (level === 'counters') {
132
+ let sp = 0;
133
+ for (const b of s.bodies)
134
+ sp += Math.hypot(b.vx, b.vy, b.vz);
135
+ return [{ tick, bodies: s.bodies.length, x: rnd(s.x), z: rnd(s.z), y: rnd(s.y), gait: s.gait, avgSpeed: rnd(sp / Math.max(1, s.bodies.length)) }];
136
+ }
137
+ const rows = [];
138
+ for (let i = 0; i < s.bodies.length; i++) {
139
+ const b = s.bodies[i];
140
+ if (filter.kinds && !filter.kinds.includes(b.kind))
141
+ continue;
142
+ if (filter.region && !(b.x >= filter.region[0] && b.z >= filter.region[1] && b.x <= filter.region[2] && b.z <= filter.region[3]))
143
+ continue;
144
+ if (level === 'entity')
145
+ rows.push({ tick, i, x: rnd(b.x), y: rnd(b.y), z: rnd(b.z), kind: b.kind, held: b.held ? 1 : 0 });
146
+ else
147
+ rows.push({ tick, i, x: rnd(b.x), y: rnd(b.y), z: rnd(b.z), vx: rnd(b.vx), vy: rnd(b.vy), vz: rnd(b.vz), kind: b.kind, r: rnd(b.r), held: b.held ? 1 : 0 });
148
+ }
149
+ return rows;
150
+ };
151
+ }
152
+ /** Replay the IR and CAPTURE detail at a chosen level + filter over an absolute-frame window — the IR's
153
+ * leveled, filterable extraction, riding the shared capture.ts engine. `from`/`to` are ABSOLUTE frames
154
+ * (default = the whole recorded window), clamped to what's recorded. Offline; zero live cost. */
155
+ export function captureIR(snap, opts) {
156
+ const from = Math.max(0, (opts.from ?? snap.startFrame) - snap.startFrame);
157
+ const to = Math.min(snap.inputs.length, (opts.to ?? (snap.startFrame + snap.inputs.length)) - snap.startFrame);
158
+ const rng = rngFromState(snap.startRngState);
159
+ const advance = (s, tick) => { step(s, snap.inputs[tick] ?? NO_INPUT, rng, snap.startFrame + tick); };
160
+ return captureWindow(cloneState(snap.startState), cloneState, advance, from, to, opts.probe ?? playProbe(opts.level, opts.filter ?? {}), { label: snap.meta.page, level: opts.level });
161
+ }
162
+ /** A few built-in INVARIANT checks (the two live bugs are the first test cases).
163
+ * The real value is agent-queryable data + replay; these are sanity assertions, not
164
+ * the product. Runs the deterministic replay, then inspects the reconstructed states. */
165
+ export function analyzeIR(snap) {
166
+ return analyzeTrajectory(replayTrajectory(snap), snap.inputs, snap.scene, snap.renderProbes);
167
+ }
168
+ /** The invariant checks, over any reconstructed trajectory. Exported so the ugly-game
169
+ * CLI (and tests) can run them on a replayed run — and so they act as REGRESSION
170
+ * GUARDS: feed a buggy trajectory (drifting camera / flat mouth) and they fire. */
171
+ export function analyzeTrajectory(T, inputs, scene, probes) {
172
+ const out = [];
173
+ if (T.length < 4)
174
+ return out;
175
+ // 1) Camera "size drift": camDist must not change on ticks with no zoom input.
176
+ for (let f = 1; f < T.length; f++) {
177
+ if ((inputs[f]?.dzoom ?? 0) === 0 && Math.abs(T[f].camDist - T[f - 1].camDist) > 1e-6) {
178
+ out.push({ severity: 'error', code: 'camera-distance-drift', msg: `camDist changed with no zoom input at frame ${f} (${T[f - 1].camDist.toFixed(3)}→${T[f].camDist.toFixed(3)}) — objects would appear to resize`, frames: [f - 1, f] });
179
+ break;
180
+ }
181
+ }
182
+ // 2) Mouth flat while speaking → "talking does nothing".
183
+ const speaking = T.map((s, f) => ({ f, s })).filter((x) => x.s.speaking);
184
+ if (speaking.length >= 6) {
185
+ const vals = speaking.map((x) => x.s.mClose);
186
+ const range = Math.max(...vals) - Math.min(...vals);
187
+ if (range < 0.02)
188
+ out.push({ severity: 'error', code: 'mouth-flat-while-speaking', msg: `mouth influence flat (range ${range.toFixed(3)}) across ${speaking.length} speaking frames — driver not reaching the morph`, detail: { range } });
189
+ }
190
+ // 3) Texture tiling sanity (static manifest — the cyber-floor mis-tile).
191
+ for (const m of scene.materials)
192
+ for (const map of m.maps ?? []) {
193
+ if (map.repeat && map.repeat[0] <= 1.01 && map.repeat[1] <= 1.01) {
194
+ const ent = scene.entities.find((e) => e.materialId === m.id);
195
+ if (ent && /ground|floor|disc|plane/i.test(ent.kind))
196
+ out.push({ severity: 'warn', code: 'texture-under-tiled', msg: `material '${m.id}' (${map.slot}) on '${ent.kind}' has repeat ${map.repeat[0]}×${map.repeat[1]} — likely stretched on a large surface`, detail: { material: m.id, repeat: map.repeat } });
197
+ }
198
+ }
199
+ // 4) PHYSICS invariants — the classic core-gaming bug classes, caught from the IR
200
+ // (deterministic replay reconstructs every body's position each frame).
201
+ const finite = (b) => Number.isFinite(b.x) && Number.isFinite(b.y) && Number.isFinite(b.z) && Number.isFinite(b.vx) && Number.isFinite(b.vy) && Number.isFinite(b.vz);
202
+ outer: for (let f = 0; f < T.length; f++) {
203
+ const bs = T[f].bodies;
204
+ for (let i = 0; i < bs.length; i++) {
205
+ const b = bs[i];
206
+ if (!finite(b)) {
207
+ out.push({ severity: 'error', code: 'physics-nan', msg: `body ${i} has a non-finite value at frame ${f} — solver blew up`, frames: [f, f], detail: { body: i } });
208
+ break outer;
209
+ }
210
+ if (Math.hypot(b.vx, b.vy, b.vz) > 400) {
211
+ out.push({ severity: 'error', code: 'physics-explosion', msg: `body ${i} velocity ${Math.hypot(b.vx, b.vy, b.vz).toFixed(0)}m/s at frame ${f} — instability`, frames: [f, f], detail: { body: i } });
212
+ break outer;
213
+ }
214
+ if (b.y < -0.5) {
215
+ out.push({ severity: 'error', code: 'physics-fell-through-floor', msg: `body ${i} at y=${b.y.toFixed(2)} at frame ${f} — tunneled through the ground`, frames: [f, f], detail: { body: i } });
216
+ break outer;
217
+ }
218
+ for (let j = i + 1; j < bs.length; j++) {
219
+ const c = bs[j];
220
+ if (b.held || c.held || b.kind !== 0 || c.kind !== 0)
221
+ continue; // objects only; debris/projectiles are transient
222
+ const d = Math.hypot(c.x - b.x, c.y - b.y, c.z - b.z);
223
+ const pen = b.r + c.r - d;
224
+ if (pen > 0.25 * Math.min(b.r, c.r)) {
225
+ out.push({ severity: 'error', code: 'physics-penetration', msg: `bodies ${i}+${j} penetrate ${pen.toFixed(3)}m at frame ${f} — collision not resolved`, frames: [f, f], detail: { a: i, b: j, penetration: +pen.toFixed(3) } });
226
+ break outer;
227
+ }
228
+ }
229
+ }
230
+ }
231
+ // spawn leak — projectiles/fragments must be reclaimed, so the body count stays bounded.
232
+ const maxBodies = Math.max(...T.map((s) => s.bodies.length));
233
+ if (maxBodies > 55)
234
+ out.push({ severity: 'error', code: 'physics-body-leak', msg: `body count peaked at ${maxBodies} — spawned projectiles/fragments not being reclaimed (expiry broken?)`, detail: { maxBodies } });
235
+ // 5) ANIMATION + SEMANTIC INTENT — the "it doesn't look right" bugs. These compare what
236
+ // the game INTENDS (jump lifts you; feet match speed; shooting needs a weapon) with
237
+ // what actually happened — the class of bug a state-only IR can't reason about.
238
+ const NT = T.length;
239
+ // jump must physically lift the character (not a render-only arc).
240
+ for (let i = 0; i < NT; i++) {
241
+ if (inputs[i]?.jump && T[i].jumpF >= 0) {
242
+ let maxY = 0;
243
+ for (let f = i; f < NT && T[f].jumpF >= 0; f++)
244
+ maxY = Math.max(maxY, T[f].y);
245
+ if (maxY < 0.3) {
246
+ out.push({ severity: 'error', code: 'jump-not-physical', msg: `jump at frame ${i} but character Y peaked at ${maxY.toFixed(2)}m — render-only arc, no vertical physics`, frames: [i, i] });
247
+ break;
248
+ }
249
+ }
250
+ }
251
+ // moonwalk: feet ground-speed (CLIP_BASE·animRate) must match actual move speed.
252
+ let maxSlip = 0, slipF = -1;
253
+ for (let f = 0; f < NT; f++) {
254
+ const g = T[f].gait;
255
+ if (g === 0)
256
+ continue;
257
+ const slip = Math.abs(SPEED[g] - CLIP_BASE[g] * T[f].animRate);
258
+ if (slip > maxSlip) {
259
+ maxSlip = slip;
260
+ slipF = f;
261
+ }
262
+ }
263
+ if (maxSlip > 0.3)
264
+ out.push({ severity: 'error', code: 'anim-foot-slip', msg: `locomotion foot-slip ${maxSlip.toFixed(2)} m/s @f${slipF} — feet cadence ≠ move speed (moonwalk); sync animRate to speed`, frames: [slipF, slipF], detail: { slip: +maxSlip.toFixed(2) } });
265
+ // shooting with no weapon — bullets from nothing (needs a semantic role in the scene).
266
+ if (inputs.some((x) => x.shoot) && !scene.entities.some((e) => e.role === 'weapon'))
267
+ out.push({ severity: 'error', code: 'weapon-missing', msg: `projectiles fired but no entity has role 'weapon' — bullets spawn from the body`, detail: { entities: scene.entities.length } });
268
+ // 6) RENDER-LAYER checks — over the live probes (things replay can't reconstruct).
269
+ if (probes && probes.length >= 3) {
270
+ // anim-size-change: the character's uniform scale must not pop when an animation plays.
271
+ // (idle inflating bone scale to 1.176 while locomotion clips reset it to 1.0 → shrink-on-move.)
272
+ const sc = probes.map((p) => p.charScale).filter((v) => typeof v === 'number');
273
+ if (sc.length >= 3) {
274
+ const lo = Math.min(...sc), hi = Math.max(...sc);
275
+ if (lo > 0 && hi / lo - 1 > 0.05) {
276
+ const at = probes.find((p) => p.charScale === lo)?.frame ?? -1;
277
+ out.push({ severity: 'error', code: 'anim-size-change', msg: `character render-scale varies ${((hi / lo - 1) * 100).toFixed(0)}% across animations (${lo.toFixed(3)}→${hi.toFixed(3)}) — the rig visibly resizes when a clip plays; strip scale tracks so only pose animates`, frames: [at, at], detail: { min: +lo.toFixed(3), max: +hi.toFixed(3) } });
278
+ }
279
+ }
280
+ // tts-no-audio: the app reports it is speaking but the audio graph makes no sound.
281
+ const spoke = probes.filter((p) => p.ttsSpeaking);
282
+ if (spoke.length >= 3 && spoke.every((p) => p.audioPlaying === false)) {
283
+ out.push({ severity: 'error', code: 'tts-no-audio', msg: `TTS reported speaking on ${spoke.length} probes but the audio graph never played — visemes animate silently (audio context suspended / not unlocked)`, frames: [spoke[0].frame, spoke[spoke.length - 1].frame], detail: { speakingProbes: spoke.length } });
284
+ }
285
+ }
286
+ return out;
287
+ }
package/dist/kcc.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ export declare const GRAVITY = 24, MOVE_SPEED = 6, JUMP_V = 9, STEP_H = 0.55, CHAR_R = 0.4, SNAP = 0.35;
2
+ export interface Box {
3
+ minx: number;
4
+ miny: number;
5
+ minz: number;
6
+ maxx: number;
7
+ maxy: number;
8
+ maxz: number;
9
+ }
10
+ export interface Char {
11
+ x: number;
12
+ y: number;
13
+ z: number;
14
+ vx: number;
15
+ vy: number;
16
+ vz: number;
17
+ grounded: 0 | 1;
18
+ r: number;
19
+ }
20
+ export interface KccState {
21
+ boxes: Box[];
22
+ ch: Char;
23
+ tick: number;
24
+ }
25
+ export interface KccInput {
26
+ mv: number;
27
+ strafe: number;
28
+ yaw: number;
29
+ jump: 0 | 1;
30
+ }
31
+ export declare const NO_KCC_INPUT: KccInput;
32
+ export declare const box: (minx: number, miny: number, minz: number, maxx: number, maxy: number, maxz: number) => Box;
33
+ export interface Contact {
34
+ pen: number;
35
+ nx: number;
36
+ ny: number;
37
+ nz: number;
38
+ }
39
+ /** Sphere-vs-AABB overlap → penetration depth + outward normal, or null if disjoint. */
40
+ export declare function sphereBox(cx: number, cy: number, cz: number, r: number, b: Box): Contact | null;
41
+ /** One deterministic physics tick: gravity/jump → vertical move → horizontal move+slide → step-up → ground snap. */
42
+ export declare function kccStep(s: KccState, inp: KccInput): void;
43
+ export declare function initChar(x: number, y: number, z: number): Char;
44
+ export declare function cloneKcc(s: KccState): KccState;
45
+ export declare function hashKcc(s: KccState): string;
46
+ export declare function anyPenetration(s: KccState): number;
47
+ export declare function buildLevel(): KccState;