fuzzyworld

explodorama / commit

Fix red CI: longer test timeout, faster Decoy demo, demo generation perf test

9ed991c9907cc38b6617ce4e8dceeb9a70923bbd · Pavel Safronov · 2026-11-03 00:32:06 -0800 · parents 805e7a9ffde3

Claude Code usage: this commit 33.1K out / 3.0M cached (first commit of its session: everything since it began) · cost unknown (no per-model trailer) · session 14 tally 128 in / 33.1K out / 3.0M cached · usage by commit and session

Fix red CI: longer test timeout, faster Decoy demo, demo generation perf test

CI had been red since 2026-11-01: the Decoy demo tests (demo, keyDemo, padDemo)
exceeded vitest's default 5 s timeout on the slower runner.

- vite.config.ts: defineConfig from vitest/config; test.testTimeout 30000.
- src/sim/demo.ts: the Decoy demo scene is steered (walk to the drop, put the
  decoy down), so seeds 1-6 are found in ~0.9 s instead of ~3.2 s. Steering Freeze
  the same way gave no gain and was left out.
- test/perf.test.ts: times demo scene generation per kind (seeds 1-3), prints the
  slowest, and fails over 4000 ms a seed, so a slow scene shows up locally first.

Tests: 1299 pass, tsc clean, build ok.

FW-Tokens-In: 128
FW-Tokens-Out: 33087
FW-Tokens-Cached: 3008748
FW-Session: 250dcd81-fba7-433f-b6a4-058046047560
FW-Rtk-Saved: 4312250
FW-Rtk-Cmds: 13255

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 src/sim/demo.ts   | 14 ++++++++++++++
 test/perf.test.ts | 28 ++++++++++++++++++++++++++++
 vite.config.ts    |  5 ++++-
 3 files changed, 46 insertions(+), 1 deletion(-)

diff --git a/src/sim/demo.ts b/src/sim/demo.ts
index b67fc1a..e1067d8 100644
--- a/src/sim/demo.ts
+++ b/src/sim/demo.ts
@@ -174,6 +174,19 @@ const inBlast = (t: Turn, p: Vec) => explosions(t).some((e) => e.tiles.some((q)
 /** A charge lying on a free floor tile two steps from the player (or `n`), to walk over first. */
 const dropping = (kind: ChargeId, n = 2) => (s: GameState, rng: Rng) => dropNear(s, rng, kind, n)
 
+/**
+ * A nudge for the Decoy scene: head for its drop, then put it down. Random play rarely chains
+ * pickup, use and a kill (seeds 1-6 took 3.2 s to find, now 0.9 s); a little randomness stays so
+ * the scenes differ. (Tried for Freeze too: no faster, its slow part is the kill while frozen.)
+ */
+const fetchAndUse = (kind: 'decoy') => (s: GameState, rng: Rng): Play | null => {
+  if (rng.next() < 0.15) return null
+  if (isAvailable(s, kind)) return playOf(SCENES[kind], s, randomTarget(s, rng, kind))
+  const drop = s.drops.find((d) => d.kind === kind)
+  const next = drop && playerPath(s, drop.pos)?.[0]
+  return next && isAvailable(s, 'move') && isValidTarget(s, 'move', next) ? playOf(SCENES[kind], s, { id: 'move', target: next }) : null
+}
+
 /** A test scene's entry (sim/testScenes.ts). */
 const testDef = (kind: TestKind): SceneDef => ({
   caption: TEST_SCENES[kind].caption,
@@ -465,6 +478,7 @@ export const SCENES: Record<SceneKind, SceneDef> = {
     keyCaption: 'Decoy: {V} and {arrows} (or the {shift} cursor, then {V}) put down a copy of you that Seekers chase instead.',
     enemies: ['seeker', 'seeker'],
     prepare: dropping('decoy'),
+    steer: fetchAndUse('decoy'),
     shows: (t, seen) => seen.picked.has('decoy') && kills(t).length > 0 && !!t.prev.decoy,
   },
   // One charge at a time (user question, 2026-10-25: "is there already a demo for the charge
diff --git a/test/perf.test.ts b/test/perf.test.ts
new file mode 100644
index 0000000..a89f199
--- /dev/null
+++ b/test/perf.test.ts
@@ -0,0 +1,28 @@
+import { expect, it } from 'vitest'
+import { generateScene, SCENE_KINDS } from '../src/sim/demo'
+
+/**
+ * Perf budget for demo generation. Scenes are found by random play, so a scene kind whose moment
+ * is a rare chain gets slow without anything failing (CI went red on the Decoy tests' default
+ * 5 s timeout, 2026-11-01). Each seed of each kind is timed; the slowest are printed, and any seed
+ * over the budget fails here, naming the kind. The budget is several times the slowest seen
+ * (Freeze, ~1.1 s on a laptop) to leave room for a slow CI runner.
+ */
+const SEEDS = [1, 2, 3]
+const BUDGET_MS = 4000
+
+it(`every demo scene is found within ${BUDGET_MS} ms a seed`, () => {
+  const times: { kind: string; seed: number; ms: number }[] = []
+  for (const kind of SCENE_KINDS) {
+    for (const seed of SEEDS) {
+      const t = performance.now()
+      generateScene(kind, seed)
+      times.push({ kind, seed, ms: Math.round(performance.now() - t) })
+    }
+  }
+  times.sort((a, b) => b.ms - a.ms)
+  console.log('slowest demo scenes:\n' + times.slice(0, 5).map((t) => `  ${t.kind} seed ${t.seed}: ${t.ms} ms`).join('\n'))
+  const slow = times.filter((t) => t.ms > BUDGET_MS)
+  expect(slow, `over ${BUDGET_MS} ms: ${slow.map((t) => `${t.kind}#${t.seed} ${t.ms} ms`).join(', ')}`).toEqual([])
+  // Every kind runs three times, so this test as a whole is slow (~9 s here): its own timeout.
+}, 120_000)
diff --git a/vite.config.ts b/vite.config.ts
index 812e5e8..44eabee 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,4 +1,4 @@
-import { defineConfig } from 'vite'
+import { defineConfig } from 'vitest/config'
 import { qrcode } from 'vite-plugin-qrcode'
 import { execSync } from 'node:child_process'
 import { readFileSync } from 'node:fs'
@@ -64,4 +64,7 @@ export default defineConfig({
   // Sprites stay separate files rather than being inlined into the script: the art for enemies and
   // pickups that aren't in the game yet then costs nothing until something draws it.
   build: { target: 'es2022', assetsInlineLimit: 0 },
+  // The decoy demo tests replay seeds 1-6 turn by turn: ~3 s here, over vitest's default 5 s on
+  // the slower CI runner (CI was red from 2026-11-01 for that reason alone).
+  test: { testTimeout: 30000 },
 })