fuzzyworld

explodorama / commit

Two gems on one turn offer one after the other; Developer-mode test scenes (0.2.0 work)

8edb5cb927ba907d7ebdd338927a4b661c59aa15 · Pavel Safronov · 2026-10-31 23:50:54 -0700 · parents 1f0038acdda6

Claude Code usage: this commit 6.9K out / 1.2M cached · cost unknown (no per-model trailer) · session 8 tally 218 in / 68.6K out / 9.7M cached · usage by commit and session

Two gems on one turn offer one after the other; Developer-mode test scenes (0.2.0 work)

A slide, run or oil slide that takes two gems used to lose the second offer.
The second gem now waits in waitingGems (rises away at pickup: gemQueued) and
offers once the first, and any charge choice, is answered (offerAgain), also
when the level was cleared by that move. SIM_VERSION 42.

Developer mode gets fixed edge-case scenes (src/sim/testScenes.ts): an
almanac Tests tab and the demo with ?dev. The tests' board parser moves to
src/sim/picture.ts.

FW-Tokens-In: 218
FW-Tokens-Out: 68649
FW-Tokens-Cached: 9684977
FW-Session: 59d4e4eb-e946-4c26-9ff4-bea067039a2a
FW-Rtk-Saved: 2783415
FW-Rtk-Cmds: 7870

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 README.md                |   4 +-
 decisions.md             |  23 ++++++-
 src/changelog.ts         |   7 ++
 src/main.ts              |  15 +++-
 src/render/effects.ts    |   1 +
 src/sim/demo.ts          |  20 +++++-
 src/sim/picture.ts       |  53 +++++++++++++++
 src/sim/record.ts        |   2 +-
 src/sim/state.ts         |   2 +-
 src/sim/testScenes.ts    | 173 +++++++++++++++++++++++++++++++++++++++++++++++
 src/sim/turn.ts          |  24 ++++++-
 src/sim/types.ts         |   6 ++
 src/ui/almanac.ts        |  20 ++++--
 src/ui/clip.ts           |   2 +-
 src/ui/feedback.ts       |   3 +-
 test/demo.test.ts        |   3 +-
 test/helpers.ts          |  51 +-------------
 test/oil-slide.test.ts   |  52 ++++++++++++++
 test/test-scenes.test.ts |  73 ++++++++++++++++++++
 19 files changed, 467 insertions(+), 67 deletions(-)

diff --git a/README.md b/README.md
index e32dda7..f0e770a 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@ only `explodorama.earlyRuns` is shared. The released menu offers **Try it early*
 **Coming next** list; runs finished on the next copy are marked **Early adopter** on its board.
 Developer mode is separate and can be switched on in either.
 
-Rules version: **41** (`SIM_VERSION` in [src/sim/record.ts](src/sim/record.ts)). It goes up by one
+Rules version: **42** (`SIM_VERSION` in [src/sim/record.ts](src/sim/record.ts)). It goes up by one
 whenever a rule change would make an old run replay differently, independent of the release
 version (one release can span several). A run saved under another rules version is not resumed,
 and a seed gives the same dungeon only within one rules version.
@@ -184,7 +184,7 @@ buttons, kept compact: **Resume** and **New** side by side (New starts a new run
 since the current run can't be got back), then one row of small ones — **Almanac** (every
 power-up, charge and enemy type, and everything on the board (crates, pillars, bushes, metal
 crates, oil, spikes, oil barrels, rubble, with the level each first turns up on), with its picture
-and what it does, in four tabs; an entry with a green ▶ opens its clip), **Scores** (the high scores), **Demo** (the demo again) and **Bug** (an
+and what it does, in four tabs, and in a Developer run a fifth, **Tests**: fixed edge-case situations such as a slide over two gems, each with what to expect; the same scenes join the landing demo with `?dev`; an entry with a green ▶ opens its clip), **Scores** (the high scores), **Demo** (the demo again) and **Bug** (an
 email to the game's bug-report address, which needs no account, filled in with the
 game's version, your device and the run, so it can be replayed; a long run's record goes on your
 clipboard to paste in).
diff --git a/decisions.md b/decisions.md
index 63b8d25..e575bc5 100644
--- a/decisions.md
+++ b/decisions.md
@@ -2319,8 +2319,7 @@ out in `step()` (player) and planning (enemies). Auto-move (`path.ts`) plans on
 so it **keeps off any step that would slide** (`slips`), else it could bounce past its destination
 forever or slide onto the open exit (reviewer, 2026-10-31): a tile in the middle of a spill is not
 an auto-move destination, a single tap next to you still moves and slides. Walking onto an enemy on
-oil is a death although the slide carries on. Known, left: a slide over two gems uses the first
-one's offer unseen (as the Slide power-up already does), and passes over raised spikes and the
+oil is a death although the slide carries on. Known, left: slides pass over raised spikes and the
 Decoy without effect; (5) an enemy's slide is
 announced like any move: its plan holds the tile where it stops, so the arrows and the danger
 overlay stay truthful (a doubled arrow, as for a Sprinter's dash). `SIM_VERSION` 41.
@@ -2330,3 +2329,23 @@ overlay stay truthful (a doubled arrow, as for a Sprinter's dash). `SIM_VERSION`
 User request (Pavel, 2026-10-31). This reverses the 2026-10-21 decision that the credit lives in
 the menu. Kenney's packs are CC0, so no credit is required; the README's Attribution section stays
 as the credit (and still thanks Kenney and links his donation page).
+
+### 2026-10-31 — Two gems on one turn offer one after the other
+
+User report: a slide (oil, or the Slide power-up) over two gems used the first gem's offer and
+dropped the second unseen. Now the second waits in `waitingGems` and its offer is drawn when the
+first is answered (and any charge choice from it), so it knows what the first gave (a held ability
+is not offered again). Event `gemQueued` makes the second gem rise away at pickup (sound too); `offerAgain` opens its dialog. It works when the first gem's slide ends on the open exit, too (the level is cleared, the offers still come). A death the same turn drops the queue;
+the retry has the gems back. `SIM_VERSION` 42.
+
+### 2026-10-31 — Developer mode's test scenes
+
+User request: to check edge cases by eye (two gems on one turn, and the like), add test-only demos
+and almanac entries to Developer mode. They are **fixed** scenes (`SceneDef.fixed`, built by
+`sim/testScenes.ts`): a hand-drawn room from `sim/picture.ts` (the tests' `board`, moved into `src/`;
+`%` is a gem) and a scripted game, not a random rollout, so they are exact and repeatable. Shown on
+the almanac's **Tests** tab in a Developer run (each with "what to expect") and in the landing demo
+with `?dev`. In the demo the gem and charge choices open as real dialogs (new gesture `pick`); in
+an almanac clip they are answered unseen. `test/test-scenes.test.ts` asserts each scene's outcome,
+so the same cases are checked without looking. To add a case: a name in `TestKind` and a `TEST_SCENES` entry; the
+demo table and icons follow from the list. Not in the 0.2.0 change list (a developer aid).
diff --git a/src/changelog.ts b/src/changelog.ts
index 7cbc3ec..18fbd4c 100644
--- a/src/changelog.ts
+++ b/src/changelog.ts
@@ -48,6 +48,13 @@ export const RELEASES: Release[] = [
 
 /** Finished since the latest release, not yet part of one. Moved into RELEASES when one is tagged. */
 export const UNRELEASED: Change[] = [
+  {
+    kind: 'changed',
+    icon: 'gem_green',
+    title: 'Two gems in one move',
+    text: ['A slide, a run or an oil slide that takes two gems now asks for both choices, one after the other. The second used to be lost.'],
+    date: '2026-10-31',
+  },
   {
     kind: 'changed',
     icon: 'oil',
diff --git a/src/main.ts b/src/main.ts
index fc99548..252478b 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -52,6 +52,7 @@ import { openScores, recordScore } from './ui/scores'
 import { countsForScores, scoreEntry } from './sim/scores'
 import { device, recentErrors, reportError } from './devlog'
 import { hashSeed } from './sim/rng'
+import { TEST_KINDS, TEST_SCENES, type TestKind } from './sim/testScenes'
 import { CONTROL_KINDS, DYING_KINDS, LIVING_KINDS, SCENES, type Gesture, type Scene, type SceneKind } from './sim/demo'
 import { spriteUrl } from './render/sprites'
 import { countFrame, timed } from './ui/fps'
@@ -240,6 +241,8 @@ function act(action: Action, auto = false): void {
   for (const e of result.events) {
     // A gem taken: its choice opens once the gem has risen away (render/effects.ts).
     if (e.type === 'powerUpTaken') window.setTimeout(showOffer, PICKUP_DIALOG_MS)
+    // The next of several gems taken on one turn.
+    if (e.type === 'offerAgain') window.setTimeout(showOffer, 150)
     // A charge picked up while carrying another: sell it, or swap.
     if (e.type === 'chargeOffered') window.setTimeout(showChargeOffer, PICKUP_DIALOG_MS)
     // The first charge of each kind this run says what it does, so a new button is never a
@@ -507,7 +510,7 @@ function showMenu(): void {
     buttons: [
       { label: 'Resume', kind: 'primary', icon: playIcon },
       { label: 'New', kind: 'danger', icon: newRunIcon, onClick: newRunDialog },
-      { label: 'Almanac', kind: 'plain', onClick: () => openAlmanac() },
+      { label: 'Almanac', kind: 'plain', onClick: () => openAlmanac(undefined, state.options.dev) },
       { label: 'Scores', kind: 'plain', onClick: openScores },
       { label: 'Demo', kind: 'plain', onClick: startDemo },
       { label: 'Bug', kind: 'plain', onClick: reportBug },
@@ -815,6 +818,8 @@ const DEMO_ORDER: readonly DemoItem[] = [
   'whatIsThat',
   ...LIVING_KINDS.flatMap((k, i) => (i % 4 === 3 && DYING_KINDS[(i - 3) / 4] ? [k, DYING_KINDS[(i - 3) / 4]!] : [k])),
   ...DYING_KINDS.slice(Math.floor(LIVING_KINDS.length / 4)),
+  // Developer mode's edge-case checks (sim/testScenes.ts), with `?dev`.
+  ...(params.has('dev') ? TEST_KINDS : []),
 ]
 /** Where the demo is, kept so it carries on from there next time (not for a test URL: from 1). */
 const DEMO_KEY = storageKey('demoAt')
@@ -981,6 +986,7 @@ function placeDemoBar(): void {
  * together: for a power-up or charge, the one it has on the board, in the top bar and the almanac.
  */
 const SCENE_ICONS: Record<SceneKind, string> = {
+  ...(Object.fromEntries(TEST_KINDS.map((k) => [k, TEST_SCENES[k].icon])) as Record<TestKind, string>),
   tapMove: 'icon_move', holdBomb: 'bomb', bombButton: 'bomb', wait: 'icon_wait', autoMove: 'icon_move',
   kill: 'bomb', arrows: 'slime_normal_walk_a', escape: 'icon_move', chain: 'bomb', lingering: 'fireball', enemies: 'slime_normal_rest',
   coins: 'hud_coin', gem: 'gem_green',
@@ -1197,6 +1203,13 @@ function beatsFor(g: Gesture, action: Action, last: boolean): Beat[] {
     // A far tile: auto-move's way shows, and its first move is made.
     return [...onTile(g.tile, () => ((destination = g.tile), afterChange())), play]
   }
+  if (g.kind === 'pick') {
+    // A test scene's answer to the dialog that opens (a gem's choice, or a charge's): shown, then taken.
+    return [
+      { run: () => (state.offer ? openOffer(state, state.offer, () => {}) : openChargeOffer(state, () => {})), then: INFO_SECONDS },
+      { ...play, run: () => (closeDialog(), play.run()) },
+    ]
+  }
   if (g.kind === 'choice') {
     // The charge picked up asks, as in play: the dialog opens over the scene (not dimming the
     // caption), and the finger taps Take (swap) or Keep.
diff --git a/src/render/effects.ts b/src/render/effects.ts
index 0d5cbb0..687345d 100644
--- a/src/render/effects.ts
+++ b/src/render/effects.ts
@@ -177,6 +177,7 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even
         fx.appeared.set(key.gem(e.powerUp.pos), boom)
         break
       case 'powerUpTaken':
+      case 'gemQueued':
         fx.gone.push({ kind: 'taken', sprite: GEM_SPRITES[e.powerUp.color], pos: e.powerUp.pos, size: 0.7, at: now + TAKE_DELAY })
         break
       case 'powerUpBurned':
diff --git a/src/sim/demo.ts b/src/sim/demo.ts
index 2ac03e1..e9689cb 100644
--- a/src/sim/demo.ts
+++ b/src/sim/demo.ts
@@ -6,6 +6,7 @@ import { Rng } from './rng'
 import { autoMoveAction, playerPath, safeStep } from './path'
 import { membersOf, resolvePick, tapMeaning } from './picks'
 import { exitOpen, idx, inBounds, noPowers, playerCanEnter, START_LIVES, START_STATS } from './state'
+import { buildTestScene, TEST_KINDS, TEST_SCENES, type TestKind } from './testScenes'
 import { step } from './turn'
 import {
   DEFAULT_OPTIONS,
@@ -43,6 +44,7 @@ export type SceneKind =
   | 'exit' | 'doorBlast'
   | 'lastEnemy' | 'crate' | 'pillar' | 'rainbowGem' | 'immortal'
   | 'bush' | 'bushFire' | 'metal' | 'oil' | 'oilSlide' | 'spikes' | 'barrel'
+  | TestKind
   | 'dieIntoBlast' | 'dieNotFar' | 'dieBlockedIn' | 'dieChain' | 'dieEnemy' | 'dieDetonator' | 'dieRun'
 
 /**
@@ -62,6 +64,8 @@ export type Gesture =
   | { kind: 'go' }
   /** A charge picked up while carrying another: a tap on the dialog's Take (swap) or Keep button. */
   | { kind: 'choice'; swap: boolean }
+  /** A gem's offer, or a charge's sell-or-swap, answered (test scenes; the demo opens the dialog). */
+  | { kind: 'pick' }
 
 /** The action bar's buttons (ui/controls.ts). */
 export type ButtonId = 'move' | 'bomb' | 'charged' | 'wait'
@@ -125,6 +129,8 @@ interface SceneDef {
   maxSteps?: number
   /** Turns of waiting played after the moment, as far as they are safe, to see its result (user request, 2026-10-31). */
   tail?: number
+  /** A fixed scene (sim/testScenes.ts), not rolled: Developer mode's edge-case checks. Not in the demo's rotation. */
+  fixed?: () => Scene
   /** A scene about the sell-or-swap choice: turns that ask it are kept (elsewhere they are skipped). */
   choice?: boolean
 }
@@ -158,7 +164,17 @@ 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 test scene's entry (sim/testScenes.ts). */
+const testDef = (kind: TestKind): SceneDef => ({
+  caption: TEST_SCENES[kind].caption,
+  enemies: [],
+  shows: () => true,
+  clipOnly: true,
+  fixed: () => buildTestScene(kind),
+})
+
 export const SCENES: Record<SceneKind, SceneDef> = {
+  ...(Object.fromEntries(TEST_KINDS.map((k) => [k, testDef(k)])) as Record<TestKind, SceneDef>),
   // The controls (user request, 2026-10-24), shown first.
   tapMove: {
     caption: 'Tap a tile next to you to step onto it.',
@@ -610,6 +626,8 @@ export const LIVING_KINDS = SCENE_KINDS.filter((k) => !SCENES[k].dies && !SCENES
 
 /** A scene of `kind`, from `seed`; null if no room gave one (rare). */
 export function generateScene(kind: SceneKind, seed: number): Scene | null {
+  const fixed = SCENES[kind].fixed
+  if (fixed) return fixed()
   const rng = new Rng(seed)
   for (let room = 0; room < ROOMS; room++) {
     const start = makeRoom(kind, rng)
@@ -697,7 +715,7 @@ function makeRoom(kind: SceneKind, rng: Rng): GameState {
     seed: rng.int(2 ** 31), options: { ...DEFAULT_OPTIONS }, level: 1, turn: 0, width: WIDTH, height: HEIGHT,
     cells, player: { ...player }, lives: START_LIVES, stats: { ...START_STATS }, bombs: [], status: 'playing',
     enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: DEMO_PAR, coins: 0,
-    charge: null, chargeOffer: null, frozen: 0, decoy: null, burning: [],
+    charge: null, chargeOffer: null, waitingGems: [], frozen: 0, decoy: null, burning: [],
     powers: noPowers(), star: 0, gems: [], offer: null, shop: null, death: null, runTurns: 0, runKills: 0, deaths: 0, crumbling: [], bushes: [], smoldering: [], oil: [], spikes: [], cracked: [], collapsing: [], lastSeen: { ...player },
     aiRng: rng.int(2 ** 31), dropRng: rng.int(2 ** 31),
   }
diff --git a/src/sim/picture.ts b/src/sim/picture.ts
new file mode 100644
index 0000000..d2f2559
--- /dev/null
+++ b/src/sim/picture.ts
@@ -0,0 +1,53 @@
+import { noPowers, START_LIVES, START_STATS } from './state'
+import { DEFAULT_OPTIONS, type Cell, type EnemyKind, type GameState, type Stats, type Vec } from './types'
+
+const ENEMIES: Record<string, EnemyKind> = { B: 'blob', S: 'seeker', D: 'drifter', R: 'sprinter', G: 'ghost', W: 'wisp', H: 'hunter' }
+
+/**
+ * A state from a picture: `#` wall, `+` soft block, `.` floor, `P` player (on floor),
+ * `1`–`9` a bomb with that timer (on floor, placed on an earlier turn),
+ * `B` / `S` / `D` / `R` / `G` / `W` / `H` a Blob / Seeker / Drifter / Sprinter (runner) / Ghost /
+ * Wisp / Hunter facing right with an empty plan (it stays put unless the test gives it one),
+ * `E` the exit uncovered, `e` the exit under a soft block, `$` a 1-coin drop;
+ * `M` a metal crate, `O` an oil barrel, `r` rubble, `*` a bush, `~` oil, `^` spikes up this turn,
+ * `v` spikes down this turn (all on floor but the first three), `%` an uncovered gem.
+ * Shared by the tests and the developer-mode test scenes (sim/testScenes.ts).
+ * Without `E` or `e` the exit is off the board, so it never opens.
+ */
+export function board(picture: string, stats: Partial<Stats> = {}): GameState {
+  const rows = picture.trim().split('\n').map((r) => r.trim())
+  const height = rows.length
+  const width = rows[0]!.length
+  const cells: Cell[] = []
+  let player: Vec | undefined
+  const s: GameState = {
+    seed: 1, options: { ...DEFAULT_OPTIONS }, level: 1, turn: 5, width, height, cells, player: { x: 0, y: 0 },
+    lives: START_LIVES, stats: { ...START_STATS, ...stats }, bombs: [], status: 'playing',
+    enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 100, coins: 0,
+    charge: null, chargeOffer: null, waitingGems: [], frozen: 0, decoy: null, burning: [],
+    powers: noPowers(), star: 0, gems: [], offer: null, shop: null, death: null, runTurns: 0, runKills: 0, deaths: 0, crumbling: [], bushes: [], smoldering: [], oil: [], spikes: [], cracked: [], collapsing: [], lastSeen: { x: 0, y: 0 }, aiRng: 1, dropRng: 1,
+  }
+  rows.forEach((row, y) => {
+    if (row.length !== width) throw new Error(`row ${y} is ${row.length} wide, not ${width}`)
+    ;[...row].forEach((ch, x) => {
+      cells.push(ch === '#' ? 'wall' : ch === '+' || ch === 'e' ? 'block' : ch === 'M' ? 'metal' : ch === 'O' ? 'barrel' : ch === 'r' ? 'rubble' : 'floor')
+      const i = y * width + x
+      if (ch === '*') s.bushes.push(i)
+      if (ch === '~') s.oil.push(i)
+      // Up when (turn + phase) is even; the picture's turn is 5.
+      if (ch === '^' || ch === 'v') s.spikes.push({ cell: i, phase: ch === '^' ? 1 : 0 })
+      if (ch === 'P') player = { x, y }
+      if (/[1-9]/.test(ch)) s.bombs.push({ pos: { x, y }, timer: Number(ch), fire: s.stats.fire, placedTurn: 0 })
+      const kind = ENEMIES[ch]
+      if (kind) s.enemies.push({ id: s.nextEnemyId++, kind, pos: { x, y }, dir: 'right', plan: [], spawned: false, sheltered: false })
+      if (ch === 'E' || ch === 'e') s.exit = { x, y }
+      if (ch === '%') s.gems.push({ pos: { x, y }, color: 'red' })
+      if (ch === '$') s.drops.push({ pos: { x, y }, kind: 'coins', amount: 1 })
+    })
+  })
+  if (!player) throw new Error('no P in the picture')
+  s.player = player
+  s.lastSeen = { ...player }
+  return s
+}
+
diff --git a/src/sim/record.ts b/src/sim/record.ts
index 8ea17a9..8ce7857 100644
--- a/src/sim/record.ts
+++ b/src/sim/record.ts
@@ -6,7 +6,7 @@ import { DEFAULT_OPTIONS, type Action, type ChargeId, type GameEvent, type GameS
  * Bumped whenever a rule change would make an old record replay differently. Records from
  * another version can't be trusted to replay (PLAN.md §11).
  */
-export const SIM_VERSION = 41
+export const SIM_VERSION = 42
 
 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */
 export interface RunRecord {
diff --git a/src/sim/state.ts b/src/sim/state.ts
index a78e7fe..42e0f27 100644
--- a/src/sim/state.ts
+++ b/src/sim/state.ts
@@ -65,7 +65,7 @@ export function createLevel(seed: number, level: number, options: RunOptions, ca
     enemies: enemies.map((e, id) => ({ id, kind: e.kind, pos: e.pos, dir: e.dir, plan: [], spawned: false, sheltered: false })),
     nextEnemyId: enemies.length,
     drops: [], exit, par, frozen: 0, decoy: null, burning: [],
-    powers: { ...carry.powers }, star: carry.star, gems: [powerUp], offer: null, shop: null, death: null, runTurns: carry.runTurns, runKills: carry.runKills, deaths: carry.deaths, crumbling: [],
+    powers: { ...carry.powers }, star: carry.star, gems: [powerUp], offer: null, waitingGems: [], shop: null, death: null, runTurns: carry.runTurns, runKills: carry.runKills, deaths: carry.deaths, crumbling: [],
     bushes, smoldering: [], oil, spikes, lastSeen: { ...start }, cracked: [], collapsing: [],
     aiRng: hashSeed(seed, 'ai', level), dropRng: hashSeed(seed, 'drops', level),
   }
diff --git a/src/sim/testScenes.ts b/src/sim/testScenes.ts
new file mode 100644
index 0000000..57c7dd4
--- /dev/null
+++ b/src/sim/testScenes.ts
@@ -0,0 +1,173 @@
+import type { Gesture, Scene } from './demo'
+import { board } from './picture'
+import { idx } from './state'
+import { step } from './turn'
+import type { Action, GameState, Vec } from './types'
+
+/**
+ * Developer mode's test scenes (user request, 2026-10-31): fixed situations that check the edge
+ * cases, each a hand-drawn room and a scripted game, played like the demo's scenes. They are not
+ * in the landing page's demo (unless `?dev`), and the almanac lists them on its Tests tab in a
+ * Developer run. Each says what to expect, so a look is a check; test/test-scenes.test.ts checks
+ * the same by running them.
+ *
+ * To add one: a name in TestKind and an entry in TEST_SCENES (picture as in test/helpers.ts
+ * `board`, `%` a gem); the demo's table (sim/demo.ts) and the icons (main.ts) follow from the list.
+ */
+export type TestKind =
+  | 'testTwoGemsSlide' | 'testTwoGemsOil' | 'testGemsAndCharge' | 'testGemUnderRun'
+  | 'testGemDeath' | 'testOilBomb' | 'testOilEnemy' | 'testOilExit'
+
+/** A turn of the script: an action, or one worked out from the state it is played in (a choice from the offer). */
+type Turn = Action | ((s: GameState) => Action)
+
+export interface TestScene {
+  name: string
+  caption: string
+  /** What should happen, one point per line, for the almanac. */
+  expect: string[]
+  icon: string
+  picture: string
+  setup?: (s: GameState) => void
+  script: Turn[]
+}
+
+/** The first power-up on the gem's offer. */
+const pick: Turn = (s) => ({ id: 'choose', kind: s.offer![0]! })
+const sell: Turn = { id: 'sellCharge' }
+const at = (x: number, y: number): Vec => ({ x, y })
+/** Oil under every gem, bomb and enemy of the picture, so a spill runs through them. */
+const oilUnder = (s: GameState): void => {
+  for (const p of [...s.gems.map((g) => g.pos), ...s.bombs.map((b) => b.pos), ...s.enemies.map((e) => e.pos)]) s.oil.push(idx(s, p))
+}
+
+export const TEST_SCENES: Record<TestKind, TestScene> = {
+  testTwoGemsSlide: {
+    name: 'Slide over two gems',
+    caption: 'Test: a Slide over two gems asks for the first gem’s choice, then the second’s.',
+    expect: ['Two gems are taken on one turn.', 'The first choice opens; once it is made, the second opens (not skipped).', 'An ability chosen first is not offered again the second time.'],
+    icon: 'gem_green',
+    picture: `
+      #########
+      #P.%.%..#
+      #########`,
+    setup: (s) => (s.powers.slide = true),
+    script: [{ id: 'slide', target: at(7, 1) }, pick, pick],
+  },
+  testTwoGemsOil: {
+    name: 'Oil slide over two gems',
+    caption: 'Test: sliding on oil over two gems asks for both choices, one after the other.',
+    expect: ['The slide carries the player over both gems.', 'Two choices follow, one after the other.'],
+    icon: 'oil',
+    picture: `
+      #########
+      #P~%~%~.#
+      #########`,
+    setup: oilUnder,
+    script: [{ id: 'move', target: at(2, 1) }, pick, pick],
+  },
+  testGemsAndCharge: {
+    name: 'Two gems and a charge',
+    caption: 'Test: a Nuke between two gems, carrying a Freeze: both gem choices, and the charge’s sell-or-swap.',
+    expect: ['The first gem’s choice opens.', 'Then the charge’s: sell the Nuke, or swap it for the Freeze.', 'Only then the second gem’s choice.'],
+    icon: 'gem_green',
+    picture: `
+      #########
+      #P.%.%..#
+      #########`,
+    setup: (s) => {
+      s.powers.slide = true
+      s.charge = { kind: 'freeze', left: 1 }
+      s.drops.push({ pos: at(4, 1), kind: 'nuke' })
+    },
+    script: [{ id: 'slide', target: at(7, 1) }, pick, sell, pick],
+  },
+  testGemUnderRun: {
+    name: 'Run over a gem',
+    caption: 'Test: a Run passes over the tile in between, and picks up the gem lying there.',
+    expect: ['The gem is on the tile the Run crosses, not the one it lands on.', 'Its choice opens.'],
+    icon: 'gem_green',
+    picture: `
+      #########
+      #P%.....#
+      #########`,
+    setup: (s) => (s.powers.run = true),
+    script: [{ id: 'run', target: at(3, 1) }, pick],
+  },
+  testGemDeath: {
+    name: 'Gem taken, then killed',
+    caption: 'Test: taking a gem on the turn a blast kills you. No choice opens; the retry has the gem back.',
+    expect: ['The player steps onto the gem and the bomb goes off.', 'A life is lost and no choice opens.'],
+    icon: 'fireball',
+    picture: `
+      #########
+      #P%1....#
+      #########`,
+    script: [{ id: 'move', target: at(2, 1) }],
+  },
+  testOilBomb: {
+    name: 'Oil slide stops at a bomb',
+    caption: 'Test: sliding on oil stops short of a bomb lying on the spill.',
+    expect: ['The player slides along the oil and stops on the tile before the bomb.'],
+    icon: 'bomb',
+    picture: `
+      #########
+      #P~~~5..#
+      #########`,
+    setup: oilUnder,
+    script: [{ id: 'move', target: at(2, 1) }],
+  },
+  testOilEnemy: {
+    name: 'Oil slide stops at an enemy',
+    caption: 'Test: sliding on oil stops short of an enemy standing on the spill, instead of into it.',
+    expect: ['The player slides and stops next to the Blob; no life is lost in the slide itself.'],
+    icon: 'slime_normal_rest',
+    picture: `
+      #########
+      #P~~~B..#
+      #########`,
+    setup: oilUnder,
+    script: [{ id: 'move', target: at(2, 1) }],
+  },
+  testOilExit: {
+    name: 'Oil slide stops on the exit',
+    caption: 'Test: the slide stops on an open exit, and the level is cleared.',
+    expect: ['The exit is open (no enemies).', 'The slide ends on it, not at the end of the spill.'],
+    icon: 'door_open_top',
+    picture: `
+      #########
+      #P~~~E~.#
+      #########`,
+    setup: (s) => {
+      for (const x of [2, 3, 4, 5, 6]) s.oil.push(idx(s, at(x, 1)))
+    },
+    script: [{ id: 'move', target: at(2, 1) }],
+  },
+}
+
+export const TEST_KINDS = Object.keys(TEST_SCENES) as TestKind[]
+
+/** The gesture a turn's action is played with (the demo's finger; a choice has no tile). */
+function gestureOf(a: Action): Gesture {
+  if (a.id === 'choose' || a.id === 'sellCharge' || a.id === 'swapCharge' || a.id === 'fireCharge') return { kind: 'pick' }
+  return 'target' in a && a.target ? { kind: 'tap', tile: a.target } : { kind: 'button', button: 'wait' }
+}
+
+/** A test scene played out: its room and turns, as the demo's scenes are. */
+export function buildTestScene(kind: TestKind): Scene {
+  const def = TEST_SCENES[kind]
+  const s = board(def.picture)
+  def.setup?.(s)
+  // The room starts at turn 0 like the demo's; spikes keep their rhythm.
+  const spikes = s.spikes.map((sp) => ({ ...sp, phase: (sp.phase + s.turn) % 2 }))
+  // A bomb of the picture was placed before turn 0, so it ticks on the first turn.
+  const start: GameState = { ...s, turn: 0, spikes, bombs: s.bombs.map((b) => ({ ...b, placedTurn: -1 })) }
+  const actions: Action[] = []
+  let now = start
+  for (const turn of def.script) {
+    const action = typeof turn === 'function' ? turn(now) : turn
+    actions.push(action)
+    now = step(now, action).state
+  }
+  return { kind, start, actions, gestures: actions.map(gestureOf), moment: actions.length - 1 }
+}
diff --git a/src/sim/turn.ts b/src/sim/turn.ts
index a8ebd50..78f56d6 100644
--- a/src/sim/turn.ts
+++ b/src/sim/turn.ts
@@ -36,6 +36,7 @@ export function step(prev: GameState, action: Action): StepResult {
     // A charge chosen while carrying another kind asks, as a pickup does.
     if (isCharge(action.kind)) takeCharge(s, action.kind, events)
     else applyPowerUp(s, action.kind)
+    nextOffer(s, events)
     return { state: s, events }
   }
   // A charge picked up while carrying another: sell it, or swap it in. No turn passes.
@@ -50,6 +51,7 @@ export function step(prev: GameState, action: Action): StepResult {
       ACTIONS[use].apply(s, s.player, events)
       swapOffered(s, events)
     }
+    nextOffer(s, events)
     return { state: s, events }
   }
   // An offer must be answered first, even with the level cleared; a death drops it.
@@ -283,6 +285,7 @@ export function step(prev: GameState, action: Action): StepResult {
   if (s.status === 'dead' || s.status === 'gameOver') {
     s.offer = null
     s.chargeOffer = null
+    s.waitingGems = []
   }
 
   s.turn++
@@ -383,12 +386,29 @@ function pickUp(s: GameState, p: Vec, events: GameEvent[]): void {
   }
   const gem = s.gems.find((g) => eq(g.pos, p) && cellAt(s, p) === 'floor')
   if (gem) {
-    s.offer = drawOffer(s, gem.extra)
-    events.push({ type: 'powerUpTaken', powerUp: gem, offer: s.offer })
     s.gems.splice(s.gems.indexOf(gem), 1)
+    // A second gem on the same turn (a slide over two) waits its turn: its offer is drawn once the
+    // first is answered, so it knows what that choice gave (user report, 2026-10-31).
+    if (s.offer) {
+      s.waitingGems.push(gem)
+      events.push({ type: 'gemQueued', powerUp: gem })
+    }
+    else {
+      s.offer = drawOffer(s, gem.extra)
+      events.push({ type: 'powerUpTaken', powerUp: gem, offer: s.offer })
+    }
   }
 }
 
+/** Once the offer and any charge choice are answered, the next gem waiting (if any) makes its offer. */
+function nextOffer(s: GameState, events: GameEvent[]): void {
+  if (s.offer || s.chargeOffer) return
+  const gem = s.waitingGems.shift()
+  if (!gem) return
+  s.offer = drawOffer(s, gem.extra)
+  events.push({ type: 'offerAgain', powerUp: gem, offer: s.offer })
+}
+
 /** How many power-ups a gem offers (user request, 2026-10-23). */
 export const OFFER_SIZE = 5
 
diff --git a/src/sim/types.ts b/src/sim/types.ts
index af714fa..3807657 100644
--- a/src/sim/types.ts
+++ b/src/sim/types.ts
@@ -299,6 +299,8 @@ export interface GameState {
    * retry has the gem back.
    */
   offer: OfferKind[] | null
+  /** Gems taken on the same turn as the one on offer (a slide over two): each offers in turn, after the one before is answered. */
+  waitingGems: PowerUp[]
   /** The shop, open after a cleared level every few levels until the player goes on (sim/shop.ts). */
   shop: Shop | null
   /**
@@ -400,6 +402,10 @@ export type GameEvent =
   | { type: 'powerUpRevealed'; powerUp: PowerUp }
   | { type: 'powerUpBurned'; powerUp: PowerUp }
   | { type: 'powerUpTaken'; powerUp: PowerUp; offer: OfferKind[] }
+  /** A second gem taken on the turn: it leaves the board now, its offer comes later (`offerAgain`). */
+  | { type: 'gemQueued'; powerUp: PowerUp }
+  /** A gem taken earlier this turn now makes its offer (the first one is answered). */
+  | { type: 'offerAgain'; powerUp: PowerUp; offer: OfferKind[] }
   | { type: 'powerUpChosen'; kind: OfferKind }
   | { type: 'shopOpened' }
   | { type: 'bought'; kind: OfferKind; price: number }
diff --git a/src/ui/almanac.ts b/src/ui/almanac.ts
index a8562bb..34a3c1b 100644
--- a/src/ui/almanac.ts
+++ b/src/ui/almanac.ts
@@ -5,6 +5,7 @@ import { CHARGE_ICONS, ENEMY_SPRITES, POWER_UP_ICONS, spriteUrl } from '../rende
 import type { SceneKind } from '../sim/demo'
 import { openDialog } from './dialog'
 import { startClip } from './clip'
+import { TEST_KINDS, TEST_SCENES } from '../sim/testScenes'
 import { BOARD_TEXT, CHARGE_TEXT, ENEMY_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info'
 
 /**
@@ -12,13 +13,14 @@ import { BOARD_TEXT, CHARGE_TEXT, ENEMY_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } fro
  * picture and what it does, opened from the menu. Its tabs swap the list in place. Since 2026-10-25
  * a Board tab too: crates, pillars and the board's objects (PLAN.md §19.3).
  */
-export type AlmanacTab = 'powerUps' | 'charges' | 'enemies' | 'board'
+export type AlmanacTab = 'powerUps' | 'charges' | 'enemies' | 'board' | 'tests'
 
 const TABS: [AlmanacTab, string][] = [
   ['powerUps', 'Power-ups'],
   ['charges', 'Charges'],
   ['enemies', 'Enemies'],
   ['board', 'Board'],
+  ['tests', 'Tests'],
 ]
 
 /** The power-ups in the order they make sense to read: stats, abilities, passes, the rest. */
@@ -48,7 +50,8 @@ function entry(sprite: string, name: string, lines: string[], clip?: SceneKind):
 }
 
 function body(tab: AlmanacTab): string {
-  const tabs = TABS.map(([id, label]) => `<button type="button" data-tab="${id}" aria-pressed="${id === tab}">${label}</button>`).join('')
+  // The Tests tab is for a Developer run (user request, 2026-10-31).
+  const tabs = TABS.filter(([id]) => id !== 'tests' || shownDev).map(([id, label]) => `<button type="button" data-tab="${id}" aria-pressed="${id === tab}">${label}</button>`).join('')
   let list: string
   if (tab === 'powerUps') {
     list =
@@ -64,6 +67,10 @@ function body(tab: AlmanacTab): string {
       Object.values(BOARD_TEXT)
         .map((t) => entry(t.icon, t.name, t.from ? [...t.text, `From level ${t.from}.`] : t.text, t.clip))
         .join('')
+  } else if (tab === 'tests') {
+    list =
+      '<p class="almanac-intro">Developer mode: fixed situations that check edge cases. Each clip replays the same turns; the gem and charge choices play unseen here (they open in the demo with ?dev). What to expect is under each.</p>' +
+      TEST_KINDS.map((k) => entry(TEST_SCENES[k].icon, TEST_SCENES[k].name, [TEST_SCENES[k].caption, ...TEST_SCENES[k].expect.map((e) => `• ${e}`)], k)).join('')
   } else {
     list =
       '<p class="almanac-intro">Every enemy shows its next 3 moves as arrows. Flame kills them; touching one costs you a life.</p>' +
@@ -73,10 +80,13 @@ function body(tab: AlmanacTab): string {
 }
 
 let shownTab: AlmanacTab = 'powerUps'
+/** Whether the run is a Developer one (it shows the Tests tab); kept for Back from an entry. */
+let shownDev = false
 
-export function openAlmanac(tab: AlmanacTab = shownTab): void {
-  shownTab = tab
-  openDialog({ title: 'Almanac', body: body(tab), buttons: [{ label: 'Close', kind: 'plain' }] })
+export function openAlmanac(tab: AlmanacTab = shownTab, dev: boolean = shownDev): void {
+  shownDev = dev
+  shownTab = tab === 'tests' && !dev ? 'powerUps' : tab
+  openDialog({ title: 'Almanac', body: body(shownTab), buttons: [{ label: 'Close', kind: 'plain' }] })
 }
 
 /** An entry's own dialog: its clip above what it does, and Back to the almanac where it was. */
diff --git a/src/ui/clip.ts b/src/ui/clip.ts
index 467b040..a1d17df 100644
--- a/src/ui/clip.ts
+++ b/src/ui/clip.ts
@@ -198,7 +198,7 @@ function play(c: Clip, action: Action): void {
 function gestureBeats(c: Clip, g: Gesture, action: Action, last: boolean): Clip['beats'] {
   const s = c.state!
   // A choice in a dialog (the charge swap) has no tile on the board: the clip just plays it.
-  const tile = g.kind === 'go' || g.kind === 'choice' ? null : g.kind === 'button' ? (g.tile ?? (g.button === 'wait' ? null : s.player)) : g.tile
+  const tile = g.kind === 'go' || g.kind === 'choice' || g.kind === 'pick' ? null : g.kind === 'button' ? (g.tile ?? (g.button === 'wait' ? null : s.player)) : g.tile
   if (!tile) return [{ run: () => play(c, action), then: last ? HOLD_SECONDS : STEP_SECONDS }]
   const finger = c.finger!
   const hold = g.kind === 'hold'
diff --git a/src/ui/feedback.ts b/src/ui/feedback.ts
index 51964ff..b88b24e 100644
--- a/src/ui/feedback.ts
+++ b/src/ui/feedback.ts
@@ -140,7 +140,8 @@ export function turnFeedback(events: readonly GameEvent[], action: Action, anim:
         if (e.drop.kind === 'coins') pickedCoins = true
         else pickedCharge = true
         break
-      case 'powerUpTaken': play('gem', 1, 1, arrive); vibrate(15, arrive); break
+      case 'powerUpTaken':
+      case 'gemQueued': play('gem', 1, 1, arrive); vibrate(15, arrive); break
       case 'powerUpChosen': play('switch'); break
       case 'bought': play('coin', 1, 0.8); break
       case 'rerolled': play('switch'); break
diff --git a/test/demo.test.ts b/test/demo.test.ts
index 307d316..7dfbb10 100644
--- a/test/demo.test.ts
+++ b/test/demo.test.ts
@@ -27,6 +27,7 @@ function controlsTake(s: GameState, g: Gesture, destination: Vec | null): Action
   }
   if (g.kind === 'walk') return tapMeaning(s, g.tile) === 'walk' ? auto(g.tile) : null
   if (g.kind === 'go') return destination && auto(destination)
+  if (g.kind === 'pick') return null
   if (g.kind === 'choice') return s.chargeOffer ? { id: g.swap ? 'swapCharge' : 'sellCharge' } : null
   if (g.button === 'wait') return { id: 'wait' }
   const id = g.tile && resolvePick(s, g.button, g.tile)
@@ -34,7 +35,7 @@ function controlsTake(s: GameState, g: Gesture, destination: Vec | null): Action
 }
 
 describe('demo scenes', () => {
-  for (const kind of SCENE_KINDS) {
+  for (const kind of SCENE_KINDS.filter((k) => !SCENES[k].fixed)) {
     it(`${kind}: found, its moment ${MIN_STEPS}-${MAX_STEPS} turns in from a calm start, each turn played by its gesture, the player alive (or dying at the end of a death scene)`, () => {
       for (let seed = 1; seed <= 6; seed++) {
         const scene = generateScene(kind, seed)!
diff --git a/test/helpers.ts b/test/helpers.ts
index 31ceb47..d7de528 100644
--- a/test/helpers.ts
+++ b/test/helpers.ts
@@ -1,52 +1,5 @@
-import { noPowers, START_LIVES, START_STATS } from '../src/sim/state'
-import { DEFAULT_OPTIONS, type Cell, type EnemyKind, type GameState, type Stats, type Vec } from '../src/sim/types'
+import type { Vec } from '../src/sim/types'
 
-const ENEMIES: Record<string, EnemyKind> = { B: 'blob', S: 'seeker', D: 'drifter', R: 'sprinter', G: 'ghost', W: 'wisp', H: 'hunter' }
-
-/**
- * A state from a picture: `#` wall, `+` soft block, `.` floor, `P` player (on floor),
- * `1`–`9` a bomb with that timer (on floor, placed on an earlier turn),
- * `B` / `S` / `D` / `R` / `G` / `W` / `H` a Blob / Seeker / Drifter / Sprinter (runner) / Ghost /
- * Wisp / Hunter facing right with an empty plan (it stays put unless the test gives it one),
- * `E` the exit uncovered, `e` the exit under a soft block, `$` a 1-coin drop;
- * `M` a metal crate, `O` an oil barrel, `r` rubble, `*` a bush, `~` oil, `^` spikes up this turn,
- * `v` spikes down this turn (all on floor but the first three).
- * Without `E` or `e` the exit is off the board, so it never opens.
- */
-export function board(picture: string, stats: Partial<Stats> = {}): GameState {
-  const rows = picture.trim().split('\n').map((r) => r.trim())
-  const height = rows.length
-  const width = rows[0]!.length
-  const cells: Cell[] = []
-  let player: Vec | undefined
-  const s: GameState = {
-    seed: 1, options: { ...DEFAULT_OPTIONS }, level: 1, turn: 5, width, height, cells, player: { x: 0, y: 0 },
-    lives: START_LIVES, stats: { ...START_STATS, ...stats }, bombs: [], status: 'playing',
-    enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 100, coins: 0,
-    charge: null, chargeOffer: null, frozen: 0, decoy: null, burning: [],
-    powers: noPowers(), star: 0, gems: [], offer: null, shop: null, death: null, runTurns: 0, runKills: 0, deaths: 0, crumbling: [], bushes: [], smoldering: [], oil: [], spikes: [], cracked: [], collapsing: [], lastSeen: { x: 0, y: 0 }, aiRng: 1, dropRng: 1,
-  }
-  rows.forEach((row, y) => {
-    if (row.length !== width) throw new Error(`row ${y} is ${row.length} wide, not ${width}`)
-    ;[...row].forEach((ch, x) => {
-      cells.push(ch === '#' ? 'wall' : ch === '+' || ch === 'e' ? 'block' : ch === 'M' ? 'metal' : ch === 'O' ? 'barrel' : ch === 'r' ? 'rubble' : 'floor')
-      const i = y * width + x
-      if (ch === '*') s.bushes.push(i)
-      if (ch === '~') s.oil.push(i)
-      // Up when (turn + phase) is even; the picture's turn is 5.
-      if (ch === '^' || ch === 'v') s.spikes.push({ cell: i, phase: ch === '^' ? 1 : 0 })
-      if (ch === 'P') player = { x, y }
-      if (/[1-9]/.test(ch)) s.bombs.push({ pos: { x, y }, timer: Number(ch), fire: s.stats.fire, placedTurn: 0 })
-      const kind = ENEMIES[ch]
-      if (kind) s.enemies.push({ id: s.nextEnemyId++, kind, pos: { x, y }, dir: 'right', plan: [], spawned: false, sheltered: false })
-      if (ch === 'E' || ch === 'e') s.exit = { x, y }
-      if (ch === '$') s.drops.push({ pos: { x, y }, kind: 'coins', amount: 1 })
-    })
-  })
-  if (!player) throw new Error('no P in the picture')
-  s.player = player
-  s.lastSeen = { ...player }
-  return s
-}
+export { board } from '../src/sim/picture'
 
 export const v = (x: number, y: number): Vec => ({ x, y })
diff --git a/test/oil-slide.test.ts b/test/oil-slide.test.ts
index 6d64cf3..1253319 100644
--- a/test/oil-slide.test.ts
+++ b/test/oil-slide.test.ts
@@ -231,3 +231,55 @@ describe('oil is slippery (user request, 2026-10-31)', () => {
     for (const p of s.enemies[0]!.plan) expect(p.x).toBeLessThanOrEqual(2)
   })
 })
+
+describe('two gems on one turn (user report, 2026-10-31)', () => {
+  it('a slide over two gems offers them one after the other', () => {
+    const s = board(`
+      ########
+      #P.....#
+      ########`)
+    s.powers.slide = true
+    s.gems = [{ pos: v(2, 1), color: 'red' }, { pos: v(3, 1), color: 'red' }]
+    const r = step(s, { id: 'slide', target: v(6, 1) })
+    expect(r.state.offer).not.toBeNull()
+    expect(r.state.waitingGems).toHaveLength(1)
+    expect(r.state.gems).toHaveLength(0)
+    const first = r.state.offer![0]!
+    const t = step(r.state, { id: 'choose', kind: first })
+    expect(t.state.offer).not.toBeNull()
+    expect(t.state.waitingGems).toHaveLength(0)
+    expect(t.events.some((e) => e.type === 'offerAgain')).toBe(true)
+    const u = step(t.state, { id: 'choose', kind: t.state.offer![0]! })
+    expect(u.state.offer).toBeNull()
+  })
+})
+
+describe('two gems, more cases (reviewer, 2026-10-31)', () => {
+  it('a slide over two gems onto the open exit still asks for both', () => {
+    const s = board(`
+      #########
+      #P.%.%.E#
+      #########`)
+    s.powers.slide = true
+    const r = step(s, { id: 'slide', target: v(7, 1) })
+    expect(r.state.status).toBe('cleared')
+    expect(r.state.offer).not.toBeNull()
+    const t = step(r.state, { id: 'choose', kind: r.state.offer![0]! })
+    expect(t.state.offer).not.toBeNull()
+    expect(t.events.filter((e) => e.type === 'offerAgain')).toHaveLength(1)
+    expect(r.events.filter((e) => e.type === 'gemQueued')).toHaveLength(1)
+  })
+
+  it('dying on the turn two gems are taken drops both', () => {
+    const s = board(`
+      #########
+      #P%%1...#
+      #########`)
+    s.powers.slide = true
+    s.gems = [{ pos: v(2, 1), color: 'red' }, { pos: v(3, 1), color: 'red' }]
+    const r = step(s, { id: 'slide', target: v(3, 1) })
+    expect(r.state.status).toBe('dead')
+    expect(r.state.offer).toBeNull()
+    expect(r.state.waitingGems).toEqual([])
+  })
+})
diff --git a/test/test-scenes.test.ts b/test/test-scenes.test.ts
new file mode 100644
index 0000000..9446cb7
--- /dev/null
+++ b/test/test-scenes.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from 'vitest'
+import { generateScene, replayScene } from '../src/sim/demo'
+import { buildTestScene, TEST_KINDS } from '../src/sim/testScenes'
+import { step } from '../src/sim/turn'
+import type { GameState } from '../src/sim/types'
+
+/** Plays a test scene to its end: the state after every turn. */
+function states(kind: (typeof TEST_KINDS)[number]): GameState[] {
+  const scene = buildTestScene(kind)
+  const out: GameState[] = []
+  let s = scene.start
+  for (const a of scene.actions) {
+    s = step(s, a).state
+    out.push(s)
+  }
+  return out
+}
+
+describe('developer-mode test scenes (user request, 2026-10-31)', () => {
+  for (const kind of TEST_KINDS) {
+    it(`${kind}: is built, each turn is legal, and generateScene gives the same scene`, () => {
+      const scene = buildTestScene(kind)
+      expect(scene.actions.length).toBe(scene.gestures.length)
+      expect(generateScene(kind, 1)).toEqual(scene)
+      expect(() => replayScene(scene)).not.toThrow()
+    })
+  }
+
+  it('two gems on one slide offer one after the other and end with none open', () => {
+    const [slid, first, second] = states('testTwoGemsSlide')
+    expect(slid!.offer).not.toBeNull()
+    expect(slid!.waitingGems).toHaveLength(1)
+    expect(first!.offer).not.toBeNull()
+    expect(second!.offer).toBeNull()
+    expect(second!.waitingGems).toHaveLength(0)
+  })
+
+  it('oil over two gems does the same', () => {
+    const [slid, first, second] = states('testTwoGemsOil')
+    expect(slid!.offer).not.toBeNull()
+    expect(first!.offer).not.toBeNull()
+    expect(second!.offer).toBeNull()
+  })
+
+  it('two gems with a charge between: the second waits for the charge choice', () => {
+    const [slid, first, sold, second] = states('testGemsAndCharge')
+    expect(slid!.offer).not.toBeNull()
+    expect(slid!.chargeOffer).not.toBeNull()
+    expect(first!.offer).toBeNull()
+    expect(first!.chargeOffer).not.toBeNull()
+    expect(sold!.offer).not.toBeNull()
+    expect(second!.offer).toBeNull()
+    expect(second!.chargeOffer).toBeNull()
+  })
+
+  it('a gem under a Run is taken', () => {
+    expect(states('testGemUnderRun')[0]!.offer).not.toBeNull()
+  })
+
+  it('a gem taken on the turn of death offers nothing', () => {
+    const [dead] = states('testGemDeath')
+    expect(dead!.status).toBe('dead')
+    expect(dead!.offer).toBeNull()
+  })
+
+  it('oil slides stop at a bomb and at an enemy, and on an open exit', () => {
+    expect(states('testOilBomb')[0]!.player).toEqual({ x: 4, y: 1 })
+    const [e] = states('testOilEnemy')
+    expect(e!.player).toEqual({ x: 4, y: 1 })
+    expect(e!.status).toBe('playing')
+    expect(states('testOilExit')[0]!.status).toBe('cleared')
+  })
+})