explodorama / commit
Flame Pass and Teleport as charges; a death drops the charge; boards fitted
CHANGELOG.md | 3 ++ PLAN.md | 41 ++++++++++++++-- README.md | 58 +++++++++++------------ decisions.md | 23 +++++++++ docs/balance/powers-average.md | 84 +++++++++++++++++---------------- docs/balance/powers-careful.md | 88 +++++++++++++++++++---------------- docs/balance/structure.md | 38 +++++++++------ docs/screenshots/almanac-charges.png | Bin 403787 -> 481755 bytes docs/screenshots/almanac-enemies.png | Bin 316636 -> 319768 bytes docs/screenshots/almanac.png | Bin 540083 -> 514897 bytes docs/screenshots/auto-move.png | Bin 216816 -> 216796 bytes docs/screenshots/bomb-picked.png | Bin 215802 -> 215828 bytes docs/screenshots/charges.png | Bin 224648 -> 224644 bytes docs/screenshots/countdown.png | Bin 219713 -> 219691 bytes docs/screenshots/death.png | Bin 233705 -> 233745 bytes docs/screenshots/demo.png | Bin 951309 -> 958036 bytes docs/screenshots/desktop.png | Bin 270235 -> 270193 bytes docs/screenshots/dev-offer.png | Bin 202642 -> 198900 bytes docs/screenshots/dev.png | Bin 219408 -> 219379 bytes docs/screenshots/enemies.png | Bin 243296 -> 246259 bytes docs/screenshots/gem-offer.png | Bin 197669 -> 207918 bytes docs/screenshots/gem.png | Bin 200049 -> 200124 bytes docs/screenshots/info-hud.png | Bin 182952 -> 182283 bytes docs/screenshots/lingering.png | Bin 219228 -> 219247 bytes docs/screenshots/long-press.png | Bin 219391 -> 219450 bytes docs/screenshots/menu.png | Bin 150439 -> 151057 bytes docs/screenshots/move-picked.png | Bin 216090 -> 216082 bytes docs/screenshots/shop.png | Bin 199118 -> 209118 bytes docs/screenshots/start.png | Bin 215464 -> 215447 bytes src/main.ts | 8 ++-- src/render/board.ts | 3 ++ src/render/sprites.ts | 5 +- src/sim/actions.ts | 26 +++++++++-- src/sim/charges.ts | 25 ++++++---- src/sim/demo.ts | 12 ++--- src/sim/levelgen.ts | 19 ++++---- src/sim/path.ts | 17 ++----- src/sim/picks.ts | 8 ++-- src/sim/record.ts | 2 +- src/sim/shop.ts | 4 +- src/sim/state.ts | 52 ++++++++++++++++++--- src/sim/turn.ts | 22 +++++---- src/sim/types.ts | 30 +++++++----- src/ui/almanac.ts | 4 +- src/ui/chargeOffer.ts | 9 ++-- src/ui/controls.ts | 6 +-- src/ui/feedback.ts | 2 + src/ui/info.ts | 26 +++++++---- test/animation.test.ts | 4 +- test/charges.test.ts | 86 +++++++++++++++++++++++++++++++--- test/enemies.test.ts | 2 +- test/helpers.ts | 2 +- test/new-powers.test.ts | 6 +-- test/options.test.ts | 10 ++-- test/path.test.ts | 34 +++----------- test/picks.test.ts | 15 +++--- test/powerups.test.ts | 22 ++++----- test/shop.test.ts | 2 +- tools/balance/lab.ts | 7 ++- 59 files changed, 508 insertions(+), 297 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b06f273..8454cd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,9 @@ saved before the change is dropped and the next visit starts a new run. ### Changed +- **Flame Pass and Teleport are charges now**, not power-ups: Flame Pass, raised with the charge button, takes the next 3 fire hits; Teleport gives 3 jumps from the charge button. Each alone used to win the game. +- **A death drops the charge you carry** where you fell, to pick up again on the retry. +- **Board sizes fitted:** up to 13 × 17, so late levels take about as long as early ones; slightly more enemies on late levels. Version 35. - **The gem's and the shop's cards show what they change:** "Blast 2 → 3", "Bombs 1 → 2", "Lives 3 → 4"… - **Auto-move's way** shows as a line of arrows through its blue tiles, one per move, and only the tiles you will land on are lit: with Run, every other one. - **Shield and Freeze ask for a confirmation:** the charge button picks them; tap yourself (or press it again) to use them. Version 33. diff --git a/PLAN.md b/PLAN.md index f58ba1c..7fe5c4a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1028,12 +1028,24 @@ tools/ compose-icons.sh (composed sprites and the app icons), sc ### Backlog +- **A worn dungeon (user request, 2026-10-25; UI, to build):** use the Splat Pack (approved) to make + each level look dilapidated, old, tarnished, wet or dirty — stains, puddles, soot, moss, rust — in + a random mix and amount drawn from the seed and level, so a level always looks the same and + levels differ. Decoration only: drawn on the floor (and walls) under everything, never in the + sim, never mistaken for a hazard (19.3's oil and acid spills must look clearly different: brighter, + outlined, or animated). Ideas: a "theme" per level (damp: blue-grey puddles and green moss; + scorched: soot; rusty: orange stains on pillars), denser in corners and along walls, lighter on + paths; a few splats shared with the demo's rooms. - *Built 2026-10-25 (user request):* **TNT**, a power-up worth two Fire +1s (blast +2; weight 6, price 7), and **limits on the stats**: bombs, blast and fuse each at most 5 (`STAT_MAX`). A stat power-up without room for all of its step isn't offered (`heldAlready`); its card shows the limit ("Blast 2 → 4 (max 5)"). Teleport's reach gets its own limit when it becomes a charge (next task). -- **Next, after bigger boards (§19.2) are playtested and done (user, 2026-10-25): Flame Pass and - Teleport become charges.** The lab measured both turning a weak player into a near-certain winner +- ~~**Next: Flame Pass and Teleport become charges.**~~ *Built 2026-10-25.* Flame Pass: raised with + the charge button (a tap on yourself), it takes the next 3 fire hits (`flameHits`, an orange + ring, kept from level to level). Teleport: 3 jumps from the charge button (key P too); no longer + in the Move group, a tap's meaning, or auto-move, so nothing spends a jump but the player. Their + power-up entries are gone (`powers.teleport`, `powers.flamePass`). Shop: 7 each. Was: + **Flame Pass and Teleport become charges.** The lab measured both turning a weak player into a near-certain winner (§19.1). Decided by the user: - **Flame Pass → a charge counted in flame hits, not turns:** it saves the player from **3 fire hits** (a blast, a chain, lingering flame), one used up each time flame would have hurt them, @@ -1045,7 +1057,9 @@ tools/ compose-icons.sh (composed sprites and the app icons), sc finds them strong but in line with Run or Kick). - Then run the lab's `powers` experiment again on the same seeds, and the 3s are first guesses. - **Decided (user, 2026-10-25): on a death, the carried charge drops where the player fell**, with - what is left of it, as a drop the retry can pick up again (option 1 below). To build. + what is left of it, as a drop the retry can pick up again (option 1 below). *Built 2026-10-25* + (`Carry.fallen`; a charge drop can carry `left`; the nearest open floor if the player fell in a + crate). Was: **Lose the carried charge on a death? (user idea, 2026-10-25.)** Today the charge carried goes through a death (§3.3). Dropping it would make a death cost more than the life and give a reason to use a charge rather than hoard it; but it also hits hardest the player who is @@ -1164,6 +1178,20 @@ The bots don't shop, and take the first thing a gem offers. untouched by these changes, and the next thing to decide (19.5: the passes leave the power-ups or become charges). +**After Flame Pass and Teleport became charges, and the boards were fitted (2026-10-25, rules +version 35),** 150 seeds, now also each charge carried from level 1: + +- **Teleport and Flame Pass are tamed:** as charges, +0.2 and +0.3 levels for the average bot + (they were +14.5 and +14.3 as power-ups) and about 0 for the careful one. +- **Diagonal is now the strongest thing** for the average bot: +8 levels (wins 0% → 10%), and the + best for the careful bot too (+0.8). Also boosted by auto-move cutting corners with it. A + candidate for the next look. +- **Kick** (+2.6) comes next for the average bot; everything else is within about ±1.3 levels. +- **Bigger blasts still hurt the weaker player:** TNT −2.3, Fire +1 −1.8, Fuse +1 −1.3. +- **No charge is far stronger or weaker than the rest** (−0.7 to +0.4 for the average bot). The + bots use charges crudely (whenever their scoring likes it), so these say little about a person. +- The careful bot: 91% of runs won (82% under rules 28), about 84 turns a level, stalls 3%. + ### 19.2 Bigger boards for later levels **Today:** every level is 9 × 11, the whole board on screen at once (a choice made 2026-10-23). It @@ -1221,8 +1249,11 @@ render/camera.ts; `drawIndicators` in render/board.ts): Threat weights (first guesses, to fit with the lab): Blob 1, Seeker 1.4, Drifter 1.1, Sprinter 1.6, Ghost 1.7, Wisp 1.3, Hunter 2. Level 20 gets about 12 enemies on 193 open tiles. - **Par** = 10 + 0.3 × open tiles + 8 × enemies (41 on level 1, as before). -- **Not yet:** fitting the curve and the weights with the balance lab; the largest size by how - long a level takes. `SIM_VERSION` 32. +- **Fitted with the lab (2026-10-25):** with boards up to 15 × 21 the careful bot took 100–145 turns + on late levels; the sizes are now 9 × 11 (1–3), 11 × 13 (4–7), 11 × 15 (8–11), 13 × 15 (12–15), + 13 × 17 (16 on), which take it about 75–95 turns throughout. Concentration rises to 1/9 by level + 20 (not 1/11): sparser late levels left the bot hunting the last enemy for a long time (its stalls + went from 3% to 8%). The threat weights are still guesses. `SIM_VERSION` 32, then 35. **Concentration: one number that sets how crowded a level is.** Today each level's enemy count is a table (2, then one more every 4 levels). Instead: diff --git a/README.md b/README.md index 1df59bd..543c295 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: **34**, shown at the bottom of the menu with the commit the game was built from (a link +Version: **35**, 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 @@ -47,8 +47,8 @@ screen — and the finger in front of the glass. The rest show every feature on board: bombs and their countdown, watching the enemies' arrows to bomb where they're going, a narrow escape, chain reactions, lingering flame, the first three enemy types, drops, the gem, every power-up you can see in action (Bomb +1, Fire +1, Fuse +1, Run, Kick — a tap on a bomb next to -you —, Throw, Teleport, Diagonal, Slide, RPG, the Detonator — a tap on your bomb —, Proximity -alert, Wall / Bomb / Flame Pass, +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 Pierce, the player picks up a Nuke; the choice opens over the scene and the finger taps Take @@ -91,15 +91,14 @@ they make Move and Bomb do more. | **Wait** | Tap **Wait**, or tap **yourself** | Space | Do nothing; the world takes its turn. | When Move or Bomb can do several things, the tile you tap decides: next to you is a step (or a -kick, into a bomb), 2 straight is a run, further is a teleport (or, out of its reach, where a slide -stops). Without picking anything, a tap on the tile where a slide stops slides there; next to you is a bomb, 2 away a throw, further an ICBM while you have launches, otherwise an -RPG bomb. With Diagonal, a diagonal neighbour is a step too — tap it, picked or not — and Teleport is -for tiles further away. +kick, into a bomb), 2 straight is a run, further is where a slide stops. Without picking anything, a tap on the tile where a slide stops slides there; next to you is a bomb, 2 away a throw, further an ICBM while you have launches, otherwise an +RPG bomb. With Diagonal, a diagonal neighbour is a step too — tap it, picked or not. (Teleport is a +charge, used only from the charge button.) **Auto-move:** tap any tile further away that you can walk to — empty floor, the exit, the gem or a drop. **You take the first step at once**, just as a tap next to you would (if that step is safe). Every tile you will **land on** along the way, including where you're going, is -highlighted blue — with Run or Teleport that's only the tiles you land on, not the ones you skip; with +highlighted blue — with Run that's only the tiles you land on, not the ones you skip; with Diagonal the way cuts corners with diagonal steps — with **a line of arrows** through them from you to there, one arrow on each pointing the way that move goes (the first in red when it isn't safe), and the **Move** button becomes **Go** (same walk icon, with a green border): each press of @@ -116,12 +115,9 @@ flame this turn. The next step then shows red, and Go / the destination wait for steps a second (still one turn per step). **How far one move of auto-move goes** (and the first one, when you tap a far tile): a tile next -to you is always a step. Otherwise it's whichever of a step, a **Run** (2 tiles straight along the -way) or a **Teleport** (anywhere within 3) leaves you closest — so a tile 2 away is two steps -without either, one run with Run, one teleport with Teleport (with or without Run); further away, -it runs or teleports as far toward the target as it can, then works it out again next turn. It -only lands on safe tiles. With Teleport, tapping a tile you can't walk to but can teleport to -(behind a pillar, say) teleports you there. +to you is always a step. Otherwise it's a **Run** (2 tiles straight along the way) when you have +Run and the way goes straight, or a step — then it works it out again next turn. It only lands on +safe tiles. Auto-move never spends a charge: a Teleport is yours to use, from the charge button. Tapping an enemy, a bomb, burning flame, a wall or a crate does nothing (apart from ending auto-move, for a wall or crate; and with Kick, tapping a bomb next to you kicks it) — a tap @@ -137,7 +133,7 @@ themselves with a plain tap. Most of these dialogs **play a short clip** of what made on the spot like the demo's scenes and new every loop: a bomb counting down and going off, lingering flame, a crate breaking, flame stopping at a pillar, the last enemy dying and the exit opening, losing a life, the exit, the gem, coins, each power-up and charge; for the actions (Move, Bomb, -Wait) and the power-ups you work yourself (Run, Kick, Throw, Teleport, Diagonal, the Detonator), +Wait) and the power-ups you work yourself (Run, Kick, Throw, Diagonal, the Detonator), a finger shows how. Enemies have only their picture. With reduced motion turned on, each clip is a still picture of its moment instead. @@ -185,6 +181,8 @@ piled up). The top bar shows it too. | **Freeze** | a padlock | Press the charge button, then tap yourself — or press it again: every enemy freezes in place for **3 turns**, that one included: they turn blue inside a block of ice and thaw a step each turn, then carry on with the moves their (faded, icy) arrows show. | | **Decoy** | three pawns | Press the charge button, then tap where it goes — **up to 3 tiles away in a straight line**, with nothing in the way (a wall, crate, bomb, enemy or flame stops it, as it stops a slide): a pale copy of you. Enemies that chase you (Seekers, Ghosts, Hunters, Sprinters) go after it instead, **until the level ends** — or until an enemy reaches it or flame burns it, which destroys it. It doesn't block anyone. | | **ICBM** | bomb with a target badge | 3 launches: the charge button fires a bomb as an RPG's (or **I**, then a direction) and it goes off the moment it lands, at any distance. Your bomb limit doesn't count. | +| **Flame Pass** | flame with a shield badge | Press the charge button, then tap yourself: flame can't hurt you for the next **3 fire hits** (an orange ring round you; the top bar counts them), level after level until used. Enemies still can. A power-up that worked for good until 2026-10-25, when the balance lab found it won the game alone. | +| **Teleport** | the Move figure with an outbox badge | **3 jumps**: press the charge button (or **P**), then tap any empty tile within 3 steps (counting across and down), over anything. **With Run, 4 steps;** **with Diagonal, a square** (a diagonal step counts as one). A power-up until 2026-10-25, for the same reason. | A Pierce, Power or Nuke bomb wears its charge's icon as a small badge. **Bomb** always puts down a plain bomb. @@ -209,7 +207,8 @@ will end up, a faded bomb with its blast, a kicked bomb's stopping place, a chan - **Flame lingers for one more turn.** After a blast, its tiles keep burning (orange, with flickering fireballs) through the next turn: any enemy that walks in dies, and so do you if you wait in it. You can't step or put a bomb into burning tiles — unless flame can't hurt you - (Flame Pass, a Star), then you can walk through it like floor. It goes out after that turn. + (a raised Flame Pass, 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 can't be placed on an enemy. - Pierce, Power and Nuke bombs are put down with the charge button (see Charges above); such a @@ -256,8 +255,8 @@ enemy. Each level mixes the types met so far. How many enemies a level has follows its **crowding**: each type counts for how dangerous it is (a Blob 1, up to a Hunter 2), and they are added until that reaches 1 per 26 open tiles on level 1, -rising to 1 per 11 by level 20 — so a bigger board brings more enemies, and later levels are more -crowded. Level 1 has 2; level 20 about a dozen, on a much bigger board. Enemies never start within +rising to 1 per 9 by level 20 — so a bigger board brings more enemies, and later levels are more +crowded. Level 1 has 2; level 20 about ten, on a bigger board. Enemies never start within 4 tiles of you. ### Drops and coins @@ -293,7 +292,7 @@ taps: **tap a card** to select it (it turns green, and a clip above the cards sh then **Choose** to take it. A card for something with a number shows what it changes from where you are: **Blast 2 → 3** for Fire +1, Bombs, Fuse, Lives, Coins, a Star's turns, or a charge's uses when you carry the same kind (the shop's cards too). The first card is selected to begin with. Heart and Coin bag, which have nothing to show on the board, show their picture there instead. The 5 are drawn at random, but not all are equally likely: common ones (Bomb +1, Fire +1) come up far more often -than rare ones (Teleport, Heart). An ability or pass you already hold is never offered. The +than rare ones (Proximity alert, Heart). An ability or pass you already hold is never offered. The offer is fixed by the seed and level, so a retry after a death offers the same. If you die on the turn you take the gem, there is no choice, and the retry has the gem back. If the same step also reaches the exit, you choose before moving on. @@ -311,20 +310,18 @@ go: **Blast 2 → 4 (max 5)**. | **Run** | the Move figure with a fast-forward badge | **Move** can run: 2 tiles in a straight line in one turn (or **R**, then a direction). Both tiles must be empty; an enemy on the tile in between is run into. Anything on that tile is picked up. | | **Kick** | bomb with a right-arrow badge | Walk into a bomb next to you and it slides away until the next tile is blocked (a wall, crate, bomb or enemy), keeping its number. | | **Throw** | bomb with an up-right-arrow badge | **Bomb** can also go exactly 2 tiles away in a straight line, over a crate, bomb or enemy in between — but not over a pillar or the outer wall (or **T**, then a direction). | -| **Teleport** (rare) | the Move figure with an outbox badge | **Move** can take you to any empty tile within 3 steps (counting across and down), over anything (or **P**, then tap a tile). **With Run as well, 4 steps:** Teleport reaches everywhere a run does, so Run adds to it instead. **With Diagonal as well, a square:** a diagonal step counts as one, so it reaches up to 3 across and 3 down at once (a 7 × 7 square; 9 × 9 with Run too). | -| **Diagonal** | the Move figure with a diagonal-arrows badge | **Move** can also step diagonally, even squeezing between two blocked tiles (or **Q E Z C**: up-left, up-right, down-left, down-right). With Teleport, it widens Teleport's reach to a square. A charge until 2026-10-23. | +| **Diagonal** | the Move figure with a diagonal-arrows badge | **Move** can also step diagonally, even squeezing between two blocked tiles (or **Q E Z C**: up-left, up-right, down-left, down-right); auto-move cuts corners with it. With the Teleport charge, it widens Teleport's reach to a square. A charge until 2026-10-23. | | **Slide** | the Move figure with a two-way-arrow badge | **Move** can also slide in a straight line until the next tile is a wall, crate or bomb — **tap the tile where it stops** (or **L**, then a direction) — picking up whatever it passes. It can't stop early: an enemy on the way is crashed into (a life lost), and flame on the way burns (unless Flame Pass or a Star). The open exit stops it. | | **RPG** | bomb with a forward-arrow badge | **Bomb** can also fire a bomb in a straight line (or **F**, then a direction): it flies until the next tile is a wall, crate, bomb or enemy and lands just before it, then counts down as usual. | | **Detonator** | bomb with a ! badge | Your bombs pulse with a red glow: **tap one** and it goes off this turn (or **X**, then tap it). No button. | | **Proximity alert** | bomb with a warning-sign badge | Your bombs go off the turn an enemy is **right next to** one — one that walks up, or one already there when you place it — wherever you are standing. (Until 2026-10-25, anywhere in its reach.) The danger overlay shows it ahead, from the enemies' arrows. | | **Wall Pass** | crate with an ✕ badge | Walk through crates. | | **Bomb Pass** | bomb with a shield badge | Walk over bombs. | -| **Flame Pass** | flame with a shield badge | Flame doesn't hurt you (enemies still do), and you can walk through it. | | **Star** | star | Nothing hurts you for 8 turns (a gold ring; the top bar counts down); you can walk through flame meanwhile. | | **Heart** | heart | One more life. | | **Coin bag** | coin | 10 coins. | -Run, Kick, Throw, Teleport, Diagonal, Slide and RPG add no buttons — they +Run, Kick, Throw, Diagonal, Slide and RPG add no buttons — they make Move and Bomb do more (**?** then the button says how). The Detonator adds none either: your bombs pulse, and a tap on one sets it off. The top bar shows a small icon for each ability and pass you hold. @@ -334,8 +331,8 @@ and pass you hold. After every **3rd level** (3, 6, 9, 12, 15 and 18), going on from the level-cleared banner opens **the shop** before the next level. It has **4 items**: power-ups (none you already hold, and no Coin bag) and charges, drawn with the gem's rarity weights, each with its **price** — common ones -cheap (Bomb +1 and Fire +1 cost 4, most abilities and passes 6), rare ones dear (Teleport and -Proximity alert 10, Heart 12), charges 3–6. Three levels earn about 12 coins, or about 27 when +cheap (Bomb +1 and Fire +1 cost 4, most abilities and passes 6), rare ones dear (Proximity alert +10, Heart 12), charges 3–7. Three levels earn about 12 coins, or about 27 when each is cleared within par, so the first shop buys two cheap things, or three or four. Tap an item to select it (a clip above shows it in action), then its **Buy** button (or, when it's too dear, how many coins you still need); what you buy works at once and lasts for the rest of the run. **Restock** draws 4 new items for 1 coin, @@ -349,8 +346,10 @@ runs: leftover coins only count on the high-score board. You have **3 lives**. Caught in a blast or touched by an enemy, 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) and the charge you carry. (The original took -the passes and the Detonator away; since 2026-10-23 a death costs only the life.) The banner says how and where you died — "Level 3, turn 12: you walked into a Blob." — and so +power-up (abilities and passes included), a raised Shield or Flame Pass. **The charge you carry +drops where you fell** (since 2026-10-25), with what was left of it: walk back over it on the +retry to pick it up again, before a blast burns it. (The original took the passes and the +Detonator away; since 2026-10-23 a death otherwise costs only the life.) The banner says how and where you died — "Level 3, turn 12: you walked into a Blob." — and so does the game-over banner. Both replay **your own last turns** before the death, over and over, on the part of the level where it happened, so you can see what went wrong. With none left, the run is over and the seed is shown, so the same board can be replayed. @@ -408,8 +407,9 @@ The demo and the clips in the dialogs are silent. On an iPhone, the silent switc ### The board -**Boards grow with the level:** 9 × 11 on levels 1–3, 11 × 13 from level 4, 11 × 15 from 7, -13 × 17 from 10, 13 × 19 from 13 and 15 × 21 from 16. **You always see 9 × 13 tiles**, on a phone +**Boards grow with the level:** 9 × 11 on levels 1–3, 11 × 13 from level 4, 11 × 15 from 8, +13 × 15 from 12 and 13 × 17 from 16 (fitted with the balance lab, so a late level takes about as +long as an early one, not two or three times as long). **You always see 9 × 13 tiles**, on a phone and on a desktop alike (bars cover the rest of a wide or tall screen), so no screen sees more of the board than another. A bigger board **scrolls with you**, smoothly, and stops at its edges. **Drag the board** (with nothing picked) to look around it; letting go does nothing, and your next turn diff --git a/decisions.md b/decisions.md index 5df70a6..8e8dc42 100644 --- a/decisions.md +++ b/decisions.md @@ -2068,3 +2068,26 @@ User report: with Diagonal, auto-move walked round corners it could cut. The wal now takes diagonal steps too when the player has Diagonal — squeezes included, as the action allows — and a diagonal first step is taken as the Diagonal action. Among equally short ways the search keeps its order (straight steps first), so the number of moves is what is saved. + +### 2026-10-25 — Flame Pass and Teleport become charges; a death drops the charge; boards fitted + +The user's order: stages 1–3 of the plan. + +1. **Flame Pass and Teleport are charges.** Flame Pass is raised with the charge button and a tap + on yourself, like the Shield, and then takes the next **3 fire hits** — a blast, a chain or + lingering flame each use one; every flame in that turn counts as that one hit — whatever the + level, until used. While raised the player may also walk into flame (each turn there uses a + hit). Teleport gives **3 jumps** from the charge button, with its reach as before (Run 4, + Diagonal a square). It left the Move group, the meaning of a tap (a far tile only Teleport + reached was a teleport) and auto-move, so a jump is only ever spent on purpose. Both power-ups + are gone from the gem's offers and the shop's power-ups; the shop sells the charges for 7. +2. **The boards, fitted with the lab** (40 seeds, careful bot). Up to 15 × 21, late levels took + 100–145 turns; capping at 13 × 17 (and stepping later) brings every level to about 75–95, in + the 80–120 aimed at. A sparser board (concentration 1/11 at level 20) made the bot hunt its last + enemy for long (stalls 8%); 1/9 keeps it at 3% and wins at 93%. The average bot still dies + early (about 5 levels), as before: the careful bot is no measure of difficulty for people. +3. **A death drops the charge carried** where the player fell, with what was left; the retry has + it lying there (or on the nearest open floor, if the player fell inside a crate with Wall Pass). + A raised Shield or Flame Pass is kept. A pending choice now carries how much is left too. + +`SIM_VERSION` 35. The lab now also measures each charge carried from level 1. diff --git a/docs/balance/powers-average.md b/docs/balance/powers-average.md index b9927be..edbff1e 100644 --- a/docs/balance/powers-average.md +++ b/docs/balance/powers-average.md @@ -1,6 +1,6 @@ # Balance lab: powers (average bot) -2026-10-25 · rules version 29 · 200 seeds per arm · 179 s. +2026-10-25 · rules version 35 · 150 seeds per arm · 196 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,48 +8,52 @@ 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** | 5.42 | 2% | 2.98 | 60.3 | 3.4% | 0% | -| teleport | +14.52 ± 0.54 ✱ | +97 ± 2 ✱% | −2.90 ± 0.06 ✱ | −23.8 ± 2.4 ✱ | −1.0 ± 0.2 ✱ | 0% | -| flamePass | +14.32 ± 0.59 ✱ | +96 ± 3 ✱% | −2.65 ± 0.09 ✱ | −12.0 ± 2.5 ✱ | +7.1 ± 0.4 ✱ | 0% | -| diagonal | +8.27 ± 0.82 ✱ | +23 ± 6 ✱% | −0.44 ± 0.12 ✱ | −15.1 ± 2.4 ✱ | −0.2 ± 0.2 ✱ | 0% | -| kick | +3.42 ± 0.77 ✱ | +7 ± 4 ✱% | −0.18 ± 0.10 ✱ | −4.7 ± 2.8 ✱ | +0.3 ± 0.2 ✱ | 0% | -| run | +2.08 ± 0.83 ✱ | +9 ± 4 ✱% | −0.27 ± 0.12 ✱ | −10.8 ± 2.7 ✱ | −0.3 ± 0.2 ✱ | 0% | -| detonator | +1.89 ± 0.76 ✱ | +2 ± 3% | −0.07 ± 0.07 ✱ | −7.8 ± 3.1 ✱ | −0.4 ± 0.2 ✱ | 0% | -| wallPass | +1.08 ± 0.74 ✱ | +5 ± 4 ✱% | −0.12 ± 0.08 ✱ | −2.8 ± 4.3 | +0.4 ± 0.2 ✱ | 1% | -| proximity | +0.70 ± 0.59 ✱ | +2 ± 3% | −0.07 ± 0.07 ✱ | −13.6 ± 2.5 ✱ | +0.8 ± 0.2 ✱ | 0% | -| heart | +0.61 ± 0.25 ✱ | +2 ± 2 ✱% | +0.96 ± 0.03 ✱ | +1.6 ± 1.0 ✱ | +0.3 ± 0.1 ✱ | 0% | -| rpg | +0.49 ± 0.59 | −1 ± 2% | −0.01 ± 0.04 | −15.1 ± 2.6 ✱ | +0.3 ± 0.3 ✱ | 0% | -| star | +0.38 ± 0.44 | +3 ± 2 ✱% | −0.07 ± 0.05 ✱ | +1.7 ± 3.7 | +0.0 ± 0.1 | 1% | -| throw | +0.05 ± 0.65 | +4 ± 4% | −0.09 ± 0.07 ✱ | −9.6 ± 2.8 ✱ | +0.5 ± 0.3 ✱ | 0% | -| bombPlus | −0.34 ± 0.70 | +2 ± 3% | −0.07 ± 0.06 ✱ | −4.0 ± 3.1 ✱ | +1.5 ± 0.3 ✱ | 1% | -| bombPass | −0.45 ± 0.60 | +2 ± 3% | −0.06 ± 0.06 ✱ | −2.5 ± 2.8 | +5.7 ± 0.3 ✱ | 0% | -| slide | −1.06 ± 0.60 ✱ | +1 ± 3% | −0.03 ± 0.05 | −3.5 ± 2.8 ✱ | −0.2 ± 0.2 | 0% | -| fusePlus | −1.64 ± 0.57 ✱ | −1 ± 2% | −0.04 ± 0.05 | +23.2 ± 5.5 ✱ | −0.8 ± 0.2 ✱ | 1% | -| firePlus | −2.69 ± 0.59 ✱ | −1 ± 1% | +0.01 ± 0.01 | −6.7 ± 3.4 ✱ | +1.2 ± 0.3 ✱ | 0% | +| **baseline** | 4.20 | 0% | 2.99 | 76.1 | 3.5% | 1% | +| diagonal | +8.03 ± 0.84 ✱ | +10 ± 5 ✱% | −0.17 ± 0.10 ✱ | −9.4 ± 4.2 ✱ | −0.2 ± 0.2 | 0% | +| kick | +2.56 ± 0.63 ✱ | +1 ± 2% | −0.05 ± 0.07 | −3.4 ± 4.2 | +0.7 ± 0.3 ✱ | 1% | +| detonator | +1.16 ± 0.57 ✱ | +1 ± 1% | −0.03 ± 0.06 | −6.4 ± 5.4 ✱ | −0.2 ± 0.3 | 1% | +| run | +1.11 ± 0.53 ✱ | +2 ± 2% | −0.03 ± 0.05 | −9.4 ± 4.5 ✱ | −0.3 ± 0.3 ✱ | 1% | +| wallPass | +0.97 ± 0.56 ✱ | 0 ± 0% | +0.01 ± 0.03 | −8.1 ± 4.4 ✱ | +0.5 ± 0.2 ✱ | 0% | +| rpg | +0.79 ± 0.49 ✱ | 0 ± 0% | +0.01 ± 0.03 | −21.1 ± 4.0 ✱ | +0.3 ± 0.3 | 0% | +| heart | +0.47 ± 0.20 ✱ | 0 ± 0% | +0.99 ± 0.01 ✱ | +4.5 ± 1.2 ✱ | +0.3 ± 0.1 ✱ | 1% | +| shield (charge) | +0.35 ± 0.49 | +1 ± 1% | −0.01 ± 0.05 | −0.3 ± 4.0 | +0.2 ± 0.3 | 0% | +| flamePass (charge) | +0.31 ± 0.50 | 0 ± 0% | −0.02 ± 0.05 | +2.7 ± 4.5 | +0.1 ± 0.2 | 1% | +| proximity | +0.25 ± 0.44 | 0 ± 0% | −0.06 ± 0.08 | −12.8 ± 5.0 ✱ | +0.7 ± 0.3 ✱ | 3% | +| pierce (charge) | +0.23 ± 0.45 | 0 ± 0% | −0.03 ± 0.06 | −0.1 ± 4.7 | −0.1 ± 0.3 | 2% | +| teleport (charge) | +0.20 ± 0.54 | +3 ± 3 ✱% | −0.12 ± 0.10 ✱ | +10.7 ± 10.6 ✱ | −0.1 ± 0.2 | 3% | +| icbm (charge) | +0.13 ± 0.45 | +1 ± 1% | +0.01 ± 0.03 | −5.7 ± 4.2 ✱ | +0.2 ± 0.3 | 0% | +| decoy (charge) | +0.13 ± 0.47 | +1 ± 1% | −0.01 ± 0.05 | −2.3 ± 3.8 | +0.0 ± 0.2 | 1% | +| freeze (charge) | −0.02 ± 0.48 | +1 ± 1% | −0.05 ± 0.07 | +0.2 ± 4.8 | −0.1 ± 0.2 | 3% | +| star | −0.03 ± 0.19 | 0 ± 0% | +0.01 ± 0.03 | +1.2 ± 2.2 | −0.0 ± 0.1 | 0% | +| nuke (charge) | −0.16 ± 0.45 | +1 ± 1% | +0.01 ± 0.03 | −8.0 ± 4.6 ✱ | +0.8 ± 0.6 ✱ | 0% | +| throw | −0.29 ± 0.53 | 0 ± 0% | +0.01 ± 0.03 | −16.5 ± 4.4 ✱ | +0.7 ± 0.4 ✱ | 0% | +| bombPlus | −0.53 ± 0.43 ✱ | +1 ± 1% | 0.00 ± 0.04 | −6.9 ± 4.3 ✱ | +1.7 ± 0.3 ✱ | 0% | +| power (charge) | −0.72 ± 0.46 ✱ | 0 ± 0% | −0.01 ± 0.05 | −7.8 ± 5.2 ✱ | +1.2 ± 0.7 ✱ | 1% | +| bombPass | −0.82 ± 0.51 ✱ | 0 ± 0% | +0.01 ± 0.03 | −7.3 ± 4.8 ✱ | +6.1 ± 0.3 ✱ | 0% | +| slide | −1.23 ± 0.49 ✱ | 0 ± 0% | +0.01 ± 0.03 | −7.0 ± 4.8 ✱ | −0.3 ± 0.3 ✱ | 0% | +| fusePlus | −1.25 ± 0.49 ✱ | +1 ± 1% | −0.05 ± 0.07 | +21.8 ± 7.0 ✱ | −0.8 ± 0.3 ✱ | 3% | +| firePlus | −1.78 ± 0.55 ✱ | 0 ± 0% | 0.00 ± 0.04 | −17.3 ± 5.1 ✱ | +1.3 ± 0.3 ✱ | 1% | +| tnt | −2.25 ± 0.50 ✱ | +1 ± 1% | +0.01 ± 0.03 | −25.9 ± 4.5 ✱ | +2.7 ± 0.4 ✱ | 0% | ## The baseline, level by level | Level | Runs reaching it | Lives lost there | Turns there | Within par | |---|---|---|---|---| -| 1 | 100% | 0.20 | 73 | 14% | -| 2 | 97% | 0.26 | 70 | 25% | -| 3 | 93% | 0.44 | 58 | 30% | -| 4 | 81% | 0.66 | 52 | 30% | -| 5 | 62% | 0.65 | 56 | 36% | -| 6 | 48% | 0.56 | 57 | 40% | -| 7 | 39% | 0.56 | 58 | 37% | -| 8 | 31% | 0.80 | 59 | 36% | -| 9 | 23% | 0.67 | 62 | 43% | -| 10 | 18% | 0.69 | 44 | 46% | -| 11 | 12% | 0.50 | 47 | 46% | -| 12 | 8% | 0.44 | 48 | 56% | -| 13 | 7% | 0.54 | 45 | 77% | -| 14 | 5% | 0.30 | 45 | 80% | -| 15 | 5% | 0.22 | 46 | 67% | -| 16 | 4% | 0.00 | 42 | 88% | -| 17 | 4% | 0.25 | 32 | 63% | -| 18 | 3% | 0.33 | 27 | 67% | -| 19 | 2% | 0.50 | 42 | 75% | -| 20 | 2% | 0.33 | 46 | 100% | +| 1 | 100% | 0.20 | 75 | 13% | +| 2 | 97% | 0.40 | 77 | 25% | +| 3 | 89% | 0.54 | 68 | 27% | +| 4 | 75% | 0.94 | 88 | 22% | +| 5 | 49% | 0.84 | 85 | 18% | +| 6 | 32% | 0.60 | 75 | 31% | +| 7 | 25% | 0.54 | 106 | 19% | +| 8 | 19% | 0.86 | 101 | 21% | +| 9 | 14% | 0.67 | 73 | 43% | +| 10 | 10% | 1.47 | 71 | 40% | +| 11 | 4% | 1.17 | 31 | 33% | +| 12 | 1% | 0.00 | 105 | 50% | +| 13 | 1% | 0.00 | 59 | 100% | +| 14 | 1% | 0.50 | 82 | 50% | +| 15 | 1% | 0.00 | 74 | 100% | +| 16 | 1% | 2.00 | 12 | 0% | -Lives lost in the baseline, by cause: blast 516, caughtByEnemy 17, walkedIntoEnemy 46, chain 14, lingering 4. +Lives lost in the baseline, by cause: blast 389, chain 5, lingering 9, walkedIntoEnemy 33, caughtByEnemy 12. diff --git a/docs/balance/powers-careful.md b/docs/balance/powers-careful.md index c3ab824..59bd159 100644 --- a/docs/balance/powers-careful.md +++ b/docs/balance/powers-careful.md @@ -1,6 +1,6 @@ # Balance lab: powers (careful bot) -2026-10-25 · rules version 29 · 200 seeds per arm · 622 s. +2026-10-25 · rules version 35 · 150 seeds per arm · 1839 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,48 +8,56 @@ 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** | 18.82 | 85% | 0.59 | 52.6 | 4.3% | 1% | -| teleport | +1.18 ± 0.50 ✱ | +16 ± 5 ✱% | −0.59 ± 0.15 ✱ | −13.8 ± 1.3 ✱ | −2.3 ± 0.2 ✱ | 0% | -| flamePass | +1.07 ± 0.46 ✱ | +15 ± 5 ✱% | −0.57 ± 0.15 ✱ | −7.1 ± 1.4 ✱ | +5.7 ± 0.3 ✱ | 0% | -| diagonal | +1.06 ± 0.51 ✱ | +13 ± 5 ✱% | −0.47 ± 0.16 ✱ | −7.2 ± 1.3 ✱ | −1.9 ± 0.2 ✱ | 0% | -| run | +0.95 ± 0.53 ✱ | +11 ± 6 ✱% | −0.36 ± 0.18 ✱ | −7.9 ± 1.5 ✱ | −0.5 ± 0.2 ✱ | 0% | -| wallPass | +0.85 ± 0.56 ✱ | +12 ± 6 ✱% | −0.44 ± 0.16 ✱ | −4.2 ± 1.5 ✱ | +0.5 ± 0.2 ✱ | 1% | -| bombPass | +0.78 ± 0.52 ✱ | +11 ± 5 ✱% | −0.33 ± 0.15 ✱ | −0.1 ± 1.5 | +5.2 ± 0.3 ✱ | 0% | -| throw | +0.73 ± 0.53 ✱ | +11 ± 6 ✱% | −0.32 ± 0.16 ✱ | −8.9 ± 1.4 ✱ | +0.6 ± 0.2 ✱ | 0% | -| rpg | +0.69 ± 0.52 ✱ | +10 ± 5 ✱% | −0.34 ± 0.17 ✱ | −11.4 ± 1.4 ✱ | +0.7 ± 0.2 ✱ | 0% | -| slide | +0.69 ± 0.54 ✱ | +7 ± 6 ✱% | −0.21 ± 0.18 ✱ | −5.8 ± 1.4 ✱ | −0.6 ± 0.2 ✱ | 1% | -| detonator | +0.64 ± 0.54 ✱ | +5 ± 6% | −0.10 ± 0.18 | −5.3 ± 1.5 ✱ | −0.7 ± 0.2 ✱ | 0% | -| firePlus | +0.54 ± 0.51 ✱ | +4 ± 6% | −0.04 ± 0.18 | −6.2 ± 1.3 ✱ | +0.9 ± 0.2 ✱ | 1% | -| kick | +0.41 ± 0.59 | +4 ± 6% | −0.11 ± 0.19 | −4.0 ± 1.5 ✱ | +0.2 ± 0.2 ✱ | 0% | -| bombPlus | +0.21 ± 0.47 | 0 ± 6% | +0.03 ± 0.17 | −0.1 ± 1.4 | +0.4 ± 0.2 ✱ | 0% | -| star | +0.18 ± 0.21 | +2 ± 2% | −0.04 ± 0.08 | −0.2 ± 0.9 | +0.1 ± 0.1 | 0% | -| heart | +0.18 ± 0.21 | +2 ± 2% | +0.14 ± 0.05 ✱ | +0.3 ± 0.3 | +0.0 ± 0.0 | 1% | -| fusePlus | +0.06 ± 0.67 | −2 ± 7% | +0.13 ± 0.20 | +11.1 ± 2.1 ✱ | −0.5 ± 0.2 ✱ | 1% | -| proximity | −0.71 ± 0.65 ✱ | −11 ± 8 ✱% | +0.55 ± 0.22 ✱ | −7.1 ± 1.4 ✱ | +0.5 ± 0.2 ✱ | 0% | +| **baseline** | 19.08 | 91% | 0.37 | 84.0 | 5.1% | 3% | +| diagonal | +0.84 ± 0.54 ✱ | +7 ± 5 ✱% | −0.21 ± 0.14 ✱ | −13.0 ± 2.6 ✱ | −2.3 ± 0.2 ✱ | 1% | +| throw | +0.78 ± 0.57 ✱ | +6 ± 5 ✱% | −0.23 ± 0.14 ✱ | −15.1 ± 2.3 ✱ | +0.9 ± 0.3 ✱ | 0% | +| rpg | +0.51 ± 0.59 | +3 ± 6% | −0.23 ± 0.14 ✱ | −19.7 ± 2.6 ✱ | +0.9 ± 0.3 ✱ | 3% | +| wallPass | +0.47 ± 0.65 | +5 ± 5 ✱% | −0.29 ± 0.14 ✱ | −7.8 ± 2.8 ✱ | +0.4 ± 0.3 ✱ | 2% | +| freeze (charge) | +0.43 ± 0.59 | +2 ± 5% | −0.07 ± 0.14 | −1.7 ± 2.3 | +0.3 ± 0.2 ✱ | 1% | +| slide | +0.15 ± 0.74 | +1 ± 7% | −0.05 ± 0.16 | −11.1 ± 2.8 ✱ | −0.7 ± 0.3 ✱ | 5% | +| run | +0.13 ± 0.71 | +2 ± 6% | −0.27 ± 0.13 ✱ | −8.8 ± 3.0 ✱ | −0.4 ± 0.3 ✱ | 5% | +| kick | +0.15 ± 0.56 | +1 ± 6% | −0.04 ± 0.16 | −8.5 ± 2.5 ✱ | +0.1 ± 0.2 | 2% | +| fusePlus | +0.14 ± 0.64 | +1 ± 6% | −0.03 ± 0.17 | +8.3 ± 2.6 ✱ | −0.6 ± 0.2 ✱ | 1% | +| nuke (charge) | +0.13 ± 0.67 | 0 ± 6% | −0.02 ± 0.16 | −0.9 ± 2.4 | +0.0 ± 0.3 | 4% | +| icbm (charge) | +0.10 ± 0.66 | 0 ± 6% | −0.03 ± 0.15 | +0.3 ± 3.0 | −0.1 ± 0.2 | 3% | +| heart | +0.10 ± 0.14 | +1 ± 2% | +0.04 ± 0.03 ✱ | +0.2 ± 0.3 | +0.0 ± 0.0 | 3% | +| detonator | +0.07 ± 0.62 | −1 ± 6% | +0.03 ± 0.18 | −4.9 ± 2.7 ✱ | −0.7 ± 0.3 ✱ | 2% | +| star | +0.05 ± 0.37 | −1 ± 4% | +0.03 ± 0.09 | +0.6 ± 1.2 | −0.1 ± 0.1 | 3% | +| bombPass | 0.00 ± 0.61 | −1 ± 6% | −0.23 ± 0.15 ✱ | +0.0 ± 2.9 | +4.6 ± 0.3 ✱ | 7% | +| tnt | −0.01 ± 0.72 | −1 ± 6% | −0.04 ± 0.14 | −11.6 ± 2.7 ✱ | +0.6 ± 0.2 ✱ | 3% | +| flamePass (charge) | −0.03 ± 0.69 | −2 ± 6% | −0.09 ± 0.15 | +0.6 ± 2.6 | −0.0 ± 0.2 | 6% | +| decoy (charge) | −0.13 ± 0.70 | −3 ± 6% | −0.05 ± 0.13 | +0.1 ± 3.3 | +0.0 ± 0.3 | 6% | +| bombPlus | −0.13 ± 0.64 | −3 ± 6% | +0.03 ± 0.16 | −4.6 ± 2.4 ✱ | +0.2 ± 0.2 | 3% | +| pierce (charge) | −0.22 ± 0.74 | −4 ± 6% | +0.05 ± 0.14 | +0.3 ± 3.2 | +0.0 ± 0.2 | 5% | +| firePlus | −0.28 ± 0.80 | −1 ± 6% | −0.03 ± 0.16 | −7.6 ± 2.6 ✱ | +0.4 ± 0.3 ✱ | 4% | +| power (charge) | −0.35 ± 0.67 | −5 ± 6% | +0.01 ± 0.15 | −0.0 ± 2.7 | +0.2 ± 0.2 | 7% | +| shield (charge) | −0.45 ± 0.73 | −5 ± 7% | +0.06 ± 0.18 | +1.5 ± 2.8 | −0.1 ± 0.2 | 6% | +| teleport (charge) | −1.05 ± 1.05 ✱ | −5 ± 7% | +0.04 ± 0.17 | +23.4 ± 13.6 ✱ | −0.3 ± 0.3 | 9% | +| proximity | −1.26 ± 0.84 ✱ | −15 ± 7 ✱% | +0.57 ± 0.21 ✱ | −6.4 ± 2.8 ✱ | +0.3 ± 0.3 ✱ | 4% | ## The baseline, level by level | Level | Runs reaching it | Lives lost there | Turns there | Within par | |---|---|---|---|---| -| 1 | 100% | 0.02 | 67 | 19% | -| 2 | 100% | 0.02 | 57 | 33% | -| 3 | 99% | 0.00 | 56 | 31% | -| 4 | 99% | 0.02 | 48 | 42% | -| 5 | 99% | 0.02 | 57 | 46% | -| 6 | 98% | 0.05 | 54 | 52% | -| 7 | 97% | 0.00 | 54 | 52% | -| 8 | 97% | 0.02 | 55 | 49% | -| 9 | 97% | 0.04 | 58 | 55% | -| 10 | 96% | 0.00 | 53 | 70% | -| 11 | 95% | 0.04 | 51 | 65% | -| 12 | 94% | 0.01 | 52 | 69% | -| 13 | 94% | 0.03 | 53 | 76% | -| 14 | 94% | 0.03 | 49 | 82% | -| 15 | 93% | 0.05 | 50 | 81% | -| 16 | 92% | 0.04 | 47 | 87% | -| 17 | 91% | 0.02 | 47 | 85% | -| 18 | 91% | 0.06 | 45 | 87% | -| 19 | 89% | 0.10 | 47 | 84% | -| 20 | 87% | 0.08 | 44 | 84% | +| 1 | 100% | 0.01 | 69 | 17% | +| 2 | 100% | 0.01 | 71 | 23% | +| 3 | 100% | 0.00 | 65 | 30% | +| 4 | 100% | 0.00 | 83 | 33% | +| 5 | 100% | 0.05 | 86 | 35% | +| 6 | 99% | 0.06 | 79 | 47% | +| 7 | 97% | 0.01 | 88 | 40% | +| 8 | 96% | 0.00 | 93 | 50% | +| 9 | 96% | 0.01 | 92 | 51% | +| 10 | 96% | 0.02 | 82 | 61% | +| 11 | 95% | 0.00 | 86 | 66% | +| 12 | 95% | 0.03 | 95 | 64% | +| 13 | 94% | 0.03 | 87 | 78% | +| 14 | 94% | 0.00 | 84 | 80% | +| 15 | 93% | 0.04 | 80 | 85% | +| 16 | 93% | 0.04 | 95 | 84% | +| 17 | 93% | 0.01 | 92 | 86% | +| 18 | 93% | 0.01 | 86 | 90% | +| 19 | 93% | 0.04 | 81 | 94% | +| 20 | 91% | 0.00 | 72 | 97% | -Lives lost in the baseline, by cause: walkedIntoEnemy 67, blast 30, caughtByEnemy 21, chain 1. +Lives lost in the baseline, by cause: walkedIntoEnemy 27, blast 18, caughtByEnemy 9, lingering 1. diff --git a/docs/balance/structure.md b/docs/balance/structure.md index a5d458b..9565f55 100644 --- a/docs/balance/structure.md +++ b/docs/balance/structure.md @@ -1,6 +1,6 @@ # Balance lab: structure -2026-10-25 · rules version 29 · 1781 positions from 60 seeds · 21 s. +2026-10-25 · rules version 35 · 1800 positions from 60 seeds · 26 s. Positions from the Careful bot's games (every 10th turn), measured as they are and with each power-up added; the columns are the difference from the baseline, with a 95% interval (✱: real). @@ -12,21 +12,29 @@ power-up added; the columns are the difference from the baseline, with a 95% int | Arm | Escapable bombs | Reach in 3 turns | Safe actions | |---|---|---|---| -| **baseline** | 77% | 11.0 | 5.1 | -| teleport | +16 ± 2 ✱ | +27.9 ± 0.3 ✱ | +11.4 ± 0.2 ✱ | -| slide | +2 ± 1 ✱ | +10.0 ± 0.3 ✱ | +1.5 ± 0.1 ✱ | -| diagonal | +12 ± 2 ✱ | +7.6 ± 0.2 ✱ | +1.4 ± 0.1 ✱ | -| run | +2 ± 1 ✱ | +8.4 ± 0.3 ✱ | +1.4 ± 0.1 ✱ | -| star | +23 ± 2 ✱ | +1.7 ± 0.1 ✱ | +0.6 ± 0.0 ✱ | -| flamePass | +21 ± 2 ✱ | +1.2 ± 0.1 ✱ | +0.5 ± 0.0 ✱ | -| wallPass | +5 ± 1 ✱ | +1.7 ± 0.1 ✱ | +0.2 ± 0.0 ✱ | +| **baseline** | 80% | 10.2 | 4.9 | +| teleport (charge) | +13 ± 2 ✱ | +32.3 ± 0.4 ✱ | +11.1 ± 0.2 ✱ | +| slide | +1 ± 0 ✱ | +11.1 ± 0.3 ✱ | +1.6 ± 0.1 ✱ | +| run | +1 ± 0 ✱ | +10.1 ± 0.3 ✱ | +1.5 ± 0.1 ✱ | +| diagonal | +12 ± 2 ✱ | +8.4 ± 0.2 ✱ | +1.4 ± 0.1 ✱ | +| star | +20 ± 2 ✱ | +1.6 ± 0.1 ✱ | +0.5 ± 0.0 ✱ | +| wallPass | +5 ± 1 ✱ | +1.4 ± 0.1 ✱ | +0.1 ± 0.0 ✱ | | bombPass | +7 ± 1 ✱ | +0.6 ± 0.1 ✱ | +0.2 ± 0.0 ✱ | -| kick | +3 ± 1 ✱ | +0.0 ± 0.0 | +0.2 ± 0.0 ✱ | +| kick | +4 ± 1 ✱ | +0.0 ± 0.0 | +0.2 ± 0.0 ✱ | | fusePlus | +1 ± 0 ✱ | 0.0 ± 0.0 | +0.0 ± 0.0 ✱ | -| throw | 0 ± 0 | +0.0 ± 0.0 | +1.3 ± 0.1 ✱ | +| throw | 0 ± 0 | +0.0 ± 0.0 | +1.4 ± 0.1 ✱ | +| rpg | 0 ± 0 | +0.0 ± 0.0 | +1.3 ± 0.1 ✱ | | detonator | 0 ± 0 | +0.0 ± 0.0 | +0.3 ± 0.0 ✱ | -| bombPlus | 0 ± 0 | 0.0 ± 0.0 | +0.3 ± 0.0 ✱ | -| rpg | 0 ± 0 | 0.0 ± 0.0 | +1.3 ± 0.1 ✱ | +| bombPlus | 0 ± 0 | 0.0 ± 0.0 | +0.4 ± 0.0 ✱ | | heart | 0 ± 0 | 0.0 ± 0.0 | 0.0 ± 0.0 | -| proximity | −1 ± 0 ✱ | 0.0 ± 0.0 | −0.0 ± 0.0 ✱ | -| firePlus | −4 ± 1 ✱ | 0.0 ± 0.0 | −0.1 ± 0.0 ✱ | +| pierce (charge) | −0 ± 0 | −0.1 ± 0.1 | +1.4 ± 0.1 ✱ | +| power (charge) | −0 ± 0 | −0.1 ± 0.1 | +1.3 ± 0.1 ✱ | +| nuke (charge) | −0 ± 0 | −0.1 ± 0.1 | +1.3 ± 0.1 ✱ | +| shield (charge) | −0 ± 0 | −0.1 ± 0.1 | +0.8 ± 0.0 ✱ | +| freeze (charge) | −0 ± 0 | −0.1 ± 0.1 | +0.8 ± 0.0 ✱ | +| decoy (charge) | −0 ± 0 | −0.1 ± 0.1 | +4.7 ± 0.1 ✱ | +| icbm (charge) | −0 ± 0 | −0.1 ± 0.1 | +1.3 ± 0.1 ✱ | +| flamePass (charge) | −0 ± 0 | −0.1 ± 0.1 | +0.8 ± 0.0 ✱ | +| proximity | −2 ± 0 ✱ | +0.0 ± 0.0 | −0.0 ± 0.0 ✱ | +| firePlus | −3 ± 1 ✱ | 0.0 ± 0.0 | −0.0 ± 0.0 ✱ | +| tnt | −4 ± 1 ✱ | +0.0 ± 0.0 | −0.1 ± 0.0 ✱ | diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png index 45351ae..ddff9b7 100644 Binary files a/docs/screenshots/almanac-charges.png and b/docs/screenshots/almanac-charges.png differ diff --git a/docs/screenshots/almanac-enemies.png b/docs/screenshots/almanac-enemies.png index 14bb191..f1d98fd 100644 Binary files a/docs/screenshots/almanac-enemies.png and b/docs/screenshots/almanac-enemies.png differ diff --git a/docs/screenshots/almanac.png b/docs/screenshots/almanac.png index 5e9bcb4..ee8922f 100644 Binary files a/docs/screenshots/almanac.png and b/docs/screenshots/almanac.png differ diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png index 6f34fbf..a7825b8 100644 Binary files a/docs/screenshots/auto-move.png and b/docs/screenshots/auto-move.png differ diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png index 018e461..6c627c9 100644 Binary files a/docs/screenshots/bomb-picked.png and b/docs/screenshots/bomb-picked.png differ diff --git a/docs/screenshots/charges.png b/docs/screenshots/charges.png index d9a3320..f743733 100644 Binary files a/docs/screenshots/charges.png and b/docs/screenshots/charges.png differ diff --git a/docs/screenshots/countdown.png b/docs/screenshots/countdown.png index 3907931..f49cd68 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 a489246..a4a2a8b 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 1070046..fb92286 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 d3d7f06..928815e 100644 Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ diff --git a/docs/screenshots/dev-offer.png b/docs/screenshots/dev-offer.png index 304de90..7f5c2f3 100644 Binary files a/docs/screenshots/dev-offer.png and b/docs/screenshots/dev-offer.png differ diff --git a/docs/screenshots/dev.png b/docs/screenshots/dev.png index 71da21e..9a37ec1 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 d8829da..0fb7762 100644 Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ diff --git a/docs/screenshots/gem-offer.png b/docs/screenshots/gem-offer.png index f56e52a..e398cab 100644 Binary files a/docs/screenshots/gem-offer.png and b/docs/screenshots/gem-offer.png differ diff --git a/docs/screenshots/gem.png b/docs/screenshots/gem.png index 3b7288c..cb161cb 100644 Binary files a/docs/screenshots/gem.png and b/docs/screenshots/gem.png differ diff --git a/docs/screenshots/info-hud.png b/docs/screenshots/info-hud.png index 21422a7..25b68e7 100644 Binary files a/docs/screenshots/info-hud.png and b/docs/screenshots/info-hud.png differ diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png index 0073e71..ce39c0b 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 d4169ca..2184f30 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 1b84269..4d54205 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 e6c2ffc..e05b382 100644 Binary files a/docs/screenshots/move-picked.png and b/docs/screenshots/move-picked.png differ diff --git a/docs/screenshots/shop.png b/docs/screenshots/shop.png index e69affb..2759f9a 100644 Binary files a/docs/screenshots/shop.png and b/docs/screenshots/shop.png differ diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png index 4b4846d..12386de 100644 Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ diff --git a/src/main.ts b/src/main.ts index ac4c96f..ac37915 100644 --- a/src/main.ts +++ b/src/main.ts @@ -583,10 +583,10 @@ function pick(id: ActionId, pressed = selected === id): void { // Wait happens at once. Everything else is picked first, then aimed at a tile. const now = membersOf(id).find((a) => isAvailable(state, a) && !ACTIONS[a].needsTarget) if (now) return act({ id: now }) - // A Freeze or Shield picked, whose only tile is the player's own: pressing the button again + // A Freeze, Shield or Flame Pass picked, whose only tile is the player's own: pressing the button again // confirms it (as tapping yourself does), rather than putting it away. const self = pressed && selected ? resolvePick(state, selected, state.player) : null - if (self && (self === 'freeze' || self === 'shield')) return act({ id: self, target: state.player }) + if (self && (self === 'freeze' || self === 'shield' || self === 'flamePass')) return act({ id: self, target: state.player }) selected = pressed ? null : id afterChange() } @@ -868,9 +868,9 @@ const SCENE_ICONS: Record<SceneKind, string> = { kill: 'bomb', arrows: 'slime_normal_walk_a', escape: 'icon_move', chain: 'bomb', lingering: 'fireball', enemies: 'slime_normal_rest', coins: 'hud_coin', gem: 'gem_green', bombPlus: POWER_UP_ICONS.bombPlus, firePlus: POWER_UP_ICONS.firePlus, fusePlus: POWER_UP_ICONS.fusePlus, - run: POWER_UP_ICONS.run, kick: POWER_UP_ICONS.kick, throw: POWER_UP_ICONS.throw, teleport: POWER_UP_ICONS.teleport, + run: POWER_UP_ICONS.run, kick: POWER_UP_ICONS.kick, throw: POWER_UP_ICONS.throw, teleport: CHARGE_ICONS.teleport, diagonal: POWER_UP_ICONS.diagonal, detonate: POWER_UP_ICONS.detonator, wallPass: POWER_UP_ICONS.wallPass, - bombPass: POWER_UP_ICONS.bombPass, flamePass: POWER_UP_ICONS.flamePass, star: POWER_UP_ICONS.star, + bombPass: POWER_UP_ICONS.bombPass, flamePass: CHARGE_ICONS.flamePass, star: POWER_UP_ICONS.star, chargeSwap: CHARGE_ICONS.nuke, pierce: CHARGE_ICONS.pierce, power: CHARGE_ICONS.power, nuke: CHARGE_ICONS.nuke, shield: CHARGE_ICONS.shield, freeze: CHARGE_ICONS.freeze, decoy: CHARGE_ICONS.decoy, diff --git a/src/render/board.ts b/src/render/board.ts index 1d308f0..157a57d 100644 --- a/src/render/board.ts +++ b/src/render/board.ts @@ -98,6 +98,7 @@ const C = { ghost: rgb(1, 1, 1, 0.45), intent: rgb(0.85, 0.2, 0.2), shield: rgb(0.25, 0.62, 0.87, 0.9), + flameRing: rgb(1, 0.55, 0.15, 1), clear: rgb(0, 0, 0, 0), burning: rgb(1, 0.55, 0.1, 0.5), star: rgb(1, 0.85, 0.2, 0.9), @@ -268,6 +269,8 @@ export function drawBoard(view: View): void { // the player's tile. const at = playerPos(s, view.playerMove) if (s.shieldUp) drawCircle(at, 0.88 * appearScale(fx, key.shield, time), C.clear, 0.06, C.shield) + // A raised Flame Pass (2026-10-25): a flickering orange ring, just outside the Shield's. + if (s.flameHits > 0) drawCircle(at, 0.98, C.clear, 0.05, C.flameRing.scale(1, 0.7 + 0.25 * Math.sin(time * 9))) if (s.star > 0) drawCircle(at, 1.0 + 0.06 * Math.sin(time * 8), C.clear, 0.08, C.star) if (after && !eq(after.player, s.player)) { drawTile(toWorld(s, after.player).add(PLAYER_LIFT), tileSize, sprite('character_green_idle'), C.ghost, 0, view.facingLeft) diff --git a/src/render/sprites.ts b/src/render/sprites.ts index 0fe2cbb..0dacf0f 100644 --- a/src/render/sprites.ts +++ b/src/render/sprites.ts @@ -180,6 +180,9 @@ export const CHARGE_ICONS: Record<ChargeId, BoardSprite> = { freeze: 'charge_freeze', decoy: 'charge_decoy', icbm: 'charge_icbm', + // Power-ups until 2026-10-25, with the icons they had. + flamePass: 'powerup_flame_pass', + teleport: 'powerup_teleport', } /** @@ -231,14 +234,12 @@ export const POWER_UP_ICONS: Record<PowerUpKind, BoardSprite> = { run: 'powerup_run', wallPass: 'powerup_wall_pass', bombPass: 'powerup_bomb_pass', - flamePass: 'powerup_flame_pass', detonator: 'powerup_detonator', star: 'star', coinBag: 'hud_coin', heart: 'heart', kick: 'powerup_kick', throw: 'powerup_throw', - teleport: 'powerup_teleport', diagonal: 'powerup_diagonal', rpg: 'powerup_rpg', proximity: 'powerup_proximity', diff --git a/src/sim/actions.ts b/src/sim/actions.ts index fa690a5..05f84d4 100644 --- a/src/sim/actions.ts +++ b/src/sim/actions.ts @@ -1,4 +1,4 @@ -import { BOMB_CHARGES, holds, spendCharge } from './charges' +import { BOMB_CHARGES, FLAME_PASS_HITS, holds, spendCharge } from './charges' import { enemyAt } from './enemies' import { add, bombAt, cellAt, eq, exitOpen, isBurning, isFree, playerCanEnter } from './state' import { DIRS, DIR_LIST, type ActionId, type Bomb, type GameEvent, type GameState, type Powers, type Vec } from './types' @@ -240,11 +240,12 @@ export const ACTIONS: Record<ActionId, ActionDef> = { .filter((p) => playerCanEnter(s, p)), apply: (s, target, events) => walkTo(s, target!, events), }, + // A charge of 3 jumps since 2026-10-25 (a power-up until then), used from the charge button. teleport: { id: 'teleport', label: 'Teleport', needsTarget: true, - ability: 'teleport', + available: (s) => holds(s, 'teleport'), validTargets(s) { const range = teleportRange(s) const tiles: Vec[] = [] @@ -256,7 +257,10 @@ export const ACTIONS: Record<ActionId, ActionDef> = { } return tiles }, - apply: (s, target, events) => walkTo(s, target!, events), + apply(s, target, events) { + spendCharge(s) + walkTo(s, target!, events) + }, }, rpg: { id: 'rpg', @@ -348,6 +352,20 @@ export const ACTIONS: Record<ActionId, ActionDef> = { events.push({ type: 'shieldRaised' }) }, }, + // Flame Pass, a charge since 2026-10-25: raised like the Shield (a tap on yourself), it saves the + // player from FLAME_PASS_HITS fire hits. + flamePass: { + id: 'flamePass', + label: 'Flame Pass', + needsTarget: true, + available: (s) => holds(s, 'flamePass') && s.flameHits === 0, + validTargets: (s) => [s.player], + apply(s, _target, events) { + spendCharge(s) + s.flameHits = FLAME_PASS_HITS + events.push({ type: 'flamePassRaised', hits: FLAME_PASS_HITS }) + }, + }, slide: { id: 'slide', label: 'Slide', @@ -369,7 +387,7 @@ export function slidePassed(s: GameState, target: Vec): Vec[] { } export const ACTION_ORDER: readonly ActionId[] = [ - 'move', 'bomb', 'wait', 'run', 'detonate', 'kick', 'throw', 'diagonal', 'teleport', 'rpg', 'icbm', 'slide', 'charged', 'freeze', 'decoy', 'shield', + 'move', 'bomb', 'wait', 'run', 'detonate', 'kick', 'throw', 'diagonal', 'teleport', 'rpg', 'icbm', 'slide', 'charged', 'freeze', 'decoy', 'shield', 'flamePass', ] export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean { diff --git a/src/sim/charges.ts b/src/sim/charges.ts index f028c5f..4193d3a 100644 --- a/src/sim/charges.ts +++ b/src/sim/charges.ts @@ -14,8 +14,13 @@ export const BOMB_BOOST = 3 /** What one charge gives when picked up: bombs or launches, or a single use. */ export const CHARGE_AMOUNT: Record<ChargeId, number> = { pierce: BOMB_BOOST, power: BOMB_BOOST, nuke: BOMB_BOOST, icbm: BOMB_BOOST, shield: 1, freeze: 1, decoy: 1, + // Teleport: 3 jumps (user's choice, 2026-10-25); Flame Pass: one raise, worth FLAME_PASS_HITS. + teleport: 3, flamePass: 1, } +/** How many fire hits a raised Flame Pass saves the player from (user's choice, 2026-10-25). */ +export const FLAME_PASS_HITS = 3 + /** The charges used as a bomb from the charge button. */ export const BOMB_CHARGES: readonly ChargeId[] = ['pierce', 'power', 'nuke'] @@ -36,32 +41,32 @@ function sell(s: GameState, kind: ChargeId, left: number, events: GameEvent[]): * charge replaced the one held without asking). `swap`: the choice made, take it and sell the one * held. A second one coming in the same turn, while a choice is already waiting, is sold. */ -export function takeCharge(s: GameState, kind: ChargeId, events: GameEvent[], swap = false): void { +export function takeCharge(s: GameState, kind: ChargeId, events: GameEvent[], swap = false, left = CHARGE_AMOUNT[kind]): void { const held = s.charge - if (!held) s.charge = { kind, left: CHARGE_AMOUNT[kind] } - else if (held.kind === kind) held.left += CHARGE_AMOUNT[kind] + if (!held) s.charge = { kind, left } + else if (held.kind === kind) held.left += left else if (swap) { sell(s, held.kind, held.left, events) - s.charge = { kind, left: CHARGE_AMOUNT[kind] } - } else if (s.chargeOffer) sell(s, kind, CHARGE_AMOUNT[kind], events) + s.charge = { kind, left } + } else if (s.chargeOffer) sell(s, kind, left, events) else { - s.chargeOffer = kind + s.chargeOffer = { kind, left } events.push({ type: 'chargeOffered', kind, held: held.kind }) } } /** The choice: keep the charge held and sell the new one. */ export function sellOffered(s: GameState, events: GameEvent[]): void { - const kind = s.chargeOffer! + const offered = s.chargeOffer! s.chargeOffer = null - sell(s, kind, CHARGE_AMOUNT[kind], events) + sell(s, offered.kind, offered.left, events) } /** The choice: take the new charge; the one held is sold, for what is left of it. */ export function swapOffered(s: GameState, events: GameEvent[]): void { - const kind = s.chargeOffer! + const offered = s.chargeOffer! s.chargeOffer = null - takeCharge(s, kind, events, true) + takeCharge(s, offered.kind, events, true, offered.left) } /** One use of the charge held: the slot empties when none is left. */ diff --git a/src/sim/demo.ts b/src/sim/demo.ts index 2717b12..5148279 100644 --- a/src/sim/demo.ts +++ b/src/sim/demo.ts @@ -282,9 +282,9 @@ export const SCENES: Record<SceneKind, SceneDef> = { shows: (t, seen) => seen.used.has('throw') && kills(t).length > 0, }, teleport: { - caption: 'Teleport: press Move, then tap any empty tile up to 3 steps away, over anything.', + caption: 'Teleport: press the charge button, then tap any empty tile up to 3 steps away, over anything.', enemies: ['seeker', 'blob'], - prepare: (s) => (s.powers.teleport = true), + prepare: (s) => (s.charge = { kind: 'teleport', left: 3 }), shows: (t) => t.action.id === 'teleport' && escaped(t), }, diagonal: { @@ -338,9 +338,9 @@ export const SCENES: Record<SceneKind, SceneDef> = { shows: (t) => t.events.some((e) => e.type === 'moved' && t.prev.bombs.some((b) => at(b.pos, e.to))), }, flamePass: { - caption: 'Flame Pass: flame can’t hurt you (enemies still can).', + caption: 'Flame Pass: raise it with the charge button, and the next 3 fire hits can’t hurt you.', enemies: ['blob'], - prepare: (s) => (s.powers.flamePass = true), + prepare: (s) => (s.charge = { kind: 'flamePass', left: 1 }), shows: (t) => explosions(t).some((e) => e.tiles.some((p) => at(p, t.next.player))), }, star: { @@ -606,7 +606,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, shieldUp: false, frozen: 0, decoy: null, burning: [], + charge: null, chargeOffer: null, shieldUp: false, flameHits: 0, frozen: 0, decoy: null, burning: [], powers: noPowers(), star: 0, gems: [], offer: null, shop: null, death: null, runTurns: 0, runKills: 0, deaths: 0, crumbling: [], aiRng: rng.int(2 ** 31), dropRng: rng.int(2 ** 31), } @@ -789,7 +789,7 @@ function playOf(def: SceneDef, s: GameState, action: Action | null): Play | null /** Weights for a random action: mostly moving, bombing now and then, sometimes waiting; abilities when held. */ const WEIGHTS: Partial<Record<ActionId, number>> = { move: 5, bomb: 2, wait: 1, detonate: 3, run: 2, kick: 3, throw: 2, teleport: 2, diagonal: 2, rpg: 2, icbm: 3, slide: 3, - charged: 3, freeze: 2, decoy: 2, shield: 2, + charged: 3, freeze: 2, decoy: 2, shield: 2, flamePass: 3, } function randomAction(s: GameState, rng: Rng): Action { diff --git a/src/sim/levelgen.ts b/src/sim/levelgen.ts index 2e39876..d2504c3 100644 --- a/src/sim/levelgen.ts +++ b/src/sim/levelgen.ts @@ -5,15 +5,16 @@ import { DIR_LIST, type Cell, type Dir, type EnemyKind, type GemColor, type Powe * The board: a wall all round and a pillar at every even (x, y). It grows with the level (PLAN.md * §19.2; user request, 2026-10-24: later levels felt too small), past the fixed view (render/ * camera.ts), which then follows the player. Odd sizes, so the pillars meet the walls evenly. - * (One size, 9 × 11, from 2026-10-23 until then.) + * (One size, 9 × 11, from 2026-10-23 until then.) The sizes were fitted with the balance lab + * (2026-10-25): up to 15 × 21 at first, late levels took its careful bot 100–145 turns; up to + * 13 × 17 they take about 75–95, in the 80–120 aimed at. */ const BOARD_SIZES: readonly { from: number; width: number; height: number }[] = [ { from: 1, width: 9, height: 11 }, { from: 4, width: 11, height: 13 }, - { from: 7, width: 11, height: 15 }, - { from: 10, width: 13, height: 17 }, - { from: 13, width: 13, height: 19 }, - { from: 16, width: 15, height: 21 }, + { from: 8, width: 11, height: 15 }, + { from: 12, width: 13, height: 15 }, + { from: 16, width: 13, height: 17 }, ] /** The board's size on `level`: the last size whose first level it has reached. */ @@ -55,14 +56,14 @@ export const ENEMY_THREAT: Record<EnemyKind, number> = { /** * The level's crowding: threat (the enemies' ENEMY_THREAT added up) per open tile (floor and * crates, wherever an enemy could be), rising from 1 per 26 tiles on level 1 (2 Blobs on its 51 - * tiles, as before) to 1 per 11 on level 20, and on past it, more slowly, toward 1 per 8. To fit - * with the balance lab. + * tiles, as before) to 1 per 9 on level 20, and on past it, more slowly, toward 1 per 8. Fitted + * with the balance lab (2026-10-25): sparser late levels left long hunts for the last enemy. */ export function concentration(level: number): number { const first = 1 / 26 - const last = 1 / 11 + const last = 1 / 9 if (level <= LAST_LEVEL) return first + ((last - first) * (level - 1)) / (LAST_LEVEL - 1) - return Math.min(1 / 8, last + 0.002 * (level - LAST_LEVEL)) + return Math.min(1 / 8, last + 0.001 * (level - LAST_LEVEL)) } /** At least this many enemies on any level. */ diff --git a/src/sim/path.ts b/src/sim/path.ts index fa38908..deafc20 100644 --- a/src/sim/path.ts +++ b/src/sim/path.ts @@ -1,4 +1,4 @@ -import { ACTIONS, isAvailable, isValidTarget } from './actions' +import { isAvailable, isValidTarget } from './actions' import type { Danger } from './danger' import { enemyAt, moveTiles } from './enemies' import { add, eq, exitOpen, flameSafe, idx, playerCanPass } from './state' @@ -45,27 +45,20 @@ export function playerPath(s: GameState, to: Vec): Vec[] | null { /** * Auto-move's next action toward `to`, also the first one when a far tile is tapped (user * request, 2026-10-23): a tile next to the player is a step; otherwise whichever of a step along - * the way, a Run (2 straight along it) or a Teleport (anywhere it reaches) leaves the player - * closest to `to`, counted as the walk still left. On a tie, Teleport before Run before a step, - * so 2 tiles away is one teleport with Teleport, one run with Run, two steps without. Then it is - * worked out again next turn. Only tiles `safe` allows are landed on, and never the open exit + * the way or a Run (2 straight along it) leaves the player closest to `to`, counted as the walk + * still left; on a tie, a Run. (Teleport took part until it became a charge, 2026-10-25.) Then it + * is worked out again next turn. Only tiles `safe` allows are landed on, and never the open exit * unless it is `to`. Null if nothing gets closer. */ export function autoMoveAction(s: GameState, to: Vec, safe: (p: Vec) => boolean): Action | null { const path = playerPath(s, to) - const walkLeft = (from: Vec) => (eq(from, to) ? 0 : (playerPath({ ...s, player: from }, to)?.length ?? Infinity)) // The first step along it: a plain step, or a diagonal one (with Diagonal). const first = path?.[0] const stepId = first && Math.abs(first.x - s.player.x) + Math.abs(first.y - s.player.y) === 2 ? 'diagonal' : 'move' const step: Action | null = first && safe(first) && isValidTarget(s, stepId, first) ? { id: stepId, target: first } : null if (path?.length === 1) return step + // (Teleport was an option too until 2026-10-25; as a charge it isn't auto-move's to spend.) const options: { action: Action; left: number }[] = [] - if (isAvailable(s, 'teleport')) { - for (const t of ACTIONS.teleport.validTargets(s)) { - if (!safe(t) || (exitOpen(s) && eq(t, s.exit) && !eq(t, to))) continue - options.push({ action: { id: 'teleport', target: t }, left: walkLeft(t) }) - } - } const [next, after] = [path?.[0], path?.[1]] const straight = next && after && after.x - next.x === next.x - s.player.x && after.y - next.y === next.y - s.player.y if (straight && isAvailable(s, 'run') && isValidTarget(s, 'run', after) && safe(next) && safe(after)) { diff --git a/src/sim/picks.ts b/src/sim/picks.ts index ba4ccd3..ff9a9dc 100644 --- a/src/sim/picks.ts +++ b/src/sim/picks.ts @@ -18,9 +18,9 @@ import type { ActionId, GameState, Vec } from './types' * whichever use the charge held has: a charged bomb, an ICBM launch, a Freeze or a Decoy. */ const GROUPS: Partial<Record<ActionId, readonly ActionId[]>> = { - move: ['kick', 'move', 'run', 'diagonal', 'teleport', 'slide'], + move: ['kick', 'move', 'run', 'diagonal', 'slide'], bomb: ['bomb', 'throw', 'rpg', 'detonate'], - charged: ['charged', 'icbm', 'freeze', 'decoy', 'shield'], + charged: ['charged', 'icbm', 'freeze', 'decoy', 'shield', 'flamePass', 'teleport'], } /** The actions a picked one stands for: its group, or just itself. */ @@ -69,7 +69,7 @@ export function tapMeaning(s: GameState, p: Vec): TapMeaning | null { if (isAvailable(s, 'diagonal') && isValidTarget(s, 'diagonal', p)) return 'diagonal' if (isAvailable(s, 'slide') && isValidTarget(s, 'slide', p)) return 'slide' if (cellAt(s, p) === 'floor' && playerPath(s, p)) return 'walk' - // Out of walking reach (behind a pillar, say) but within Teleport's: one teleport there. - if (isAvailable(s, 'teleport') && isValidTarget(s, 'teleport', p)) return 'teleport' + // (A tile only Teleport could reach was a teleport there until 2026-10-25; Teleport is a charge + // now, used only from the charge button, so a tap never spends it.) return null } diff --git a/src/sim/record.ts b/src/sim/record.ts index caebaad..9467df0 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 = 34 +export const SIM_VERSION = 35 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */ export interface RunRecord { diff --git a/src/sim/shop.ts b/src/sim/shop.ts index 2f8ee5e..80c1eb2 100644 --- a/src/sim/shop.ts +++ b/src/sim/shop.ts @@ -21,8 +21,10 @@ export const SHOP_SIZE = 4 */ export const PRICES: Record<OfferKind, number> = { bombPlus: 4, firePlus: 4, tnt: 7, fusePlus: 5, run: 6, kick: 6, throw: 6, diagonal: 6, slide: 8, rpg: 8, detonator: 8, - wallPass: 6, bombPass: 6, flamePass: 9, star: 6, heart: 12, coinBag: 0, teleport: 10, proximity: 10, + wallPass: 6, bombPass: 6, star: 6, heart: 12, coinBag: 0, proximity: 10, pierce: 4, power: 4, nuke: 6, shield: 5, freeze: 4, decoy: 3, icbm: 6, + // Charges since 2026-10-25 (power-ups at 9 and 10 until then). + flamePass: 7, teleport: 7, } /** How often each charge comes up in the shop: as often as an uncommon power-up. */ diff --git a/src/sim/state.ts b/src/sim/state.ts index f0ef6f7..581ee07 100644 --- a/src/sim/state.ts +++ b/src/sim/state.ts @@ -22,7 +22,13 @@ export interface Carry { * next level (no death can happen while it is up: it absorbs every hit of the turn it breaks in). */ charge: HeldCharge | null + /** + * A death drops the charge carried where the player fell (user's choice, 2026-10-25): the retry + * has it lying there, to pick up again. + */ + fallen?: { pos: Vec; charge: HeldCharge } | null shieldUp: boolean + flameHits: number powers: Powers star: number runTurns: number @@ -32,7 +38,7 @@ export interface Carry { export function noPowers(): Powers { return { - run: false, kick: false, throw: false, teleport: false, diagonal: false, detonate: false, wallPass: false, bombPass: false, flamePass: false, + run: false, kick: false, throw: false, diagonal: false, detonate: false, wallPass: false, bombPass: false, rpg: false, proximity: false, slide: false, } } @@ -43,7 +49,7 @@ export function noPowers(): Powers { */ export function newRun(seed: number, level = 1, charge: ChargeId | null = null, options: RunOptions = DEFAULT_OPTIONS): GameState { return createLevel(seed, level, options, { - lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charge: charge && { kind: charge, left: CHARGE_AMOUNT[charge] }, shieldUp: false, + lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charge: charge && { kind: charge, left: CHARGE_AMOUNT[charge] }, shieldUp: false, flameHits: 0, powers: noPowers(), star: 0, runTurns: 0, runKills: 0, deaths: 0, }) } @@ -60,7 +66,7 @@ export function createLevel(seed: number, level: number, options: RunOptions, ca bombs: [], status: 'playing', 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, shieldUp: carry.shieldUp, frozen: 0, decoy: null, burning: [], + drops: [], exit, par, shieldUp: carry.shieldUp, flameHits: carry.flameHits, 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: [], aiRng: hashSeed(seed, 'ai', level), dropRng: hashSeed(seed, 'drops', level), } @@ -70,23 +76,52 @@ export function createLevel(seed: number, level: number, options: RunOptions, ca // as usual (user request: it used to be that gem, moved). It is a rainbow gem, so it can't be // taken for the level's own (user request, 2026-10-24). if (options.dev) s.gems.push({ pos: { x: start.x + 1, y: start.y }, color: 'rainbow', extra: true }) + // The charge dropped by a death, where the player fell (or the nearest open floor to it). + if (carry.fallen) s.drops.push({ pos: nearestFloor(s, carry.fallen.pos), kind: carry.fallen.charge.kind, left: carry.fallen.charge.left }) planIntents(s) return s } /** - * What the player takes on, to the next level or the retry after a death: everything. Since + * What the player takes on, to the next level or the retry after a death: everything — except, + * since 2026-10-25 (user's choice), a death drops the charge carried where the player fell, for the + * retry to pick up again. Since * 2026-10-23 (user request) a death costs no power-ups; the NES rule lost the passes, Detonator * and Star. (A Star can't be running at a death: nothing hurts the player while it lasts.) There * is no Keep / Fresh choice (dropped 2026-10-24, PLAN.md §9): a retry is the same try again. */ export function carryOf(s: GameState): Carry { return { - lives: s.lives, stats: s.stats, coins: s.coins, charge: s.charge, shieldUp: s.shieldUp, powers: s.powers, star: s.star, + lives: s.lives, stats: s.stats, coins: s.coins, powers: s.powers, star: s.star, shieldUp: s.shieldUp, flameHits: s.flameHits, + // A death leaves the charge where the player fell; otherwise it goes on with them. + charge: s.status === 'dead' ? null : s.charge, + fallen: s.status === 'dead' && s.charge ? { pos: { ...s.player }, charge: { ...s.charge } } : null, runTurns: s.runTurns, runKills: s.runKills, deaths: s.deaths, } } +/** + * `p` if it is open floor (no wall or crate), else the nearest + * open floor to it, by steps over the board; a fallen charge can't lie inside a crate (with Wall + * Pass the player can die in one). + */ +function nearestFloor(s: GameState, p: Vec): Vec { + const seen = new Set([idx(s, p)]) + const queue = [p] + for (let i = 0; i < queue.length; i++) { + const q = queue[i]! + if (cellAt(s, q) === 'floor') return q + for (const d of [{ x: 1, y: 0 }, { x: -1, y: 0 }, { x: 0, y: 1 }, { x: 0, y: -1 }]) { + const n = add(q, d) + if (inBounds(s, n) && !seen.has(idx(s, n)) && cellAt(s, n) !== 'wall') { + seen.add(idx(s, n)) + queue.push(n) + } + } + } + return s.player +} + /** The exit can be used once its block is gone and every enemy is dead. */ export function exitOpen(s: GameState): boolean { return s.enemies.length === 0 && cellAt(s, s.exit) === 'floor' @@ -137,9 +172,12 @@ export function playerCanPass(s: GameState, p: Vec): boolean { return (cell === 'floor' || (cell === 'block' && s.powers.wallPass)) && (!bombAt(s, p) || s.powers.bombPass) } -/** Flame can't hurt the player this turn: Flame Pass, a Star, or Immortal. */ +/** + * The player may go into flame this turn: a Star or Immortal (nothing hurts), or a raised Flame + * Pass (each turn in flame then uses one of its hits). + */ export function flameSafe(s: GameState): boolean { - return s.powers.flamePass || s.star > 0 || s.options.immortal + return s.flameHits > 0 || s.star > 0 || s.options.immortal } /** diff --git a/src/sim/turn.ts b/src/sim/turn.ts index 5febce2..ac563d1 100644 --- a/src/sim/turn.ts +++ b/src/sim/turn.ts @@ -105,9 +105,17 @@ export function step(prev: GameState, action: Action): StepResult { // A shield absorbs every hit for the rest of the turn it breaks in. A Star stops every hit; // Flame Pass stops flame. let shielded = false + let flameBlocked = false const hurt = (how: Death): void => { const cause = how.cause === 'walkedIntoEnemy' || how.cause === 'caughtByEnemy' ? 'enemy' : 'flame' - if (s.options.immortal || shielded || s.star > 0 || (cause === 'flame' && s.powers.flamePass)) return + if (s.options.immortal || shielded || s.star > 0 || (cause === 'flame' && flameBlocked)) return + // A raised Flame Pass takes a fire hit (and every other flame that turn), using one of its hits. + if (cause === 'flame' && s.flameHits > 0) { + s.flameHits-- + flameBlocked = true + events.push({ type: 'flameBlocked', pos: s.player, hitsLeft: s.flameHits }) + return + } if (s.shieldUp) { s.shieldUp = false shielded = true @@ -299,7 +307,7 @@ function pickUp(s: GameState, p: Vec, events: GameEvent[]): void { s.drops.splice(s.drops.indexOf(d), 1) if (d.kind === 'coins') s.coins += d.amount events.push({ type: 'pickedUp', drop: d }) - if (d.kind !== 'coins') takeCharge(s, d.kind, events) + if (d.kind !== 'coins') takeCharge(s, d.kind, events, false, d.left) } const gem = s.gems.find((g) => eq(g.pos, p) && cellAt(s, p) === 'floor') if (gem) { @@ -318,15 +326,15 @@ export const OFFER_SIZE = 5 * or 2 before gems (the user chose no guarantee). To tune by playtesting. */ export const POWER_UP_WEIGHTS: [PowerUpKind, number][] = [ - ['bombPlus', 20], ['firePlus', 20], ['tnt', 6], ['fusePlus', 6], ['run', 10], ['kick', 8], ['throw', 8], ['teleport', 3], ['diagonal', 8], - ['slide', 6], ['rpg', 6], ['detonator', 9], ['proximity', 5], ['wallPass', 8], ['bombPass', 8], ['flamePass', 5], ['star', 8], + ['bombPlus', 20], ['firePlus', 20], ['tnt', 6], ['fusePlus', 6], ['run', 10], ['kick', 8], ['throw', 8], ['diagonal', 8], + ['slide', 6], ['rpg', 6], ['detonator', 9], ['proximity', 5], ['wallPass', 8], ['bombPass', 8], ['star', 8], ['heart', 4], ['coinBag', 12], ] /** The power-ups that are an ability or pass: offering one already held would change nothing. */ const HELD_AS: Partial<Record<PowerUpKind, keyof Powers>> = { - run: 'run', kick: 'kick', throw: 'throw', teleport: 'teleport', diagonal: 'diagonal', detonator: 'detonate', - wallPass: 'wallPass', bombPass: 'bombPass', flamePass: 'flamePass', rpg: 'rpg', proximity: 'proximity', slide: 'slide', + run: 'run', kick: 'kick', throw: 'throw', diagonal: 'diagonal', detonator: 'detonate', + wallPass: 'wallPass', bombPass: 'bombPass', rpg: 'rpg', proximity: 'proximity', slide: 'slide', } /** @@ -384,13 +392,11 @@ export function applyPowerUp(s: GameState, kind: PowerUpKind): void { case 'detonator': s.powers.detonate = true; break case 'wallPass': s.powers.wallPass = true; break case 'bombPass': s.powers.bombPass = true; break - case 'flamePass': s.powers.flamePass = true; break case 'star': s.star = STAR_TURNS; break case 'coinBag': s.coins += COIN_BAG; break case 'heart': s.lives++; break case 'kick': s.powers.kick = true; break case 'throw': s.powers.throw = true; break - case 'teleport': s.powers.teleport = true; break case 'diagonal': s.powers.diagonal = true; break case 'rpg': s.powers.rpg = true; break case 'proximity': s.powers.proximity = true; break diff --git a/src/sim/types.ts b/src/sim/types.ts index c57be27..743b228 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -78,8 +78,8 @@ export const DEFAULT_OPTIONS: RunOptions = { dev: false, immortal: false } /** The power-ups (PLAN.md §5, after the NES eight). Taking a level's gem offers a choice of them. */ export type PowerUpKind = - | 'bombPlus' | 'firePlus' | 'tnt' | 'fusePlus' | 'run' | 'wallPass' | 'bombPass' | 'flamePass' | 'detonator' - | 'star' | 'coinBag' | 'heart' | 'kick' | 'throw' | 'teleport' | 'diagonal' + | 'bombPlus' | 'firePlus' | 'tnt' | 'fusePlus' | 'run' | 'wallPass' | 'bombPass' | 'detonator' + | 'star' | 'coinBag' | 'heart' | 'kick' | 'throw' | 'diagonal' | 'rpg' | 'proximity' | 'slide' /** @@ -113,8 +113,6 @@ export interface Powers { kick: boolean /** Throw: a bomb 2 tiles away in a straight line, over a crate, bomb or enemy (not a pillar or wall). */ throw: boolean - /** Teleport: move to any free tile up to TELEPORT_RANGE steps away, over anything. */ - teleport: boolean /** Diagonal: Move can also step diagonally (a charge until 2026-10-23, user request). */ diagonal: boolean /** Detonator: the Detonate action, set off one of your bombs now. */ @@ -123,8 +121,6 @@ export interface Powers { wallPass: boolean /** Walk over bombs. */ bombPass: boolean - /** Flame doesn't hurt. */ - flamePass: boolean /** RPG: Bomb can also fire a bomb in a straight line, as far as it flies (user request, 2026-10-24). */ rpg: boolean /** Proximity alert: your bombs go off as soon as an enemy is in their reach. */ @@ -167,9 +163,10 @@ export interface Enemy { * until then, and Diagonal, which became a power-up the same day; Kick, Throw and Teleport until * 2026-10-22; Fuse ±1 was removed on 2026-10-22.) */ -export type ChargeId = 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy' | 'icbm' +export type ChargeId = 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy' | 'icbm' | 'flamePass' | 'teleport' -export const CHARGE_IDS: readonly ChargeId[] = ['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy', 'icbm'] +/** Flame Pass and Teleport were power-ups until 2026-10-25, when the balance lab found each won the game alone. */ +export const CHARGE_IDS: readonly ChargeId[] = ['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy', 'icbm', 'flamePass', 'teleport'] /** * What a gem can offer: a power-up, or a charge (only Developer mode's rainbow gem, to test them @@ -203,7 +200,10 @@ export interface Decoy { } /** Something lying on a floor tile to walk over and collect: coins, or one action charge. */ -export type Drop = { pos: Vec; kind: 'coins'; amount: number } | { pos: Vec; kind: ChargeId } +export type Drop = + | { pos: Vec; kind: 'coins'; amount: number } + /** `left`: what is left of it, for a charge dropped where the player died (full when absent). */ + | { pos: Vec; kind: ChargeId; left?: number } export interface GameState { seed: number @@ -234,13 +234,18 @@ export interface GameState { * A charge just picked up while another is carried: the player sells it, or swaps it for the one * held (which is sold). Nothing else happens until they choose; it takes no turn. */ - chargeOffer: ChargeId | null + chargeOffer: HeldCharge | null /** * A Shield raised with the charge button (2026-10-25: it worked by itself while carried). It stays * up, from level to level, until a hit lands: it absorbs that one (and anything else that hits * that turn), then it is down (user request, 2026-10-25: no time limit). */ shieldUp: boolean + /** + * Flame Pass raised with the charge button (2026-10-25; a power-up until then): fire hits it + * still saves the player from. Each turn flame would hurt them uses one. Kept from level to level. + */ + flameHits: number /** Freeze: how many more enemy moves are skipped (FREEZE_TURNS when used; the turn it is used counts). */ frozen: number powers: Powers @@ -284,7 +289,7 @@ export interface GameState { export type ActionId = | 'move' | 'bomb' | 'wait' | 'run' | 'detonate' | 'kick' | 'throw' | 'teleport' | 'diagonal' | 'rpg' | 'icbm' | 'slide' /** The carried charge, used (2026-10-24): a Pierce, Power or Nuke bomb; a Freeze; a Decoy; a Shield raised. */ - | 'charged' | 'freeze' | 'decoy' | 'shield' + | 'charged' | 'freeze' | 'decoy' | 'shield' | 'flamePass' /** `choose`: one of the gem's offer. It takes no turn. */ export type Action = @@ -350,6 +355,9 @@ export type GameEvent = | { type: 'chargeSold'; kind: ChargeId; coins: number } /** Shield raised with the charge button: up until the next hit. */ | { type: 'shieldRaised' } + /** Flame Pass raised with the charge button, and when flame would have hurt: hits left. */ + | { type: 'flamePassRaised'; hits: number } + | { type: 'flameBlocked'; pos: Vec; hitsLeft: number } /** Freeze used: every enemy stops for FREEZE_TURNS turns. */ | { type: 'frozen'; turns: number } | { type: 'decoyPlaced'; pos: Vec } diff --git a/src/ui/almanac.ts b/src/ui/almanac.ts index 8a9c09f..5b073b8 100644 --- a/src/ui/almanac.ts +++ b/src/ui/almanac.ts @@ -21,8 +21,8 @@ const TABS: [AlmanacTab, string][] = [ /** The power-ups in the order they make sense to read: stats, abilities, passes, the rest. */ const POWER_UPS: PowerUpKind[] = [ - 'bombPlus', 'firePlus', 'tnt', 'fusePlus', 'run', 'kick', 'throw', 'teleport', 'diagonal', 'slide', 'rpg', 'detonator', 'proximity', - 'wallPass', 'bombPass', 'flamePass', 'star', 'heart', 'coinBag', + 'bombPlus', 'firePlus', 'tnt', 'fusePlus', 'run', 'kick', 'throw', 'diagonal', 'slide', 'rpg', 'detonator', 'proximity', + 'wallPass', 'bombPass', 'star', 'heart', 'coinBag', ] const ENEMIES = ENEMY_ORDER diff --git a/src/ui/chargeOffer.ts b/src/ui/chargeOffer.ts index 7662b2c..301cbfc 100644 --- a/src/ui/chargeOffer.ts +++ b/src/ui/chargeOffer.ts @@ -1,4 +1,4 @@ -import { CHARGE_AMOUNT, salePrice } from '../sim/charges' +import { salePrice } from '../sim/charges' import type { GameState } from '../sim/types' import { CHARGE_ICONS, spriteUrl } from '../render/sprites' import { openDialog } from './dialog' @@ -10,20 +10,21 @@ import { CHARGE_TEXT } from './info' * the one held is sold for what is left of it. It must be answered; it takes no turn. */ export function openChargeOffer(s: GameState, answer: (swap: boolean) => void): void { - const offered = s.chargeOffer + const offered = s.chargeOffer?.kind + const offeredLeft = s.chargeOffer?.left ?? 0 const held = s.charge if (!offered || !held) return const card = (kind: typeof offered, note: string) => `<div class="almanac-entry"><img src="${spriteUrl(CHARGE_ICONS[kind])}" alt="" draggable="false">` + `<div><strong>${CHARGE_TEXT[kind].name}</strong> <small>${note}</small><p>${CHARGE_TEXT[kind].text}</p></div></div>` - const newPrice = salePrice(offered) + const newPrice = salePrice(offered, offeredLeft) const oldPrice = salePrice(held.kind, held.left) openDialog({ icon: spriteUrl(CHARGE_ICONS[offered]), title: `${CHARGE_TEXT[offered].name}!`, body: `<p>You can carry one charge at a time. The one you don’t keep is sold for coins.</p>` + - card(offered, `new · ${CHARGE_AMOUNT[offered] > 1 ? `${CHARGE_AMOUNT[offered]} uses` : '1 use'}`) + + card(offered, `new · ${offeredLeft === 1 ? '1 use' : `${offeredLeft} uses`}`) + card(held.kind, `yours · ${held.left} ${held.left === 1 ? 'use' : 'uses'} left`), buttons: [ { label: `Keep ${CHARGE_TEXT[held.kind].name} · +${newPrice}`, kind: 'plain', onClick: () => answer(false) }, diff --git a/src/ui/controls.ts b/src/ui/controls.ts index a1e1862..2b6163f 100644 --- a/src/ui/controls.ts +++ b/src/ui/controls.ts @@ -41,9 +41,9 @@ interface ButtonDef { } const BUTTONS: ButtonDef[] = [ - { id: 'move', label: 'Move', icon: 'icon_move', key: 'M', group: ['move', 'run', 'teleport', 'kick', 'diagonal', 'slide'] }, + { id: 'move', label: 'Move', icon: 'icon_move', key: 'M', group: ['move', 'run', 'kick', 'diagonal', 'slide'] }, { id: 'bomb', label: 'Bomb', icon: 'bomb', key: 'B', group: ['bomb', 'throw', 'rpg', 'detonate'] }, - { id: 'charged', label: 'Charge', icon: 'bomb', key: 'V', group: ['charged', 'icbm', 'freeze', 'decoy', 'shield'] }, + { id: 'charged', label: 'Charge', icon: 'bomb', key: 'V', group: ['charged', 'icbm', 'freeze', 'decoy', 'shield', 'flamePass', 'teleport'] }, { id: 'wait', label: 'Wait', icon: 'icon_wait', key: 'Space', group: ['wait'] }, ] @@ -52,12 +52,10 @@ const PASSES: [keyof Powers, PowerUpKind, string][] = [ ['run', 'run', 'Run'], ['kick', 'kick', 'Kick'], ['throw', 'throw', 'Throw'], - ['teleport', 'teleport', 'Teleport'], ['diagonal', 'diagonal', 'Diagonal'], ['detonate', 'detonator', 'Detonator'], ['wallPass', 'wallPass', 'Wall Pass'], ['bombPass', 'bombPass', 'Bomb Pass'], - ['flamePass', 'flamePass', 'Flame Pass'], ['rpg', 'rpg', 'RPG'], ['proximity', 'proximity', 'Proximity alert'], ['slide', 'slide', 'Slide'], diff --git a/src/ui/feedback.ts b/src/ui/feedback.ts index a0588a9..ba9a4c2 100644 --- a/src/ui/feedback.ts +++ b/src/ui/feedback.ts @@ -146,6 +146,8 @@ export function turnFeedback(events: readonly GameEvent[], action: Action, anim: case 'exitOpened': play('magic', 0.8, 0.8, boom); break case 'shieldBroke': play('bump', 1, 0.7, boom); vibrate(40, boom); break case 'shieldRaised': play('magic', 0.8, 1.1); break + case 'flamePassRaised': play('magic', 0.8, 0.9); break + case 'flameBlocked': play('bump', 0.8, 1.3, boom); break case 'frozen': play('magic', 0.8, 0.7); break case 'enemiesSpawned': play('hurt', 0.8, 0.5, boom); break case 'decoyPlaced': play('charge'); break diff --git a/src/ui/info.ts b/src/ui/info.ts index cc4f7e7..062f647 100644 --- a/src/ui/info.ts +++ b/src/ui/info.ts @@ -1,5 +1,5 @@ -import { DECOY_RANGE, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE, teleportRange } from '../sim/actions' -import { BOMB_BOOST, CHARGE_AMOUNT, salePrice } from '../sim/charges' +import { DECOY_RANGE, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE } from '../sim/actions' +import { BOMB_BOOST, CHARGE_AMOUNT, FLAME_PASS_HITS, salePrice } from '../sim/charges' import { LAST_LEVEL } from '../sim/levelgen' import { bombAt, cellAt, eq, exitOpen, isBurning, CLEAR_BONUS, PAR_BONUS } from '../sim/state' import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS, STAT_MAX, STAT_STEP } from '../sim/turn' @@ -42,13 +42,11 @@ export const POWER_UP_TEXT: Record<PowerUpKind, { name: string; text: string }> detonator: { name: 'Detonator', text: 'Your bombs pulse: tap one and it goes off this turn.' }, wallPass: { name: 'Wall Pass', text: 'You can walk through crates.' }, bombPass: { name: 'Bomb Pass', text: 'You can walk over bombs.' }, - flamePass: { name: 'Flame Pass', text: 'Flame can’t hurt you (enemies still can).' }, star: { name: 'Star', text: `Nothing can hurt you for ${STAR_TURNS} turns.` }, coinBag: { name: 'Coin bag', text: `${COIN_BAG} coins.` }, heart: { name: 'Heart', text: 'One more life.' }, kick: { name: 'Kick', text: 'Walk into a bomb next to you and it slides away until something stops it, keeping its number.' }, throw: { name: 'Throw', text: 'Bomb can also put a bomb 2 tiles away in a straight line, over a crate, bomb or enemy in between (not a pillar).' }, - teleport: { name: 'Teleport', text: `Move can also take you to any empty tile up to ${TELEPORT_RANGE} steps away, over anything (${TELEPORT_RANGE + 1} with Run; with Diagonal, a diagonal step counts as one).` }, diagonal: { name: 'Diagonal', text: 'Move can also step diagonally, even squeezing between two blocked tiles. With Teleport, it reaches a square around you: a diagonal step counts as one.' }, rpg: { name: 'RPG', text: 'Bomb can also fire a bomb in a straight line: it flies until a wall, crate, bomb or enemy is next, and lands just before it.' }, proximity: { name: 'Proximity alert', text: 'Your bombs go off as soon as an enemy is right next to one — wherever you are standing.' }, @@ -58,7 +56,7 @@ export const POWER_UP_TEXT: Record<PowerUpKind, { name: string; text: string }> /** The scene that shows each power-up, for its clip (PLAN.md §13c); Heart and Coin bag have none. */ export const POWER_UP_CLIP: Partial<Record<PowerUpKind, SceneKind>> = { bombPlus: 'bombPlus', firePlus: 'firePlus', tnt: 'firePlus', fusePlus: 'fusePlus', run: 'run', detonator: 'detonate', wallPass: 'wallPass', - bombPass: 'bombPass', flamePass: 'flamePass', star: 'star', kick: 'kick', throw: 'throw', teleport: 'teleport', diagonal: 'diagonal', + bombPass: 'bombPass', star: 'star', kick: 'kick', throw: 'throw', diagonal: 'diagonal', rpg: 'rpg', proximity: 'proximity', slide: 'slide', } @@ -74,6 +72,9 @@ export const CHARGE_TEXT: Record<ChargeId, { name: string; text: string }> = { freeze: { name: 'Freeze', text: `Press the charge button, then tap yourself (or press it again): every enemy freezes in place for ${FREEZE_TURNS} turns, that one included, thawing as it goes.` }, decoy: { name: 'Decoy', text: `Press the charge button, then tap where it goes: up to ${DECOY_RANGE} tiles away in a straight line, with nothing in the way. A copy of you that enemies chasing you go after instead, until the level ends, an enemy reaches it, or flame burns it.` }, icbm: { name: 'ICBM', text: `${BOMB_BOOST} launches: the charge button fires a bomb as far as it flies, as an RPG's, and it goes off the moment it lands. Your bomb limit doesn't count.` }, + // Power-ups until 2026-10-25 (the balance lab found each won the game alone): charges now. + flamePass: { name: 'Flame Pass', text: `Press the charge button, then tap yourself (or press it again): flame can’t hurt you for the next ${FLAME_PASS_HITS} fire hits, level after level until they are used. Enemies still can.` }, + teleport: { name: 'Teleport', text: `${CHARGE_AMOUNT.teleport} jumps: press the charge button, then tap any empty tile up to ${TELEPORT_RANGE} steps away, over anything (${TELEPORT_RANGE + 1} with Run; with Diagonal, a diagonal step counts as one).` }, } /** The choice when a second charge comes along (PLAN.md §19.7), for the charge button's explanation. */ @@ -121,6 +122,7 @@ export function chargeRule(kind: ChargeId): string { export function chargeLeft(s: GameState, id: ChargeId): number | true | 0 { if (s.charge?.kind === id) return s.charge.left > 1 || ['pierce', 'power', 'nuke', 'icbm'].includes(id) ? s.charge.left : true if (id === 'shield') return s.shieldUp ? true : 0 + if (id === 'flamePass') return s.flameHits if (id === 'freeze') return s.frozen if (id === 'decoy') return s.decoy ? true : 0 return 0 @@ -164,8 +166,8 @@ export function modeLines(s: GameState): string[] { /** Each ability or pass, as the power-up that gives it (for its icon and name). */ export const PASS_KIND: Record<keyof Powers, PowerUpKind> = { - run: 'run', kick: 'kick', throw: 'throw', teleport: 'teleport', diagonal: 'diagonal', detonate: 'detonator', wallPass: 'wallPass', - bombPass: 'bombPass', flamePass: 'flamePass', rpg: 'rpg', proximity: 'proximity', slide: 'slide', + run: 'run', kick: 'kick', throw: 'throw', diagonal: 'diagonal', detonate: 'detonator', wallPass: 'wallPass', + bombPass: 'bombPass', rpg: 'rpg', proximity: 'proximity', slide: 'slide', } export function hudInfo(item: HudItem, s: GameState): Info { @@ -239,6 +241,8 @@ export function hudInfo(item: HudItem, s: GameState): Info { case 'freeze': case 'decoy': case 'icbm': + case 'flamePass': + case 'teleport': return { icon: CHARGE_ICONS[item], title: chargeTitle(s, item), body: [CHARGE_TEXT[item].text], clip: item } default: { const kind = PASS_KIND[item] @@ -365,6 +369,8 @@ function chargeTitle(s: GameState, id: ChargeId): string { if (left === true) return name if (id === 'pierce' || id === 'power' || id === 'nuke') return `${name}: next ${left} ${left === 1 ? 'bomb' : 'bombs'}` if (id === 'icbm') return `${name}: ${left} ${left === 1 ? 'launch' : 'launches'} left` + if (id === 'teleport') return `${name}: ${left} ${left === 1 ? 'jump' : 'jumps'} left` + if (id === 'flamePass') return s.charge?.kind === 'flamePass' ? `${name}: carried` : `${name}: ${left} fire ${left === 1 ? 'hit' : 'hits'} left` return `${name}: ${turns(left)} left` } @@ -383,13 +389,11 @@ export function actionInfo(s: GameState, picked: ActionId): Info { switch (picked) { case 'move': case 'run': - case 'teleport': case 'kick': case 'diagonal': case 'slide': { const body = ['Tap a highlighted tile: a step to a tile next to you.'] if (s.powers.run) body.push('Run: 2 tiles in a straight line, if both are empty.') - if (s.powers.teleport) body.push(`Teleport: any empty tile up to ${teleportRange(s)} steps away${s.powers.diagonal ? ' (diagonal steps count as one)' : ''}, over anything.`) if (s.powers.kick) body.push('Kick: walk into a bomb next to you and it slides away until something stops it.') if (s.powers.diagonal) body.push('Diagonal: a diagonal step, even between two blocked tiles.') if (s.powers.slide) body.push('Slide: the tile where a slide that way stops, by a wall, crate or bomb.') @@ -413,7 +417,9 @@ export function actionInfo(s: GameState, picked: ActionId): Info { case 'icbm': case 'freeze': case 'decoy': - case 'shield': { + case 'shield': + case 'flamePass': + case 'teleport': { const intro = 'The charge button uses the charge you carry. Enemies sometimes drop charges, and the shop sells them; you carry one at a time.' if (!s.charge) { return { diff --git a/test/animation.test.ts b/test/animation.test.ts index acf54aa..032ab86 100644 --- a/test/animation.test.ts +++ b/test/animation.test.ts @@ -36,10 +36,10 @@ describe('the player always moves on screen', () => { ###### #P...# ######`) - s.powers.teleport = true + s.charge = { kind: 'teleport', left: 3 } const tele = step(s, { id: 'teleport', target: v(4, 1) }) expect(playerTransition(s, tele.state, tele.events, true, 10).move).toEqual({ from: v(1, 1), at: 10, teleport: true, tiles: 3 }) - const bomb = step(s, { id: 'bomb', target: v(2, 1) }) + const bomb = step({ ...s, charge: null }, { id: 'bomb', target: v(2, 1) }) const t = playerTransition(s, bomb.state, bomb.events, false, 10) expect(t.move).toBeNull() expect(t.pose.kind).toBe('duck') diff --git a/test/charges.test.ts b/test/charges.test.ts index 98c8a2e..2d889d5 100644 --- a/test/charges.test.ts +++ b/test/charges.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest' import { ACTIONS, DECOY_RANGE, FREEZE_TURNS, isAvailable, NUKE_RANGE, TELEPORT_RANGE } from '../src/sim/actions' -import { BOMB_BOOST, salePrice } from '../src/sim/charges' +import { BOMB_BOOST, FLAME_PASS_HITS, salePrice } from '../src/sim/charges' import { PRICES } from '../src/sim/shop' import { stepToward } from '../src/sim/enemies' import { blastTiles } from '../src/sim/explosions' -import { carryOf, createLevel } from '../src/sim/state' +import { carryOf, createLevel, newRun } from '../src/sim/state' import { step } from '../src/sim/turn' -import type { ChargeId, GameState } from '../src/sim/types' +import { DEFAULT_OPTIONS, type ChargeId, type GameState } from '../src/sim/types' import { board, v } from './helpers' /** The state after walking right onto a charge of `kind` lying next to the player. */ @@ -35,7 +35,7 @@ describe('one charge at a time (PLAN.md §19.7)', () => { it('another kind asks: nothing else happens until the player sells it or swaps', () => { const s = pickUp(pickUp(corridor(), 'pierce'), 'nuke') - expect(s.chargeOffer).toBe('nuke') + expect(s.chargeOffer?.kind).toBe('nuke') expect(s.charge).toEqual({ kind: 'pierce', left: BOMB_BOOST }) expect(() => step(s, { id: 'wait' })).toThrow() }) @@ -207,7 +207,7 @@ describe('charges are used on purpose', () => { s.charge = { kind: 'pierce', left: 2 } s.offer = ['nuke'] const { state } = step(s, { id: 'choose', kind: 'nuke' }) - expect(state.chargeOffer).toBe('nuke') + expect(state.chargeOffer?.kind).toBe('nuke') expect(state.charge).toEqual({ kind: 'pierce', left: 2 }) const swapped = step(state, { id: 'swapCharge' }).state expect(swapped.charge).toEqual({ kind: 'nuke', left: BOMB_BOOST }) @@ -236,7 +236,7 @@ describe('Teleport', () => { ######### #P#+...$# #########`) - s.powers.teleport = true + s.charge = { kind: 'teleport', left: 3 } expect(ACTIONS.teleport.validTargets(s)).toEqual([v(4, 1)]) const { state } = step(s, { id: 'teleport', target: v(4, 1) }) expect(state.player).toEqual(v(4, 1)) @@ -247,7 +247,7 @@ describe('Teleport', () => { ###### #P#$.# ######`) - s.powers.teleport = true + s.charge = { kind: 'teleport', left: 3 } expect(step(s, { id: 'teleport', target: v(3, 1) }).state.coins).toBe(1) }) }) @@ -290,3 +290,75 @@ describe('charged bombs', () => { expect(tiles).not.toContainEqual(v(3, 2)) }) }) + +describe('Flame Pass and Teleport, charges since 2026-10-25', () => { + it('Flame Pass, raised, saves the player from 3 fire hits, then runs out', () => { + let s = board(` + ##### + #P..# + #####`) + s.charge = { kind: 'flamePass', left: 1 } + s = step(s, { id: 'flamePass', target: s.player }).state + expect(s.flameHits).toBe(FLAME_PASS_HITS) + expect(s.charge).toBeNull() + for (let hit = 1; hit <= FLAME_PASS_HITS; hit++) { + s.bombs = [{ pos: v(2, 1), timer: 1, fire: 1, placedTurn: -9 }] + const r = step(s, { id: 'wait' }) + expect(r.state.status).toBe('playing') + expect(r.state.flameHits).toBe(FLAME_PASS_HITS - hit) + s = { ...r.state, burning: [] } + } + s.bombs = [{ pos: v(2, 1), timer: 1, fire: 1, placedTurn: -9 }] + expect(step(s, { id: 'wait' }).state.status).toBe('dead') + }) + + it('carried but not raised, Flame Pass does nothing; raised, it lasts to the next level', () => { + const s = board(` + ##### + #P1.# + #####`) + s.charge = { kind: 'flamePass', left: 1 } + s.bombs[0]!.timer = 1 + expect(step(s, { id: 'wait' }).state.status).toBe('dead') + const raised = { ...s, charge: null, flameHits: 2 } + expect(createLevel(raised.seed, 2, raised.options, carryOf(raised)).flameHits).toBe(2) + }) + + it('Teleport: 3 jumps from the charge button, then it is gone', () => { + let s = board(` + ########## + #P.......# + ##########`) + s.charge = { kind: 'teleport', left: 3 } + for (const x of [4, 7, 8]) s = step(s, { id: 'teleport', target: v(x, 1) }).state + expect(s.player).toEqual(v(8, 1)) + expect(s.charge).toBeNull() + expect(isAvailable(s, 'teleport')).toBe(false) + }) +}) + +describe('a death drops the charge carried (user’s choice, 2026-10-25)', () => { + it('it lies where the player fell on the retry, with what was left, to pick up again', () => { + const s = board(` + ###### + #P1..# + ######`) + s.charge = { kind: 'nuke', left: 2 } + s.bombs[0]!.timer = 1 + const dead = step(s, { id: 'wait' }).state + expect(dead.status).toBe('dead') + const carry = carryOf(dead) + expect(carry.charge).toBeNull() + expect(carry.fallen).toEqual({ pos: v(1, 1), charge: { kind: 'nuke', left: 2 } }) + }) + + it('on the retried level the drop lies on the nearest open floor, and gives back what was left', () => { + const fresh = newRun(5) + const s = createLevel(5, 1, DEFAULT_OPTIONS, { ...carryOf(fresh), fallen: { pos: v(1, 1), charge: { kind: 'pierce', left: 1 } } }) + expect(s.drops).toContainEqual({ pos: v(1, 1), kind: 'pierce', left: 1 }) + // Walked onto, it is carried again with what was left. + s.player = v(2, 1) + const back = step(s, { id: 'move', target: v(1, 1) }).state + expect(back.charge).toEqual({ kind: 'pierce', left: 1 }) + }) +}) diff --git a/test/enemies.test.ts b/test/enemies.test.ts index 17dc44a..9d1ef52 100644 --- a/test/enemies.test.ts +++ b/test/enemies.test.ts @@ -679,6 +679,6 @@ describe('charge drops are never wasted', () => { it('Pierce, Power, Nuke and ICBM still drop when one lies there: they add up', () => { expect(dropped((s) => s.drops.push({ pos: v(2, 1), kind: 'pierce' }))).toContain('pierce') expect(dropped((s) => s.drops.push({ pos: v(2, 1), kind: 'icbm' }))).toContain('icbm') - expect(dropped(() => {})).toEqual(new Set(['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy', 'icbm'])) + expect(dropped(() => {})).toEqual(new Set(['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy', 'icbm', 'flamePass', 'teleport'])) }) }) diff --git a/test/helpers.ts b/test/helpers.ts index bddf65f..dbe7c81 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -21,7 +21,7 @@ export function board(picture: string, stats: Partial<Stats> = {}): 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, shieldUp: false, frozen: 0, decoy: null, burning: [], + charge: null, chargeOffer: null, shieldUp: false, flameHits: 0, frozen: 0, decoy: null, burning: [], powers: noPowers(), star: 0, gems: [], offer: null, shop: null, death: null, runTurns: 0, runKills: 0, deaths: 0, crumbling: [], aiRng: 1, dropRng: 1, } rows.forEach((row, y) => { diff --git a/test/new-powers.test.ts b/test/new-powers.test.ts index 6ae5024..2e90c78 100644 --- a/test/new-powers.test.ts +++ b/test/new-powers.test.ts @@ -14,7 +14,7 @@ describe('Run extends Teleport', () => { ######## #P.....# ########`) - s.powers.teleport = true + s.charge = { kind: 'teleport', left: 3 } expect(ACTIONS.teleport.validTargets(s)).not.toContainEqual(v(5, 1)) s.powers.run = true expect(ACTIONS.teleport.validTargets(s)).toContainEqual(v(5, 1)) @@ -172,7 +172,7 @@ describe('Slide', () => { s.powers.slide = true s.burning = [idx(s, v(3, 1))] expect(step(s, { id: 'slide', target: v(6, 1) }).state.status).toBe('dead') - s.powers.flamePass = true + s.flameHits = 99 expect(step(s, { id: 'slide', target: v(6, 1) }).state.status).toBe('playing') }) @@ -196,7 +196,7 @@ describe('Diagonal widens Teleport', () => { #.......# #...P...# #########`) - s.powers.teleport = true + s.charge = { kind: 'teleport', left: 3 } // 3 across and 3 up: 6 steps across plus down, 3 counting diagonal steps as one. expect(ACTIONS.teleport.validTargets(s)).not.toContainEqual(v(1, 1)) s.powers.diagonal = true diff --git a/test/options.test.ts b/test/options.test.ts index 0952120..1935890 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -34,11 +34,11 @@ describe('the board (growing with the level since 2026-10-25)', () => { }) } - it('boards grow with the level, from 9 × 11 to 15 × 21, over a 20-level run', () => { + it('boards grow with the level, from 9 × 11 to 13 × 17, over a 20-level run', () => { expect(boardSize(1)).toEqual({ width: 9, height: 11 }) expect(boardSize(4)).toEqual({ width: 11, height: 13 }) - expect(boardSize(20)).toEqual({ width: 15, height: 21 }) - expect(boardSize(50)).toEqual({ width: 15, height: 21 }) + expect(boardSize(20)).toEqual({ width: 13, height: 17 }) + expect(boardSize(50)).toEqual({ width: 13, height: 17 }) expect(generateLevel(1, 1).enemies).toHaveLength(2) expect(LAST_LEVEL).toBe(20) }) @@ -132,10 +132,10 @@ describe('developer mode', () => { it('the extra gem leaves out what the player already has', () => { const s = newRun(1, 1, null, dev) s.powers.run = true - s.powers.flamePass = true + s.powers.wallPass = true const offer = drawOffer(s, true) expect(offer).not.toContain('run') - expect(offer).not.toContain('flamePass') + expect(offer).not.toContain('wallPass') expect(offer).toHaveLength(POWER_UP_WEIGHTS.length - 2 + CHARGE_IDS.length) // Stats, a Star, a heart and coins can always be had again. expect(offer).toEqual(expect.arrayContaining(['bombPlus', 'firePlus', 'fusePlus', 'star', 'heart', 'coinBag'])) diff --git a/test/path.test.ts b/test/path.test.ts index cf2aca1..85ece7d 100644 --- a/test/path.test.ts +++ b/test/path.test.ts @@ -83,19 +83,17 @@ describe('auto-move: a step, a Run or a Teleport (user request, 2026-10-23)', () it('a tile next to you is a step, even with Run and Teleport', () => { const s = room() s.powers.run = true - s.powers.teleport = true + s.charge = { kind: 'teleport', left: 3 } expect(autoMoveAction(s, v(2, 1), always)).toEqual({ id: 'move', target: v(2, 1) }) }) - it('2 tiles away: two steps without either, one run with Run, one teleport with Teleport (or both)', () => { + it('2 tiles away: two steps without Run, one run with it; never a Teleport, a charge (2026-10-25)', () => { const s = room() expect(autoMoveAction(s, v(3, 1), always)).toEqual({ id: 'move', target: v(2, 1) }) s.powers.run = true expect(autoMoveAction(s, v(3, 1), always)).toEqual({ id: 'run', target: v(3, 1) }) - s.powers.teleport = true - expect(autoMoveAction(s, v(3, 1), always)).toEqual({ id: 'teleport', target: v(3, 1) }) - s.powers.run = false - expect(autoMoveAction(s, v(3, 1), always)).toEqual({ id: 'teleport', target: v(3, 1) }) + s.charge = { kind: 'teleport', left: 3 } + expect(autoMoveAction(s, v(3, 1), always)).toEqual({ id: 'run', target: v(3, 1) }) }) it('2 tiles away round a corner, Run alone is no help: a step', () => { @@ -108,15 +106,6 @@ describe('auto-move: a step, a Run or a Teleport (user request, 2026-10-23)', () expect(autoMoveAction(s, v(2, 2), always)?.id).toBe('move') }) - it('further: Teleport as far toward it as it reaches, then again', () => { - const s = room() - s.powers.teleport = true - const first = autoMoveAction(s, v(8, 1), always)! - expect(first).toEqual({ id: 'teleport', target: v(4, 1) }) - const again = autoMoveAction({ ...s, player: v(4, 1) }, v(8, 1), always)! - expect(again).toEqual({ id: 'teleport', target: v(7, 1) }) - }) - it('further with Run only: runs where the way goes straight', () => { const s = room() s.powers.run = true @@ -125,18 +114,9 @@ describe('auto-move: a step, a Run or a Teleport (user request, 2026-10-23)', () it('never lands on an unsafe tile: picks the next best', () => { const s = room() - s.powers.teleport = true - const unsafe = (p: { x: number; y: number }) => !(p.x === 4 && p.y === 1) - expect(autoMoveAction(s, v(8, 1), unsafe)).toEqual({ id: 'teleport', target: v(3, 1) }) - }) - - it('teleports to a tile it can reach but not walk to', () => { - const s = board(` - ###### - #P#..# - ######`) - s.powers.teleport = true - expect(autoMoveAction(s, v(3, 1), always)).toEqual({ id: 'teleport', target: v(3, 1) }) + s.powers.run = true + const unsafe = (p: { x: number; y: number }) => !(p.x === 3 && p.y === 1) + expect(autoMoveAction(s, v(8, 1), unsafe)).toEqual({ id: 'move', target: v(2, 1) }) }) }) diff --git a/test/picks.test.ts b/test/picks.test.ts index f67d10a..8d549b4 100644 --- a/test/picks.test.ts +++ b/test/picks.test.ts @@ -48,14 +48,15 @@ describe('Move stands for every way of moving', () => { expect(resolvePick(s, 'move', v(2, 1))).toBe('move') }) - it('with Teleport, up to 3 steps away over anything; a plain step is still a step', () => { + it('Teleport is the charge button’s: up to 3 steps away over anything (a charge since 2026-10-25)', () => { const s = board(` ####### #P#+..# #######`) - s.powers.teleport = true - expect(resolvePick(s, 'move', v(4, 1))).toBe('teleport') - expect(resolvePick(s, 'move', v(5, 1))).toBeNull() + s.charge = { kind: 'teleport', left: 3 } + expect(resolvePick(s, 'move', v(4, 1))).toBeNull() + expect(resolvePick(s, 'charged', v(4, 1))).toBe('teleport') + expect(resolvePick(s, 'charged', v(5, 1))).toBeNull() }) it('a diagonal neighbour is a diagonal step, even with Teleport (a tile next to you is a step)', () => { @@ -66,10 +67,8 @@ describe('Move stands for every way of moving', () => { #####`) s.powers.diagonal = true expect(resolvePick(s, 'move', v(2, 2))).toBe('diagonal') - s.powers.teleport = true + s.charge = { kind: 'teleport', left: 3 } expect(resolvePick(s, 'move', v(2, 2))).toBe('diagonal') - // Further away, Teleport still does it. - expect(resolvePick(s, 'move', v(3, 2))).toBe('teleport') }) }) @@ -91,7 +90,7 @@ describe('Bomb stands for placing and throwing', () => { ##### #P..# #####`) - s.powers.teleport = true + s.charge = { kind: 'teleport', left: 3 } expect(resolvePick(s, 'teleport', v(3, 1))).toBe('teleport') expect(resolvePick(s, 'teleport', v(1, 1))).toBeNull() }) diff --git a/test/powerups.test.ts b/test/powerups.test.ts index b36a935..093cbee 100644 --- a/test/powerups.test.ts +++ b/test/powerups.test.ts @@ -53,7 +53,7 @@ describe('the offer', () => { it('leaves out abilities and passes already held', () => { const s = newRun(3) - s.powers = { run: true, kick: true, throw: true, teleport: true, diagonal: true, detonate: true, wallPass: true, bombPass: false, flamePass: true, rpg: true, proximity: true, slide: true } + s.powers = { run: true, kick: true, throw: true, diagonal: true, detonate: true, wallPass: true, bombPass: false, rpg: true, proximity: true, slide: true } for (let level = 1; level <= 30; level++) { s.level = level const offer = drawOffer(s) @@ -64,21 +64,21 @@ describe('the offer', () => { it('is smaller when fewer would do anything', () => { const s = newRun(3) - s.powers = { run: true, kick: true, throw: true, teleport: true, diagonal: true, detonate: true, wallPass: true, bombPass: true, flamePass: true, rpg: true, proximity: true, slide: true } + s.powers = { run: true, kick: true, throw: true, diagonal: true, detonate: true, wallPass: true, bombPass: true, rpg: true, proximity: true, slide: true } // Left: Bomb+, Fire+, Fuse+, Star, Heart, Coin bag. expect(drawOffer(s)).toHaveLength(OFFER_SIZE) }) it('offers rare ones less often than common ones', () => { let fire = 0 - let teleport = 0 + let proximity = 0 for (let seed = 1; seed <= 400; seed++) { const offer = drawOffer(newRun(seed)) if (offer.includes('firePlus')) fire++ - if (offer.includes('teleport')) teleport++ + if (offer.includes('proximity')) proximity++ } - expect(fire).toBeGreaterThan(teleport * 2) - expect(teleport).toBeGreaterThan(0) + expect(fire).toBeGreaterThan(proximity * 2) + expect(proximity).toBeGreaterThan(0) }) }) @@ -154,11 +154,9 @@ describe('the power-ups', () => { expect(take('detonator').state.powers.detonate).toBe(true) expect(take('kick').state.powers.kick).toBe(true) expect(take('throw').state.powers.throw).toBe(true) - expect(take('teleport').state.powers.teleport).toBe(true) expect(take('diagonal').state.powers.diagonal).toBe(true) expect(take('wallPass').state.powers.wallPass).toBe(true) expect(take('bombPass').state.powers.bombPass).toBe(true) - expect(take('flamePass').state.powers.flamePass).toBe(true) }) it('a gem still under its block cannot be taken', () => { @@ -267,13 +265,13 @@ describe('passes', () => { ##### #P1.# #####`) - s.powers.flamePass = true + s.flameHits = 99 expect(step(s, { id: 'wait' }).state.status).toBe('playing') const e = board(` ##### #PB.# #####`) - e.powers.flamePass = true + e.flameHits = 99 expect(step(e, { id: 'move', target: v(2, 1) }).state.status).toBe('dead') }) @@ -287,7 +285,7 @@ describe('passes', () => { s.star = 2 expect(ACTIONS.move.validTargets(s)).toEqual([v(2, 1)]) s.star = 0 - s.powers.flamePass = true + s.flameHits = 99 expect(ACTIONS.move.validTargets(s)).toEqual([v(2, 1)]) }) @@ -305,7 +303,7 @@ describe('passes', () => { ##### #P1.# #####`, { capacity: 2 }), star: 0 } - s.powers = { run: true, kick: true, throw: true, teleport: true, diagonal: true, detonate: true, wallPass: true, bombPass: true, flamePass: false, rpg: true, proximity: true, slide: true } + s.powers = { run: true, kick: true, throw: true, diagonal: true, detonate: true, wallPass: true, bombPass: true, rpg: true, proximity: true, slide: true } const dead = step(s, { id: 'wait' }).state expect(dead.deaths).toBe(1) const again = step(dead, { id: 'continue' }).state diff --git a/test/shop.test.ts b/test/shop.test.ts index a637a0f..dadfb9d 100644 --- a/test/shop.test.ts +++ b/test/shop.test.ts @@ -64,7 +64,7 @@ describe('the shop', () => { s.charge = { kind: 'pierce', left: 3 } s.shop!.items = ['nuke', 'run', 'heart', 'bombPlus'] s = step(s, { id: 'buy', kind: 'nuke' }).state - expect(s.chargeOffer).toBe('nuke') + expect(s.chargeOffer?.kind).toBe('nuke') expect(() => step(s, { id: 'continue' })).toThrow() s = step(s, { id: 'swapCharge' }).state expect(s.charge).toEqual({ kind: 'nuke', left: 3 }) diff --git a/tools/balance/lab.ts b/tools/balance/lab.ts index 2eeafa3..2327558 100644 --- a/tools/balance/lab.ts +++ b/tools/balance/lab.ts @@ -5,7 +5,8 @@ import { LAST_LEVEL } from '../../src/sim/levelgen' import { SIM_VERSION } from '../../src/sim/record' import { newRun } from '../../src/sim/state' import { applyPowerUp, POWER_UP_WEIGHTS, step } from '../../src/sim/turn' -import type { Action, PowerUpKind } from '../../src/sim/types' +import { CHARGE_IDS, type Action, type PowerUpKind } from '../../src/sim/types' +import { CHARGE_AMOUNT } from '../../src/sim/charges' import { DEBUG, makeBot, SKILLS } from './bot' import { playRun, type RunStats, type Setup } from './play' import { show } from './show' @@ -26,13 +27,15 @@ import { measureShape, samplePositions, type Shape } from './structure' /** An experiment's arms: a name and what it changes at the start of a run. */ type Arms = Record<string, Setup> -/** Every power-up, held from level 1 (Coin bag left out: the bots don't shop). */ +/** Every power-up held from level 1, and every charge carried from it (Coin bag left out: the bots don't shop). */ function powerArms(): Arms { const arms: Arms = { baseline: () => {} } for (const [kind] of POWER_UP_WEIGHTS) { if (kind === 'coinBag') continue arms[kind] = (s) => applyPowerUp(s, kind as PowerUpKind) } + // Each charge carried from level 1 (one pickup's worth), since charges are used on purpose. + for (const kind of CHARGE_IDS) arms[`${kind} (charge)`] = (s) => void (s.charge = { kind, left: CHARGE_AMOUNT[kind] }) return arms }