fuzzyworld

explodorama / commit

No second Decoy, Freeze or Shield while one would be wasted

8da5a7c028bfad1a932775edb40fb4c5e2cefb04 · Pavel Safronov · 2026-10-23 22:02:57 -0700 · parents fa3e81a7853f

Claude Code usage: this commit ≈0 out / ~14.6M cached · cost unknown (no per-model trailer) · session 3 tally 2.0K in / 1.0M out / 380.7M cached · usage by commit and session

No second Decoy, Freeze or Shield while one would be wasted

An enemy's charge drop leaves out a Decoy, Freeze or Shield that 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 can
always drop. Seen in the demo as a second Decoy dropped beside the first.
SIM_VERSION 16.

Usage: 2.0K in / 1.0M out / 380.7M cached (session tally)
rtk:   1.8M saved, 40.4% over 3304 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 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']))
+  })
+})