Back to Shiftfield
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.

src/engine.ts
/* Minesweeper DX — Phase 1 engine.
 *
 * Scope (master spec §6 Phase 1):
 *  - 10×10 grid with tile state management (HIDDEN / REVEALED / FLAGGED)
 *  - Collision suite:
 *      MINE_ON_REVEALED — a mine rests on a REVEALED tile
 *      MINE_ON_MINE     — two mines settle on the same coordinate
 *      Toroidal wrap    — shifting past a boundary wraps to the opposite edge
 *      Anchor rule     — a FLAGGED tile is LOCKED: a mine shifts NEITHER
 *                       out of nor into it; blocked mines stay in place
 *
 * Shift model: shifts are applied UNIFORM/SIMULTANEOUS (spec §2.2: "shifts
 * occur uniformly across the grid"). Every mine resolves its destination in
 * one pass; a mine on a FLAGGED tile, or whose destination is FLAGGED, keeps
 * its position. Two mines ending on the same tile → MINE_ON_MINE.
 * (Two distinct mines moving by one direction never converge — a toroidal
 * translation is a bijection — so collision arises only from the blocked-
 * landing case and from manual placement.)
 *
 * Rule: flagging NEVER detonates. Only a mine on a REVEALED tile ends the run.
 *
 * Out of scope (Phases 2–4): telegraphed shifts UI, ice/fire hazards,
 * flow/abilities, workshop economy, decision screens, persistence.
 */

export const GRID_SIZE = 10;

/** Base mine count (master spec §5.2: GridSize² × 0.15 → 15). */
export const MINE_COUNT: number = Math.floor(GRID_SIZE * GRID_SIZE * 0.15);

export type TileState = 'HIDDEN' | 'REVEALED' | 'FLAGGED';

export interface Tile {
  coord: [number, number]; // [x, y]
  state: TileState;
  hasMine: boolean;
}

export type ExplosionCause =
  | 'MINE_ON_REVEALED'
  | 'MINE_ON_MINE'
  | 'MINE_ON_FIRE'; // reserved for Phase 2; unused here

/** The 8 Moore neighbors, used for adjacency counts and flood reveal. */
const NEIGHBOR_OFFSETS: ReadonlyArray<readonly [number, number]> = [
  [1, 0], [-1, 0], [0, 1], [0, -1],
  [1, 1], [1, -1], [-1, 1], [-1, -1],
];

export interface ShiftResult {
  moved: number;
  blocked: number;
  /** Cell index (y*width+x) where two mines collided (MINE_ON_MINE), else
   * null. The board never stores a stacked tile (hasMine is boolean), so the
   * collision cell exists ONLY here — a shield absorb needs it to flag the
   * collision tile (player ruling 2026-10-27). */
  collisionCell: number | null;
}

export class MinesweeperEngine {
  readonly width = GRID_SIZE;
  readonly height = GRID_SIZE;
  readonly tiles: Tile[];
  readonly rng: () => number;
  /** Set once an explosion occurs; never cleared. */
  explosion: ExplosionCause | null = null;

  constructor(seed: number, mines: number = MINE_COUNT) {
    this.rng = mulberry32(seed);
    this.tiles = [];
    for (let y = 0; y < this.height; y++) {
      for (let x = 0; x < this.width; x++) {
        this.tiles.push({ coord: [x, y], state: 'HIDDEN', hasMine: false });
      }
    }
    this.placeMines(Math.floor(mines));
  }

  // ----------------------------- grid access ------------------------------

  tileAt(x: number, y: number): Tile {
    return this.tiles[this.wrapY(y) * this.width + this.wrapX(x)];
  }

  wrapX(x: number): number {
    return this.mod(x, this.width);
  }

  wrapY(y: number): number {
    return this.mod(y, this.height);
  }

  private mod(n: number, size: number): number {
    return ((n % size) + size) % size;
  }

  private placeMines(count: number): void {
    const occupied = new Set<number>();
    while (occupied.size < count) {
      const x = Math.floor(this.rng() * this.width);
      const y = Math.floor(this.rng() * this.height);
      const idx = y * this.width + x;
      if (occupied.has(idx)) continue;
      occupied.add(idx);
      this.tiles[idx].hasMine = true;
    }
  }

  // --------------------------- movement / shift ----------------------------

  /**
   * Uniform (simultaneous) shift: every mine resolves its destination from its
   * ORIGINAL position in one pass, with toroidal wrap.
   *
   * Anchor rule: a FLAGGED tile is LOCKED. A mine sitting on a flagged tile
   * is blocked ("nothing moves out of a LOCKED tile"); a mine whose target
   * is flagged is blocked ("nothing moves into a LOCKED tile"). Blocked
   * mines stay in place.
   *
   * MINE_ON_MINE: two mines settle on the same tile. Under a uniform shift
   * this happens only when one mine is stopped by a locked tile while
   * another lands on it (two distinct mines moving by one direction never
   * converge — a toroidal translation is a bijection). The shift ABORTS on
   * collision (no mines move); ShiftResult.collisionCell records where.
   * A shield charge may absorb this hit (ruling 2026-10-27): it flags the
   * collision tile, which anchors the chain (the stacked mine can never be
   * entered again while the flag stands).
   */
  shift(dx: number, dy: number): ShiftResult {
    if (this.explosion) return { moved: 0, blocked: 0, collisionCell: null };
    const movers: Array<{ fromIdx: number; targetIdx: number }> = [];
    const blockedIdx = new Set<number>();
    let moved = 0;
    let blocked = 0;
    for (let i = 0; i < this.tiles.length; i++) {
      const tile = this.tiles[i];
      if (!tile.hasMine) continue;
      const targetIdx = this.wrapY(tile.coord[1] + dy) * this.width + this.wrapX(tile.coord[0] + dx);
      if (tile.state === 'FLAGGED' || this.tiles[targetIdx].state === 'FLAGGED') {
        blocked++;
        blockedIdx.add(i);
        continue;
      }
      moved++;
      movers.push({ fromIdx: i, targetIdx });
    }
    // Full resolution from the ORIGINAL layout (both passes complete before
    // any square changes). Under a uniform shift two movers never share a
    // target, so the only collision is a mover landing on a mine that stayed
    // in place (it never vacates) → MINE_ON_MINE.
    for (const m of movers) {
      if (blockedIdx.has(m.targetIdx)) {
        this.explosion = 'MINE_ON_MINE';
        return { moved, blocked, collisionCell: m.targetIdx };
      }
    }
    for (const m of movers) this.tiles[m.fromIdx].hasMine = false;
    for (const m of movers) this.tiles[m.targetIdx].hasMine = true;
    this.checkExplosions();
    return { moved, blocked, collisionCell: null };
  }

  /**
   * Manual placement (authoring/tests). Placing onto a mine tile is
   * MINE_ON_MINE.
   */
  placeMineAt(x: number, y: number, opts: { explode?: boolean } = {}): ExplosionCause | null {
    const tile = this.tileAt(this.wrapX(x), this.wrapY(y));
    if (tile.hasMine) {
      this.explosion = 'MINE_ON_MINE';
      return this.explosion;
    }
    tile.hasMine = true;
    return opts.explode ? this.checkExplosions() : null;
  }

  // ------------------------------ collisions -------------------------------

  /** Rule of collision (master spec §2.1). Returns the cause, if any. */
  checkExplosions(): ExplosionCause | null {
    if (this.explosion) return this.explosion;
    const seen = new Set<number>();
    for (const tile of this.tiles) {
      if (!tile.hasMine) continue;
      const key = tile.coord[0] * this.height + tile.coord[1];
      if (seen.has(key)) {
        this.explosion = 'MINE_ON_MINE';
        return this.explosion;
      }
      seen.add(key);
      if (tile.state === 'REVEALED') {
        this.explosion = 'MINE_ON_REVEALED';
        return this.explosion;
      }
    }
    return null;
  }

  isExploded(): boolean {
    return this.explosion !== null;
  }

  // ------------------------------- player actions --------------------------

  /**
   * Reveal a tile. Locked tiles ignore the action. A mine underneath is
   * MINE_ON_REVEALED.
   *
   * Flood (classic zero-region open): when the revealed tile is safe AND has
   * zero adjacent mines (8-neighbor, toroidal), the open cascades — every
   * zero-adjacent neighbor opens in turn, and numbered (1–8) border tiles
   * open but do NOT conduct. FLAGGED and mine tiles are walls: flood never
   * opens them, so a flood can never detonate. The cascade is synchronous —
   * it fully resolves inside this one call, i.e. ONE player action.
   *
   * `flood: false` suppresses the cascade (used by abilities that promise a
   * literal square, e.g. radar's 3×3).
   *
   * Returns the number of tiles this call moved from HIDDEN to REVEALED
   * (1 for a single open, N for a flood).
   */
  reveal(x: number, y: number, opts: { flood?: boolean } = {}): {
    result: 'safe' | 'detonated';
    cleared: number;
  } {
    const flood = opts.flood ?? true;
    const tile = this.tileAt(x, y);
    if (tile.state === 'FLAGGED') return { result: 'safe', cleared: 0 };
    tile.state = 'REVEALED';
    let cleared = 1;
    if (tile.hasMine) {
      this.checkExplosions();
      return { result: 'detonated', cleared };
    }
    if (flood && this.adjacentMineCount(x, y) === 0) {
      const seen = new Set<number>([this.indexOf(x, y)]);
      const queue: Array<[number, number]> = [[this.wrapX(x), this.wrapY(y)]];
      while (queue.length > 0) {
        const [cx, cy] = queue.shift() as [number, number];
        for (const [dx, dy] of NEIGHBOR_OFFSETS) {
          const nx = this.wrapX(cx + dx);
          const ny = this.wrapY(cy + dy);
          const nidx = this.indexOf(nx, ny);
          if (seen.has(nidx)) continue;
          seen.add(nidx);
          const n = this.tiles[nidx];
          if (n.state !== 'HIDDEN' || n.hasMine) continue; // walls
          n.state = 'REVEALED';
          cleared++;
          // Numbered border tiles open but stop the cascade; only
          // zero-adjacent tiles conduct it further.
          if (this.adjacentMineCount(nx, ny) === 0) queue.push([nx, ny]);
        }
      }
    }
    return { result: 'safe', cleared };
  }

  /** 8-neighbor (toroidal) mine count around a coordinate. Used by the
   * flood rule and available for numbered-tile rendering. */
  adjacentMineCount(x: number, y: number): number {
    let count = 0;
    for (const [dx, dy] of NEIGHBOR_OFFSETS) {
      if (this.tileAt(x + dx, y + dy).hasMine) count++;
    }
    return count;
  }

  private indexOf(x: number, y: number): number {
    return this.wrapY(y) * this.width + this.wrapX(x);
  }

  /** Flag a tile. A flag is a lock: blocks mine movement, and NEVER
   * detonates a mine on that tile. Raw state write — the toggle/rejection
   * rules (only HIDDEN tiles, right-click removes) live in GameEngine.flag.
   * The one legitimate non-player caller is the shield flagging a detonated
   * (REVEALED) mine. */
  flag(x: number, y: number): TileState {
    const tile = this.tileAt(x, y);
    tile.state = 'FLAGGED';
    return tile.state;
  }

  /** Remove a flag: FLAGGED → HIDDEN. A no-op on any other state. */
  unflag(x: number, y: number): TileState {
    const tile = this.tileAt(x, y);
    if (tile.state === 'FLAGGED') tile.state = 'HIDDEN';
    return tile.state;
  }

  // ------------------------------ counters -----------------------------------

  revealedCount(): number {
    return this.tiles.filter((t) => t.state === 'REVEALED').length;
  }

  flaggedCount(): number {
    return this.tiles.filter((t) => t.state === 'FLAGGED').length;
  }

  mineCount(): number {
    return this.tiles.filter((t) => t.hasMine).length;
  }

  /** "All non-mine tiles cleared" per the state machine. */
  isBoardCleared(): boolean {
    for (const t of this.tiles) {
      if (!t.hasMine && t.state !== 'REVEALED') return false;
    }
    return true;
  }
}

// --------------------------- deterministic RNG -----------------------------

// Reference mulberry32 (Rory McCune). The Math.imul steps are the actual
// avalanche: without them consecutive outputs are near-perfectly correlated,
// and placeMines() draws x and y from CONSECUTIVE calls — which pinned every
// mine onto two toroidal diagonals (y - x mod 10 constant). Regressed once;
// see tests/engine.test.mjs diagonal-spread check before "simplifying".
export function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    let t = (a += 0x6d2b79f5) >>> 0;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}