explodorama / commit
Board scenes and almanac tab, boxed in costs a life, NES placement measured
CHANGELOG.md | 2 + PLAN.md | 6 +- README.md | 59 +++++++++++++++++--- decisions.md | 52 ++++++++++++++++++ docs/balance/runs-careful.md | 42 +++++++------- docs/balance/underfoot-average.md | 36 ++++++++++++ docs/balance/underfoot-careful.md | 39 +++++++++++++ src/main.ts | 1 + src/sim/actions.ts | 17 +++++- src/sim/demo.ts | 81 ++++++++++++++++++++++++++- src/sim/record.ts | 2 +- src/sim/turn.ts | 7 ++- src/sim/types.ts | 7 ++- src/style.css | 11 +++- src/ui/almanac.ts | 14 ++++- src/ui/info.ts | 112 +++++++++++++++++++++++++------------- test/charges.test.ts | 30 ++++++++++ test/new-enemies.test.ts | 2 + tools/balance/bot.ts | 15 +++-- tools/balance/lab.ts | 22 ++++++-- tools/balance/show.ts | 11 +++- 21 files changed, 476 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4192c26..a1f47d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ saved before the change is dropped and the next visit starts a new run. ### Added +- **Demo scenes for the board objects** (hiding in a bush, a bush fire, metal stopping flame, an oil spill, crossing spikes, a barrel going off), and a **Board** tab in the almanac listing crates, pillars, every object and rubble, with clips. - **Board objects:** bushes (they hide you from chasers, and burn a bush a turn), metal crates (nothing breaks them), oil spills (flame burns a whole spill at once), spikes (up every other turn) and oil barrels (flame bursts one: oil round it and a blast the next turn), each from its own level on. Version 37: runs saved before don't resume. - **The Acid charge:** a bomb that cracks pillars; a later blast breaks a cracked one, and 4 turns on the ceiling round it falls as rubble for good, crushing what is under it. Two new ways to lose a life: crushed, and walled in (trapped). - **TNT**, a power-up worth two Fire +1s, and **a limit of 5 on bombs, blast and fuse**; a power-up with no room left isn't offered, and cards show the limit. Version 34. @@ -95,6 +96,7 @@ saved before the change is dropped and the next visit starts a new run. ### Changed +- **Boxed in costs a life:** with nowhere to step and nowhere to put a bomb, and nothing left on the board that could change it (a Teleport into a gap between crates, rubble falling round you), you lose a life instead of being stuck for good. Version 39: runs saved before don't resume. - **The danger overlay marks a bomb's tiles from the moment it is placed**, as a red outline until its last two turns, however long the fuse. - **Shield and Flame Pass work by themselves while carried:** no button to press. A hit of their kind uses them up (Flame Pass after 3 fire hits) and the charge slot empties. Version 38: runs saved before don't resume. - **Diagonal no longer squeezes** between two blocked tiles: one of the two tiles beside a diagonal step must be open. The squeeze was most of what made Diagonal so strong. Version 36. diff --git a/PLAN.md b/PLAN.md index ecf51e7..748005e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1344,7 +1344,11 @@ spikes**, up or down (simpler to read than planning round the raised ones, and s hurt them); a metal crate is placed only where every open tile stays reachable, and Kick doesn't move it. Sprites: `bush`, `spikes`, the strong crate in steel grey (`block_metal`, composed), `block_strong_danger` for the barrel, a near-black glossy splat for oil. Death causes `spikes` and -`spread` (fire over oil or bushes). Not yet: the almanac's **Board** tab, the spikes' turn count. +`spread` (fire over oil or bushes). *Also 2026-10-26:* being **boxed in** (nowhere to step or put a bomb, nothing left to change it) +costs a life. *Also built the same day:* the almanac's **Board** tab (crates, +pillars, the five objects and rubble, with their first levels and clips) and a demo scene for each +object (a bush hiding you, a bush fire, metal stopping flame, oil, spikes, a barrel). Not yet: the +spikes' turn count. ### 19.4 The Acid bomb: weakening pillars, bringing the roof down diff --git a/README.md b/README.md index 62aeb17..1aabddb 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ seven charges (one carried at a time, used when you choose), a hidden gem on eve mid-run work. Curses and the Reaper enemy are planned — see [PLAN.md](PLAN.md). -Version: **38**, shown at the bottom of the menu with the commit the game was built from (a link +Version: **39**, shown at the bottom of the menu with the commit the game was built from (a link to it on GitLab, plus "plus local changes" in a build with uncommitted changes) and a link to the [source code](https://gitlab.com/PavelSafronov/explodorama). The version is the rules version (`SIM_VERSION` in [src/sim/record.ts](src/sim/record.ts)): it goes up by one whenever a rule @@ -49,8 +49,10 @@ narrow escape, chain reactions, lingering flame, the first three enemy types, dr power-up you can see in action (Bomb +1, Fire +1, Fuse +1, Run, Kick — a tap on a bomb next to you —, Throw, Diagonal, Slide, RPG, the Detonator — a tap on your bomb —, Proximity alert, Wall / Bomb Pass, -Star) and every charge (Pierce, Power, Nuke, Shield, -Freeze, Decoy, ICBM — each used with the charge button), **the charge swap** (carrying a +Star), every charge (Pierce, Power, Nuke, Acid, Shield, Freeze, Decoy, ICBM, Flame Pass — +the ones you use, with the charge button), **the board's objects** (hiding in a bush, a bush fire +creeping on, flame stopping at a metal crate, an oil spill burning all at once, crossing spikes +while they're down, an oil barrel bursting and going off), **the charge swap** (carrying a Pierce, the player picks up a Nuke; the choice opens over the scene and the finger taps Take Nuke), and the exit (walking out once it opens; blasting it lets enemies out). Heart and Coin bag only change numbers, so they have no scene; nor does the shop. Every other scene shows @@ -141,8 +143,9 @@ a still picture of its moment instead. and seed, the **Sound** and **Vibration** switches (see *Sound and vibration* below), and its buttons, kept compact: **Resume** and **New** side by side (New starts a new run and asks first, since the current run can't be got back), then one row of small ones — **Almanac** (every -power-up, charge and enemy type, with its picture and what it does, in three tabs; an entry with -a green ▶ opens its clip), **Scores** (the high scores), **Demo** (the demo again) and **Bug** (an +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 email to the game's GitLab Service Desk, which needs no GitLab 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). @@ -213,6 +216,9 @@ will end up, a faded bomb with its blast, a kicked bomb's stopping place, a chan (a Flame Pass carried, a Star), then you can walk through it like floor (with Flame Pass, using one of its hits). It goes out after that turn. - Bombs block movement, for you and for enemies. +- **Bombs go on a tile next to you**, not under you as in the original. Both were measured with + the balance lab; see *Measured: bombs under the player* below for how the original rule would + change the game. - Bombs can't be placed on an enemy. - Pierce, Power, Nuke and Acid bombs are put down with the charge button (see Charges above); such a bomb wears its charge's icon as a badge. @@ -354,7 +360,9 @@ runs: leftover coins only count on the high-score board. ### Lives You have **3 lives**. Caught in a blast or touched by an enemy (or on raised spikes, under a -falling ceiling, or walled in by rubble), you lose one and the level starts +falling ceiling, walled in by rubble, or **boxed in** — nowhere to step and nowhere to put a +bomb, with nothing left on the board that could change that, as after a Teleport into a gap +between two crates), you lose one and the level starts again with the same layout and enemies. **You keep everything else**: coins, stats, every power-up (abilities and passes included). **The charge you carry drops where you fell** (since 2026-10-25), with what was left of it: walk back over it on the @@ -459,7 +467,7 @@ npm run balance -- help # the balance lab: bots play many games to measure the **The balance lab** (`tools/balance/`, PLAN.md §19.1) measures what a rule or a power-up does to the game. Bots play the real rules in Node, on all CPU cores, at three skill levels: **careful** -(checks every turn that it can survive what the enemies and bombs will do next; wins about 80% of +(checks every turn that it can survive what the enemies and bombs will do next; wins about 90% of runs), **average** and **sloppy** (look fewer turns ahead and slip now and then; they clear about 5 and 3 levels). An experiment plays the same seeds once per arm, so differences come from the change, not the boards, and reports each with a 95% interval in `docs/balance/`: @@ -467,12 +475,47 @@ change, not the boards, and reports each with a 95% interval in `docs/balance/`: - `npm run balance -- powers --skill careful` — each power-up held from level 1, against none. - `npm run balance -- structure` — what each power-up changes on real positions, no bot judging: how many bomb placements can be survived, tiles reachable in 3 turns, safe actions. +- `npm run balance -- underfoot` — bombs next to the player (the rule) against under them (the + original's rule; a lab-only switch). Results below. - `npm run balance -- runs` and `npm run balance -- trace <seed> <level>` — the bot's play, run by - run or board by board. + run or board by board (`--arm <name>` plays a power-up or rule variant). Options: `--seeds N` (200 by default), `--skill careful|average|sloppy`, `--levels N`. The 3,600 runs of `powers` take about 10 minutes on 10 cores. +#### Measured: bombs under the player (2026-10-26) + +In the original, a bomb goes down on your own tile; here it goes on a tile next to you. To see what +the original's rule would change, the lab played both on the same 200 seeds (reports: +[careful](docs/balance/underfoot-careful.md), [average](docs/balance/underfoot-average.md)). Under +the original's rule you walk off your bomb and can't walk back; enemies can't step onto it, so +not onto you while you stand there. Differences from the current rule (✱: larger than chance): + +| Bot | Rule | Levels cleared | Won | Lives lost | Turns per level | Turns in danger | Stuck | +|---|---|---|---|---|---|---|---| +| careful | next to you (current) | 19.3 | 93% | 0.24 | 83 | 5.4% | 4% | +| careful | under you (original) | −0.6 | −6% ✱ | +0.12 | +12 ✱ | +5.0 points ✱ | 8% | +| average | next to you (current) | 4.1 | 0% | 3.00 | 76 | 3.4% | 0% | +| average | under you (original) | +0.6 ✱ | 0% | ±0 | +9 ✱ | +9.3 points ✱ | 1% | + +What it would change: + +1. **Slower:** 10–15% more turns per level. Every bomb starts with you inside its blast, and + getting out takes moves; next to you, you start a tile away. +2. **Riskier:** about twice the turns spent on tiles about to burn, and the careful bot dies to its + own blasts about twice as often. +3. **A little kinder to weaker play:** the average bot clears 0.6 more levels. Standing on your + bomb keeps enemies off you, and you can't shut your only way out by dropping a bomb into it. +4. **Cornered enemies get harder:** to bomb an enemy at the end of a dead end, you have to stand in + the corridor's blast line and still get out in time. Half of the careful bot's lost wins are + such levels; the other half are it refusing to spend a life when boxed in (a person would bomb + their way out). + +**Decided (2026-10-26): bombs stay next to you.** The original's rule is a fair choice, not a +balance risk — it trades pace for the classic feel — but the current rule keeps turns quick. +What the original's rule did have going for it, never being stuck with nowhere to put a bomb, is +covered by **boxed in** (see *Lives*): it costs a life instead of leaving you stuck. + URL parameters: `?seed=123` plays a fixed board; `?level=4` starts at a later level (level 19 has all seven enemy types); `?charge=nuke` (or any charge) starts carrying that charge, for trying it out; `?level=3&shop` opens the shop after that level straight away, with 9 coins; `?dev` and `?immortal` set the modes; `?demo` shows the demo, which test URLs otherwise skip (with `?seed`, the same demo every time). `?fps` shows a frame-rate meter in the bottom left corner: frames a second, the slowest frame, the game's own time per frame, and the canvas size. For tracking down slowness on a device: `?gl=0` draws with Canvas 2D instead of WebGL (no demo TV effect, no clips), `?ratio=1` draws at one pixel per CSS pixel, `?aa` turns WebGL's antialiasing on (it is off: it held Android Chrome to 12 fps). These four keep your saved run. A URL with any of these parameters plays a test board: it doesn't load or overwrite your diff --git a/decisions.md b/decisions.md index 2cb5b92..f28d10e 100644 --- a/decisions.md +++ b/decisions.md @@ -2150,3 +2150,55 @@ the player waiting and enemies left where they are, every tile burned (chains, o or crushed until nothing is left to go off (at most 12 more turns), minus what is already `now` or `next`. Drawn as a red outline, so it reads as "not yet" beside the two fills. Not used by auto-move's safety check or the bots, which still look at `now` only. No rule change. + +### 2026-10-25 — Board scenes, the almanac's Board tab, and the lab's stalls + +User request: work on the open items. + +1. **Demo scenes for the objects:** `bush` (stepping into one), `bushFire` (a smouldering bush + catching), `metal` (a flame stopped short at one), `oil` (a spill burning at once), `spikes` + (stepping on them while down), `barrel` (a burst barrel going off). A room gets a few of the + object by the player (`placeObjects` in demo.ts); a scene's reset to turn 0 shifts the spikes' + phase so they keep their rhythm. Found for every test seed like the rest. +2. **The almanac's Board tab:** `BOARD_TEXT` in ui/info.ts, shared with "What is that?" so the two + can't disagree. Four tabs don't fit a phone's row: two rows of two below 440 px. +3. **The lab's stalls (15% of runs since the objects):** the traces (the board printout now shows + the objects) found two causes. (a) The bot never bombed an oil barrel, so barrels shutting in the + exit or the last enemy (with a metal crate on the other side) stopped it: a bot fault, fixed in + the bot (barrels count as crates in its plans). (b) Its last Teleport jump landed in a one-tile + pocket between two crates: nowhere to step, nowhere to put a bomb, stuck for good — a trap a + person could fall into too. **Rule:** the last jump of a Teleport charge may only land on a tile + with a passable tile next to it (or next to the player's own); earlier jumps may, since the next + gets out (*replaced the next day by "boxed in costs a life", below*). `SIM_VERSION` 39. Now: stalls 3% (1 of 40, the bot dithering by an enemy, its old + weakness), wins 90%, 19.1 levels, 0.53 lives lost. + +### 2026-10-26 — Measured: bombs under the player (the NES rule) + +User question: how would the original's placement — the bomb on the player's own tile — change +things? A lab-only rule switch (`RunOptions.lab.underfoot`, never set in play): Bomb (and a charged +bomb) goes on the player's tile; the player walks off it and can't walk back, enemies can't enter +it. The bots stand on the spot instead of next to it; nothing else of theirs changed. +`npm run balance -- underfoot` (reports in docs/balance/underfoot-*.md), 200 seeds: + +| Bot | Levels | Won | Lives lost | Turns per level | Turns in danger | Stalled | +|---|---|---|---|---|---|---| +| careful, next to (now) | 19.32 | 93% | 0.24 | 83.4 | 5.4% | 4% | +| careful, under (NES) | −0.56 ± 0.61 | −6 ± 5% ✱ | +0.12 ± 0.13 | +12.3 ✱ | +5.0 ✱ | 8% | +| average, next to (now) | 4.11 | 0% | 3.00 | 75.5 | 3.4% | 0% | +| average, under (NES) | +0.62 ± 0.47 ✱ | 0 | −0.02 | +9.2 ✱ | +9.3 ✱ | 1% | + +Read: slower (a bomb starts with you in its blast, and getting out takes turns), about twice the +turns spent in danger, own-blast deaths up for the careful bot; the weaker bot does a little better +(enemies can't step onto you while you stand on your bomb, and a bomb can't shut your only way +out). Half the careful bot's extra stalls are its refusal to spend a life: walled in by rubble +next to a crate it waited for ever, where a person would bomb out. That case is a **soft-lock under +the current rule** too: no free tile to put a bomb on, and the crate means it doesn't count as +trapped. Left to the user. +- **Decided (user, same day): bombs stay next to the player**, and the summary goes in the README + (*Measured: bombs under the player*). The variant stays in the lab. With it: **boxed in costs a + life** (`boxedIn` in actions.ts, death cause `boxedIn`): at the end of a turn, nothing available + but Wait, and no bomb, flame, falling ceiling, smouldering bush or crumbling crate left that could + change it. A Shield or Star doesn't help. It replaces the Teleport restriction of the day before + (the last jump couldn't land in a dead end): the user would rather the player pay for the + mistake, and it covers rubble boxing the player in next to a crate too. Lab: stalls 0% (40 + seeds), wins 93%. diff --git a/docs/balance/runs-careful.md b/docs/balance/runs-careful.md index f9c715d..8cfcef7 100644 --- a/docs/balance/runs-careful.md +++ b/docs/balance/runs-careful.md @@ -1,6 +1,6 @@ # Balance lab: runs (careful bot) -2026-10-25 · rules version 37 · 40 seeds per arm · 29 s. +2026-10-26 · rules version 39 · 40 seeds per arm · 34 s. Each arm plays the same seeds as the baseline; its columns are the difference from the baseline, per run, with a 95% interval. ✱ marks a difference the interval says is real. Sorted by levels @@ -8,7 +8,7 @@ gained. The bots answer a gem by taking the first thing offered and never shop. | Arm | Levels cleared | Won | Lives lost | Turns per level | Turns in danger | Stalled | |---|---|---|---|---|---|---| -| **baseline** | 19.45 | 80% | 0.45 | 91.1 | 5.1% | 15% | +| **baseline** | 19.23 | 93% | 0.47 | 87.1 | 5.0% | 0% | ## The baseline, level by level @@ -16,23 +16,23 @@ gained. The bots answer a gem by taking the first thing offered and never shop. |---|---|---|---|---| | 1 | 100% | 0.03 | 73 | 13% | | 2 | 100% | 0.00 | 76 | 20% | -| 3 | 100% | 0.00 | 62 | 33% | -| 4 | 100% | 0.00 | 91 | 30% | -| 5 | 100% | 0.03 | 96 | 30% | -| 6 | 100% | 0.03 | 95 | 23% | -| 7 | 100% | 0.05 | 85 | 38% | -| 8 | 100% | 0.05 | 107 | 33% | -| 9 | 100% | 0.05 | 100 | 48% | -| 10 | 100% | 0.03 | 99 | 40% | -| 11 | 100% | 0.00 | 100 | 58% | -| 12 | 100% | 0.00 | 100 | 63% | -| 13 | 100% | 0.00 | 91 | 68% | -| 14 | 100% | 0.05 | 87 | 75% | -| 15 | 100% | 0.03 | 87 | 88% | -| 16 | 98% | 0.00 | 100 | 77% | -| 17 | 98% | 0.00 | 103 | 87% | -| 18 | 93% | 0.00 | 84 | 95% | -| 19 | 93% | 0.11 | 95 | 92% | -| 20 | 85% | 0.03 | 81 | 94% | +| 3 | 100% | 0.00 | 63 | 30% | +| 4 | 100% | 0.00 | 92 | 30% | +| 5 | 100% | 0.03 | 93 | 28% | +| 6 | 100% | 0.07 | 84 | 30% | +| 7 | 98% | 0.05 | 89 | 31% | +| 8 | 98% | 0.05 | 101 | 36% | +| 9 | 98% | 0.05 | 94 | 59% | +| 10 | 98% | 0.00 | 96 | 41% | +| 11 | 98% | 0.00 | 92 | 51% | +| 12 | 98% | 0.00 | 93 | 74% | +| 13 | 98% | 0.10 | 85 | 72% | +| 14 | 93% | 0.05 | 92 | 76% | +| 15 | 93% | 0.00 | 79 | 86% | +| 16 | 93% | 0.00 | 101 | 81% | +| 17 | 93% | 0.00 | 86 | 86% | +| 18 | 93% | 0.03 | 89 | 89% | +| 19 | 93% | 0.03 | 79 | 97% | +| 20 | 93% | 0.00 | 75 | 100% | -Lives lost in the baseline, by cause: walkedIntoEnemy 7, caughtByEnemy 4, blast 5, trapped 1, chain 1. +Lives lost in the baseline, by cause: walkedIntoEnemy 9, blast 5, trapped 1, caughtByEnemy 3, chain 1. diff --git a/docs/balance/underfoot-average.md b/docs/balance/underfoot-average.md new file mode 100644 index 0000000..5cd5c56 --- /dev/null +++ b/docs/balance/underfoot-average.md @@ -0,0 +1,36 @@ +# Balance lab: underfoot (average bot) + +2026-10-26 · rules version 39 · 200 seeds per arm · 28 s. + +Each arm plays the same seeds as the baseline; its columns are the difference from the baseline, +per run, with a 95% interval. ✱ marks a difference the interval says is real. Sorted by levels +gained. The bots answer a gem by taking the first thing offered and never shop. + +| Arm | Levels cleared | Won | Lives lost | Turns per level | Turns in danger | Stalled | +|---|---|---|---|---|---|---| +| **baseline** | 4.11 | 0% | 3.00 | 75.5 | 3.4% | 0% | +| underfoot (NES rule) | +0.62 ± 0.47 ✱ | 0 ± 0% | −0.02 ± 0.03 | +9.2 ± 5.1 ✱ | +9.3 ± 0.4 ✱ | 1% | + +## The baseline, level by level + +| Level | Runs reaching it | Lives lost there | Turns there | Within par | +|---|---|---|---|---| +| 1 | 100% | 0.21 | 75 | 14% | +| 2 | 97% | 0.45 | 75 | 26% | +| 3 | 87% | 0.63 | 65 | 23% | +| 4 | 69% | 0.90 | 90 | 20% | +| 5 | 49% | 0.86 | 86 | 17% | +| 6 | 34% | 0.64 | 86 | 25% | +| 7 | 25% | 0.66 | 80 | 26% | +| 8 | 18% | 0.80 | 87 | 17% | +| 9 | 11% | 0.73 | 85 | 36% | +| 10 | 8% | 0.87 | 83 | 60% | +| 11 | 6% | 0.45 | 74 | 27% | +| 12 | 4% | 0.63 | 83 | 63% | +| 13 | 3% | 1.67 | 76 | 33% | +| 14 | 1% | 0.50 | 57 | 0% | +| 15 | 1% | 0.00 | 62 | 100% | +| 16 | 1% | 0.00 | 67 | 100% | +| 17 | 1% | 1.00 | 5 | 0% | + +Lives lost in the baseline, by cause: blast 496, lingering 11, chain 16, walkedIntoEnemy 47, caughtByEnemy 24, spikes 1, spread 5. diff --git a/docs/balance/underfoot-careful.md b/docs/balance/underfoot-careful.md new file mode 100644 index 0000000..87397c5 --- /dev/null +++ b/docs/balance/underfoot-careful.md @@ -0,0 +1,39 @@ +# Balance lab: underfoot (careful bot) + +2026-10-26 · rules version 39 · 200 seeds per arm · 287 s. + +Each arm plays the same seeds as the baseline; its columns are the difference from the baseline, +per run, with a 95% interval. ✱ marks a difference the interval says is real. Sorted by levels +gained. The bots answer a gem by taking the first thing offered and never shop. + +| Arm | Levels cleared | Won | Lives lost | Turns per level | Turns in danger | Stalled | +|---|---|---|---|---|---|---| +| **baseline** | 19.32 | 93% | 0.24 | 83.4 | 5.4% | 4% | +| underfoot (NES rule) | −0.56 ± 0.61 | −6 ± 5 ✱% | +0.12 ± 0.13 | +12.3 ± 2.9 ✱ | +5.0 ± 0.3 ✱ | 8% | + +## The baseline, level by level + +| Level | Runs reaching it | Lives lost there | Turns there | Within par | +|---|---|---|---|---| +| 1 | 100% | 0.02 | 66 | 19% | +| 2 | 100% | 0.01 | 69 | 25% | +| 3 | 100% | 0.01 | 65 | 32% | +| 4 | 100% | 0.00 | 85 | 34% | +| 5 | 100% | 0.01 | 81 | 36% | +| 6 | 100% | 0.02 | 80 | 38% | +| 7 | 99% | 0.01 | 85 | 38% | +| 8 | 99% | 0.02 | 100 | 45% | +| 9 | 98% | 0.02 | 96 | 47% | +| 10 | 98% | 0.03 | 85 | 59% | +| 11 | 97% | 0.01 | 85 | 63% | +| 12 | 96% | 0.00 | 93 | 73% | +| 13 | 96% | 0.01 | 84 | 74% | +| 14 | 96% | 0.04 | 84 | 78% | +| 15 | 95% | 0.01 | 77 | 88% | +| 16 | 95% | 0.01 | 94 | 85% | +| 17 | 94% | 0.00 | 86 | 87% | +| 18 | 94% | 0.01 | 87 | 94% | +| 19 | 94% | 0.02 | 79 | 96% | +| 20 | 93% | 0.02 | 75 | 98% | + +Lives lost in the baseline, by cause: walkedIntoEnemy 25, blast 14, trapped 1, caughtByEnemy 6, chain 2, spikes 1. diff --git a/src/main.ts b/src/main.ts index b97402b..57fb09e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -880,6 +880,7 @@ const SCENE_ICONS: Record<SceneKind, string> = { dieDetonator: POWER_UP_ICONS.detonator, dieRun: POWER_UP_ICONS.run, rpg: POWER_UP_ICONS.rpg, proximity: POWER_UP_ICONS.proximity, slide: POWER_UP_ICONS.slide, icbm: CHARGE_ICONS.icbm, acid: CHARGE_ICONS.acid, + bush: 'bush', bushFire: 'bush', metal: 'block_metal', oil: 'oil', spikes: 'spikes', barrel: 'block_strong_danger', } diff --git a/src/sim/actions.ts b/src/sim/actions.ts index 4e15d4b..476b847 100644 --- a/src/sim/actions.ts +++ b/src/sim/actions.ts @@ -113,6 +113,18 @@ export function teleportSteps(s: GameState, dx: number, dy: number): number { return s.powers.diagonal ? Math.max(Math.abs(dx), Math.abs(dy)) : Math.abs(dx) + Math.abs(dy) } +/** + * Boxed in for good (user's choice, 2026-10-26): nothing the player can do but wait, and nothing + * left that could change that — no bomb on the board, no flame, no ceiling about to fall, no bush + * smouldering, no crates crumbling. Nowhere to step and nowhere to put a bomb: a + * jump into a pocket between crates, or rubble falling round the player. It costs a life, as + * walled in by rubble does, rather than leaving the player waiting for ever. + */ +export function boxedIn(s: GameState): boolean { + if (s.bombs.length || s.burning.length || s.collapsing.length || s.smoldering.length || s.crumbling.length) return false + return ACTION_ORDER.every((id) => id === 'wait' || !isAvailable(s, id)) +} + function hasCapacity(s: GameState): boolean { // A burst oil barrel's blast isn't one of the player's bombs. return s.bombs.filter((b) => !b.barrel).length < s.stats.capacity @@ -120,7 +132,10 @@ function hasCapacity(s: GameState): boolean { /** The adjacent tiles a bomb can go on, if the player may place one. */ function bombTargets(s: GameState): Vec[] { - return hasCapacity(s) ? neighbours(s.player).filter((p) => canHoldBomb(s, p)) : [] + if (!hasCapacity(s)) return [] + // The lab's `underfoot` variant: the NES rule, on the player's own tile. + if (s.options.lab?.underfoot) return canHoldBomb(s, s.player) ? [s.player] : [] + return neighbours(s.player).filter((p) => canHoldBomb(s, p)) } function walkTo(s: GameState, target: Vec, events: GameEvent[]): void { diff --git a/src/sim/demo.ts b/src/sim/demo.ts index 97c373f..301eb69 100644 --- a/src/sim/demo.ts +++ b/src/sim/demo.ts @@ -42,6 +42,7 @@ export type SceneKind = | 'pierce' | 'power' | 'nuke' | 'acid' | 'shield' | 'freeze' | 'decoy' | 'chargeSwap' | 'exit' | 'doorBlast' | 'lastEnemy' | 'crate' | 'pillar' | 'rainbowGem' | 'immortal' + | 'bush' | 'bushFire' | 'metal' | 'oil' | 'spikes' | 'barrel' | 'dieIntoBlast' | 'dieNotFar' | 'dieBlockedIn' | 'dieChain' | 'dieEnemy' | 'dieDetonator' | 'dieRun' /** @@ -469,6 +470,54 @@ export const SCENES: Record<SceneKind, SceneDef> = { clipOnly: true, shows: (t) => explosions(t).some((e) => DIR_LIST.some((d) => t.prev.cells[idx(t.prev, { x: e.pos.x + DIRS[d].x, y: e.pos.y + DIRS[d].y })] === 'wall' && !isEdge(t.prev, { x: e.pos.x + DIRS[d].x, y: e.pos.y + DIRS[d].y }))), }, + // The board's objects (PLAN.md §19.3; 2026-10-25), each in a room with a few of them by the player. + bush: { + caption: 'Bushes: step into one to hide. Enemies chasing you head for where they last saw you.', + enemies: ['hunter', 'seeker'], + prepare: (s, rng) => placeObjects(s, rng, 'bush', 2, 3), + shows: (t) => + t.next.status === 'playing' && t.next.bushes.includes(idx(t.next, t.next.player)) && !t.prev.bushes.includes(idx(t.prev, t.prev.player)), + }, + bushFire: { + caption: 'Bushes burn, and the fire creeps on to the next bush a turn later.', + enemies: ['blob'], + prepare: (s, rng) => placeObjects(s, rng, 'bush', 2, 4), + shows: (t) => t.next.status === 'playing' && t.prev.smoldering.length > 0 && t.events.some((e) => e.type === 'fireSpread'), + }, + metal: { + caption: 'Metal crates: nothing breaks them, and flame stops before them.', + enemies: ['blob'], + prepare: (s, rng) => ((s.stats.fire = 2), placeObjects(s, rng, 'metal', 2, 2)), + // A flame that stopped short of its reach at a metal crate. + shows: (t) => + explosions(t).some((e) => + DIR_LIST.some((d) => { + let p = e.pos + while (e.tiles.some((q) => at(q, { x: p.x + DIRS[d].x, y: p.y + DIRS[d].y }))) p = { x: p.x + DIRS[d].x, y: p.y + DIRS[d].y } + const next = { x: p.x + DIRS[d].x, y: p.y + DIRS[d].y } + return inBounds(t.prev, next) && t.prev.cells[idx(t.prev, next)] === 'metal' + }), + ), + }, + oil: { + caption: 'Oil: safe to walk on, but flame on any of it burns the whole spill at once.', + enemies: ['blob', 'blob'], + prepare: (s, rng) => placeObjects(s, rng, 'oil', 2, 4), + shows: (t) => t.next.status === 'playing' && t.events.some((e) => e.type === 'fireSpread' && e.tiles.length >= 2), + }, + spikes: { + caption: 'Spikes: up one turn, down the next. Cross while they are down; enemies never step on them.', + enemies: ['blob'], + prepare: (s, rng) => placeObjects(s, rng, 'spikes', 1, 3), + shows: (t) => t.next.status === 'playing' && moved(t) && t.prev.spikes.some((sp) => sp.cell === idx(t.next, t.next.player)), + }, + barrel: { + caption: 'Oil barrel: flame bursts it, spilling oil, and the next turn it explodes.', + enemies: ['blob'], + // Fire 2 and two barrels, so random play bursts one often enough. + prepare: (s, rng) => ((s.stats.fire = 2), placeObjects(s, rng, 'barrel', 2, 2)), + shows: (t) => t.next.status === 'playing' && explosions(t).some((e) => t.prev.bombs.some((b) => b.barrel && at(b.pos, e.pos))), + }, // The ways to die. dieIntoBlast: { caption: 'Ouch! Walked straight into a blast. Check the red tiles before you step.', @@ -588,7 +637,9 @@ function ending(kind: SceneKind, game: GameTurn[]): Scene | null { if (s.bombs.length || s.burning.length || first.gesture.kind === 'go' || first.walking) continue if (s.drops.some((d) => !room.drops.some((r) => at(r.pos, d.pos) && r.kind === d.kind))) continue const turns = game.slice(-n) - const scene = { kind, start: { ...s, turn: 0, coins: 0 }, actions: turns.map((t) => t.action), gestures: turns.map((t) => t.gesture) } + // Spikes keep their rhythm across the reset to turn 0. + const spikes = s.spikes.map((sp) => ({ ...sp, phase: (sp.phase + s.turn) % 2 })) + const scene = { kind, start: { ...s, turn: 0, coins: 0, spikes }, actions: turns.map((t) => t.action), gestures: turns.map((t) => t.gesture) } if (showsAtEnd(scene)) return scene } return null @@ -613,7 +664,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: DEMO_PAR, 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: { ...player }, + 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), } const open = floor().filter((p) => steps(p, player) >= 3) @@ -682,6 +733,32 @@ function placeExit(s: GameState, rng: Rng, covered: boolean): void { if (covered) s.cells[idx(s, s.exit)] = 'block' } +/** + * `count` of a board object round a free floor tile `n` steps from the player: bushes and oil in a + * touching clump, spikes, metal crates and barrels on tiles of their own (never next to the player, + * for crates and barrels, so there is room to move). + */ +function placeObjects(s: GameState, rng: Rng, kind: 'bush' | 'oil' | 'spikes' | 'metal' | 'barrel', n: number, count: number): void { + const near = tilesAt(s, n) + if (!near.length) return + const placed: Vec[] = [near[rng.int(near.length)]!] + const free = (p: Vec) => + inBounds(s, p) && s.cells[idx(s, p)] === 'floor' && !at(p, s.player) && !placed.some((q) => at(q, p)) && !s.enemies.some((e) => at(e.pos, p)) + while (placed.length < count) { + const from = kind === 'bush' || kind === 'oil' ? placed : tilesAt(s, n).concat(tilesAt(s, n + 1)) + const next = from.flatMap((p) => (kind === 'bush' || kind === 'oil' ? DIR_LIST.map((d) => ({ x: p.x + DIRS[d].x, y: p.y + DIRS[d].y })) : [p])).filter(free) + if (!next.length) break + placed.push(next[rng.int(next.length)]!) + } + for (const p of placed) { + const i = idx(s, p) + if (kind === 'bush') s.bushes.push(i) + else if (kind === 'oil') s.oil.push(i) + else if (kind === 'spikes') s.spikes.push({ cell: i, phase: rng.int(2) }) + else if (steps(p, s.player) > 1) s.cells[i] = kind + } +} + /** Put a charge `n` steps from the player. */ function dropNear(s: GameState, rng: Rng, kind: ChargeId, n = 2): void { const near = tilesAt(s, n) diff --git a/src/sim/record.ts b/src/sim/record.ts index 283d3f3..0321dc1 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 = 38 +export const SIM_VERSION = 39 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */ export interface RunRecord { diff --git a/src/sim/turn.ts b/src/sim/turn.ts index dbb27d0..9ed251a 100644 --- a/src/sim/turn.ts +++ b/src/sim/turn.ts @@ -1,4 +1,4 @@ -import { ACTIONS, isAvailable, isValidTarget, slidePassed } from './actions' +import { ACTIONS, boxedIn, isAvailable, isValidTarget, slidePassed } from './actions' import { holds, sellOffered, spendCharge, swapOffered, takeCharge } from './charges' import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies' import { enemyKinds, LAST_LEVEL } from './levelgen' @@ -107,7 +107,7 @@ export function step(prev: GameState, action: Action): StepResult { let shielded = false let flameBlocked = false const hurt = (how: Death): void => { - const cause = how.cause === 'walkedIntoEnemy' || how.cause === 'caughtByEnemy' ? 'enemy' : how.cause === 'spikes' ? 'spikes' : how.cause === 'crushed' || how.cause === 'trapped' ? 'rubble' : 'flame' + const cause = how.cause === 'walkedIntoEnemy' || how.cause === 'caughtByEnemy' ? 'enemy' : how.cause === 'spikes' ? 'spikes' : how.cause === 'crushed' || how.cause === 'trapped' || how.cause === 'boxedIn' ? 'rubble' : 'flame' if (s.status !== 'playing' || s.options.immortal) return // Nothing saves the player from a fallen ceiling: not a Shield, nor a Star. if (cause !== 'rubble' && (shielded || s.star > 0 || (cause === 'flame' && flameBlocked))) return @@ -245,6 +245,9 @@ export function step(prev: GameState, action: Action): StepResult { planIntents(s) if (s.star > 0) s.star-- + // Boxed in for good: nowhere to step or put a bomb, and nothing left that could change it. + if (s.status === 'playing' && !s.offer && !s.chargeOffer && boxedIn(s)) hurt({ cause: 'boxedIn', turn: s.turn + 1 }) + // Died the turn a gem was taken: no choice. The retry has the gem back. A charge on offer the // same turn is gone too: the retry has it lying where it was. if (s.status === 'dead' || s.status === 'gameOver') { diff --git a/src/sim/types.ts b/src/sim/types.ts index 9c28523..b1c5284 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -95,8 +95,10 @@ export interface RunOptions { /** * Rule variants for the balance lab to compare (PLAN.md §19.1), never set in play. `squeeze`: the * old Diagonal, which could step between two blocked tiles (the rule until 2026-10-25). + * `underfoot`: the NES rule, a bomb goes on the player's own tile, not one next to them (to + * measure, 2026-10-25; the player walks off it, and nothing walks onto it). */ - lab?: { squeeze?: boolean } + lab?: { squeeze?: boolean; underfoot?: boolean } } export const DEFAULT_OPTIONS: RunOptions = { dev: false, immortal: false } @@ -347,7 +349,8 @@ export interface Shop { */ export interface Death { /** `spread`: flame over oil or through bushes, with no blast there (PLAN.md §19.3). */ - cause: 'walkedIntoEnemy' | 'caughtByEnemy' | 'blast' | 'chain' | 'lingering' | 'spread' | 'spikes' | 'crushed' | 'trapped' + /** `boxedIn`: nowhere to step or put a bomb, for good (2026-10-26). */ + cause: 'walkedIntoEnemy' | 'caughtByEnemy' | 'blast' | 'chain' | 'lingering' | 'spread' | 'spikes' | 'crushed' | 'trapped' | 'boxedIn' /** The enemy, for the two enemy causes. */ enemy?: EnemyKind /** The turn it happened on (counted from 1, as the top bar shows after it). */ diff --git a/src/style.css b/src/style.css index 15d64c8..5500094 100644 --- a/src/style.css +++ b/src/style.css @@ -624,8 +624,15 @@ img { background: #ece3cc; } -/* Three equal tabs that always fit the row (user report, 2026-10-25: on a phone they ran off both - ends, cut off). */ +/* Equal tabs that always fit the row (user report, 2026-10-25: on a phone they ran off both + ends, cut off). Four since the Board tab: two rows of two on a phone. */ +@media (max-width: 440px) { + .almanac-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + } +} + .almanac-tabs button { flex: 1 1 0; min-width: 0; diff --git a/src/ui/almanac.ts b/src/ui/almanac.ts index 5b073b8..a8562bb 100644 --- a/src/ui/almanac.ts +++ b/src/ui/almanac.ts @@ -5,18 +5,20 @@ 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 { CHARGE_TEXT, ENEMY_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info' +import { BOARD_TEXT, CHARGE_TEXT, ENEMY_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info' /** * The almanac (user request, 2026-10-22): every power-up, action charge and enemy type, with its - * picture and what it does, opened from the menu. Its tabs swap the list in place. + * 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' +export type AlmanacTab = 'powerUps' | 'charges' | 'enemies' | 'board' const TABS: [AlmanacTab, string][] = [ ['powerUps', 'Power-ups'], ['charges', 'Charges'], ['enemies', 'Enemies'], + ['board', 'Board'], ] /** The power-ups in the order they make sense to read: stats, abilities, passes, the rest. */ @@ -56,6 +58,12 @@ function body(tab: AlmanacTab): string { list = '<p class="almanac-intro">Enemies sometimes drop these. You carry one at a time and use it with the charge button, beside Bomb; a Shield works by itself.</p>' + CHARGE_IDS.map((id) => entry(CHARGE_ICONS[id], CHARGE_TEXT[id].name, [CHARGE_TEXT[id].text], id)).join('') + } else if (tab === 'board') { + list = + '<p class="almanac-intro">What you find on the board. New things turn up a few levels apart, so each is met on its own first. Tap one on the board to read about it there too.</p>' + + 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 { list = '<p class="almanac-intro">Every enemy shows its next 3 moves as arrows. Flame kills them; touching one costs you a life.</p>' + diff --git a/src/ui/info.ts b/src/ui/info.ts index 85d0f2b..b558901 100644 --- a/src/ui/info.ts +++ b/src/ui/info.ts @@ -1,6 +1,6 @@ import { DECOY_RANGE, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE } from '../sim/actions' import { BOMB_BOOST, CHARGE_AMOUNT, FLAME_PASS_HITS, PASSIVE_CHARGES, salePrice } from '../sim/charges' -import { LAST_LEVEL } from '../sim/levelgen' +import { LAST_LEVEL, OBJECTS_FROM } from '../sim/levelgen' import { bombAt, cellAt, eq, exitOpen, isBurning, CLEAR_BONUS, PAR_BONUS } from '../sim/state' import { BARREL_FIRE, COLLAPSE_TURNS, spikesUp } from '../sim/explosions' import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS, STAT_MAX, STAT_STEP } from '../sim/turn' @@ -156,6 +156,7 @@ export function deathLine(level: number, d: Death | null): string { spikes: 'you stepped on raised spikes', crushed: 'the ceiling fell on you', trapped: 'fallen rubble walled you in, away from the exit or an enemy', + boxedIn: 'you were boxed in, with nowhere to step and nowhere to put a bomb', }[d.cause] return `Level ${level}, turn ${d.turn}: ${how}.` } @@ -256,6 +257,68 @@ export function hudInfo(item: HudItem, s: GameState): Info { } } +/** The things on the board, for "What is that?" and the almanac's Board tab (2026-10-25). */ +export type BoardThing = 'crate' | 'pillar' | 'bush' | 'metal' | 'oil' | 'spikes' | 'barrel' | 'rubble' + +export const BOARD_TEXT: Record<BoardThing, { icon: string; name: string; text: string[]; clip?: SceneKind; from?: number }> = { + crate: { icon: 'block_planks', name: 'Crate', text: ['Flame destroys it (and stops there). On every level one crate hides the exit and another hides a gem.'], clip: 'crate' }, + pillar: { + icon: 'block_blue', + name: 'Pillar', + text: ['Indestructible: nothing gets through, not even flame. Pillars change colour from level to level; Acid can crack them.'], + clip: 'pillar', + }, + bush: { + icon: 'bush', + name: 'Bush', + text: [ + 'Walk into it to hide: enemies can’t see you there, and head for where they last saw you instead. Anything lying in it is hidden too; an enemy in one shows faint.', + 'Flame burns it, and sets the bushes next to it smouldering: they catch fire the next turn.', + ], + clip: 'bush', + from: OBJECTS_FROM.bush, + }, + metal: { + icon: 'block_metal', + name: 'Metal crate', + text: ['Nothing breaks it, and nothing gets through, not even flame (not a Pierce bomb’s either). Go round.'], + clip: 'metal', + from: OBJECTS_FROM.metal, + }, + oil: { + icon: 'oil', + name: 'Oil', + text: ['Safe to walk on, until flame touches any part of the spill: then the whole spill burns at once, and sets off any bomb lying in it.'], + clip: 'oil', + from: OBJECTS_FROM.oil, + }, + spikes: { + icon: 'spikes', + name: 'Spikes', + text: ['Up one turn, down the next. Up, they cost a life to whoever stands on them, and show red in the danger overlay. Enemies never step on them.'], + clip: 'spikes', + from: OBJECTS_FROM.spikes, + }, + barrel: { + icon: 'block_strong_danger', + name: 'Oil barrel', + text: [`It blocks the way like a crate. Flame bursts it: oil spills round it, and the next turn it explodes with a blast of ${BARREL_FIRE}, lighting the spill. Keep clear!`], + clip: 'barrel', + from: OBJECTS_FROM.barrel, + }, + rubble: { + icon: 'rock', + name: 'Rubble', + text: [`A fallen ceiling: Acid cracked a pillar, a later blast broke it, and ${COLLAPSE_TURNS} turns on the ceiling round it came down. Nothing gets through rubble, not even flame, and nothing clears it.`], + clip: 'acid', + }, +} + +function boardInfo(thing: BoardThing): Info { + const t = BOARD_TEXT[thing] + return { icon: t.icon, title: t.name, body: t.text, clip: t.clip } +} + /** * What is on a tile, most important first, or null for plain empty floor (a tap there is * auto-move, not info). @@ -339,52 +402,25 @@ export function tileInfo(s: GameState, p: Vec): Info | null { clip: cracked ? 'acid' : 'pillar', } } - if (cell === 'metal') { - return { icon: 'block_metal', title: 'Metal crate', body: ['Nothing breaks it, and nothing gets through, not even flame (not a Pierce bomb’s either). Go round.'] } - } - if (cell === 'barrel') { - return { - icon: 'block_strong_danger', - title: 'Oil barrel', - body: [`It blocks the way like a crate. Flame bursts it: oil spills round it, and the next turn it explodes with a blast of ${BARREL_FIRE}, lighting the spill. Keep clear!`], - } - } - if (cell === 'rubble') { - return { icon: 'rock', title: 'Rubble', body: ['A fallen ceiling. Nothing gets through it, not even flame, and nothing clears it.'] } - } + if (cell === 'metal') return boardInfo('metal') + if (cell === 'barrel') return boardInfo('barrel') + if (cell === 'rubble') return boardInfo('rubble') const spike = s.spikes.find((sp) => sp.cell === i) if (spike) { const up = spikesUp(s, i) + const info = boardInfo('spikes') return { - icon: 'spikes', + ...info, title: up ? 'Spikes: up' : 'Spikes: down', - body: [ - `They go up and down every turn: ${up ? 'up now, so stepping or standing here this turn costs a life' : 'down now, safe to step on this turn, but up the next'}. They show red in the danger overlay when up.`, - 'Enemies never step on them.', - ], + body: [up ? 'Up now: stepping or standing here this turn costs a life.' : 'Down now: safe to step on this turn, but up the next.', ...info.body], } } if (s.bushes.includes(i)) { - return { - icon: 'bush', - title: 'Bush', - body: [ - 'Walk into it to hide: enemies can’t see you there, and head for where they last saw you instead. Anything lying in it is hidden too.', - `Flame burns it, and sets the bushes next to it smouldering: they catch fire the next turn${s.smoldering.includes(i) ? ' — this one is smouldering now' : ''}.`, - ], - } - } - if (s.oil.includes(i)) { - return { icon: 'oil', title: 'Oil', body: ['Safe to walk on, until flame touches any part of the spill: then the whole spill burns at once, and sets off any bomb lying in it.'] } - } - if (cell === 'block') { - return { - icon: 'block_planks', - title: 'Crate', - body: ['Flame destroys it (and stops there). On every level one crate hides the exit and another hides a gem.'], - clip: 'crate', - } + const info = boardInfo('bush') + return s.smoldering.includes(i) ? { ...info, title: 'Bush: smouldering', body: [...info.body, 'This one is smouldering: it catches fire next turn.'] } : info } + if (s.oil.includes(i)) return boardInfo('oil') + if (cell === 'block') return boardInfo('crate') const gem = s.gems.find((g) => eq(g.pos, p)) if (gem) { return { diff --git a/test/charges.test.ts b/test/charges.test.ts index 56912fc..923b8f9 100644 --- a/test/charges.test.ts +++ b/test/charges.test.ts @@ -315,6 +315,36 @@ describe('Flame Pass and Teleport, charges since 2026-10-25', () => { expect(createLevel(s.seed, 2, s.options, carryOf(s)).charge).toEqual({ kind: 'flamePass', left: 2 }) }) + it('the lab’s NES rule (`underfoot`): the bomb goes on the player’s own tile, and they walk off it', () => { + const s = board(` + ##### + #P..# + #####`) + s.options = { ...s.options, lab: { underfoot: true } } + expect(ACTIONS.bomb.validTargets(s)).toEqual([v(1, 1)]) + const placed = step(s, { id: 'bomb', target: v(1, 1) }).state + expect(placed.bombs[0]!.pos).toEqual(v(1, 1)) + const off = step(placed, { id: 'move', target: v(2, 1) }).state + expect(off.player).toEqual(v(2, 1)) + // Not back onto it. + expect(ACTIONS.move.validTargets(off)).not.toContainEqual(v(1, 1)) + }) + + it('a jump into a pocket with nowhere to step or put a bomb costs a life: boxed in (2026-10-26)', () => { + const s = board(` + ####### + #.P.+.# + ###.#+# + #######`) + // (5, 1): crates left and below, walls round the rest. + s.charge = { kind: 'teleport', left: 1 } + const r = step(s, { id: 'teleport', target: v(5, 1) }) + expect(r.state.death?.cause).toBe('boxedIn') + // With a jump left, there is a way out: no death. + s.charge = { kind: 'teleport', left: 2 } + expect(step(s, { id: 'teleport', target: v(5, 1) }).state.status).toBe('playing') + }) + it('Teleport: 3 jumps from the charge button, then it is gone', () => { let s = board(` ########## diff --git a/test/new-enemies.test.ts b/test/new-enemies.test.ts index 3cbcd59..1a8fce7 100644 --- a/test/new-enemies.test.ts +++ b/test/new-enemies.test.ts @@ -29,7 +29,9 @@ describe('Ghosts and Wisps go through crates', () => { const s = board(` ####### #P++.G# + #.##### #######`) + // (A tile below the player, so they aren't boxed in.) let s2 = s planIntents(s2) const been: Vec[] = [] diff --git a/tools/balance/bot.ts b/tools/balance/bot.ts index 9fb49f7..78d4b3c 100644 --- a/tools/balance/bot.ts +++ b/tools/balance/bot.ts @@ -162,7 +162,7 @@ function scoreOf(s: GameState, next: GameState, events: GameEvent[], goals: Int3 let score = 0 for (const e of events) { if (e.type === 'enemyDied') score += 200 - else if (e.type === 'blockDestroyed') score += 15 + else if (e.type === 'blockDestroyed' || e.type === 'barrelLit') score += 15 else if (e.type === 'pickedUp') score += e.drop.kind === 'coins' ? 25 : 60 else if (e.type === 'powerUpTaken') score += 300 else if (e.type === 'enemiesSpawned') score -= 400 @@ -187,7 +187,9 @@ function bombValue(s: GameState, bomb: GameState['bombs'][number]): number { const route = [e.pos, ...e.plan.slice(0, bomb.timer)] if (route.some(has)) value += 60 } - for (const t of tiles) if (cellAt(s, t) === 'block') value += 12 + // Crates, and oil barrels (2026-10-25: the bot never burst one, and stalled where barrels shut + // in the exit or the last enemy). + for (const t of tiles) if (cellAt(s, t) === 'block' || cellAt(s, t) === 'barrel') value += 12 // Never blast the uncovered exit or gem: that lets enemies out. if (cellAt(s, s.exit) === 'floor' && has(s.exit)) value -= 500 for (const g of s.gems) if (cellAt(s, g.pos) === 'floor' && has(g.pos)) value -= 500 @@ -216,10 +218,13 @@ function goalDistances(s: GameState): Int32Array { const spot = { x: i % s.width, y: Math.floor(i / s.width) } if (s.cells[i] !== 'floor' || s.bombs.some((b) => b.pos.x === spot.x && b.pos.y === spot.y)) continue const reach = blastTiles(s, { pos: spot, ...kind }) - const worth = reach.some((t) => cellAt(s, t) === 'block' || routes.some((r) => r.x === t.x && r.y === t.y)) + const worth = reach.some((t) => cellAt(s, t) === 'block' || cellAt(s, t) === 'barrel' || routes.some((r) => r.x === t.x && r.y === t.y)) if (!worth) continue - // Somewhere to stand next to the bomb with a walk out of the blast before it goes off. - for (const from of near(spot)) if (canGetClear(s, from, spot, reach)) goals.push(from) + // Somewhere to stand next to the bomb with a walk out of the blast before it goes off; with + // the lab's NES rule (`underfoot`), the bomb's own tile. + if (s.options.lab?.underfoot) { + if (canGetClear(s, spot, spot, reach)) goals.push(spot) + } else for (const from of near(spot)) if (canGetClear(s, from, spot, reach)) goals.push(from) } } } diff --git a/tools/balance/lab.ts b/tools/balance/lab.ts index 6e7b04f..75befef 100644 --- a/tools/balance/lab.ts +++ b/tools/balance/lab.ts @@ -20,6 +20,7 @@ import { measureShape, samplePositions, type Shape } from './structure' * * npm run balance -- powers [--seeds 200] [--skill careful|sloppy] each power-up held from level 1 * npm run balance -- diagonal [--seeds 200] [--skill …] Diagonal now, and with the old squeeze + * npm run balance -- underfoot [--seeds 200] [--skill …] bombs next to the player, or under them (NES) * npm run balance -- structure [--seeds 60] what each power-up changes on real positions * npm run balance -- runs [--seeds 20] [--skill …] the baseline, run by run * npm run balance -- trace <seed> <level> [turns] one level, board by board @@ -44,6 +45,18 @@ function powerArms(): Arms { * Diagonal as it is (no squeezing, since 2026-10-25) against the old rule that let it step between * two blocked tiles: without it, with it, and with the squeeze back. */ +/** + * Where a bomb goes (user question, 2026-10-25): next to the player, as now, against the NES rule, + * on the player's own tile. + */ +function underfootArms(): Arms { + const underfoot: Setup = (s) => void (s.options = { ...s.options, lab: { underfoot: true } }) + return { + baseline: () => {}, + 'underfoot (NES rule)': underfoot, + } +} + function diagonalArms(): Arms { return { baseline: () => {}, @@ -58,8 +71,9 @@ function diagonalArms(): Arms { const EXPERIMENTS: Record<string, () => Arms> = { powers: powerArms, diagonal: diagonalArms, - // The baseline alone, or with one power-up (`--arm <power-up>`), run by run. - runs: () => ({ baseline: powerArms()[process.env.BALANCE_ARM ?? 'baseline'] ?? (() => {}) }), + underfoot: underfootArms, + // The baseline alone, or with one power-up or rule variant (`--arm <name>`), run by run. + runs: () => ({ baseline: { ...powerArms(), ...underfootArms() }[process.env.BALANCE_ARM ?? 'baseline'] ?? (() => {}) }), } interface Task { @@ -100,7 +114,7 @@ async function main(): Promise<void> { if (command === 'structure') return structure(Number(option(args, 'seeds', '60'))) if (command === 'record-until') return recordUntil(args[0] ?? 'chargeOffered') if (!EXPERIMENTS[command]) { - console.log('Commands: powers, diagonal, structure, runs, trace <seed> <level> [turns]. Options: --seeds N, --skill careful|average|sloppy, --levels N.') + console.log('Commands: powers, diagonal, underfoot, structure, runs, trace <seed> <level> [turns]. Options: --seeds N, --skill careful|average|sloppy, --levels N.') return } const seeds = Number(option(args, 'seeds', command === 'runs' ? '20' : '200')) @@ -225,7 +239,7 @@ function writeReport(experiment: string, skill: string, seeds: number, byArm: Ma /** One level of one seed, as the Careful bot plays it, board by board. */ function trace([seed = 1, level = 1, turns = 60]: number[]): void { let s = newRun(seed) - powerArms()[process.env.BALANCE_ARM ?? 'baseline']?.(s) + ;({ ...powerArms(), ...underfootArms() })[process.env.BALANCE_ARM ?? 'baseline']?.(s) const bot = makeBot(seed) let shown = 0 while (s.status !== 'gameOver' && s.status !== 'won' && shown < turns) { diff --git a/tools/balance/show.ts b/tools/balance/show.ts index 508cd22..ed3a68b 100644 --- a/tools/balance/show.ts +++ b/tools/balance/show.ts @@ -1,6 +1,9 @@ import type { GameState } from '../../src/sim/types' -/** The board as text, for following a bot: P you, E enemy, 1–9 a bomb, + crate, x/X exit (hidden/open), g gem, $ drop. */ +/** + * The board as text, for following a bot: P you, E enemy, 1–9 a bomb, + crate, x/X exit (hidden/open), + * g gem, $ drop; M metal crate, O oil barrel, r rubble, * bush, ~ oil, ^ spikes (test/helpers.ts's letters). + */ export function show(s: GameState): string { const rows: string[] = [] for (let y = 0; y < s.height; y++) { @@ -15,7 +18,11 @@ export function show(s: GameState): string { : at(s.exit) ? (c === 'floor' ? 'X' : 'x') : s.gems.some((g) => at(g.pos)) ? (c === 'floor' ? 'g' : 'G') : s.drops.some((d) => at(d.pos)) ? '$' - : c === 'wall' ? '#' : c === 'block' ? '+' : '.' + : c === 'wall' ? '#' : c === 'block' ? '+' : c === 'metal' ? 'M' : c === 'barrel' ? 'O' : c === 'rubble' ? 'r' + : s.bushes.includes(y * s.width + x) ? '*' + : s.oil.includes(y * s.width + x) ? '~' + : s.spikes.some((sp) => sp.cell === y * s.width + x) ? '^' + : '.' } rows.push(r) }