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 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;
};
}
