SOURCE / PINNED RELEASE
Made of little things.
Shiftfield
- Release
- 5a170c505f6b…
- Author-recorded commit
- a2e23e0171aa…
- License
- LICENSE
- Author’s source reference
- nostr://npub16nzc4ql748r2kv9202rgzrt0fmtk4n3tzsjl86yf6ty9agqe9u0qjcc7hc/wss%3A%2F%2Fgit.napplet.soy%2F/n-e4fb6341ccd
Archive hash verified: 66de7623efd91da1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/* Minesweeper DX — Phase 2 "Chaos" layer.
*
* Turns advance on EFFECTIVE player actions only:
* - a reveal that clears at least one tile (a flood of 30 still = 1 turn)
* - a flag placement OR a flag removal (either toggle is a real state change)
* - a successful ability use — exactly ONE turn total, even when the ability
* itself flags (seeker) or reveals tiles (radar); the internal write must
* never re-commit
* - a shield-absorbed detonation (the charge was consumed; run continues)
* Uneffective attempts cost NOTHING and tick nothing:
* - ice-blocked reveal/flag, flag rejected on REVEALED ground, a click on
* already-revealed tiles, a failed ability (no Flow / on cooldown)
* Hazard and shift timers run off the turn counter.
*
* First-click safety (classic Minesweeper law): the opening reveal of every
* board NEVER detonates. A mine under the first tile the player opens moves to
* another HIDDEN, mine-free, hazard-free tile before the tile is opened. The
* guarantee is spent only by a reveal that actually opens tiles — ice-blocked
* attempts, clicks on flagged/opened ground, and radar (intrinsically mine-
* free) neither spend nor bypass it.
*
* Waves = boards (depth).
* - depth >= 6: The Shifter is active (uniform toroidal shifts on a timer)
* - depth >= 11: environmental hazards appear (spawns on wave advance)
*
* Ice: blocks interaction on its tile for `iceDuration` turns, then
* reverts to HIDDEN. Does NOT block mine shifts.
* Fire: spreads on ODD player turns of each board (turn counters 1, 3, 5
* ...; a per-board counter, reset by advance()). Each burning tile
* randomly ignites UP TO 2 of its eligible 8-neighbors: HIDDEN,
* unflagged, not ICE, not already FIRE — and mine tiles ARE valid
* ignition targets. RULES (player ruling 2026-10-27, supersedes the
* Phase 2 all-8-directions / fireInterval-cadence / adjacency-
* lethality behavior):
* - Initial spawns (advance, spawnFire) NEVER land on a mine tile;
* spawning next to a mine is fine (adjacency is harmless).
* - A fire that SPREADS ONTO a tile with a mine under it detonates
* that mine: MINE_ON_FIRE (shield may absorb; the ignited fire
* tile is extinguished by the absorb).
* - Fire merely ADJACENT to a mine: no effect at all. Fire only
* stops at revealed/flagged/ice tiles and existing fire.
* - Revealing a fire tile extinguishes it — DIRECTLY or via a
* flood cascade (ruling 2026-10-28: fire is NOT a flood wall; a
* fire tile opens exactly as its plain hidden counterpart would).
* Flagging also extinguishes it (a flag then locks the tile as
* usual). Fire only exists on HIDDEN tiles.
*
* Flow: +flowPerSafe per player ACTION that clears tiles (a flood-open
* of 30 tiles still pays once), +flowPerClear per board clear.
* Radar grants nothing — it is a pure Flow sink.
* GAINS clamp at the Flow ceiling: 100 base, +100 per Flow
* Capacitor Workshop level, max 500 (ruling 2026-10-27,
* flow_cap_ruling). Excess over the ceiling is wasted.
*
* Abilities: Flow-cost, turn-based cooldown, one player turn each.
* radar — reveals the safe subset of a 3×3 square (50f — steep on
* purpose, rulings 2026-10-27 and 2026-10-29: info is the
* strongest offense and was still over-dominating)
* seeker — flags one mine (50f, same steep rationale)
* shields — NOT an ability any more (ruling 2026-10-27,
* shop_flow_rebalance): the Workshop Shield level is the
* number of charges a RUN STARTS with (max 5). No Flow
* purchase, no recharge — once spent, spent. Each charge
* absorbs the next mine hit:
* MINE_ON_FIRE → extinguish the fire TILE under/ignited-on
* the mine (the exact tile carrying the mine-fire contact,
* whether a spread ignited the mine tile or a shift moved a
* mine onto fire) and clear the explosion. Ruling
* 2026-10-27: fire merely ADJACENT to a mine is harmless, so
* the absorb targets the single contact tile, NOT a whole
* "contact front" (the old front-defuse is obsolete —
* adjacency no longer detonates);
* MINE_ON_REVEALED → flag the detonated mine;
* MINE_ON_MINE (shift collision, ruling 2026-10-27) → flag
* the collision tile. The anchor rule then freezes the
* chain: nothing moves out of or into a FLAGGED tile, so the
* collision can never recur while the flag stands.
* Every absorb publishes `absorbed` so the shielded-boom
* fx/sound/status surface (the explosion itself clears
* inside the same commit and is invisible to the UI).
* stabilizer — Workshop-gated (ruling 2026-10-27): pauses the shift
* countdown for stabilizerDurationFor(level) turns —
* Lv1 3 · Lv2 5 · Lv3 7 · Lv4 10. Cooldown stays 10 turns
* at every level, so Lv4 recast-on-cooldown = permanent
* Shifter lockout. Level 0 = LOCKED: using it fails at NO
* cost, like the other Workshop abilities.
*/
import {
MinesweeperEngine,
GRID_SIZE,
type ExplosionCause,
} from './engine.ts';
import {
ZERO_UPGRADES,
flowCapFor,
radarOffsets,
stabilizerDurationFor,
type UpgradeLevels,
} from './upgrades.ts';
export const SHIFTS_MIN_DEPTH = 6;
export const HAZARDS_MIN_DEPTH = 11;
/* Hazard spawn counts per NEW board (player ruling 2026-10-27): board #N
* since hazards began (N = depth - HAZARDS_MIN_DEPTH + 1) starts at 1 ICE +
* 1 FIRE and grows: ICE +1 every board (cap 15), FIRE +1 every odd board
* (cap 5). Depth 15 → 3 fire + 5 ice; caps reached at depth 25 (ice) and
* depth 19 (fire). */
export const HAZARD_ICE_CAP = 15;
export const HAZARD_FIRE_CAP = 5;
export interface ChaosConfig {
iceDuration: number; // turns ice blocks a tile
flowPerSafe: number;
flowPerClear: number;
}
export const DEFAULT_CHAOS: ChaosConfig = {
iceDuration: 3,
flowPerSafe: 5,
flowPerClear: 20,
};
export const ABILITIES = {
// Radar/Seeker Flow costs raised 5f→30f (ruling 2026-10-27), then again
// 30f→50f (ruling 2026-10-29, ability_cost_rebalance): at 30f a mid-run
// board still self-funds several casts; Intel must stay a rare spend.
// Cooldowns unchanged.
radar: { cost: 50, cooldown: 2 },
seeker: { cost: 50, cooldown: 3 },
// Shield is NO LONGER a Flow ability (ruling 2026-10-27): the Workshop
// level is the number of charges a run STARTS with — see GameEngine ctor.
// Stabilizer cooldown is 10 at EVERY Workshop level (ruling 2026-10-27):
// only the hold duration scales, so Lv4 (10-turn hold) spans the cooldown.
// Cast cost raised 10f→30f (ruling 2026-10-29, ability_cost_rebalance):
// at 10f the Shifter threat was trivially rented away.
stabilizer: { cost: 30, cooldown: 10 },
} as const;
const DIRECTIONS: ReadonlyArray<readonly [number, number]> = [
[1, 0], [-1, 0], [0, 1], [0, -1],
[1, 1], [1, -1], [-1, 1], [-1, -1],
];
function pickDirection(rng: () => number): readonly [number, number] {
return DIRECTIONS[Math.floor(rng() * DIRECTIONS.length)];
}
/** Master spec §5.2, expressed in player turns. */
export function shiftIntervalFor(depth: number): number {
return Math.max(10, Math.floor(45 / (1 + depth * 0.1)));
}
/** Hard cap on per-board mines (ruling 2026-10-27): 50 = 50% density, hit at depth 175. */
export const MINE_COUNT_CAP = 50;
/** Master spec §5.2 (linear scaling, capped at MINE_COUNT_CAP). */
export function mineCountFor(depth: number): number {
return Math.min(
MINE_COUNT_CAP,
Math.floor(GRID_SIZE * GRID_SIZE * 0.15 + depth * 0.02 * GRID_SIZE),
);
}
export class GameEngine {
world: MinesweeperEngine;
cfg: ChaosConfig;
depth: number;
seed: number;
/** Workshop ability levels (Phase 3). radar/seeker/shield at 0 = LOCKED. */
upgrades: UpgradeLevels;
turn = 0;
/** Player turns elapsed on the CURRENT board (reset by advance()). Fire
* spreads on ODD values of this counter — so a fresh board always gets its
* first spread after its own turn 1, independent of run-wide turn parity. */
boardTurn = 0;
flow = 0;
/** idx → turns remaining until the ice reverts to HIDDEN. */
ice: Map<number, number> = new Map();
/** Active fire tiles (always on HIDDEN tiles). */
fire: Set<number> = new Set();
/** Turns until the next scheduled shift (0 while shifts disabled). */
nextShiftIn = 0;
/** Direction the next shift will use (announced in advance). */
telegraph: readonly [number, number] | null = null;
/** Cell index of the most recent shift collision (MINE_ON_MINE), consumed
* by resolveShield() to flag the collision tile. Reset per board. */
lastShiftCollision: number | null = null;
/** Turns remaining on a Stabilizer pause. */
stabilizedFor = 0;
/** abilityId → turns remaining until usable. */
cooldowns: Map<string, number> = new Map();
/** Shield charges left for THIS run. Set once at construction to the
* Workshop Shield level (ruling 2026-10-27: a run STARTS with N shields;
* mine-hit absorbs spend them, nothing recharges them in-run). */
shieldCharges = 0;
boardsCleared = 0;
/**
* First-click safety (classic Minesweeper law): while true, this board's
* OPENING reveal cannot detonate — a mine under the first tile the player
* opens relocates before the tile opens. Spent the moment a reveal truly
* opens HIDDEN ground; ice-blocked attempts, clicks on flagged or
* already-open ground open nothing and do NOT spend it. Radar neither
* spends nor bypasses it (radar is intrinsically mine-free). Re-armed on
* every new board (advance() / a fresh GameEngine).
*/
firstClickSafe = true;
/**
* Set by resolveShield() the moment a charge absorbs a detonation, consumed
* by Board.diffFx() (shielded-boom fx + sound + status). The explosion
* clears inside the same commit(), so the UI has no other way to see it.
* Reseeds (advance(), newRun, QA engine swaps) clear it silently.
*/
absorbed: { cause: ExplosionCause; x: number; y: number } | null = null;
constructor(
depth = 0,
cfg: ChaosConfig = DEFAULT_CHAOS,
seed = 1,
world?: MinesweeperEngine,
upgrades: UpgradeLevels = ZERO_UPGRADES,
) {
this.depth = depth;
this.cfg = cfg;
this.world = world ?? new MinesweeperEngine(seed, mineCountFor(depth));
this.seed = seed;
// SAFE default: an engine knows NOTHING unless levels are explicitly
// granted (Workshop purchases or a test passing MAX_UPGRADES). Defaulting
// to MAX was a trap — board.ts builds with defaults, and fresh saves
// woke with 5 free shield charges (regression pinned in browser suite).
this.upgrades = upgrades;
// Ruling 2026-10-27: a run STARTS with exactly `shield_lvl` charges.
// advance() never refills them — spend them on absorbs and that's it.
this.shieldCharges = upgrades.shield;
if (this.depth >= SHIFTS_MIN_DEPTH) {
this.nextShiftIn = shiftIntervalFor(this.depth);
this.telegraph = pickDirection(this.world.rng);
}
}
// ------------------------------ helpers --------------------------------
idx(x: number, y: number): number {
return this.world.wrapY(y) * this.world.width + this.world.wrapX(x);
}
tile(x: number, y: number): ReturnType<MinesweeperEngine['tileAt']> {
return this.world.tileAt(x, y);
}
iceAt(x: number, y: number): boolean {
return this.ice.has(this.idx(x, y));
}
fireAt(x: number, y: number): boolean {
return this.fire.has(this.idx(x, y));
}
canInteract(x: number, y: number): boolean {
return !this.iceAt(x, y) && this.tile(x, y).state !== 'FLAGGED';
}
/** A tile can host ice or fire only if HIDDEN, mine-free, and hazard-free. */
private eligible(x: number, y: number): boolean {
const t = this.tile(x, y);
const c = this.idx(x, y);
return t.state === 'HIDDEN' && !t.hasMine && !this.ice.has(c) && !this.fire.has(c);
}
// --------------------------- player actions ---------------------------
/**
* Reveal a tile. Ice blocks the attempt at NO cost (no turn). Revealing
* a fire tile extinguishes it and counts as a normal safe clear.
*
* Flood: a zero-adjacent safe tile cascades open (see engine.reveal). The
* flood treats ICE tiles as walls (a hazard needing an explicit player
* action to clear) but NOT FIRE: a fire tile opens exactly as the plain
* hidden tile underneath it would — zero-adjacent fire conducts, numbered
* fire opens as border — and opening a fire tile extinguishes it (player
* ruling 2026-10-28, fire_flood_reveal_ruling_2026-10-28: fire never
* stops a flood).
*
* Turn: +1 only when the action is EFFECTIVE — at least one tile moved
* HIDDEN→REVEALED, or the detonation was absorbed by a shield charge (the
* charge was consumed and the run continues). Clicking already-open ground
* clears nothing: no turn. A fatal, unshielded detonation ends the run:
* no turn is committed.
*
* Flow: +flowPerSafe ONCE per player action that clears at least one tile,
* regardless of how many tiles the flood opened.
*/
reveal(x: number, y: number): 'safe' | 'detonated' | 'blocked' {
const cell = this.idx(x, y);
if (this.ice.has(cell)) return 'blocked';
this.protectOpeningReveal(x, y);
if (this.fire.has(cell)) this.fire.delete(cell); // reveal extinguishes fire
const cleared = this.floodReveal(x, y);
if (this.world.explosion) {
this.resolveShield();
// Shield absorbed the hit → effective action: exactly ONE turn.
if (!this.world.explosion) this.commit();
return 'detonated';
}
if (cleared === 0) return 'safe'; // already-open ground: no-op, no turn
this.addFlow(this.cfg.flowPerSafe);
this.commit();
return 'safe';
}
/**
* BFS flood-open sharing the engine's zero-region rule, with ICE as an
* extra wall. FIRE is NOT a wall (ruling 2026-10-28): a fire tile opens
* and is extinguished exactly like the plain hidden tile underneath it.
* The clicked tile is opened directly by the caller's
* fire/lock bookkeeping; this returns how many tiles moved HIDDEN→REVEALED
* (0 when the click hit an already-revealed tile).
*/
private floodReveal(x: number, y: number): number {
// Clicking an already-REVEALED tile opens nothing and pays nothing.
if (this.tile(x, y).state === 'REVEALED') return 0;
const start = this.world.reveal(x, y, { flood: false });
if (start.result === 'detonated' || start.cleared === 0) return start.cleared;
if (this.world.adjacentMineCount(x, y) !== 0) return start.cleared;
let cleared = start.cleared;
const seen = new Set<number>([this.idx(x, y)]);
const queue: Array<[number, number]> = [[this.world.wrapX(x), this.world.wrapY(y)]];
while (queue.length > 0) {
const [cx, cy] = queue.shift() as [number, number];
for (const [dx, dy] of DIRECTIONS) {
const nx = this.world.wrapX(cx + dx);
const ny = this.world.wrapY(cy + dy);
const ncell = this.idx(nx, ny);
if (seen.has(ncell)) continue;
seen.add(ncell);
if (this.ice.has(ncell)) continue; // ice: wall
const r = this.world.reveal(nx, ny, { flood: false });
if (r.cleared === 0) continue; // flagged neighbor: wall, doesn't conduct
// Fire is not a flood wall (ruling 2026-10-28): a fire tile the flood
// reaches opens like any hidden tile, and opening extinguishes it.
if (this.fire.has(ncell)) this.fire.delete(ncell);
cleared += r.cleared;
if (this.world.adjacentMineCount(nx, ny) === 0) queue.push([nx, ny]);
}
}
return cleared;
}
/**
* First-click safety gate, run at the top of every player reveal (before
* any state write). If this board's opening reveal has not happened yet and
* THIS click is about to open a HIDDEN tile, spend the guarantee — and if a
* mine sits under that tile, move it away first so the opening can never
* detonate. Clicks on FLAGGED or already-REVEALED ground open nothing, so
* they neither spend nor need the guarantee.
*/
private protectOpeningReveal(x: number, y: number): void {
if (!this.firstClickSafe) return;
const t = this.tile(x, y);
if (t.state !== 'HIDDEN') return; // opens nothing: guarantee stays armed
if (t.hasMine) this.relocateMineOff(x, y);
this.firstClickSafe = false;
}
/**
* Move the mine at (x, y) to another HIDDEN, mine-free tile. Player ruling
* 2026-10-27 (rule 2): a first-click-relocated mine may NEVER land on a
* live fire tile — fire tiles are hard-excluded from every candidate pool.
* Within the fire-free pool it still prefers ice-free ground (ice is a
* temporary hazard, not a rule-2 violation). The clicked tile itself is a
* mine and so can never be its own destination. Direct state writes (not
* placeMineAt): this is a relocation, not a placement, and MINE_ON_MINE
* must not fire. Proximity numbers are read live every refresh (board.ts),
* so moving a mine needs no re-render pass.
*/
private relocateMineOff(x: number, y: number): void {
const w = this.world;
const safeSpots: number[] = []; // HIDDEN, mine-free, fire-free, ice-free
const iceSpots: number[] = []; // HIDDEN, mine-free, fire-free (ice OK)
for (const t of w.tiles) {
if (t.hasMine || t.state !== 'HIDDEN') continue;
const c = t.coord[1] * w.width + t.coord[0];
if (this.fire.has(c)) continue; // rule 2: never under fire
if (this.ice.has(c)) iceSpots.push(c);
else safeSpots.push(c);
}
const pool = safeSpots.length > 0 ? safeSpots : iceSpots;
if (pool.length === 0) return; // degenerate: nowhere to move (unreachable in play)
const dest = pool[Math.floor(w.rng() * pool.length)];
w.tileAt(x, y).hasMine = false;
w.tiles[dest].hasMine = true;
}
/**
* Flag a tile (lock). Classic Minesweeper toggle: right-clicking an
* existing flag REMOVES it (back to HIDDEN). Flags may only be placed on
* HIDDEN tiles — revealed tiles reject the attempt. Flagging a hidden FIRE
* tile EXTINGUISHES the fire first (player ruling 2026-10-27) and then
* places the lock.
*
* Turn: +1 on either toggle (place OR remove — both are real state changes:
* a lock placed, a lock released). Ice-blocked and rejected attempts change
* nothing: no turn.
*/
flag(x: number, y: number): 'flagged' | 'unflagged' | 'blocked' | 'rejected' {
const cell = this.idx(x, y);
if (this.ice.has(cell)) return 'blocked';
const t = this.tile(x, y);
if (t.state === 'FLAGGED') {
this.world.unflag(x, y);
this.commit();
return 'unflagged';
}
if (t.state !== 'HIDDEN') {
// REVEALED tiles cannot host a flag. Nothing changed → no turn.
return 'rejected';
}
if (this.fire.has(cell)) this.fire.delete(cell);
this.world.flag(x, y);
this.commit();
return 'flagged';
}
// ------------------------------ tick work -----------------------------
/** One player action => one turn of game time. */
private commit(): void {
this.turn += 1;
this.boardTurn += 1;
this.tickCooldowns();
this.tickIce();
if (this.boardTurn % 2 === 1) this.tickFire(); // spread on ODD turns of each board
this.tickShift();
this.resolveShield();
}
private tickCooldowns(): void {
for (const [id, left] of this.cooldowns) {
if (left <= 0) this.cooldowns.delete(id);
else this.cooldowns.set(id, left - 1);
}
}
private tickIce(): void {
const expired: number[] = [];
for (const [cell, left] of this.ice) {
const next = left - 1;
if (next <= 0) expired.push(cell);
else this.ice.set(cell, next);
}
for (const cell of expired) this.ice.delete(cell);
}
/** Fire spread (player ruling 2026-10-27): on odd board turns each burning
* tile randomly ignites UP TO 2 eligible neighbors — HIDDEN, unflagged,
* not ICE, not already FIRE (mine tiles ARE valid targets). Fire onto a
* mine tile detonates it (MINE_ON_FIRE); mere adjacency to a mine does
* nothing. The ENTIRE wave is committed before any detonation it causes is
* recorded, so the spread never freezes mid-wave against a mine. */
private tickFire(): void {
if (this.fire.size === 0) return;
const additions = new Set<number>();
let ignitedMine = false;
for (const cell of this.fire) {
const x = cell % this.world.width;
const y = Math.floor(cell / this.world.width);
const candidates: number[] = [];
for (const dir of DIRECTIONS) {
const nx = this.world.wrapX(x + dir[0]);
const ny = this.world.wrapY(y + dir[1]);
const target = ny * this.world.width + nx;
const t = this.world.tileAt(nx, ny);
if (t.state !== 'HIDDEN') continue; // revealed/flagged: no spread
if (this.ice.has(target)) continue; // ice blocks fire spread (assumption)
if (this.fire.has(target) || additions.has(target)) continue;
candidates.push(target);
}
// Pick up to 2 distinct eligible neighbors, uniformly (partial
// Fisher-Yates over the candidate list, using the world's seeded rng).
const picks = Math.min(2, candidates.length);
for (let i = 0; i < picks; i++) {
const j = i + Math.floor(this.world.rng() * (candidates.length - i));
const swap = candidates[i];
candidates[i] = candidates[j];
candidates[j] = swap;
const target = candidates[i];
additions.add(target);
if (this.world.tiles[target].hasMine) ignitedMine = true;
}
}
for (const cell of additions) this.fire.add(cell);
if (ignitedMine) this.world.explosion = 'MINE_ON_FIRE';
}
/**
* Shift countdown. A Stabilizer pauses it. When the countdown hits zero the
* telegraphed shift fires, mines landing on fire detonate (MINE_ON_FIRE), and
* a new telegraph direction is announced. A shift mine-on-mine COLLISION
* outranks fire contact: the engine already aborted the shift, so record the
* collision cell and let resolveShield() see MINE_ON_MINE (it would otherwise
* be overwritten as MINE_ON_FIRE here).
*
* T1 fix (audit 2026-10-28): the countdown re-arm + direction reroll run
* BEFORE the explosion early-returns. An exploding (or aborted) shift still
* spent its beat — if the reset were skipped, nextShiftIn would stick at 0
* and go negative, firing a full shift + pulse storm on EVERY later turn and
* locking the telegraph forever.
*/
private tickShift(): void {
if (this.depth < SHIFTS_MIN_DEPTH) return;
if (this.stabilizedFor > 0) {
this.stabilizedFor -= 1;
return;
}
this.nextShiftIn -= 1;
if (this.nextShiftIn > 0) return;
const dir = this.telegraph ?? [1, 0];
const res = this.world.shift(dir[0], dir[1]);
this.lastShiftCollision = res.collisionCell;
this.nextShiftIn = shiftIntervalFor(this.depth);
this.telegraph = pickDirection(this.world.rng);
if (this.world.explosion) return; // MINE_ON_MINE outranks fire contact
for (const t of this.world.tiles) {
const cell = t.coord[1] * this.world.width + t.coord[0];
if (t.hasMine && this.fire.has(cell)) {
this.world.explosion = 'MINE_ON_FIRE';
return;
}
}
}
/** A stored shield charge absorbs one mine hit. The absorb must remove the
* HAZARD that caused the hit, not just the explosion flag. Ruling
* 2026-10-27 (supersedes the earlier "contact front" ruling): fire only
* endangers a mine by IGNITING the mine's own tile (spread) or a mine
* shifting ONTO fire — adjacency is harmless. So the absorb extinguishes
* exactly the fire tile(s) that carry a mine, nothing more; a re-trigger
* loop is impossible because the remaining fire no longer contacts that
* mine in a dangerous way. */
private resolveShield(): void {
if (!this.world.explosion) return;
if (this.world.explosion === 'MINE_ON_FIRE' && this.shieldCharges > 0) {
this.shieldCharges -= 1;
this.world.explosion = null;
// Extinguish the fire tile(s) WITH A MINE ON THEM — the exact contact
// that detonated (spread-ignited mine tile, or shift-landed mine) —
// and FLAG the mine tile (player ruling 2026-10-27: the absorb locks
// the mine down like a normal flag would). A FLAGGED tile is not a
// spread candidate, so the saved mine can never re-ignite while the
// flag stands; unflagging re-opens it, same as any manual flag.
let contactCell: number | null = null;
for (const cell of [...this.fire]) {
const x = cell % this.world.width;
const y = Math.floor(cell / this.world.width);
if (!this.world.tileAt(x, y).hasMine) continue;
this.fire.delete(cell);
this.world.flag(x, y);
contactCell = cell;
}
this.absorbed = {
cause: 'MINE_ON_FIRE',
x: contactCell === null ? 4 : contactCell % this.world.width,
y: contactCell === null ? 4 : Math.floor(contactCell / this.world.width),
};
} else if (this.world.explosion === 'MINE_ON_REVEALED' && this.shieldCharges > 0) {
this.shieldCharges -= 1;
this.world.explosion = null;
let flagged: [number, number] | null = null;
for (const t of this.world.tiles) {
if (t.hasMine && t.state === 'REVEALED') {
this.world.flag(t.coord[0], t.coord[1]);
flagged ??= [t.coord[0], t.coord[1]];
}
}
this.absorbed = {
cause: 'MINE_ON_REVEALED',
x: flagged ? flagged[0] : 4,
y: flagged ? flagged[1] : 4,
};
} else if (this.world.explosion === 'MINE_ON_MINE' && this.shieldCharges > 0) {
// Shift collision (player ruling 2026-10-27): the shield stops the
// blast and FLAGS the collision tile. The stacked pair never exists as
// two mines on one tile (the engine aborts the whole shift), so the
// hazard to defuse is the collision SETTING: one mine anchored in place
// with another lined up behind it. Flagging the standing mine's tile
// anchors the chain under the §2.2 anchor rule — nothing moves into or
// out of a FLAGGED tile — so the same collision can never recur while
// the flag stands (unflagging re-opens it, same as any manual flag).
this.shieldCharges -= 1;
this.world.explosion = null;
const cell = this.lastShiftCollision;
this.lastShiftCollision = null;
let flagged: [number, number] | null = null;
if (cell !== null) {
const x = cell % this.world.width;
const y = Math.floor(cell / this.world.width);
this.world.flag(x, y); // flag NEVER detonates (spec §2.1 law)
flagged = [x, y];
}
this.absorbed = {
cause: 'MINE_ON_MINE',
x: flagged ? flagged[0] : 4,
y: flagged ? flagged[1] : 4,
};
}
}
// ------------------------------- abilities ------------------------------
/** Workshop gate: an ability whose level is 0 was never purchased.
* Stabilizer has no Workshop track (always available). */
isUnlocked(id: keyof UpgradeLevels): boolean {
return this.upgrades[id] > 0;
}
/** Current Flow ceiling (ruling 2026-10-27, flow_cap_ruling): 100 base,
* +100 per Flow Capacitor Workshop level, max 500. Read live from the
* Workshop levels so a mid-run purchase takes effect on the next run's
* engine (levels are passed at construction). */
flowCap(): number {
return flowCapFor(this.upgrades.flow_cap);
}
/** The ONLY sanctioned Flow gain path: clamps at the ceiling so the
* Capacitor purchase can actually bind (ruling 2026-10-27). Spending
* (pay()) and test grants keep touching `flow` directly. */
private addFlow(amount: number): void {
this.flow = Math.min(this.flowCap(), this.flow + amount);
}
private canUse(id: string, cost: number): boolean {
if (this.world.explosion) return false;
if (id in this.upgrades && !this.isUnlocked(id as keyof UpgradeLevels)) return false;
return this.flow >= cost && (this.cooldowns.get(id) ?? 0) <= 0;
}
private pay(id: string, cost: number, cooldown: number): boolean {
if (!this.canUse(id, cost)) return false;
this.flow -= cost;
this.cooldowns.set(id, cooldown);
return true;
}
/** Radar Pulse: reveals the safe tiles of the level's sweep footprint
* (Lv1 3×3 · Lv2 5×5-circle · Lv3 full 5×5 — src/upgrades.ts). Pure
* expenditure — radar reveals WITHOUT a flood (it promises a literal
* shape) and grants NO Flow: it is a Flow sink, not a Flow source. */
radar(cx: number, cy: number, spec: { cost: number; cooldown: number } = ABILITIES.radar): boolean {
if (!this.pay('radar', spec.cost, spec.cooldown)) return false;
for (const [dx, dy] of radarOffsets(this.upgrades.radar)) {
const x = this.world.wrapX(cx + dx);
const y = this.world.wrapY(cy + dy);
const t = this.tile(x, y);
if (t.state !== 'HIDDEN' || t.hasMine) continue;
if (this.iceAt(x, y) || this.fireAt(x, y)) continue;
this.world.reveal(x, y, { flood: false });
}
this.commit();
return true;
}
/** Seeker: flags up to `seeker_lvl` distinct unfound mines (one turn total). */
seeker(spec: { cost: number; cooldown: number } = ABILITIES.seeker): boolean {
const count = this.upgrades.seeker;
const mines = this.world.tiles.filter((t) => t.hasMine && t.state !== 'FLAGGED');
if (count <= 0 || mines.length === 0) return false;
if (!this.pay('seeker', spec.cost, spec.cooldown)) return false;
// Partial Fisher-Yates: pick up to `count` distinct mines. The swapped-IN
// element at index i is the pick (flagging the pre-swap element would
// double-flag whenever j > i).
const picks = Math.min(count, mines.length);
for (let i = 0; i < picks; i++) {
const j = i + Math.floor(this.world.rng() * (mines.length - i));
const swap = mines[i];
mines[i] = mines[j];
mines[j] = swap;
this.world.flag(mines[i].coord[0], mines[i].coord[1]);
}
this.commit();
return true;
}
/** Stabilizer (Workshop-gated, ruling 2026-10-27): pauses the shift
* countdown for stabilizerDurationFor(level) turns — Lv1 3 · Lv2 5 ·
* Lv3 7 · Lv4 10. Level 0 fails through canUse like the other gated
* abilities (no flow, no cooldown, no turn). */
stabilizer(spec: { cost: number; cooldown: number } = ABILITIES.stabilizer): boolean {
if (!this.pay('stabilizer', spec.cost, spec.cooldown)) return false;
this.stabilizedFor = stabilizerDurationFor(this.upgrades.stabilizer);
this.commit();
return true;
}
// ----------------------------- hazards ----------------------------------
/** Authoring/tests: place an ICE hazard on an eligible tile. */
spawnIce(x: number, y: number): boolean {
if (!this.eligible(x, y)) return false;
this.ice.set(this.idx(x, y), this.cfg.iceDuration);
return true;
}
/** Authoring/tests: place a FIRE hazard on an eligible tile. */
spawnFire(x: number, y: number): boolean {
if (!this.eligible(x, y)) return false;
this.fire.add(this.idx(x, y));
return true;
}
// --------------------------- progression (wave advance) ------------------
/** "All non-mine tiles cleared": every non-mine tile REVEALED and no live
* fire (fire can only be cleared by revealing, which makes it REVEALED).
* Ice reverts automatically; while active it is still HIDDEN → not cleared. */
get isBoardCleared(): boolean {
return this.world.isBoardCleared() && this.fire.size === 0;
}
/**
* Progression hook (Phase 3): fired by advance() AT the moment a board is
* cleared, BEFORE the depth increment — the cleared board is depth
* `depth`. main.ts wires this to ProgressionManager.creditClear() so run
* scrap mints exactly once per clear, including clears driven by tests or
* the QA hook. Returns the scrap minted for this clear.
*/
onBoardCleared: ((depth: number) => number) | null = null;
/** Advance to the next wave: fires the scrap hook, advances depth, rebuilds
* the board at the depth-scaled mine density, resets hazards, and spawns
* scaling hazards at depth >= 11 (ICE = min(N,15), FIRE = min(⌈N/2⌉,5),
* N = hazard-board number). */
advance(): void {
if (!this.isBoardCleared) return;
this.onBoardCleared?.(this.depth);
this.addFlow(this.cfg.flowPerClear);
this.depth += 1;
this.boardsCleared += 1;
this.seed = (this.seed + 0x9e3779b9) >>> 0;
this.world = new MinesweeperEngine(this.seed, mineCountFor(this.depth));
this.firstClickSafe = true; // new board ⇒ opening protection re-arms
this.ice.clear();
this.fire.clear();
this.stabilizedFor = 0;
this.lastShiftCollision = null;
this.nextShiftIn = this.depth >= SHIFTS_MIN_DEPTH ? shiftIntervalFor(this.depth) : 0;
this.telegraph = this.depth >= SHIFTS_MIN_DEPTH ? pickDirection(this.world.rng) : null;
if (this.depth >= HAZARDS_MIN_DEPTH) {
const coords = this.world.tiles.map((t) => t.coord as readonly [number, number]);
// Spawn counts scale per new board (ruling 2026-10-27): board #N gets
// min(N, 15) ICE and min(ceil(N/2), 5) FIRE — ice +1 every board,
// fire +1 on every ODD board (depth 15 → 5 ice + 3 fire).
const boardNumber = this.depth - HAZARDS_MIN_DEPTH + 1;
const iceTarget = Math.min(boardNumber, HAZARD_ICE_CAP);
const fireTarget = Math.min(Math.ceil(boardNumber / 2), HAZARD_FIRE_CAP);
for (let i = 0; i < iceTarget; i++) {
// recompute each pass: a fresh spawn makes its tile ineligible
const pool = coords.filter(([x, y]) => this.eligible(x, y));
if (pool.length === 0) break; // board too dense for all spawns
const [x, y] = pool[Math.floor(this.world.rng() * pool.length)];
this.spawnIce(x, y);
}
// Fire spawn NEVER lands ON a mine tile (eligible() enforces it; player
// ruling 2026-10-27). Spawning in a mine's neighborhood is allowed —
// adjacency is harmless under the current fire rules; only SPREADING
// onto the mine tile detonates. (The old "no mine in the 8-neighborhood"
// spawn filter belonged to the retired adjacency-lethality model.)
for (let i = 0; i < fireTarget; i++) {
const pool = coords.filter(([x, y]) => this.eligible(x, y));
if (pool.length === 0) break;
const [x, y] = pool[Math.floor(this.world.rng() * pool.length)];
this.spawnFire(x, y);
}
}
}
// ------------------------------- queries -----------------------------------
isDead(): boolean {
return this.world.explosion !== null;
}
explosionCause(): ExplosionCause | null {
return this.world.explosion;
}
iceCount(): number {
return this.ice.size;
}
fireCount(): number {
return this.fire.size;
}
}
