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 — Workshop upgrade gating (headless, pure logic).
//
// Player rulings encoded here (2026-10-24):
// * Radar/Seeker/Shield START LOCKED (level 0): using them must fail
// WITHOUT paying (no Flow, no cooldown, no turn).
// * Seeker is a PURCHASEABLE upgrade: Lv1 flags 1 mine, Lv2 flags 2,
// Lv3 (max) flags 3 — one turn per use either way.
// * Shield level = max SIMULTANEOUS charges (spending is still 1/use).
// * Radar footprint scales: Lv1 3×3 (9) · Lv2 5×5-circle (21) · Lv3 5×5 (25).
// 2026-10-27 (stabilizer_shop_ruling, supersedes the old "stabilizer is NOT
// a Workshop upgrade" test): the Stabilizer ALSO starts locked — Lv1 unlocks
// the ability, and hold length scales 3/5/7/10 turns over 4 levels while the
// cooldown stays 10 turns everywhere (Lv4 = recast-on-cooldown forever).
import assert from 'node:assert/strict';
import test from 'node:test';
import { ABILITIES, GameEngine, shiftIntervalFor } from '../src/game.ts';
import { MinesweeperEngine } from '../src/engine.ts';
import {
BASE_FLOW_CAP,
MAX_FLOW_CAP,
MAX_UPGRADES,
SHOP_GROUPS,
STABILIZER_DURATIONS,
UPGRADE_DEFS,
UPGRADE_ORDER,
ZERO_UPGRADES,
economyMultiplier,
flowCapFor,
nextCost,
radarOffsets,
stabilizerDurationFor,
} from '../src/upgrades.ts';
function levels(over = {}) {
return { ...ZERO_UPGRADES, ...over };
}
/** Mine-free-world engine at exact upgrade levels (constructor defaults to
* locked levels since the 5-free-shields regression — levels are always
* passed explicitly here). */
function engineAt(over, seed = 5, mines = []) {
const w = new MinesweeperEngine(seed, 0);
for (const [x, y] of mines) w.tileAt(x, y).hasMine = true;
return new GameEngine(0, undefined, seed, w, levels(over));
}
const FIVE_MINES = [[1, 1], [3, 6], [5, 2], [7, 8], [9, 4]];
// --------------------------- locked at level 0 ----------------------------
test('level 0: radar/seeker/stabilizer fail and pay NOTHING (flow/turn/cd intact); shield Lv0 starts at 0 charges', () => {
const g = engineAt({}, 7, FIVE_MINES);
g.flow = 100;
const attempts = [g.radar(5, 5), g.seeker(), g.stabilizer()];
assert.deepEqual(attempts, [false, false, false], 'locked abilities refuse');
assert.equal(g.flow, 100, 'no Flow spent');
assert.equal(g.turn, 0, 'no turn ticked');
assert.equal(g.world.flaggedCount(), 0, 'seeker flagged nothing');
assert.equal(g.shieldCharges, 0, 'Lv0 shield: the run starts with zero charges');
assert.equal(g.stabilizedFor, 0, 'stabilizer stored no hold');
for (const id of ['radar', 'seeker', 'stabilizer'])
assert.ok(!g.cooldowns.get(id), `${id} never went on cooldown`);
});
// ------------------------------ stabilizer --------------------------------
test('stabilizer is a Workshop upgrade (ruling 2026-10-27): Lv1 unlocks it, one turn per use', () => {
const g = engineAt({ stabilizer: 1 }, 7);
g.flow = 50;
assert.ok(g.stabilizer(), 'Lv1 usable');
assert.equal(g.turn, 1, 'stabilizer cast = one turn total');
assert.equal(
g.cooldowns.get('stabilizer'),
ABILITIES.stabilizer.cooldown - 1,
'paid the 10-turn cooldown (the commit ticks it to 9)',
);
});
test('stabilizer hold scales by level: 3/5/7/10 turns — cooldown stays 10 everywhere', () => {
assert.deepEqual(STABILIZER_DURATIONS, [3, 5, 7, 10], 'the ruling curve');
for (let level = 1; level <= 4; level++) {
const g = engineAt({ stabilizer: level }, 7);
g.flow = 50;
assert.ok(g.stabilizer(), `Lv${level} usable`);
// depth 0: tickShift returns before ticking, so the fresh hold is visible
assert.equal(g.stabilizedFor, stabilizerDurationFor(level), `Lv${level} hold`);
assert.equal(
g.cooldowns.get('stabilizer'),
ABILITIES.stabilizer.cooldown - 1,
`Lv${level} cooldown unchanged`,
);
}
assert.equal(stabilizerDurationFor(0), 0, 'locked level has no hold');
assert.equal(stabilizerDurationFor(5), 0, 'beyond max falls back to 0');
});
test('Lv4: 10-turn hold spans the 10-turn cooldown — recast-on-cooldown freezes shifts forever', () => {
// Pin the Lv4 design point: cast + 9 pad turns = a full 10-turn cycle in
// which both the hold and the cooldown expire TOGETHER, so the next cast
// lands with the countdown frozen the whole time. Three consecutive
// cycles (30 turns) with zero shift ticks proves the indefinite lockout.
const w = new MinesweeperEngine(5, 0);
w.tileAt(3, 3).hasMine = true;
const g = new GameEngine(6, undefined, 5, w, levels({ stabilizer: 4 }));
const interval = shiftIntervalFor(6); // 28 — unpadded, a shift WOULD fire by t28
g.flow = 1000;
for (let cycle = 1; cycle <= 3; cycle++) {
assert.ok(g.stabilizer(), `cycle ${cycle}: usable again exactly on cooldown`);
for (let i = 0; i < 9; i++) g.flag(8, 8); // toggle-pads: effective turns, no flood
}
assert.equal(g.nextShiftIn, interval, 'the shift countdown never ticked once');
assert.equal(w.tileAt(3, 3).hasMine, true, 'the mine never moved');
});
// ------------------------------ radar shape -------------------------------
test('radarOffsets: Lv1 = 9 tiles, Lv2 = 21 (5×5 minus corners), Lv3 = 25', () => {
assert.equal(radarOffsets(0).length, 0, 'Lv0 sweeps nothing');
assert.equal(radarOffsets(1).length, 9);
const lv2 = radarOffsets(2);
assert.equal(lv2.length, 21, '5×5 minus the four corners');
for (const [dx, dy] of lv2)
assert.ok(
Math.abs(dx) !== 2 || Math.abs(dy) !== 2,
`Lv2 must drop corner (${dx},${dy})`,
);
assert.equal(radarOffsets(3).length, 25);
});
test('radar reveals exactly its footprint (no flood), paying Flow once', () => {
for (const [lvl, tiles] of [[1, 9], [2, 21], [3, 25]]) {
const g = engineAt({ radar: lvl }, 9);
g.flow = 60; // radar costs 50f now (ruling 2026-10-29)
assert.ok(g.radar(5, 5), `Lv${lvl} fires`);
assert.equal(g.world.revealedCount(), tiles, `Lv${lvl} footprint`);
assert.equal(g.turn, 1, 'radar = one turn');
}
});
// ------------------------------- seeker -----------------------------------
test('seeker Lv1/Lv2/Lv3 flag exactly 1/2/3 mines, one turn each', () => {
for (const [lvl, flags] of [[1, 1], [2, 2], [3, 3]]) {
const g = engineAt({ seeker: lvl }, 11, FIVE_MINES);
g.flow = 60; // seeker costs 50f now (ruling 2026-10-29)
assert.ok(g.seeker(), `Lv${lvl} fires`);
assert.equal(g.world.flaggedCount(), flags, `Lv${lvl} flags ${flags} mine(s)`);
assert.equal(g.turn, 1, 'seeker use = exactly one turn');
// Only mines get flagged — never safe ground.
for (const t of g.world.tiles)
if (t.state === 'FLAGGED') assert.ok(t.hasMine, 'flagged tile holds a mine');
}
});
test('seeker flags AT MOST the unfound mines (two mines, Lv3 → 2 flags)', () => {
const g = engineAt({ seeker: 3 }, 13, [[2, 2], [6, 7]]);
g.flow = 60; // seeker costs 50f (ruling 2026-10-29)
assert.ok(g.seeker());
assert.equal(g.world.flaggedCount(), 2);
});
test('second seeker use does not re-flag already-flagged mines', () => {
const g = engineAt({ seeker: 3 }, 17, FIVE_MINES);
g.flow = 150; // three casts at 50f (ruling 2026-10-29)
assert.ok(g.seeker());
// Cooldown between uses is covered by the browser suite; clear it here to
// isolate the re-flag behaviour.
g.cooldowns.delete('seeker');
assert.ok(g.seeker());
assert.equal(g.world.flaggedCount(), 5, 'all 5 mines flagged over two uses');
g.cooldowns.delete('seeker');
assert.ok(!g.seeker(), 'no unfound mines left → refused');
});
// ------------------------------- shield -----------------------------------
test('shields are the run-start grant: charges = Workshop level, never Flow-bought (ruling 2026-10-27)', () => {
assert.equal(engineAt({}, 19).shieldCharges, 0, 'Lv0: the run starts shield-less');
assert.equal(engineAt({ shield: 1 }, 19).shieldCharges, 1);
assert.equal(engineAt({ shield: 5 }, 19).shieldCharges, 5, 'Lv5 = five absorbs per run');
assert.equal(MAX_UPGRADES.shield, 5, 'the line now tops out at 5 levels');
assert.equal(
typeof GameEngine.prototype.shield,
'undefined',
'the Flow stockpile ability is gone — mid-run shields cannot be bought',
);
// The grant is per RUN, not per board: advance() must never refill.
const g = engineAt({ shield: 3 }, 23);
for (const t of g.world.tiles) if (t.state === 'HIDDEN') g.world.reveal(t.coord[0], t.coord[1]);
assert.equal(g.isBoardCleared, true);
g.advance();
assert.equal(g.shieldCharges, 3, 'charges carry across boards — spend them and that is all you get');
});
// ------------------------------ data tables -------------------------------
test('upgrade tables are coherent (costs length === maxLevel, order covers all)', () => {
for (const id of UPGRADE_ORDER) {
const def = UPGRADE_DEFS[id];
assert.equal(def.costs.length, def.maxLevel, `${id} cost curve`);
assert.ok(def.maxLevel >= 1);
}
assert.deepEqual(
[...UPGRADE_ORDER].sort(),
Object.keys(UPGRADE_DEFS).sort(),
'UPGRADE_ORDER covers exactly the defs',
);
assert.equal(nextCost('radar', 3), null, 'maxed has no next cost');
assert.equal(nextCost('radar', 0), UPGRADE_DEFS.radar.costs[0]);
});
// Shop copy micro-format (ruling 2026-10-28, shop_layout_ruling): cards are
// narrow, so every describe() line must stay short and follow the shape
// Lv0 'Locked — Lv.1 …'
// mid '<effect> · next: <delta>'
// maxed '<effect> · MAX'
// Anti-regression guards stop the old long-form prose ('. Next:' / ' — max')
// ever leaking back into the cards.
test('shop copy is micro-format (locked / next / MAX shapes, short lines)', () => {
for (const id of UPGRADE_ORDER) {
const def = UPGRADE_DEFS[id];
for (let level = 0; level <= def.maxLevel; level++) {
const line = def.describe(level);
assert.ok(line.length <= 52, `${id} Lv${level} line fits a card: "${line}"`);
if (level === 0) {
assert.match(line, /^Locked — Lv\.1 /, `${id} Lv0 reads Locked — Lv.1 …`);
} else if (level < def.maxLevel) {
assert.ok(line.includes(' · next: '), `${id} Lv${level} shows the next delta`);
assert.doesNotMatch(line, /\. Next:| — max\b/, `${id} Lv${level} is not old prose`);
} else {
assert.match(line, /· MAX\b/, `${id} maxed line ends in · MAX`);
}
}
}
});
// Grouped shop (ruling 2026-10-28): SHOP_GROUPS must partition UPGRADE_ORDER
// exactly — a new upgrade that nobody groups would silently vanish from the
// Workshop render.
test('SHOP_GROUPS partitions every upgrade exactly once', () => {
const grouped = SHOP_GROUPS.flatMap((g) => g.ids);
assert.equal(grouped.length, UPGRADE_ORDER.length, 'no missing/duplicate lines');
assert.deepEqual(
[...grouped].sort(),
[...UPGRADE_ORDER].sort(),
'SHOP_GROUPS covers exactly the upgrade set',
);
for (const { group, ids } of SHOP_GROUPS) {
for (const id of ids) {
assert.equal(UPGRADE_DEFS[id].group, group, `${id} group matches its def`);
}
}
});
test('pinned balance sheet (ruling 2026-10-27, shop_flow_rebalance)', () => {
assert.deepEqual(UPGRADE_DEFS.shield.costs, [50, 120, 250, 430, 700], 'shield 1-5, 50→700');
assert.equal(UPGRADE_DEFS.shield.maxLevel, 5);
assert.deepEqual(UPGRADE_DEFS.economy.costs, [50, 150, 300, 500], 'ledger re-priced');
assert.deepEqual(UPGRADE_DEFS.flow_cap.costs, [50, 125, 275, 500], 'flow_cap 1-4 (ruling 2026-10-27, flow_cap_ruling)');
assert.equal(UPGRADE_DEFS.flow_cap.maxLevel, 4, 'flow_cap tops out at Lv4 = 500 cap');
assert.equal(ABILITIES.radar.cost, 50, 'Intel re-priced again 2026-10-29');
assert.equal(ABILITIES.seeker.cost, 50);
assert.equal('shield' in ABILITIES, false, 'Shield is no longer a Flow ability');
assert.equal(ABILITIES.stabilizer.cost, 30, 'stabilizer 10f→30f (ruling 2026-10-29)');
assert.equal(ABILITIES.radar.cooldown, 2, 'cooldowns stayed');
assert.equal(ABILITIES.seeker.cooldown, 3);
assert.equal(ABILITIES.stabilizer.cooldown, 10);
});
test('economy multiplier follows the §5.1 modifier pattern (1 + 0.25 × level)', () => {
assert.equal(economyMultiplier(ZERO_UPGRADES), 1);
assert.equal(economyMultiplier({ ...ZERO_UPGRADES, economy: 2 }), 1.5);
assert.equal(economyMultiplier(MAX_UPGRADES), 2);
});
// ------------------ flow ceiling (ruling 2026-10-27, flow_cap_ruling) ------
test('flow ceiling curve: base 100, +100 per Capacitor level, hard max 500', () => {
assert.equal(BASE_FLOW_CAP, 100, 'default cap');
assert.equal(MAX_FLOW_CAP, 500, 'Lv4 ceiling');
assert.deepEqual(
[0, 1, 2, 3, 4].map(flowCapFor),
[100, 200, 300, 400, 500],
'the ruling ladder',
);
assert.equal(flowCapFor(7), 500, 'clamps past max level');
assert.equal(flowCapFor(undefined), 100, 'missing level (legacy fixture) → base cap');
});
test('Flow gains clamp at the ceiling: at-cap / exact / over / bought cap', () => {
// Exact: 95 + flowPerSafe(5) lands exactly on the base cap of 100.
const exact = engineAt({ flow_cap: 0 }, 5);
exact.flow = 95;
exact.reveal(0, 0); // mine-free world: one flood clears the board, pays once
assert.equal(exact.flow, 100, 'lands exactly on the cap');
// Over: 98 + 5 = 103 → the excess is wasted, never banked.
const over = engineAt({ flow_cap: 0 }, 6);
over.flow = 98;
over.reveal(0, 0);
assert.equal(over.flow, 100, 'excess over the base cap is wasted');
assert.ok(over.isBoardCleared, 'the flood cleared the board');
over.advance(); // +flowPerClear(20) is clamped the same way
assert.equal(over.flow, 100, 'the board-clear bonus cannot exceed the cap either');
// At cap already: the gain is fully wasted.
const at = engineAt({ flow_cap: 0 }, 8);
at.flow = 100;
at.reveal(0, 0);
assert.equal(at.flow, 100, 'at cap: nothing banks over');
// Bought ceiling: Lv2 Capacitor = 300, the same over-cap grant lands at 300.
const bought = engineAt({ flow_cap: 2 }, 7);
assert.equal(bought.flowCap(), 300, 'engine reads the Workshop level live');
bought.flow = 297;
bought.reveal(0, 0);
assert.equal(bought.flow, 300, 'purchase raises the binding ceiling');
bought.advance();
assert.equal(bought.flow, 300, 'still clamped at the bought cap');
});
