explodorama / commit
Developer mode adds an extra gem; levels hold a list of gems
CHANGELOG.md | 6 +++--- PLAN.md | 2 +- README.md | 2 +- decisions.md | 12 ++++++++++++ docs/screenshots/almanac-charges.png | Bin 369526 -> 369587 bytes docs/screenshots/almanac.png | Bin 430969 -> 430979 bytes docs/screenshots/countdown.png | Bin 248390 -> 248448 bytes docs/screenshots/death.png | Bin 192485 -> 192554 bytes docs/screenshots/demo.png | Bin 108596 -> 108596 bytes docs/screenshots/desktop.png | Bin 583089 -> 583108 bytes docs/screenshots/dev.png | Bin 245842 -> 245795 bytes docs/screenshots/enemies.png | Bin 238025 -> 238019 bytes docs/screenshots/lingering.png | Bin 248622 -> 248488 bytes docs/screenshots/long-press.png | Bin 247145 -> 247115 bytes docs/screenshots/menu.png | Bin 170808 -> 170738 bytes docs/screenshots/move-picked.png | Bin 243723 -> 243624 bytes docs/screenshots/scrolled.png | Bin 246848 -> 246763 bytes docs/screenshots/small-desktop.png | Bin 271648 -> 271684 bytes src/main.ts | 2 +- src/render/board.ts | 11 ++++++----- src/render/effects.ts | 4 ++-- src/sim/demo.ts | 4 ++-- src/sim/levelgen.ts | 2 +- src/sim/record.ts | 2 +- src/sim/state.ts | 14 ++++++++----- src/sim/turn.ts | 37 ++++++++++++++++++----------------- src/sim/types.ts | 11 ++++++++--- src/ui/info.ts | 9 +++++---- test/helpers.ts | 2 +- test/options.test.ts | 37 ++++++++++++++++++++++++++++------- test/powerups.test.ts | 12 ++++++------ 31 files changed, 108 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90073c6..d32601a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,9 +83,9 @@ saved before the change is dropped and the next visit starts a new run. - Stepping onto the exit walks into the doorway before the jump (it used to happen instantly). - An enemy walking into flame finishes its step before it dies (it used to die on the new tile without moving). -- Saved runs from before the gems can't be resumed (`SIM_VERSION` 18). -- Developer mode now only puts every level's gem uncovered next to the player; it no longer - gives every ability, extra stats or unlimited charges. +- Saved runs from before the gems can't be resumed (`SIM_VERSION` 19). +- Developer mode now only adds an extra gem next to the player on every level (the level's own + gem stays hidden); it no longer gives every ability, extra stats or unlimited charges. ### Removed diff --git a/PLAN.md b/PLAN.md index d559b8d..7bf8b6b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -626,7 +626,7 @@ and state (`GameState.options`), so replays are exact. Shown as tags in the top |---|---| | **Board: Normal** | 31 × 13, density 35%, 50 levels, enemies 3 + 1 per 3 levels (max 10), par 50 + 10/enemy. | | **Board: Small** | 9 × 11 (fits a phone whole; the camera scales to fit both ways, no scrolling), density 25%, **20 levels**, enemies 2 + 1 per 4 levels (max 5), par 25 + 8/enemy. Same enemy-type schedule and Run rule. Levelgen tops up crates if fewer than 2 are far enough for the exit and power-up. | -| **Developer** | *Since 2026-10-23 (user request), only this:* every level's gem lies uncovered right of the start, every level and after every death, to try the choice at once. (Until then: every ability and pass, 5 bombs, blast 5, unlimited charges shown as ∞; **killable**, split from God mode by the user.) | +| **Developer** | *Since 2026-10-23 (user request), only this:* an **extra** gem (`PowerUp.extra`, another colour, its own offer) lies right of the start, every level and after every death, to try the choice at once; the level keeps its own gem, hidden as usual (at first it was that gem, moved). (Until then: every ability and pass, 5 bombs, blast 5, unlimited charges shown as ∞; **killable**, split from God mode by the user.) | | **God** | The player can't be hurt at all. | | **Crumbling crates** | When the last enemy dies, every crate gets a countdown of 1–3 turns (seeded, `hash(seed, "crumble", level)`), shown by shaking and fading; at 0 it is gone, revealing whatever was under it. It is not flame: no punishment, nothing burned. | diff --git a/README.md b/README.md index 1891e0e..9231af9 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ replayed. |---|---| | **Normal** board | 31 × 13, scrolls sideways, **50 levels**. | | **Small** board | 9 × 11, fits the screen whole (no scrolling), **20 levels**, fewer enemies (2 on level 1, up to 5) and fewer crates. A quicker game. | -| **Developer** (checkbox) | **Every level's gem lies uncovered right next to you** at the start (and after a death), to try the power-up choice at once. Nothing else changes. | +| **Developer** (checkbox) | **An extra gem lies right next to you** at the start of every level (and after a death), in another colour, to try the power-up choice at once. The level's own gem is still hidden under a crate as usual. Nothing else changes. | | **God** (checkbox) | Nothing can kill you. | | **Crumbling crates** (checkbox) | Once the last enemy is gone, every crate crumbles away over 1–3 turns (shaking first), revealing the exit and the gem. Not flame: nothing is punished. | diff --git a/decisions.md b/decisions.md index 56552aa..8e0f7c4 100644 --- a/decisions.md +++ b/decisions.md @@ -940,3 +940,15 @@ calls safe are landed on, and never the open exit unless it is the destination. teleport doesn't end auto-move (a move off the way normally means "changed my mind"). With Teleport, a tile out of walking reach but within Teleport's (behind a pillar) is one teleport. Tests cover each case; in a browser, a tile 7 steps away took 3 turns (teleport, teleport, step). + +### 2026-10-23 — Developer mode's gem is an extra one + +User request: the gem next to the player in Developer mode is not the level's gem but an extra +one, added for the developer. It used to be the level's own gem, moved beside the start, which +left the level without its hidden gem. A level can now hold several gems (`state.gems`, which +replaces `powerUp`); Developer mode adds one marked `extra` right of the start, in the next +colour after the level gem's so the two look different, and it draws its own offer (`'offer-extra'` +stream) so it doesn't offer the same five as the level's gem. The level's own gem stays hidden +under its crate exactly as in a normal run. Burning, revealing, taking and drops' "never on a gem" +work per gem. Tests: the level's gem is unchanged and hidden; the extra one is another colour +and its offer differs on most seeds; a normal run has one gem. `SIM_VERSION` 19. diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png index accbbd8..d460d53 100644 Binary files a/docs/screenshots/almanac-charges.png and b/docs/screenshots/almanac-charges.png differ diff --git a/docs/screenshots/almanac.png b/docs/screenshots/almanac.png index 9d32667..f6f88a5 100644 Binary files a/docs/screenshots/almanac.png and b/docs/screenshots/almanac.png differ diff --git a/docs/screenshots/countdown.png b/docs/screenshots/countdown.png index a815c54..c5609b1 100644 Binary files a/docs/screenshots/countdown.png and b/docs/screenshots/countdown.png differ diff --git a/docs/screenshots/death.png b/docs/screenshots/death.png index 913089d..0d4e780 100644 Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ diff --git a/docs/screenshots/demo.png b/docs/screenshots/demo.png index 21197d3..2b171e0 100644 Binary files a/docs/screenshots/demo.png and b/docs/screenshots/demo.png differ diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png index c0cfb0c..83428b3 100644 Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ diff --git a/docs/screenshots/dev.png b/docs/screenshots/dev.png index 4d41bde..bd1801e 100644 Binary files a/docs/screenshots/dev.png and b/docs/screenshots/dev.png differ diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png index a6a187c..b6eadfa 100644 Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png index c1a0e4b..43d66c1 100644 Binary files a/docs/screenshots/lingering.png and b/docs/screenshots/lingering.png differ diff --git a/docs/screenshots/long-press.png b/docs/screenshots/long-press.png index 174ec4e..032bd2e 100644 Binary files a/docs/screenshots/long-press.png and b/docs/screenshots/long-press.png differ diff --git a/docs/screenshots/menu.png b/docs/screenshots/menu.png index f955d74..99d0710 100644 Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ diff --git a/docs/screenshots/move-picked.png b/docs/screenshots/move-picked.png index d938008..f26d04a 100644 Binary files a/docs/screenshots/move-picked.png and b/docs/screenshots/move-picked.png differ diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png index 6b1ede5..19ebf69 100644 Binary files a/docs/screenshots/scrolled.png and b/docs/screenshots/scrolled.png differ diff --git a/docs/screenshots/small-desktop.png b/docs/screenshots/small-desktop.png index a899fe6..35e6590 100644 Binary files a/docs/screenshots/small-desktop.png and b/docs/screenshots/small-desktop.png differ diff --git a/src/main.ts b/src/main.ts index b0e6247..4ff134e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -358,7 +358,7 @@ function newRunDialog(): void { `<label><input type="radio" name="size" value="normal"${checked(o.size === 'normal')}><span><b>Normal</b> 31 × 13, scrolls sideways, 50 levels.</span></label>` + `<label><input type="radio" name="size" value="small"${checked(o.size === 'small')}><span><b>Small</b> 9 × 11, fits the screen, 20 levels, fewer enemies and crates.</span></label>` + `</fieldset><fieldset class="choices"><legend>Modes</legend>` + - `<label><input type="checkbox" name="dev"${checked(o.dev)}><span><b>Developer</b> Every level’s gem lies uncovered right next to you at the start.</span></label>` + + `<label><input type="checkbox" name="dev"${checked(o.dev)}><span><b>Developer</b> An extra gem lies right next to you at the start of every level (the level’s own is still hidden).</span></label>` + `<label><input type="checkbox" name="god"${checked(o.god)}><span><b>God</b> Nothing can kill you.</span></label>` + `<label><input type="checkbox" name="crumble"${checked(o.crumble)}><span><b>Crumbling crates</b> Once the last enemy is gone, the crates crumble away over 1–3 turns.</span></label>` + `</fieldset>`, diff --git a/src/render/board.ts b/src/render/board.ts index a33240c..f9036be 100644 --- a/src/render/board.ts +++ b/src/render/board.ts @@ -144,11 +144,12 @@ export function drawBoard(view: View): void { if (cellAt(s, s.exit) === 'floor') { drawExit(toWorld(s, s.exit), exitOpen(s), appearScale(fx, key.exit, time) * pulseScale(fx, key.exit, time, 0.25)) } - // The level's gem, once its crate is gone, bobbing gently (no glow behind it: user request, 2026-10-23). - if (s.powerUp && cellAt(s, s.powerUp.pos) === 'floor') { - const pos = toWorld(s, s.powerUp.pos) - const k = appearScale(fx, key.powerUp, time) - drawTile(pos.add(vec2(0, 0.04 * Math.sin(time * 3))), vec2(0.7 * k), sprite(GEM_SPRITES[s.powerUp.color])) + // The gems, once their crate is gone, bobbing gently (no glow behind them: user request, 2026-10-23). + for (const gem of s.gems) { + if (cellAt(s, gem.pos) !== 'floor') continue + const pos = toWorld(s, gem.pos) + const k = appearScale(fx, key.gem(gem.pos), time) + drawTile(pos.add(vec2(0, 0.04 * Math.sin(time * 3))), vec2(0.7 * k), sprite(GEM_SPRITES[gem.color])) } // A destroyed crate bursts over whatever it uncovered. for (const g of fx.gone) if (g.kind === 'crate') drawGone(s, g) diff --git a/src/render/effects.ts b/src/render/effects.ts index 53abf4a..b68e87b 100644 --- a/src/render/effects.ts +++ b/src/render/effects.ts @@ -73,7 +73,7 @@ export const key = { drop: (p: Vec) => `drop:${p.x},${p.y}`, enemy: (id: number) => `enemy:${id}`, decoy: 'decoy', - powerUp: 'powerUp', + gem: (p: Vec) => `gem:${p.x},${p.y}`, exit: 'exit', shield: 'shield', } @@ -145,7 +145,7 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even fx.gone.push({ kind: 'burned', sprite: dropSprite(e.drop), pos: e.drop.pos, size: 0.62, at: now }) break case 'powerUpRevealed': - fx.appeared.set(key.powerUp, now) + fx.appeared.set(key.gem(e.powerUp.pos), now) break case 'powerUpTaken': fx.gone.push({ kind: 'taken', sprite: GEM_SPRITES[e.powerUp.color], pos: e.powerUp.pos, size: 0.7, at: now + TAKE_DELAY }) diff --git a/src/sim/demo.ts b/src/sim/demo.ts index 6121b0d..fdfa01c 100644 --- a/src/sim/demo.ts +++ b/src/sim/demo.ts @@ -386,7 +386,7 @@ function makeRoom(kind: SceneKind, rng: Rng): GameState { cells, player: { ...player }, lives: START_LIVES, stats: { ...START_STATS }, bombs: [], status: 'playing', enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 99, coins: 0, boosts: noBoosts(), shield: false, frozen: 0, decoy: null, burning: [], - powers: noPowers(), star: 0, powerUp: null, offer: null, runTurns: 0, deaths: 0, crumbling: [], + powers: noPowers(), star: 0, gems: [], offer: null, runTurns: 0, deaths: 0, crumbling: [], aiRng: rng.int(2 ** 31), dropRng: rng.int(2 ** 31), } const open = floor().filter((p) => steps(p, player) >= 3) @@ -436,7 +436,7 @@ function hideGem(s: GameState, rng: Rng): void { if (!near.length) return const pos = near[rng.int(near.length)]! s.cells[idx(s, pos)] = 'block' - s.powerUp = { pos, color: (['blue', 'green', 'red', 'yellow'] as const)[rng.int(4)]! } + s.gems.push({ pos, color: (['blue', 'green', 'red', 'yellow'] as const)[rng.int(4)]! }) } function remember(t: Turn, seen: Seen): void { diff --git a/src/sim/levelgen.ts b/src/sim/levelgen.ts index c9371e0..ec50b80 100644 --- a/src/sim/levelgen.ts +++ b/src/sim/levelgen.ts @@ -63,7 +63,7 @@ export function lastLevel(size: BoardSize): number { } /** A gem's colour is only how it looks. */ -const GEM_COLORS: readonly GemColor[] = ['blue', 'green', 'red', 'yellow'] +export const GEM_COLORS: readonly GemColor[] = ['blue', 'green', 'red', 'yellow'] export interface EnemyStart { kind: EnemyKind diff --git a/src/sim/record.ts b/src/sim/record.ts index 9555632..0ba0b39 100644 --- a/src/sim/record.ts +++ b/src/sim/record.ts @@ -6,7 +6,7 @@ import { DEFAULT_OPTIONS, type Action, type GameEvent, type GameState, type RunO * 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 = 18 +export const SIM_VERSION = 19 /** 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 8337bab..521b1aa 100644 --- a/src/sim/state.ts +++ b/src/sim/state.ts @@ -1,5 +1,5 @@ import { planIntents } from './enemies' -import { generateLevel } from './levelgen' +import { GEM_COLORS, generateLevel } from './levelgen' import { hashSeed } from './rng' import { BOMB_BOOST } from './actions' import { DEFAULT_OPTIONS, type Bomb, type Boosts, type Cell, type GameState, type Powers, type RunOptions, type Stats, type Vec } from './types' @@ -58,13 +58,17 @@ 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, shield: carry.shield, frozen: 0, decoy: null, burning: [], - powers: { ...carry.powers }, star: carry.star, powerUp, offer: null, runTurns: carry.runTurns, deaths: carry.deaths, crumbling: [], + powers: { ...carry.powers }, star: carry.star, gems: [powerUp], offer: null, runTurns: carry.runTurns, deaths: carry.deaths, crumbling: [], aiRng: hashSeed(seed, 'ai', level), dropRng: hashSeed(seed, 'drops', level), } // Developer mode: only this (user request, 2026-10-23; it used to give every ability, stats and - // unlimited charges). The level's gem lies uncovered right of the start (always open floor), - // every level and after every death, to try the choice at once. - if (options.dev) s.powerUp = { pos: { x: start.x + 1, y: start.y }, color: powerUp.color } + // unlimited charges). An extra gem, in another colour, lies uncovered right of the start (always + // open floor), every level and after every death, to try the choice at once; the level keeps + // its own gem, hidden as usual (user request: it used to be that gem, moved). + if (options.dev) { + const color = GEM_COLORS[(GEM_COLORS.indexOf(powerUp.color) + 1) % GEM_COLORS.length]! + s.gems.push({ pos: { x: start.x + 1, y: start.y }, color, extra: true }) + } planIntents(s) return s } diff --git a/src/sim/turn.ts b/src/sim/turn.ts index c232cb3..45d7314 100644 --- a/src/sim/turn.ts +++ b/src/sim/turn.ts @@ -103,7 +103,7 @@ export function step(prev: GameState, action: Action): StepResult { // Phase 3: bombs tick; those at 0 explode, in chains. Last turn's flame is still burning too. const lingering = new Set(s.burning) const exitWasOpen = cellAt(s, s.exit) === 'floor' - const powerUpWasOpen = !!s.powerUp && cellAt(s, s.powerUp.pos) === 'floor' + const gemsOpen = s.gems.filter((g) => cellAt(s, g.pos) === 'floor') const enemiesBefore = s.enemies.length tickBombs(s, events) const burned = detonate(s, events) @@ -138,15 +138,14 @@ export function step(prev: GameState, action: Action): StepResult { if (!exitWasOpen && cellAt(s, s.exit) === 'floor') events.push({ type: 'exitRevealed', pos: s.exit }) // Flaming an uncovered exit lets out a group of the level's toughest type (NES rule, PLAN.md §5). if (exitWasOpen && burned.has(idx(s, s.exit))) spawnPunishment(s, s.exit, events) - // The same for the level's power-up: flame on it once uncovered destroys it and lets enemies out. - if (s.powerUp) { - const at = idx(s, s.powerUp.pos) - if (powerUpWasOpen && burned.has(at)) { - events.push({ type: 'powerUpBurned', powerUp: s.powerUp }) - spawnPunishment(s, s.powerUp.pos, events) - s.powerUp = null - } else if (!powerUpWasOpen && cellAt(s, s.powerUp.pos) === 'floor') { - events.push({ type: 'powerUpRevealed', powerUp: s.powerUp }) + // The same for a gem: flame on it once uncovered destroys it and lets enemies out. + for (const gem of [...s.gems]) { + if (gemsOpen.includes(gem) && burned.has(idx(s, gem.pos))) { + events.push({ type: 'powerUpBurned', powerUp: gem }) + spawnPunishment(s, gem.pos, events) + s.gems.splice(s.gems.indexOf(gem), 1) + } else if (!gemsOpen.includes(gem) && cellAt(s, gem.pos) === 'floor') { + events.push({ type: 'powerUpRevealed', powerUp: gem }) } } if (enemiesBefore > 0 && s.enemies.length === 0) { @@ -187,7 +186,7 @@ function dropSpot(s: GameState, from: Vec): Vec { const taken = (p: Vec) => s.drops.some((d) => eq(d.pos, p)) || !!bombAt(s, p) || - (!!s.powerUp && eq(s.powerUp.pos, p) && cellAt(s, p) === 'floor') || + s.gems.some((g) => eq(g.pos, p) && cellAt(s, p) === 'floor') || (eq(s.exit, p) && cellAt(s, p) === 'floor') const seen = new Set([idx(s, from)]) let ring = [from] @@ -240,7 +239,7 @@ function spawnPunishment(s: GameState, pos: Vec, events: GameEvent[]): void { events.push({ type: 'enemiesSpawned', pos: { ...pos }, count: EXIT_SPAWN }) } -/** Collect every drop at `p`, and the level's gem if it lies there uncovered (which sets up its offer). */ +/** Collect every drop at `p`, and a gem if one lies there uncovered (which sets up its offer). */ function pickUp(s: GameState, p: Vec, events: GameEvent[]): void { for (const d of s.drops.filter((d) => eq(d.pos, p))) { s.drops.splice(s.drops.indexOf(d), 1) @@ -248,10 +247,11 @@ function pickUp(s: GameState, p: Vec, events: GameEvent[]): void { else applyCharge(s, d.kind, events) events.push({ type: 'pickedUp', drop: d }) } - if (s.powerUp && eq(s.powerUp.pos, p) && cellAt(s, p) === 'floor') { - s.offer = drawOffer(s) - events.push({ type: 'powerUpTaken', powerUp: s.powerUp, offer: s.offer }) - s.powerUp = null + 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) } } @@ -279,8 +279,9 @@ const HELD_AS: Partial<Record<PowerUpKind, keyof Powers>> = { * POWER_UP_WEIGHTS and listed in its order. From the seed and level, so a retry after a death * offers the same. */ -export function drawOffer(s: GameState): PowerUpKind[] { - const rng = stream(s.seed, 'offer', s.level) +export function drawOffer(s: GameState, extra = false): PowerUpKind[] { + // Developer mode's extra gem draws its own offer, not the level gem's. + const rng = stream(s.seed, extra ? 'offer-extra' : 'offer', s.level) const pool = POWER_UP_WEIGHTS.filter(([kind]) => { const held = HELD_AS[kind] return !held || !s.powers[held] diff --git a/src/sim/types.ts b/src/sim/types.ts index 6bbd6d9..dd53fc3 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -61,7 +61,7 @@ export type BoardSize = 'normal' | 'small' export interface RunOptions { /** normal: 31 × 13, 50 levels. small: 9 × 11, 20 levels, fewer enemies and crates. */ size: BoardSize - /** Developer mode: every level's gem lies uncovered right of the start, to try the choice at once. Nothing else. */ + /** Developer mode: an extra gem lies right of the start, to try the choice at once; the level keeps its own. Nothing else. */ dev: boolean /** God mode: nothing can kill the player. */ god: boolean @@ -86,6 +86,8 @@ export type GemColor = 'blue' | 'green' | 'red' | 'yellow' export interface PowerUp { pos: Vec color: GemColor + /** Developer mode's extra gem, beside the start; the level keeps its own (user request, 2026-10-23). */ + extra?: boolean } /** @@ -201,8 +203,11 @@ export interface GameState { deaths: number /** Turns of Star invincibility left. */ star: number - /** This level's gem: hidden while its block stands, then lying in the open until taken. Null once taken or burned. */ - powerUp: PowerUp | null + /** + * This level's gems: hidden while its block stands, then lying in the open until taken; gone + * once taken or burned. One per level, and in Developer mode an extra one (since 2026-10-23). + */ + gems: PowerUp[] /** * The power-ups the gem just taken offers. While it is set, the only action is `choose`; a * death (or game over) in the same turn drops it, and the retry has the gem back. diff --git a/src/ui/info.ts b/src/ui/info.ts index 284f475..3733958 100644 --- a/src/ui/info.ts +++ b/src/ui/info.ts @@ -80,7 +80,7 @@ export type HudItem = 'modes' | 'lives' | 'level' | 'enemies' | 'coins' | 'turn' /** One line per setting of the run that differs from a plain normal run. */ export function modeLines(s: GameState): string[] { const lines = [s.options.size === 'small' ? `Small board: ${s.width} × ${s.height}, ${lastLevel('small')} levels, fewer enemies and crates.` : `Normal board: ${s.width} × ${s.height}, ${lastLevel('normal')} levels.`] - if (s.options.dev) lines.push('Developer mode: every level’s gem lies uncovered right next to you at the start.') + if (s.options.dev) lines.push('Developer mode: an extra gem lies right next to you at the start of every level. The level’s own gem is still hidden under a crate.') if (s.options.god) lines.push('God mode: nothing can kill you.') if (s.options.crumble) lines.push('Crumbling crates: once the last enemy is gone, every crate crumbles away over 1–3 turns.') return lines @@ -222,10 +222,11 @@ export function tileInfo(s: GameState, p: Vec): Info | null { body: ['Flame destroys it (and stops there). On every level one crate hides the exit and another hides a gem.'], } } - if (s.powerUp && eq(s.powerUp.pos, p)) { + const gem = s.gems.find((g) => eq(g.pos, p)) + if (gem) { return { - icon: GEM_SPRITES[s.powerUp.color], - title: 'Gem', + icon: GEM_SPRITES[gem.color], + title: gem.extra ? 'Gem (Developer mode’s extra one)' : 'Gem', body: [`Walk onto it to choose one of ${OFFER_SIZE} power-ups. The colour doesn’t matter.`, PUNISH], } } diff --git a/test/helpers.ts b/test/helpers.ts index b7c2ec9..5348a7e 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -21,7 +21,7 @@ export function board(picture: string, stats: Partial<Stats> = {}): GameState { lives: START_LIVES, stats: { ...START_STATS, ...stats }, bombs: [], status: 'playing', enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 100, coins: 0, boosts: noBoosts(), shield: false, frozen: 0, decoy: null, burning: [], - powers: noPowers(), star: 0, powerUp: null, offer: null, runTurns: 0, deaths: 0, crumbling: [], aiRng: 1, dropRng: 1, + powers: noPowers(), star: 0, gems: [], offer: null, runTurns: 0, deaths: 0, crumbling: [], aiRng: 1, dropRng: 1, } rows.forEach((row, y) => { if (row.length !== width) throw new Error(`row ${y} is ${row.length} wide, not ${width}`) diff --git a/test/options.test.ts b/test/options.test.ts index 414d656..c6418d8 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { enemyCount, generateLevel, lastLevel, SIZES, START } from '../src/sim/levelgen' import { newRecord, replay } from '../src/sim/record' import { newRun } from '../src/sim/state' -import { step } from '../src/sim/turn' +import { drawOffer, step } from '../src/sim/turn' import { DEFAULT_OPTIONS, type Vec } from '../src/sim/types' import { board, v } from './helpers' @@ -79,22 +79,45 @@ describe('developer mode', () => { const dead = step(s, { id: 'move', target: { x: 1, y: 2 } }).state expect(dead.status).toBe('dead') const again = step(dead, { id: 'continue' }).state - expect(again.powerUp?.pos).toEqual({ x: START.x + 1, y: START.y }) + expect(again.gems.find((g) => g.extra)?.pos).toEqual({ x: START.x + 1, y: START.y }) }) - it('starts every level with the gem uncovered right of the player', () => { + it('starts every level with an extra gem uncovered right of the player', () => { for (const level of [1, 7]) { const s = newRun(5, level, 0, dev) - expect(s.powerUp?.pos).toEqual({ x: START.x + 1, y: START.y }) + const extra = s.gems.find((g) => g.extra)! + expect(extra.pos).toEqual({ x: START.x + 1, y: START.y }) expect(s.cells[START.y * s.width + START.x + 1]).toBe('floor') - const { state } = step(s, { id: 'move', target: s.powerUp!.pos }) + const { state } = step(s, { id: 'move', target: extra.pos }) expect(state.offer?.length).toBeGreaterThan(0) } }) - it('a normal run has no gem in the open at the start', () => { + it('keeps the level\'s own gem, hidden under its crate as usual (user request, 2026-10-23)', () => { + for (let seed = 1; seed <= 20; seed++) { + const s = newRun(seed, 1, 0, dev) + const own = s.gems.filter((g) => !g.extra) + expect(own).toHaveLength(1) + expect(own[0]).toEqual(newRun(seed).gems[0]) + expect(s.cells[own[0]!.pos.y * s.width + own[0]!.pos.x]).toBe('block') + } + }) + + it('the extra gem is another colour, and offers its own choice', () => { + let differ = 0 + for (let seed = 1; seed <= 20; seed++) { + const s = newRun(seed, 1, 0, dev) + const [own, extra] = [s.gems.find((g) => !g.extra)!, s.gems.find((g) => g.extra)!] + expect(extra.color).not.toBe(own.color) + if (JSON.stringify(drawOffer(s, true)) !== JSON.stringify(drawOffer(s))) differ++ + } + expect(differ).toBeGreaterThan(10) + }) + + it('a normal run has just the one gem, under a crate', () => { const s = newRun(5) - expect(s.cells[s.powerUp!.pos.y * s.width + s.powerUp!.pos.x]).toBe('block') + expect(s.gems).toHaveLength(1) + expect(s.cells[s.gems[0]!.pos.y * s.width + s.gems[0]!.pos.x]).toBe('block') }) }) diff --git a/test/powerups.test.ts b/test/powerups.test.ts index 22d06aa..9b99acd 100644 --- a/test/powerups.test.ts +++ b/test/powerups.test.ts @@ -23,7 +23,7 @@ function withGem(picture = ` #P...# ######`): GameState { const s = board(picture) - s.powerUp = { pos: v(2, 1), color: 'red' } + s.gems = [{ pos: v(2, 1), color: 'red' }] return s } @@ -86,7 +86,7 @@ describe('taking the gem', () => { it('sets up the offer and gives nothing yet; the gem is gone', () => { const { state, events } = step(withGem(), { id: 'move', target: v(2, 1) }) expect(state.offer).toEqual(drawOffer(withGem())) - expect(state.powerUp).toBeNull() + expect(state.gems).toEqual([]) expect(state.stats).toEqual(withGem().stats) expect(events).toContainEqual({ type: 'powerUpTaken', powerUp: { pos: v(2, 1), color: 'red' }, offer: state.offer }) }) @@ -175,10 +175,10 @@ describe('the power-ups', () => { ###### #P.1+# ######`) - s.powerUp!.pos = v(4, 1) + s.gems[0]!.pos = v(4, 1) const { state, events } = step(s, { id: 'wait' }) expect(events).toContainEqual({ type: 'powerUpRevealed', powerUp: { pos: v(4, 1), color: 'red' } }) - expect(state.powerUp).not.toBeNull() + expect(state.gems).toHaveLength(1) }) it('flame on it once uncovered destroys it and lets enemies out', () => { @@ -186,9 +186,9 @@ describe('the power-ups', () => { ###### #P..1# ######`) - s.powerUp!.pos = v(3, 1) + s.gems[0]!.pos = v(3, 1) const { state, events } = step(s, { id: 'wait' }) - expect(state.powerUp).toBeNull() + expect(state.gems).toEqual([]) expect(events.map((e) => e.type)).toContain('powerUpBurned') expect(state.enemies).toHaveLength(EXIT_SPAWN) })