explodorama / commit
Auto-move runs and teleports; no throws over pillars; playtest fixes
CHANGELOG.md | 8 +++- PLAN.md | 6 +-- README.md | 20 +++++++--- decisions.md | 48 +++++++++++++++++++++++ docs/screenshots/almanac-charges.png | Bin 369609 -> 369526 bytes docs/screenshots/almanac-enemies.png | Bin 308834 -> 308803 bytes docs/screenshots/almanac.png | Bin 429479 -> 430969 bytes docs/screenshots/auto-move.png | Bin 244588 -> 244640 bytes docs/screenshots/charges.png | Bin 256654 -> 256659 bytes docs/screenshots/countdown.png | Bin 248448 -> 248390 bytes docs/screenshots/desktop.png | Bin 583108 -> 583089 bytes docs/screenshots/enemies.png | Bin 238019 -> 238025 bytes docs/screenshots/gem-offer.png | Bin 196925 -> 201285 bytes docs/screenshots/gem.png | Bin 244285 -> 244258 bytes docs/screenshots/info-mode.png | Bin 242990 -> 242881 bytes docs/screenshots/lingering.png | Bin 248488 -> 248622 bytes docs/screenshots/menu.png | Bin 170784 -> 170808 bytes docs/screenshots/move-picked.png | Bin 243624 -> 243723 bytes docs/screenshots/scrolled.png | Bin 246872 -> 246848 bytes src/main.ts | 35 ++++++++--------- src/sim/actions.ts | 11 ++++-- src/sim/demo.ts | 2 +- src/sim/path.ts | 37 +++++++++++++++++- src/sim/picks.ts | 5 ++- src/sim/record.ts | 2 +- src/sim/turn.ts | 10 +++-- src/sim/types.ts | 2 +- src/ui/info.ts | 4 +- test/enemies.test.ts | 46 +++++++++++++++++++++- test/path.test.ts | 72 ++++++++++++++++++++++++++++++++++- test/picks.test.ts | 10 +++-- 31 files changed, 270 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6856adb..90073c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,7 +83,7 @@ saved before the change is dropped and the next visit starts a new run. - Stepping onto the exit walks into the doorway before the jump (it used to happen instantly). - An enemy walking into flame finishes its step before it dies (it used to die on the new tile without moving). -- Saved runs from before the gems can't be resumed (`SIM_VERSION` 16). +- Saved runs from before the gems can't be resumed (`SIM_VERSION` 18). - Developer mode now only puts every level's gem uncovered next to the player; it no longer gives every ability, extra stats or unlimited charges. @@ -103,6 +103,12 @@ saved before the change is dropped and the next visit starts a new run. wasted; and no Decoy, Freeze or Shield drops while one already lies there or is working. - Enemies on the same tile no longer move as one: each plans around the others, so they spread out. - A Freeze drop on the board crashed the game (its icon was the wrong size). +- Drops no longer land on the uncovered exit. +- Auto-move uses Run and Teleport: each move goes as far toward the target as it can (a + neighbour is still a step). +- With Diagonal, tapping a diagonal neighbour steps there (it used to walk round the corner). +- Throw no longer goes over a pillar or the outer wall (it still goes over crates, bombs and + enemies). - An error while drawing a frame or playing a turn no longer freezes the game; it is logged and play goes on. Under `npm run dev`, the game's logs and errors, from any device, show in the terminal. diff --git a/PLAN.md b/PLAN.md index 563bb3b..d559b8d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -161,8 +161,8 @@ they first appear on). |---|---|---| | **Run** | Run pickup (called Skates until 2026-10-22; NES "Speed"), or shop | 2 tiles in a straight line. **Both** tiles must be free — no jumping over anything. Highlight: path tile + landing tile. An enemy on the path tile = death (you ran into it). *Part of Move since 2026-10-22.* | | **Kick** | Kick power-up (*was a charge until 2026-10-22*) | Push an adjacent bomb by walking into it; it slides until the next tile is not free, keeping its timer. Part of Move. | -| **Throw** | Throw power-up (*was a charge*) | A bomb **exactly 2 tiles away** in a straight line, over anything. Part of Bomb. | -| **Teleport** | Teleport power-up, rare (*was a charge*) | Move to any free tile within Manhattan distance 3, over anything. Part of Move. | +| **Throw** | Throw power-up (*was a charge*) | A bomb **exactly 2 tiles away** in a straight line, over a crate, bomb or enemy; *since 2026-10-23 (user's choice)* not over a pillar or the outer wall (it went over anything). Part of Bomb. | +| **Teleport** | Teleport power-up, rare (*was a charge*) | Move to any free tile within Manhattan distance 3, over anything. Part of Move. *Since 2026-10-23 (user request)* auto-move uses it: each move is a step, a Run or a Teleport, whichever leaves the walk left shortest (ties: Teleport, then Run), a neighbour always a step (`autoMoveAction` in `sim/path.ts`). | | **Diagonal** | Diagonal power-up (*a charge until 2026-10-23*, user request; weight 8) | Step one tile diagonally; both orthogonal neighbours may be blocked — a squeeze. Part of Move (G, then Q E Z C). | | **Detonate** | Detonator pickup (NES "Detonator"), or shop | Explode one chosen own bomb now. *Since 2026-10-22:* tap the bomb (all of them pulse while you have it); no button. | @@ -441,7 +441,7 @@ contact. Intent shown every turn. Adapted from the NES eight: second Decoy beside the first); Pierce, Power and Nuke add up, so they always can be. Enemies spawned from a flamed exit drop nothing, so the exit can't be farmed. - Drops land on the enemy's tile and stay until collected or flamed. *Built (2026-10-21, user - request):* drops never stack — a drop whose tile is taken (by a drop, a bomb or the uncovered + request):* drops never stack — a drop whose tile is taken (by a drop, a bomb, *since 2026-10-23* the uncovered exit, or the uncovered power-up) rolls to the nearest free floor tile within 2 steps (BFS over floor), else stays. - **Introduction schedule over 50 levels** (like the NES): Blob only at first; one new type every ~6 levels; later levels mix types. Exact table to be drafted in `design.md`. diff --git a/README.md b/README.md index 3af9ad0..1891e0e 100644 --- a/README.md +++ b/README.md @@ -52,12 +52,13 @@ they make Move and Bomb do more. | Action | Phone | Keyboard | What it does | |---|---|---|---| | **Move** | Tap a tile next to you, or tap **Move** and then any highlighted tile | W A S D or arrow keys | Step one tile up, down, left or right onto an empty tile. **With the Run power-up:** or 2 tiles in a straight line. **With Teleport:** or any empty tile up to 3 steps away, over anything. **With Kick:** walking into a bomb kicks it. **With Diagonal:** or a diagonal step. | -| **Bomb** | Tap **Bomb**, then a highlighted tile — or **press and hold** a tile next to you | **B**, then a direction | Place a bomb on an empty tile next to you. You can have 1 bomb on the board at a time. **With Throw:** or 2 tiles away in a straight line, over anything. Pierce, Power and Nuke charges change your next bombs by themselves (see Charges). | +| **Bomb** | Tap **Bomb**, then a highlighted tile — or **press and hold** a tile next to you | **B**, then a direction | Place a bomb on an empty tile next to you. You can have 1 bomb on the board at a time. **With Throw:** or 2 tiles away in a straight line, over a crate, bomb or enemy (not a pillar). Pierce, Power and Nuke charges change your next bombs by themselves (see Charges). | | **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; next to you is a bomb, 2 away a -throw. A diagonal step is only taken when nothing else reaches that tile. +throw. With Diagonal, a diagonal neighbour is a step too — tap it, picked or not — and Teleport is +for tiles further away. **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 @@ -73,8 +74,15 @@ It **won't take a deadly step**: onto an enemy, onto a tile an enemy's arrow poi flame this turn. The next step then shows red, and Go / the destination wait for you to deal with it (Go falls back to picking Move, so you can step aside yourself). **Once every enemy is gone**, the way needs no more presses: you walk there by yourself, one -step a second (still one turn per step). **With the Run power-up**, a step of auto-move where the way goes -straight on for 2 tiles is a Run instead (one turn for both), if both tiles are safe. +step 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. 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 @@ -180,7 +188,7 @@ A slain enemy sometimes leaves **coins** (bronze = 1, silver = 2, gold = 5) or a left after it) is always one that lasts into the next level — Pierce, Power, Nuke or Shield — never a Freeze or Decoy, which would have nothing to work on. And no enemy drops a Decoy, Freeze or Shield while one already lies on the board or is working (a decoy out, enemies frozen, a -shield up): a second one would be wasted. Pierce, Power and Nuke add up, so they always can. Drops never share a tile: if one is already there, the new one rolls to the +shield up): a second one would be wasted. Pierce, Power and Nuke add up, so they always can. Drops never share a tile, and never land on the uncovered exit: the new one rolls to the nearest free floor tile (up to 2 steps away). Walk onto them to collect them. A later flame burns drops lying in the open, so pick them up soon. Coins will buy upgrades in the shop (not built yet). @@ -218,7 +226,7 @@ before moving on. | **Fuse +1** | bomb with a timer badge | Your bombs start one higher (more time to get away). | | **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 anything in between (or **T**, then a direction). | +| **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). | | **Diagonal** | the Move figure with a diagonal-arrows badge | **Move** can also step diagonally, even squeezing between two blocked tiles (or **G**, then **Q E Z C**: up-left, up-right, down-left, down-right). A charge until 2026-10-23. | | **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. | diff --git a/decisions.md b/decisions.md index 846863a..56552aa 100644 --- a/decisions.md +++ b/decisions.md @@ -892,3 +892,51 @@ naturally when the player walks out through the open door. the demo. Tests: every kind found on 6 seeds, the player alive throughout and dead at the end of a death scene, and `showsAtEnd` confirms the moment. In a browser: 12 scenes in 70 s, alternating, no errors. + +### 2026-10-23 — No drops on the uncovered exit + +User report: an enemy died and its charge landed on the closed (uncovered, still locked) exit. +That hides the door, and once the last enemy dies the drop can only be taken by stepping on the +exit — which ends the level — while auto-move won't cross the open exit at all. `dropSpot` now +counts the uncovered exit as taken, like a drop, a bomb or the uncovered gem: a drop there rolls +to the nearest free floor tile (up to 2 steps). An exit still under its crate isn't floor, so +drops never reached it. Test: a Blob dying on the uncovered exit (in lingering flame — a new +blast there would let enemies out) never leaves a drop on it over 500 rolls; it fails without +the rule. `SIM_VERSION` 17. + +### 2026-10-23 — No throwing over a pillar + +User question: "a bomb throw shouldn't be able to go through a column/pillar, right?" Throw had +been "exactly 2 tiles away, over anything" since the first plan (as in the later Bomberman +games, where a thrown bomb sails over blocks) — the plan's wording, not a user choice. Of the +options (not over pillars or walls; only over open floor; keep over anything) the user chose +the first: a pillar or the outer wall on the tile in between blocks a throw; a crate, a bomb or +an enemy there can still be thrown over. Pillars look like tall solid columns, and crates stay +a reason to have Throw. Teleport still goes "over anything" (the question was about throws). +Tests: a pillar blocks, a crate, bomb or enemy doesn't. `SIM_VERSION` 18. + +### 2026-10-23 — A tap on a diagonal neighbour steps there + +User report: with Diagonal, a plain tap on a free diagonal tile walked one tile sideways first, +as auto-move does for a far tile — a tap on the board took a direct step only for the four +straight neighbours, and auto-move's way uses straight steps. Now a plain tap on a diagonal +neighbour, with Diagonal, is a diagonal step, as a straight neighbour is a step. And with Move +picked, Diagonal now comes before Teleport in Move's order (`GROUPS` in picks.ts), so a tile +next to the player is always a step, and Teleport is for tiles further away (it used to win a +diagonal neighbour too). Auto-move's way still uses straight steps only. Checked in a browser: a +tap on a free diagonal neighbour moves there in one turn. No rule changed, so no new +`SIM_VERSION`. + +### 2026-10-23 — Auto-move runs and teleports + +User report: with Teleport, tapping a far tile still walked there one tile at a time. The +user's rule: a tile 1 step away is always a step; 2 steps away is two steps, or one run with +Run, or one teleport with Teleport (or both); further away, run 2 or teleport as far as possible, +then reassess. Built as `autoMoveAction` (sim/path.ts), used for every move of auto-move, +including the first one on the tap: a neighbour is a step; otherwise every option — a step along +the way, a Run along it, each Teleport landing — is scored by the walk left from where it lands, +and the shortest wins, ties going to Teleport, then Run, then the step. Only tiles auto-move +calls safe are landed on, and never the open exit unless it is the destination. Auto-move's own +teleport doesn't end auto-move (a move off the way normally means "changed my mind"). With +Teleport, a tile out of walking reach but within Teleport's (behind a pillar) is one teleport. +Tests cover each case; in a browser, a tile 7 steps away took 3 turns (teleport, teleport, step). diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png index 4d03396..accbbd8 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 0723588..22de4c0 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 7b2e903..9d32667 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 bf11d50..37829e5 100644 Binary files a/docs/screenshots/auto-move.png and b/docs/screenshots/auto-move.png differ diff --git a/docs/screenshots/charges.png b/docs/screenshots/charges.png index 4b4c9d6..0475bc3 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 c5609b1..a815c54 100644 Binary files a/docs/screenshots/countdown.png and b/docs/screenshots/countdown.png differ diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png index 83428b3..c0cfb0c 100644 Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png index b6eadfa..a6a187c 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 685532d..1da850a 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 5d3c9e6..f428aec 100644 Binary files a/docs/screenshots/gem.png and b/docs/screenshots/gem.png differ diff --git a/docs/screenshots/info-mode.png b/docs/screenshots/info-mode.png index c8c2e8a..3ba678f 100644 Binary files a/docs/screenshots/info-mode.png and b/docs/screenshots/info-mode.png differ diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png index 43d66c1..c1a0e4b 100644 Binary files a/docs/screenshots/lingering.png and b/docs/screenshots/lingering.png differ diff --git a/docs/screenshots/menu.png b/docs/screenshots/menu.png index 008af24..f955d74 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 f26d04a..d938008 100644 Binary files a/docs/screenshots/move-picked.png and b/docs/screenshots/move-picked.png differ diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png index 177a4b7..6b1ede5 100644 Binary files a/docs/screenshots/scrolled.png and b/docs/screenshots/scrolled.png differ diff --git a/src/main.ts b/src/main.ts index 4561117..b0e6247 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,7 +18,7 @@ import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, TEXTURES } from './render/spr import { isAvailable, isValidTarget, ACTIONS } from './sim/actions' import { canPick, resolvePick } from './sim/picks' import { enemyAt } from './sim/enemies' -import { playerPath } from './sim/path' +import { autoMoveAction, playerPath } from './sim/path' import { predictDanger, type Danger } from './sim/danger' import { newRecord, replay, SIM_VERSION, type RunRecord } from './sim/record' import { bombAt, cellAt, eq, flameSafe, idx, inBounds, isBurning, newRun } from './sim/state' @@ -166,11 +166,12 @@ function randomSeed(): number { /** The actions that move the player. One that doesn't land on the auto-move path means "changed my mind". */ const MOVES: ReadonlySet<Action['id']> = new Set(['move', 'run', 'diagonal', 'teleport']) -function act(action: Action): void { +/** `auto`: auto-move's own move, which may leave the walking way (a Teleport) without ending it. */ +function act(action: Action, auto = false): void { // A gem's offer is answered before anything else (its dialog opens a moment after the pickup). if (state.offer && action.id !== 'choose') return lastActionAt = time - if (MOVES.has(action.id) && 'target' in action && !path?.some((t) => eq(t, action.target!))) destination = null + if (!auto && MOVES.has(action.id) && 'target' in action && !path?.some((t) => eq(t, action.target!))) destination = null // step() throws on an action the rules don't allow; then nothing happens, and it is reported. let result: ReturnType<typeof step> try { @@ -244,8 +245,11 @@ function afterChange(): void { * With nothing picked: * - the player: Wait; * - the auto-move flag: one step toward it (like Go), with auto-move's safety check; - * - a tile next to the player: move there; - * - any other tile you can walk to: set it as the auto-move destination, and take the first step; + * - a tile next to the player: move there; with Diagonal, a diagonal neighbour too (user report, + * 2026-10-23: it was walked to round the corner, as auto-move); + * - any other tile you can walk to: set it as the auto-move destination, and take the first move + * (a step, a Run or a Teleport, whichever gets closest: `autoMoveAction`); + * - with Teleport, a tile you can't walk to but can teleport to: teleport there; * - a wall or crate, or any floor you can't walk to: clears the flag if there is one; * - one of the player's bombs, with the Detonator: set it off (user request, 2026-10-22); * - else a bomb next to the player, with Kick: kick it (walking into it); @@ -272,7 +276,10 @@ function tapTile(p: Vec): void { else if (enemyAt(state, p) || bombAt(state, p) || (isBurning(state, p) && !flameSafe(state))) return else if (destination && eq(destination, p)) stepTowardDestination() else if (isValidTarget(state, 'move', p)) act({ id: 'move', target: p }) + else if (isAvailable(state, 'diagonal') && isValidTarget(state, 'diagonal', p)) act({ id: 'diagonal', target: p }) else if (cellAt(state, p) === 'floor' && playerPath(state, p)) setDestination(p) + // Out of walking reach (behind a pillar, say) but within Teleport's: one teleport there. + else if (isAvailable(state, 'teleport') && isValidTarget(state, 'teleport', p)) act({ id: 'teleport', target: p }) else if (destination) { destination = null afterChange() @@ -386,20 +393,14 @@ function safeStep(p: Vec): boolean { } /** - * One step of auto-move, if the next step is safe. True if it stepped. With Run, a path - * going straight on for 2 tiles is taken as one Run, if both tiles are safe. + * One move of auto-move: a step, a Run or a Teleport, whichever gets closest (`autoMoveAction`), + * only onto safe tiles. True if it moved. */ function stepTowardDestination(): boolean { - const next = path?.[0] - if (!next || !safeStep(next)) return false - const after = path?.[1] - const straight = after && after.x - next.x === next.x - state.player.x && after.y - next.y === next.y - state.player.y - if (straight && isAvailable(state, 'run') && isValidTarget(state, 'run', after) && safeStep(after)) { - act({ id: 'run', target: after }) - return true - } - if (!isValidTarget(state, 'move', next)) return false - act({ id: 'move', target: next }) + if (!destination) return false + const action = autoMoveAction(state, destination, safeStep) + if (!action) return false + act(action, true) return true } diff --git a/src/sim/actions.ts b/src/sim/actions.ts index 84d839b..fe2b2dc 100644 --- a/src/sim/actions.ts +++ b/src/sim/actions.ts @@ -1,5 +1,5 @@ import { enemyAt } from './enemies' -import { add, bombAt, eq, isBurning, isFree, playerCanEnter } from './state' +import { add, bombAt, cellAt, eq, isBurning, isFree, playerCanEnter } from './state' import { DIRS, DIR_LIST, type ActionId, type Bomb, type ChargeId, type GameEvent, type GameState, type Powers, type Vec } from './types' /** @@ -178,11 +178,16 @@ export const ACTIONS: Record<ActionId, ActionDef> = { label: 'Throw', needsTarget: true, ability: 'throw', - // Exactly 2 tiles away in a straight line, over anything in between. + // 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 (user's choice, 2026-10-23: it went over anything). validTargets: (s) => !hasCapacity(s) ? [] - : DIR_LIST.map((d) => add(s.player, { x: 2 * DIRS[d].x, y: 2 * DIRS[d].y })).filter((p) => canHoldBomb(s, p)), + : DIR_LIST.flatMap((d) => { + const mid = add(s.player, DIRS[d]) + const land = add(mid, DIRS[d]) + return cellAt(s, mid) !== 'wall' && canHoldBomb(s, land) ? [land] : [] + }), apply: (s, target, events) => placeBomb(s, target!, events), }, diagonal: { diff --git a/src/sim/demo.ts b/src/sim/demo.ts index 896896b..6121b0d 100644 --- a/src/sim/demo.ts +++ b/src/sim/demo.ts @@ -165,7 +165,7 @@ export const SCENES: Record<SceneKind, SceneDef> = { shows: (t, seen) => seen.used.has('kick') && kills(t).length > 0, }, throw: { - caption: 'Throw: put a bomb two tiles away, over anything in between.', + caption: 'Throw: put a bomb two tiles away, over a crate, a bomb or an enemy.', enemies: ['blob', 'seeker'], prepare: (s) => (s.powers.throw = true), shows: (t, seen) => seen.used.has('throw') && kills(t).length > 0, diff --git a/src/sim/path.ts b/src/sim/path.ts index a914f54..e6f1026 100644 --- a/src/sim/path.ts +++ b/src/sim/path.ts @@ -1,5 +1,6 @@ +import { ACTIONS, isAvailable, isValidTarget } from './actions' import { add, eq, exitOpen, playerCanPass } from './state' -import { DIRS, DIR_LIST, type GameState, type Vec } from './types' +import { DIRS, DIR_LIST, type Action, type GameState, type Vec } from './types' /** * The shortest walk for the player to `to`, for auto-move, over tiles the player can pass (free @@ -32,3 +33,37 @@ export function playerPath(s: GameState, to: Vec): Vec[] | null { } return 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 + * 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)) + const step: Action | null = path?.[0] && safe(path[0]) && isValidTarget(s, 'move', path[0]) ? { id: 'move', target: path[0] } : null + if (path?.length === 1) return step + 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)) { + options.push({ action: { id: 'run', target: after }, left: path!.length - 2 }) + } + if (step) options.push({ action: step, left: path!.length - 1 }) + // The closest; the first listed wins a tie. + let best: { action: Action; left: number } | null = null + for (const o of options) if (!best || o.left < best.left) best = o + const now = path?.length ?? Infinity + return best && best.left < now ? best.action : null +} diff --git a/src/sim/picks.ts b/src/sim/picks.ts index fd92230..30f3629 100644 --- a/src/sim/picks.ts +++ b/src/sim/picks.ts @@ -10,10 +10,11 @@ import type { ActionId, GameState, Vec } from './types' * * Members are in order of preference: a tile two of them can reach goes to the earlier one, so * a kick beats walking onto the bomb with Bomb Pass (walking into a bomb kicks it, if it can - * slide), a plain step beats a run, and anything free beats spending a charge (Diagonal last). + * slide), a plain step beats a run, and a diagonal step beats a teleport to the same tile (a + * tile next to you is a step; user report, 2026-10-23). */ const GROUPS: Partial<Record<ActionId, readonly ActionId[]>> = { - move: ['kick', 'move', 'run', 'teleport', 'diagonal'], + move: ['kick', 'move', 'run', 'diagonal', 'teleport'], bomb: ['bomb', 'throw', 'detonate'], } diff --git a/src/sim/record.ts b/src/sim/record.ts index f01f96e..9555632 100644 --- a/src/sim/record.ts +++ b/src/sim/record.ts @@ -6,7 +6,7 @@ import { DEFAULT_OPTIONS, type Action, type GameEvent, type GameState, type RunO * Bumped whenever a rule change would make an old record replay differently. Records from * another version can't be trusted to replay (PLAN.md §11). */ -export const SIM_VERSION = 16 +export const SIM_VERSION = 18 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */ export interface RunRecord { diff --git a/src/sim/turn.ts b/src/sim/turn.ts index f679458..c232cb3 100644 --- a/src/sim/turn.ts +++ b/src/sim/turn.ts @@ -179,12 +179,16 @@ export const DROP_SPREAD = 2 /** * Where a new drop lands: the enemy's tile if nothing lies there yet, otherwise the nearest * open floor tile (within DROP_SPREAD steps, never through walls or crates) without a drop, a - * bomb or the uncovered power-up — so drops never pile up on one tile. If there is none, it - * stays on the enemy's tile after all. + * bomb, the uncovered power-up or the uncovered exit — so drops never pile up on one tile, and + * never lie on the door, where taking one would mean leaving the level (user report, + * 2026-10-23). If there is none, it stays on the enemy's tile after all. */ function dropSpot(s: GameState, from: Vec): Vec { const taken = (p: Vec) => - s.drops.some((d) => eq(d.pos, p)) || !!bombAt(s, p) || (!!s.powerUp && eq(s.powerUp.pos, p) && cellAt(s, p) === 'floor') + s.drops.some((d) => eq(d.pos, p)) || + !!bombAt(s, p) || + (!!s.powerUp && eq(s.powerUp.pos, p) && cellAt(s, p) === 'floor') || + (eq(s.exit, p) && cellAt(s, p) === 'floor') const seen = new Set([idx(s, from)]) let ring = [from] for (let dist = 0; dist <= DROP_SPREAD && ring.length; dist++) { diff --git a/src/sim/types.ts b/src/sim/types.ts index 963ba88..6bbd6d9 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -97,7 +97,7 @@ export interface Powers { run: boolean /** Kick: walk into a bomb and it slides away. */ kick: boolean - /** Throw: a bomb 2 tiles away in a straight line, over anything. */ + /** 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 diff --git a/src/ui/info.ts b/src/ui/info.ts index c7466ea..284f475 100644 --- a/src/ui/info.ts +++ b/src/ui/info.ts @@ -37,7 +37,7 @@ export const POWER_UP_TEXT: Record<PowerUpKind, { name: string; text: string }> 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 anything in between.' }, + 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.` }, diagonal: { name: 'Diagonal', text: 'Move can also step diagonally, even squeezing between two blocked tiles.' }, } @@ -293,7 +293,7 @@ export function actionInfo(s: GameState, picked: ActionId): Info { const body = [ `Tap a highlighted tile next to you. The bomb goes off after ${turns(s.stats.fuse)} of yours; its blast reaches ${s.stats.fire} ${s.stats.fire === 1 ? 'tile' : 'tiles'} each way. Up to ${s.stats.capacity} on the board at once.`, ] - if (s.powers.throw) body.push('Throw: or 2 tiles away in a straight line, over anything in between.') + if (s.powers.throw) body.push('Throw: or 2 tiles away in a straight line, over a crate, bomb or enemy (not a pillar).') if (s.powers.detonate) body.push('Detonator: tap one of your bombs (they pulse) and it goes off this turn. No need to pick Bomb first.') for (const k of ['pierce', 'power', 'nuke'] as const) { if (s.boosts[k] > 0) body.push(`${CHARGE_TEXT[k].name}: your next ${s.boosts[k]} ${s.boosts[k] === 1 ? 'bomb' : 'bombs'}.`) diff --git a/test/enemies.test.ts b/test/enemies.test.ts index a955280..11403a6 100644 --- a/test/enemies.test.ts +++ b/test/enemies.test.ts @@ -326,6 +326,24 @@ describe('drops never share a tile', () => { } expect(found).toBe(true) }) + it('a drop never lands on the uncovered exit: it rolls off it', () => { + let found = false + for (let r = 1; r < 500 && !found; r++) { + // A Blob on the uncovered exit dies in lingering flame (a new blast there would let enemies out). + const s = board(` + ######### + #P...E..# + #########`) + s.enemies.push({ id: 9, kind: 'blob', pos: v(5, 1), dir: 'left', plan: [], spawned: false, sheltered: false }) + s.burning = [1 * s.width + 5] + s.dropRng = r + const { state } = step(s, { id: 'wait' }) + if (!state.drops.length) continue + found = true + for (const d of state.drops) expect(d.pos).not.toEqual(v(5, 1)) + } + expect(found).toBe(true) + }) }) describe('the exit', () => { @@ -459,10 +477,10 @@ describe('action charges and abilities', () => { expect(ACTIONS.kick.validTargets(s)).toEqual([]) }) - it('throw: exactly 2 tiles away, over a wall', () => { + it('throw: exactly 2 tiles away, over a crate', () => { const s = board(` ###### - #P#..# + #P+..# #.#### #.#### ######`) @@ -472,6 +490,30 @@ describe('action charges and abilities', () => { expect(state.bombs).toEqual([{ pos: v(3, 1), timer: 3, fire: 1, placedTurn: 5 }]) }) + it('throw: not over a pillar or the outer wall (user\'s choice, 2026-10-23)', () => { + const s = board(` + ####### + #P#..## + #.##### + #.....# + #######`) + s.powers.throw = true + // Right: the pillar at (2, 1) is in between. Down: (1, 2) is floor, so (1, 3) is fine. + expect(ACTIONS.throw.validTargets(s)).toEqual([v(1, 3)]) + }) + + it('throw: over a bomb or an enemy in between', () => { + const s = board(` + ###### + #P3..# + #B#### + #.#### + ######`) + s.powers.throw = true + s.stats.capacity = 2 + expect(ACTIONS.throw.validTargets(s)).toEqual([v(1, 3), v(3, 1)]) + }) + it('throw: needs bomb capacity', () => { const s = board(` ####### diff --git a/test/path.test.ts b/test/path.test.ts index 8a72715..5f83aeb 100644 --- a/test/path.test.ts +++ b/test/path.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { playerPath } from '../src/sim/path' +import { autoMoveAction, playerPath } from '../src/sim/path' import { board, v } from './helpers' describe('auto-move path', () => { @@ -69,3 +69,73 @@ describe('auto-move path', () => { expect(playerPath(s, v(4, 1))).toEqual([v(2, 1), v(3, 1), v(4, 1)]) }) }) + +describe('auto-move: a step, a Run or a Teleport (user request, 2026-10-23)', () => { + const always = () => true + const room = () => + board(` + ########## + #P.......# + #.#.#.#.## + #........# + ##########`) + + 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 + 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)', () => { + 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) }) + }) + + it('2 tiles away round a corner, Run alone is no help: a step', () => { + const s = board(` + ##### + #P..# + #...# + #####`) + s.powers.run = true + 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 + expect(autoMoveAction(s, v(8, 1), always)).toEqual({ id: 'run', target: v(3, 1) }) + }) + + 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) }) + }) +}) diff --git a/test/picks.test.ts b/test/picks.test.ts index 18b65b8..f67d10a 100644 --- a/test/picks.test.ts +++ b/test/picks.test.ts @@ -58,7 +58,7 @@ describe('Move stands for every way of moving', () => { expect(resolvePick(s, 'move', v(5, 1))).toBeNull() }) - it('a diagonal step is taken only when nothing else reaches the tile', () => { + it('a diagonal neighbour is a diagonal step, even with Teleport (a tile next to you is a step)', () => { const s = board(` ##### #P#.# @@ -67,15 +67,17 @@ 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 - expect(resolvePick(s, 'move', v(2, 2))).toBe('teleport') + expect(resolvePick(s, 'move', v(2, 2))).toBe('diagonal') + // Further away, Teleport still does it. + expect(resolvePick(s, 'move', v(3, 2))).toBe('teleport') }) }) describe('Bomb stands for placing and throwing', () => { - it('next to you is a plain bomb; with Throw, 2 tiles away over anything is a throw', () => { + it('next to you is a plain bomb; with Throw, 2 tiles away over a crate is a throw', () => { const s = board(` ###### - #P#..# + #P+..# ######`) expect(pickTargets(s, 'bomb')).toEqual([]) expect(canPick(s, 'bomb')).toBe(false)