expected: ["player-0", "player-1", "player-2", "player-3"] # sheet cells 0..3 — WRONG
Root cause. The RR-VIS-001 criterion in .gitreins/tasks.yaml was written against a stale 8-cell-index mental model — it assumed the player's sprite-sheet cell index (0..7 across an 8-wide sheet) is the frame index. The engine does not address frames that way: it resolves frames via per-animation sequences (SUBJECT_TABLES.player.strips.<anim>.frames, playback index t mod sequence.length). The engine's mapping was correct (run[0-3] → run-1..4); the criterion was wrong and the judge failed a correct mapping.
The fix (two parts):
animationToFrames (engine/sprites.ts), not the task-text indices:// engine/sprites.ts — the contract to check BEFORE writing any criterion
export const SUBJECT_TABLES = {
player: {
cells: 8, // physical sheet width — NOT a frame index space
strips: {
idle: { row: 0, frames: ["idle-1", "idle-2", "idle-3", "idle-4"] },
run: { row: 1, frames: ["run-1", "run-2", "run-3", "run-4"] },
jump: { row: 2, frames: ["jump-1", "jump-2"] },
hurt: { row: 3, frames: ["hurt-1"] },
},
},
} as const;
export function animationToFrames(subject: Subject, animation: string): string[] {
return SUBJECT_TABLES[subject]?.strips[animation]
? [...SUBJECT_TABLES[subject].strips[animation].frames] : [];
}
// runtime resolution: sequence index = t mod sequence length
export function frameAt(subject: Subject, animation: string, t: number): string {
const frames = animationToFrames(subject, animation);
if (frames.length === 0) throw new Error(`unknown animation "${animation}"`);
return frames[((t % frames.length) + frames.length) % frames.length];
}
.gitreins/tasks.yaml to the real contract (before → after):# BEFORE (stale 8-cell-index mental model — FAILED a correct mapping)
criterion:
id: RR-VIS-001
asserts:
- expr: animationToFrames(player, run)
expected: ["player-0", "player-1", "player-2", "player-3"] # sheet cells 0..3 — WRONG
# AFTER (engine contract: run[0-3] -> run-1..4 via sequence index)
criterion:
id: RR-VIS-001
kind: sprite-frame-mapping
subject: player
asserts:
- expr: animationToFrames(player, run)
expected: ["run-1", "run-2", "run-3", "run-4"]
- expr: frameAt(player, run, 4)
expected: "run-1" # index mod sequence length wraps
- expr: frameAt(player, run, 7)
expected: "run-4"
snapshot:
files: [".gitreins/tasks.yaml", "level-config.ts"]
task complete only after the sibling worker commits. The judge refuses to run on a dirty tree (an uncommitted level config shifts the config snapshot and breaks determinism):// judge/determinism.ts — fails fast on in-flight sibling edits
const dirty = execFileSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" });
const treeClean = dirty.trim().length === 0; // false -> re-judge later
const drift = files.some(f => baseline[f] !== sha256(f)); // uncommitted config -> drift
// judge passes only when: all criterion assertions hold AND tree clean AND no drift
Full reproduction lives in /tmp/rr-vis-001 (engine, judge, .gitreins/tasks.yaml, tests).
Reproduced the exact bug end-to-end and verified the fix with real runs (Node 22, strict `tsc --noEmit` clean): - **Bug reproduced:** stale criterion (`expected: ["player-0".."player-3"]`) against the correct engine returned `["run-1","run-2","run-3","run-4"]` → judge FAILed a *correct* mapping (`criterion: FAIL`, exit 1). - **Fix verified:** with the corrected criterion, judge reports `criterion: PASS`, `determinism: PASS`, `judge: PASS — task complete` (exit 0) on a clean tree. - **Determinism / sibling-worker case:** uncommitted `level-config.ts` edit → judge FAILs with `tree is DIRTY` + `snapshot drift on level-config.ts`; after the sibling's commit and a baseline refresh, re-judge PASSes with a clean tree (git log shows `base → sibling: level config gravity tweak → judge: refresh baseline`). - **Edge cases tested (8/8 pass):** index wrap `t mod len` (`0→run-1`, `3→run-4`, `4→run-1`, `7→run-4`, `-1→run-4`); short sequence `jump` wraps mod 2; single-frame `hurt` wraps mod 1; unknown animation → `[]` and `frameAt` throws; stale criterion rejected end-to-end; fixed criterion accepted end-to-end; dirty-tree judge failure and clean-tree recovery. ``` # tests 8 # pass 8 # fail 0 ```
{"model": "deepseek-v4-flash", "problem_class": "typescript-sprite-wiring-judge-criterion", "result": "passed", "tests": 8}