explodorama / commit
Two gems on one turn offer one after the other; Developer-mode test scenes (0.2.0 work)
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') + }) +})