explodorama / commit
No second Decoy, Freeze or Shield while one would be wasted
CHANGELOG.md | 4 ++-- PLAN.md | 4 +++- README.md | 4 +++- decisions.md | 12 ++++++++++++ docs/screenshots/almanac-charges.png | Bin 369548 -> 369698 bytes docs/screenshots/almanac-enemies.png | Bin 309032 -> 308834 bytes docs/screenshots/almanac.png | Bin 429437 -> 429320 bytes docs/screenshots/bomb-picked.png | Bin 243580 -> 243547 bytes docs/screenshots/charges.png | Bin 256654 -> 256659 bytes docs/screenshots/countdown.png | Bin 248390 -> 248448 bytes docs/screenshots/desktop.png | Bin 583108 -> 583119 bytes docs/screenshots/dev.png | Bin 245842 -> 245790 bytes docs/screenshots/long-press.png | Bin 247163 -> 247145 bytes docs/screenshots/menu.png | Bin 170784 -> 170738 bytes docs/screenshots/start.png | Bin 243029 -> 243146 bytes src/sim/enemies.ts | 22 ++++++++++++++++++---- src/sim/record.ts | 2 +- test/enemies.test.ts | 34 ++++++++++++++++++++++++++++++++++ 18 files changed, 73 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 661f1e9..b403a1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,7 +82,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` 15). +- Saved runs from before the gems can't be resumed (`SIM_VERSION` 16). - 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. @@ -99,7 +99,7 @@ saved before the change is dropped and the next visit starts a new run. - Enemies let out by flaming the exit or the gem are no longer killed at once by that flame, which lingers a turn; a new explosion still kills them. - The last enemy's charge is always one that lasts (Pierce, Power, Nuke or Shield), so it isn't - wasted. + 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). - An error while drawing a frame or playing a turn no longer freezes the game; it is logged and diff --git a/PLAN.md b/PLAN.md index 9b15be2..051c259 100644 --- a/PLAN.md +++ b/PLAN.md @@ -436,7 +436,9 @@ contact. Intent shown every turn. Adapted from the NES eight: each plans around where the others will be, so they spread out and only share when forced (`spreadSteps`). *Since 2026-10-23 (user's choice):* when a turn's kills leave no enemy, their charge drops are only lasting ones (Pierce, Power, Nuke, Shield: `LASTING_CHARGES`), since a - Freeze or Decoy from the last enemy had nothing left to work on. Enemies spawned from a flamed exit + Freeze or Decoy from the last enemy had nothing left to work on. Nor is a Decoy, Freeze or + Shield dropped while one already lies on the board or is working (user report, 2026-10-23: a + 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 diff --git a/README.md b/README.md index 8e59836..5dbe1d6 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,9 @@ Level 1 has 3 enemies, with one more every 3 levels (at most 10). Enemies never A slain enemy sometimes leaves **coins** (bronze = 1, silver = 2, gold = 5) or a **charge** (which works as soon as you pick it up) on its tile. The last enemy's charge (when no enemies are 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. Drops never share a tile: if one is already there, the new one rolls to the +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 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). diff --git a/decisions.md b/decisions.md index 9aa9ced..acb47a5 100644 --- a/decisions.md +++ b/decisions.md @@ -855,3 +855,15 @@ when held. Pierce rarely found two crates in a row, so its room gets extra crate caption now has an icon. Tests: each kind is found on 6 seeds, replays with the player alive, and `showsAtEnd` confirms its last turn is its moment; probes found every kind on 15–30 seeds, the slowest under 0.4 s. A minute of the demo in a browser played 8 scenes with no errors. + +### 2026-10-23 — No second Decoy, Freeze or Shield while one would be wasted + +User report (from the demo): two Decoys were dropped, the second while the first still lay +there. The last-enemy rule didn't cover it (the enemy wasn't the last). One decoy is out at a +time, a second Freeze adds no turns, a second Shield nothing — so an enemy's charge drop now +leaves out a Decoy, Freeze or Shield that already lies on the board or is working (`wasted` in +enemies.ts), on top of the last-enemy rule. Pierce, Power and Nuke add up and are never left +out, so there is always something to drop. A search over 40 seeds of every demo scene found 6 +such drops before; after, only 2 Freezes dropped in the turn the old freeze ran out (rolled once +it had, so not wasted). Tests: over 2,000 rolls each, none of the three drops while one lies +there or works; Pierce still does. `SIM_VERSION` 16. diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png index 196e99b..7e35bd5 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 cc02bf0..0723588 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 eee4858..f91eb05 100644 Binary files a/docs/screenshots/almanac.png and b/docs/screenshots/almanac.png differ diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png index 527feb8..319006e 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 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 a815c54..c5609b1 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..a7c0728 100644 Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ diff --git a/docs/screenshots/dev.png b/docs/screenshots/dev.png index 4d41bde..abff8e7 100644 Binary files a/docs/screenshots/dev.png and b/docs/screenshots/dev.png differ diff --git a/docs/screenshots/long-press.png b/docs/screenshots/long-press.png index ff227e7..174ec4e 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 008af24..99d0710 100644 Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png index 7e9ebe5..03d1e8f 100644 Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ diff --git a/src/sim/enemies.ts b/src/sim/enemies.ts index f152232..dddba3e 100644 --- a/src/sim/enemies.ts +++ b/src/sim/enemies.ts @@ -177,9 +177,23 @@ const DROP_CHANCE: Record<EnemyKind, { coins: number; charge: number }> = { */ export const LASTING_CHARGES: readonly ChargeId[] = ['pierce', 'power', 'nuke', 'shield'] -/** Every charge is equally likely; with `last`, every lasting one. */ -function pickCharge(rng: Rng, last: boolean): ChargeId { - const ids = last ? LASTING_CHARGES : CHARGE_IDS +/** + * A Decoy, Freeze or Shield already lying on the board, or already working, makes another one + * useless: there is one decoy at a time, a second Freeze doesn't add turns, a second Shield + * nothing (user report, 2026-10-23: a second Decoy dropped beside the first). Pierce, Power and + * Nuke add up, so they are never wasted. + */ +function wasted(s: GameState, id: ChargeId): boolean { + const lying = s.drops.some((d) => d.kind === id) + if (id === 'decoy') return lying || !!s.decoy + if (id === 'freeze') return lying || s.frozen > 0 + if (id === 'shield') return lying || s.shield + return false +} + +/** Every charge that wouldn't be wasted is equally likely; with `last`, every lasting one. */ +function pickCharge(s: GameState, rng: Rng, last: boolean): ChargeId { + const ids = (last ? LASTING_CHARGES : CHARGE_IDS).filter((id) => !wasted(s, id)) return ids[Math.floor(rng.next() * ids.length)]! } @@ -197,7 +211,7 @@ export function rollDrops(s: GameState, e: Enemy, last = false): Drop[] { drops.push({ pos: e.pos, kind: 'coins', amount: r < 0.6 ? 1 : r < 0.9 ? 2 : 5 }) } if (rng.next() < chance.charge) { - drops.push({ pos: e.pos, kind: pickCharge(rng, last) }) + drops.push({ pos: e.pos, kind: pickCharge(s, rng, last) }) } s.dropRng = rng.state return drops diff --git a/src/sim/record.ts b/src/sim/record.ts index 805408d..f01f96e 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 = 15 +export const SIM_VERSION = 16 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */ export interface RunRecord { diff --git a/test/enemies.test.ts b/test/enemies.test.ts index a575600..a955280 100644 --- a/test/enemies.test.ts +++ b/test/enemies.test.ts @@ -601,3 +601,37 @@ describe('the last enemy\'s drop', () => { for (const c of charges) expect(LASTING_CHARGES).toContain(c) }) }) + +describe('charge drops are never wasted', () => { + /** Every charge kind a Drifter drops over many rolls, with `setup` applied first. */ + function dropped(setup: (s: ReturnType<typeof board>) => void): Set<string> { + const s = board(` + ##### + #P.D# + #####`) + setup(s) + const seen = new Set<string>() + for (let i = 1; i <= 2000; i++) { + s.dropRng = i * 7919 + for (const d of rollDrops(s, s.enemies[0]!)) if (d.kind !== 'coins') seen.add(d.kind) + } + return seen + } + + it('no second Decoy, Freeze or Shield while one lies on the board', () => { + for (const kind of ['decoy', 'freeze', 'shield'] as const) { + expect(dropped((s) => s.drops.push({ pos: v(2, 1), kind }))).not.toContain(kind) + } + }) + + it('none while one is working', () => { + expect(dropped((s) => (s.decoy = { pos: v(2, 1), turns: 2 }))).not.toContain('decoy') + expect(dropped((s) => (s.frozen = 2))).not.toContain('freeze') + expect(dropped((s) => (s.shield = true))).not.toContain('shield') + }) + + it('Pierce, Power and Nuke 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(() => {})).toEqual(new Set(['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy'])) + }) +})