fuzzyworld

explodorama / commit

The rainbow gem offers every charge too

e3f29330e9a4c9155b628cdae2bfd13b6b59aba9 · Pavel Safronov · 2026-10-24 07:55:27 -0700 · parents 6e94f995cfec

Claude Code usage: this commit ~21.1K out / ~5.1M cached · cost unknown (no per-model trailer) · session 4 tally 300 in / 103.4K out / 17.8M cached · usage by commit and session

The rainbow gem offers every charge too

Developer mode's rainbow gem now lists all six charges (Pierce, Power,
Nuke, Shield, Freeze, Decoy) after the power-ups, so they can be tested
without waiting for a drop. A gem's offer is an OfferKind (a power-up or
a charge); a chosen charge works exactly as a picked-up one
(applyCharge). A level's own gem never offers a charge. SIM_VERSION
stays 23: earlier runs' choices are still on offer, so they replay the
same.

Usage: 0.3K in / 103.4K out / 17.8M cached (session tally)
rtk:   1.8M saved, 39.6% over 3634 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 CHANGELOG.md          |  4 ++--
 PLAN.md               |  2 +-
 README.md             |  2 +-
 decisions.md          | 12 ++++++++++++
 src/main.ts           |  2 +-
 src/sim/turn.ts       | 13 +++++++------
 src/sim/types.ts      | 23 +++++++++++++++++------
 src/ui/info.ts        |  4 ++--
 src/ui/offer.ts       | 18 ++++++++++--------
 test/options.test.ts  | 30 +++++++++++++++++++++++++-----
 test/powerups.test.ts |  4 ++--
 11 files changed, 80 insertions(+), 34 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f60545a..a64a141 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -102,8 +102,8 @@ saved before the change is dropped and the next visit starts a new run.
 - Developer mode now only adds an extra gem next to the player on every level (the level's own
   gem stays hidden); it no longer gives every ability, extra stats or unlimited charges.
 - Developer mode's extra gem is a **rainbow gem** that turns and glitters, so it can't be taken
-  for the level's own, and it offers **every power-up you don't have yet** instead of 5
-  (`SIM_VERSION` 23).
+  for the level's own, and it offers **every power-up you don't have yet, and every charge**
+  (Freeze, Decoy and the rest, to test them at once) instead of 5 power-ups (`SIM_VERSION` 23).
 - The board is drawn with **WebGL** again (it was Canvas 2D).
 
 ### Removed
diff --git a/PLAN.md b/PLAN.md
index 8983246..f6ab77b 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -694,7 +694,7 @@ parameters: `?dev`, `?immortal`.
 |---|---|
 | ~~**Board: Normal**~~ | *Removed 2026-10-23.* 31 × 13, density 35%, 50 levels, enemies 3 + 1 per 3 levels (max 10), par 50 + 10/enemy. |
 | ~~**Board: Small**~~ | *Now the only board (no longer an option).* 9 × 11 (fits a phone whole; the camera scales to fit both ways, no scrolling), density 25%, **20 levels**, enemies 2 + 1 per 4 levels (max 5), par 25 + 8/enemy. Same enemy-type schedule. Levelgen tops up crates if fewer than 2 are far enough for the exit and power-up. |
-| **Developer** | *Since 2026-10-23 (user request), only this:* an **extra** gem (`PowerUp.extra`; since 2026-10-24 a turning, glittering rainbow gem offering every power-up not held yet) lies right of the start, every level and after every death, to try the choice at once; the level keeps its own gem, hidden as usual (at first it was that gem, moved). Achievements count, with a red border (the victory banner is framed in red). (Until then: every ability and pass, 5 bombs, blast 5, unlimited charges shown as ∞; **killable**, split from God mode by the user.) |
+| **Developer** | *Since 2026-10-23 (user request), only this:* an **extra** gem (`PowerUp.extra`; since 2026-10-24 a turning, glittering rainbow gem offering every power-up not held yet and every charge) lies right of the start, every level and after every death, to try the choice at once; the level keeps its own gem, hidden as usual (at first it was that gem, moved). Achievements count, with a red border (the victory banner is framed in red). (Until then: every ability and pass, 5 bombs, blast 5, unlimited charges shown as ∞; **killable**, split from God mode by the user.) |
 | **Immortal** (called God until 2026-10-23) | The player can't be hurt at all, so the run can be played to the end — but **an Immortal run earns no achievements** (the victory certificate, the Workshop's rewards, when they exist). |
 | ~~**Crumbling crates**~~ | *Always on since 2026-10-23 (no longer an option).* When the last enemy dies, every crate gets a countdown of 1–3 turns (seeded, `hash(seed, "crumble", level)`), shown by shaking and fading; at 0 it is gone, revealing whatever was under it. It is not flame: no punishment, nothing burned. |
 
diff --git a/README.md b/README.md
index 241237f..21a9c01 100644
--- a/README.md
+++ b/README.md
@@ -265,7 +265,7 @@ modes (there is one board size, and crates always crumble once the enemies are g
 
 | Mode | What it does |
 |---|---|
-| **Developer** (checkbox) | **An extra, rainbow gem lies right next to you** at the start of every level (and after a death), turning and glittering so it can't be mistaken for the level's own. It offers **every power-up you don't have yet** (not the usual 5), to try any of them at once. The level's own gem is still hidden under a crate as usual. Achievements still count, but with a red border (the victory banner is framed in red), like the red quarters some bars kept for their own jukebox plays. |
+| **Developer** (checkbox) | **An extra, rainbow gem lies right next to you** at the start of every level (and after a death), turning and glittering so it can't be mistaken for the level's own. It offers **every power-up you don't have yet, and every charge** (not the usual 5 power-ups), to try any of them at once. The level's own gem is still hidden under a crate as usual. Achievements still count, but with a red border (the victory banner is framed in red), like the red quarters some bars kept for their own jukebox plays. |
 | **Immortal** (checkbox) | Nothing can kill you, so you can play through to the end — but an Immortal run earns no achievements. (It was called God.) |
 
 **Seed:** the New run dialog also has an optional seed field. Empty means a new, random
diff --git a/decisions.md b/decisions.md
index 86cc9f1..c928103 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1119,3 +1119,15 @@ rainbow/glittering/strange gem", clearly a very special one, offering every poss
   nothing drawn (so no random stream). The dialog scrolls (15 at most). `SIM_VERSION` 23, since a
   Developer run saved before would replay differently.
 - Screenshots: `dev` shows the rainbow gem; new `dev-offer` shows its choice.
+
+### 2026-10-24 — The rainbow gem offers every charge too
+
+User request: so Freeze and the other charges can be tried without waiting for an enemy to drop
+them. The rainbow gem's offer lists every power-up that would do anything, then all six charges
+(`OfferKind` = a power-up or a charge; `isCharge`). A chosen charge works exactly as a picked-up
+one (`applyCharge`): bomb boosts add BOMB_BOOST, Shield goes up, Freeze freezes every enemy,
+Decoy is left on the gem's tile. Choosing takes no turn and comes after the gem's turn, so
+a Freeze chosen this way skips the enemies' next 3 moves (one picked up skips its own turn's). A level's own gem never offers a charge.
+`SIM_VERSION` stays 23: a run recorded before still replays the same (its choices are still on
+offer), and 23 itself is from earlier today.
+
diff --git a/src/main.ts b/src/main.ts
index 281d9aa..298ae7b 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -390,7 +390,7 @@ function newRunDialog(): void {
     body:
       warning +
       `<fieldset class="choices"><legend>Modes</legend>` +
-      `<label><input type="checkbox" name="dev"${checked(o.dev)}><span><b>Developer</b> An extra, rainbow gem lies right next to you at the start of every level, offering every power-up you don’t have yet (the level’s own is still hidden). Achievements still count, marked with a red border.</span></label>` +
+      `<label><input type="checkbox" name="dev"${checked(o.dev)}><span><b>Developer</b> An extra, rainbow gem lies right next to you at the start of every level, offering every power-up you don’t have yet and every charge (the level’s own is still hidden). Achievements still count, marked with a red border.</span></label>` +
       `<label><input type="checkbox" name="immortal"${checked(o.immortal)}><span><b>Immortal</b> Nothing can kill you. You can play through to the end, but an Immortal run earns no achievements.</span></label>` +
       `</fieldset><fieldset class="choices"><legend>Seed</legend>` +
       `<label class="seed"><input type="text" name="seed" inputmode="numeric" autocomplete="off" maxlength="40" placeholder="random"><span>Leave it empty for a new dungeon, or type the seed of one to play it (shown in the menu and at the end of a run).</span></label>` +
diff --git a/src/sim/turn.ts b/src/sim/turn.ts
index 9eccb60..60a8a5f 100644
--- a/src/sim/turn.ts
+++ b/src/sim/turn.ts
@@ -4,7 +4,7 @@ import { enemyKinds, LAST_LEVEL } from './levelgen'
 import { detonate, tickBombs } from './explosions'
 import { add, bombAt, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, PAR_BONUS } from './state'
 import { stream } from './rng'
-import { DIRS, DIR_LIST, type Action, type Death, type GameEvent, type GameState, type Powers, type PowerUpKind, type Vec } from './types'
+import { CHARGE_IDS, DIRS, DIR_LIST, isCharge, type Action, type Death, type GameEvent, type GameState, type OfferKind, type Powers, type PowerUpKind, type Vec } from './types'
 
 export interface StepResult {
   state: GameState
@@ -29,9 +29,10 @@ export function step(prev: GameState, action: Action): StepResult {
   // The gem's choice: no turn passes, nothing else moves.
   if (action.id === 'choose') {
     if (!s.offer?.includes(action.kind)) throw new Error(`${action.kind} is not on offer`)
-    applyPowerUp(s, action.kind)
     s.offer = null
     events.push({ type: 'powerUpChosen', kind: action.kind })
+    if (isCharge(action.kind)) applyCharge(s, action.kind, events)
+    else applyPowerUp(s, action.kind)
     return { state: s, events }
   }
   // An offer must be answered first, even with the level cleared; a death drops it.
@@ -293,15 +294,15 @@ const HELD_AS: Partial<Record<PowerUpKind, keyof Powers>> = {
 /**
  * A gem's offer: OFFER_SIZE different power-ups (fewer if fewer would do anything), drawn by
  * POWER_UP_WEIGHTS and listed in its order. From the seed and level, so a retry after a death
- * offers the same. Developer mode's extra gem offers every one that would do anything, nothing
- * drawn (user request, 2026-10-24).
+ * offers the same. Developer mode's extra gem offers every one that would do anything, then
+ * every charge, nothing drawn (user request, 2026-10-24).
  */
-export function drawOffer(s: GameState, extra = false): PowerUpKind[] {
+export function drawOffer(s: GameState, extra = false): OfferKind[] {
   const pool = POWER_UP_WEIGHTS.filter(([kind]) => {
     const held = HELD_AS[kind]
     return !held || !s.powers[held]
   })
-  if (extra) return pool.map(([kind]) => kind)
+  if (extra) return [...pool.map(([kind]) => kind), ...CHARGE_IDS]
   const rng = stream(s.seed, 'offer', s.level)
   const picked = new Set<PowerUpKind>()
   while (picked.size < OFFER_SIZE && pool.length) {
diff --git a/src/sim/types.ts b/src/sim/types.ts
index 0689480..30d4ed4 100644
--- a/src/sim/types.ts
+++ b/src/sim/types.ts
@@ -160,6 +160,16 @@ export type ChargeId = 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'deco
 
 export const CHARGE_IDS: readonly ChargeId[] = ['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy']
 
+/**
+ * What a gem can offer: a power-up, or a charge (only Developer mode's rainbow gem, to test them
+ * at once: user request, 2026-10-24). A chosen charge works as if picked up.
+ */
+export type OfferKind = PowerUpKind | ChargeId
+
+export function isCharge(kind: OfferKind): kind is ChargeId {
+  return (CHARGE_IDS as readonly OfferKind[]).includes(kind)
+}
+
 /** What picked-up charges still have to give. Kept from level to level and through deaths. */
 export interface Boosts {
   /** How many of the player's next bombs pierce, reach the walls, nuke. Each bomb placed uses one of each. */
@@ -220,10 +230,11 @@ export interface GameState {
    */
   gems: PowerUp[]
   /**
-   * The power-ups the gem just taken offers. While it is set, the only action is `choose`; a
-   * death (or game over) in the same turn drops it, and the retry has the gem back.
+   * The power-ups (and, from the rainbow gem, charges) the gem just taken offers. While it is
+   * set, the only action is `choose`; a death (or game over) in the same turn drops it, and the
+   * retry has the gem back.
    */
-  offer: PowerUpKind[] | null
+  offer: OfferKind[] | null
   /**
    * Tile indices still on fire from last turn's explosions. Flame lingers one extra turn: it
    * kills creatures that are in it or walk into it this turn, and the player can't step or put
@@ -244,7 +255,7 @@ export interface GameState {
 export type ActionId = 'move' | 'bomb' | 'wait' | 'run' | 'detonate' | 'kick' | 'throw' | 'teleport' | 'diagonal'
 
 /** `choose`: one of the gem's offer. It takes no turn. */
-export type Action = { id: ActionId; target?: Vec } | { id: 'continue' } | { id: 'choose'; kind: PowerUpKind }
+export type Action = { id: ActionId; target?: Vec } | { id: 'continue' } | { id: 'choose'; kind: OfferKind }
 
 /**
  * How the player died, for the death and game-over banners (user request, 2026-10-24): walked
@@ -285,8 +296,8 @@ export type GameEvent =
   | { type: 'decoyGone'; pos: Vec; burned: boolean }
   | { type: 'powerUpRevealed'; powerUp: PowerUp }
   | { type: 'powerUpBurned'; powerUp: PowerUp }
-  | { type: 'powerUpTaken'; powerUp: PowerUp; offer: PowerUpKind[] }
-  | { type: 'powerUpChosen'; kind: PowerUpKind }
+  | { type: 'powerUpTaken'; powerUp: PowerUp; offer: OfferKind[] }
+  | { type: 'powerUpChosen'; kind: OfferKind }
   | { type: 'detonated'; pos: Vec }
   | { type: 'runWon'; turns: number; deaths: number }
   | { type: 'cratesCrumbling'; count: number }
diff --git a/src/ui/info.ts b/src/ui/info.ts
index 42b1681..3328af0 100644
--- a/src/ui/info.ts
+++ b/src/ui/info.ts
@@ -102,7 +102,7 @@ export function deathLine(level: number, d: Death | null): string {
 /** One line per mode the run has on (none in a plain run). */
 export function modeLines(s: GameState): string[] {
   const lines: string[] = []
-  if (s.options.dev) lines.push('Developer mode: an extra, rainbow gem lies right next to you at the start of every level, offering every power-up you don’t have yet. The level’s own gem is still hidden under a crate. Achievements still count, marked with a red border.')
+  if (s.options.dev) lines.push('Developer mode: an extra, rainbow gem lies right next to you at the start of every level, offering every power-up you don’t have yet and every charge. The level’s own gem is still hidden under a crate. Achievements still count, marked with a red border.')
   if (s.options.immortal) lines.push('Immortal: nothing can kill you. You can play through to the end, but the run earns no achievements.')
   return lines
 }
@@ -251,7 +251,7 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
       title: gem.extra ? 'Rainbow gem (Developer mode)' : 'Gem',
       body: [
         gem.extra
-          ? 'Walk onto it to choose any power-up you don’t have yet: every one is offered.'
+          ? 'Walk onto it to choose any power-up you don’t have yet, or any charge: every one is offered.'
           : `Walk onto it to choose one of ${OFFER_SIZE} power-ups. The colour doesn’t matter.`,
         PUNISH,
       ],
diff --git a/src/ui/offer.ts b/src/ui/offer.ts
index fc4ae9e..51cb456 100644
--- a/src/ui/offer.ts
+++ b/src/ui/offer.ts
@@ -1,15 +1,16 @@
-import type { PowerUpKind } from '../sim/types'
-import { POWER_UP_ICONS, spriteUrl } from '../render/sprites'
+import { isCharge, type OfferKind } from '../sim/types'
+import { CHARGE_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
 import { closeDialog, openDialog } from './dialog'
-import { POWER_UP_TEXT } from './info'
+import { CHARGE_TEXT, POWER_UP_TEXT } from './info'
 
 /**
  * The gem's choice (user request, 2026-10-23): its offer of power-ups, each with what it does,
  * laid out like the almanac. One must be picked; the dialog can't be closed any other way.
+ * Developer mode's rainbow gem offers the charges too (2026-10-24).
  */
-let chosen: ((kind: PowerUpKind) => void) | null = null
+let chosen: ((kind: OfferKind) => void) | null = null
 
-export function openOffer(offer: PowerUpKind[], choose: (kind: PowerUpKind) => void): void {
+export function openOffer(offer: OfferKind[], choose: (kind: OfferKind) => void): void {
   chosen = choose
   openDialog({
     title: 'Choose a power-up',
@@ -17,8 +18,9 @@ export function openOffer(offer: PowerUpKind[], choose: (kind: PowerUpKind) => v
       .map(
         (k) =>
           `<button type="button" class="almanac-entry offer-choice" data-offer="${k}">` +
-          `<img src="${spriteUrl(POWER_UP_ICONS[k])}" alt="" draggable="false">` +
-          `<div><strong>${POWER_UP_TEXT[k].name}</strong><p>${POWER_UP_TEXT[k].text}</p></div></button>`,
+          `<img src="${spriteUrl(isCharge(k) ? CHARGE_ICONS[k] : POWER_UP_ICONS[k])}" alt="" draggable="false">` +
+          `<div><strong>${(isCharge(k) ? CHARGE_TEXT[k] : POWER_UP_TEXT[k]).name}</strong>` +
+          `<p>${(isCharge(k) ? CHARGE_TEXT[k] : POWER_UP_TEXT[k]).text}</p></div></button>`,
       )
       .join(''),
     buttons: [],
@@ -32,5 +34,5 @@ dialogBody.addEventListener('click', (e) => {
   if (!button || !chosen) return
   const choose = chosen
   chosen = null
-  closeDialog(() => choose(button.dataset.offer as PowerUpKind))
+  closeDialog(() => choose(button.dataset.offer as OfferKind))
 })
diff --git a/test/options.test.ts b/test/options.test.ts
index 19e8081..0f9643d 100644
--- a/test/options.test.ts
+++ b/test/options.test.ts
@@ -3,7 +3,8 @@ import { enemyCount, generateLevel, LAST_LEVEL, START } from '../src/sim/levelge
 import { newRecord, replay } from '../src/sim/record'
 import { newRun } from '../src/sim/state'
 import { drawOffer, POWER_UP_WEIGHTS, step } from '../src/sim/turn'
-import { DEFAULT_OPTIONS, type Vec } from '../src/sim/types'
+import { CHARGE_IDS, DEFAULT_OPTIONS, isCharge, type Vec } from '../src/sim/types'
+import { BOMB_BOOST, FREEZE_TURNS } from '../src/sim/actions'
 import { board, v } from './helpers'
 
 describe('the board (one size since 2026-10-23)', () => {
@@ -99,13 +100,32 @@ describe('developer mode', () => {
     }
   })
 
-  it('the extra gem is the rainbow one, and offers every power-up (user request, 2026-10-24)', () => {
+  it('the extra gem is the rainbow one, and offers every power-up and charge (user request, 2026-10-24)', () => {
     const s = newRun(1, 1, 0, dev)
     expect(s.gems.find((g) => g.extra)!.color).toBe('rainbow')
     expect(s.gems.find((g) => !g.extra)!.color).not.toBe('rainbow')
-    expect(drawOffer(s, true)).toEqual(POWER_UP_WEIGHTS.map(([kind]) => kind))
+    const everything = [...POWER_UP_WEIGHTS.map(([kind]) => kind), ...CHARGE_IDS]
+    expect(drawOffer(s, true)).toEqual(everything)
     const { state } = step(s, { id: 'move', target: { x: START.x + 1, y: START.y } })
-    expect(state.offer).toEqual(POWER_UP_WEIGHTS.map(([kind]) => kind))
+    expect(state.offer).toEqual(everything)
+  })
+
+  it('a charge chosen from the extra gem works as if picked up', () => {
+    const s = newRun(1, 1, 0, dev)
+    const taken = step(s, { id: 'move', target: { x: START.x + 1, y: START.y } }).state
+    expect(step(taken, { id: 'choose', kind: 'freeze' }).state.frozen).toBe(FREEZE_TURNS)
+    expect(step(taken, { id: 'choose', kind: 'shield' }).state.shield).toBe(true)
+    expect(step(taken, { id: 'choose', kind: 'nuke' }).state.boosts.nuke).toBe(BOMB_BOOST)
+    const decoy = step(taken, { id: 'choose', kind: 'decoy' })
+    expect(decoy.state.decoy?.pos).toEqual(taken.player)
+    expect(decoy.events).toContainEqual({ type: 'decoyPlaced', pos: taken.player })
+    expect(decoy.state.offer).toBeNull()
+  })
+
+  it('a level\'s own gem never offers a charge', () => {
+    for (let seed = 1; seed <= 30; seed++) {
+      expect(drawOffer(newRun(seed, 1, 0, dev)).some(isCharge)).toBe(false)
+    }
   })
 
   it('the extra gem leaves out what the player already has', () => {
@@ -115,7 +135,7 @@ describe('developer mode', () => {
     const offer = drawOffer(s, true)
     expect(offer).not.toContain('run')
     expect(offer).not.toContain('flamePass')
-    expect(offer).toHaveLength(POWER_UP_WEIGHTS.length - 2)
+    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/powerups.test.ts b/test/powerups.test.ts
index 9b99acd..890e4f0 100644
--- a/test/powerups.test.ts
+++ b/test/powerups.test.ts
@@ -3,7 +3,7 @@ import { ACTIONS, isAvailable } from '../src/sim/actions'
 import { generateLevel, LAST_LEVEL } from '../src/sim/levelgen'
 import { newRun } from '../src/sim/state'
 import { COIN_BAG, drawOffer, EXIT_SPAWN, OFFER_SIZE, POWER_UP_WEIGHTS, STAR_TURNS, step } from '../src/sim/turn'
-import type { GameState, PowerUpKind } from '../src/sim/types'
+import type { GameState, OfferKind, PowerUpKind } from '../src/sim/types'
 import { board, v } from './helpers'
 
 describe('levelgen: the hidden gem', () => {
@@ -37,7 +37,7 @@ function offering(...offer: PowerUpKind[]): GameState {
   return s
 }
 
-const ORDER = POWER_UP_WEIGHTS.map(([kind]) => kind)
+const ORDER: OfferKind[] = POWER_UP_WEIGHTS.map(([kind]) => kind)
 
 describe('the offer', () => {
   it(`is ${OFFER_SIZE} different power-ups, in the almanac's order, the same for the same seed and level`, () => {