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.

tests/progression.test.mjs
// Minesweeper DX — Phase 3 progression + persistence (headless, no browser).
// Covers save-data hardening (§5.2: clamp/coerce/drop, never crash), the wire
// format (spec key names), the Scrap economy numbers from the Phase-3 ruling,
// and the ProgressionManager ledger/purchase rules.

import assert from 'node:assert/strict';
import test from 'node:test';
import {
  BASE_SCRAP_PER_CLEAR,
  ProgressionManager,
  SAVE_KEY,
  SAVE_VERSION,
  SaveManager,
  initialSave,
  salvageRunScrap,
  sanitizeSave,
  scrapForClear,
} from '../src/save.ts';
import { MAX_UPGRADES, ZERO_UPGRADES } from '../src/upgrades.ts';

// ------------------------------ sanitizeSave ------------------------------

test('sanitizeSave: garbage in, clean default out (never throws)', () => {
  for (const junk of [null, undefined, 42, 'save', [], {}, { scrap: 'x', depth: -3 }]) {
    const s = sanitizeSave(junk);
    assert.equal(s.version, SAVE_VERSION);
    assert.ok(Number.isInteger(s.scrap) && s.scrap >= 0);
    assert.ok(Number.isInteger(s.depth) && s.depth >= 0);
    for (const lvl of Object.values(s.upgrades))
      assert.ok(Number.isInteger(lvl) && lvl >= 0 && lvl <= 5, 'levels never exceed the 5-level max (shield_lvl)');
  }
});

test('sanitizeSave: legacy anchor_radius_lvl is ignored (no anchor upgrade since 2.x)', () => {
  const s = sanitizeSave({
    version: 1, scrap: 300, depth: 7,
    upgrades: {
      anchor_radius_lvl: 3, mine_frequency_pct: 12, mine_value_pct: 160,
      radar_lvl: 2, seeker_lvl: 1, shield_lvl: 1, economy_multiplier_lvl: 2,
    },
  });
  assert.deepEqual(s.upgrades, { radar: 2, seeker: 1, shield: 1, stabilizer: 0, economy: 2, flow_cap: 0 },
    'legacy save: flow_cap key absent defaults to LOCKED (base cap 100, ruling 2026-10-27)');
  assert.equal(s.depth, 7);
});

test('sanitizeSave: out-of-range levels clamp, partial upgrades merge with defaults', () => {
  const s = sanitizeSave({ scrap: 1e9, depth: 1e9, upgrades: { radar_lvl: 99, seeker_lvl: -5, shield_lvl: 99 } });
  assert.equal(s.upgrades.radar, 3, 'clamped to max');
  assert.equal(s.upgrades.seeker, 0, 'negative clamped to 0');
  assert.equal(s.upgrades.shield, 5, 'shield clamps to its 5-level max (ruling 2026-10-27)');
  assert.equal(s.upgrades.stabilizer, 0, 'missing stabilizer key defaults (locked)');
  assert.equal(s.upgrades.economy, 0);
  assert.equal(s.upgrades.flow_cap, 0, 'missing flow_cap key defaults (base cap 100, ruling 2026-10-27)');
  assert.equal(s.scrap, 1e9, 'scrap kept when sane integer');
});

// ------------------------------ SaveManager -------------------------------

/** In-memory stand-in for the napplet storage KV port. */
function fakePort() {
  const mem = new Map();
  return {
    mem,
    async getItem(k) { return mem.has(k) ? mem.get(k) : null; },
    async setItem(k, v) { mem.set(k, v); },
  };
}

test('SaveManager round-trips through the SPEC wire format (storage.setItem/getItem)', async () => {
  const port = fakePort();
  const mgr = new SaveManager(port);
  const save = initialSave();
  save.scrap = 250;
  save.depth = 4;
  save.name = 'deep-diver';
  save.name_prompted = true;
  save.upgrades = { radar: 2, seeker: 1, shield: 1, stabilizer: 1, economy: 2, flow_cap: 2 };
  assert.equal(await mgr.save(save), true);

  // Wire shape: version/scrap/depth/name/name_prompted/upgrades, upgrades holds
  // *_lvl keys only (name added with the depth-v1 leaderboard ruling
  // 2026-10-28; name_prompted with the first-run name gate ruling 2026-10-29).
  const raw = JSON.parse(port.mem.get(SAVE_KEY));
  assert.deepEqual(Object.keys(raw).sort(), ['depth', 'name', 'name_prompted', 'scrap', 'upgrades', 'version']);
  assert.equal(raw.name_prompted, true, 'name_prompted persisted (first-run gate 2026-10-29)');
  assert.deepEqual(Object.keys(raw.upgrades).sort(),
    ['economy_multiplier_lvl', 'flow_cap_lvl', 'radar_lvl', 'seeker_lvl', 'shield_lvl', 'stabilizer_lvl']);
  assert.equal(raw.upgrades.radar_lvl, 2);
  assert.equal(raw.upgrades.stabilizer_lvl, 1, 'stabilizer_lvl persisted (ruling 2026-10-27)');
  assert.equal(raw.upgrades.economy_multiplier_lvl, 2);
  assert.equal(raw.upgrades.flow_cap_lvl, 2, 'flow_cap_lvl persisted (ruling 2026-10-27, flow_cap_ruling)');
  assert.equal(raw.upgrades.anchor_radius_lvl, undefined, 'legacy keys never written');

  assert.deepEqual(await mgr.load(), save);
});

test('Legacy saves without a name field default to "" (ruling D4)', async () => {
  const port = fakePort();
  // A pre-board save: no name key at all.
  port.mem.set(SAVE_KEY, JSON.stringify({
    version: 1,
    scrap: 80,
    depth: 3,
    upgrades: { radar_lvl: 1, seeker_lvl: 0, shield_lvl: 0, stabilizer_lvl: 0, economy_lvl: 0, flow_cap_lvl: 0 },
  }));
  const mgr = new SaveManager(port);
  const loaded = await mgr.load();
  assert.equal(loaded.name, '', 'legacy save gets an empty name');
  assert.equal(loaded.name_prompted, false,
    'legacy save defaults to NOT-yet-gated — the first-run gate (2026-10-29) asks legacy players once');
  // Over-long / whitespace names clamp on load.
  port.mem.set(SAVE_KEY, JSON.stringify({
    version: 1, scrap: 0, depth: 0, name: '  x'.repeat(30) + ' ', upgrades: {},
  }));
  const clamped = await mgr.load();
  assert.ok(clamped.name.length <= 24, 'name clamped to 24 chars');
  assert.ok(!/^\s|\s$/.test(clamped.name), 'name trimmed');
});

test('SaveManager: corrupt JSON degrades to defaults and records an error', async () => {
  const port = fakePort();
  port.mem.set(SAVE_KEY, '{not json');
  const mgr = new SaveManager(port);
  assert.deepEqual(await mgr.load(), initialSave());
  assert.notEqual(mgr.error, null, 'degradation is surfaced via .error');
});

test('SaveManager: no storage domain (test host) → disabled no-op, app still runs', async () => {
  const mgr = new SaveManager(null);
  assert.equal(mgr.enabled, false);
  assert.deepEqual(await mgr.load(), initialSave());
  const save = initialSave();
  save.scrap = 99;
  assert.equal(await mgr.save(save), false, 'writes silently drop');
});

// ------------------------------ economy math ------------------------------

test('scrapForClear: 10 × 1.1^depth × (1 + 0.25 × economy level), rounded', () => {
  assert.equal(scrapForClear(0, ZERO_UPGRADES), BASE_SCRAP_PER_CLEAR);
  assert.equal(scrapForClear(5, ZERO_UPGRADES), Math.round(10 * 1.1 ** 5), 'depth 5 ≈ 16');
  assert.equal(scrapForClear(0, { ...ZERO_UPGRADES, economy: 1 }), 13, 'economy Lv1: 12.5 → 13');
  assert.equal(scrapForClear(2, { ...ZERO_UPGRADES, economy: 2 }), 18, '12.1 × 1.5 = 18.15 → 18');
});

test('salvageRunScrap: death costs half of unbanked Scrap (floor salvaged, rest destroyed)', () => {
  assert.deepEqual(salvageRunScrap(0), { salvaged: 0, destroyed: 0 });
  assert.deepEqual(salvageRunScrap(10), { salvaged: 5, destroyed: 5 });
  assert.deepEqual(salvageRunScrap(11), { salvaged: 5, destroyed: 6 }, 'odd amount destroys the extra');
});

// --------------------------- ProgressionManager ---------------------------

function freshManager(scrap = 0, upgrades = { radar: 0, seeker: 0, shield: 0, stabilizer: 0, economy: 0, flow_cap: 0 }) {
  const save = initialSave();
  save.scrap = scrap;
  save.upgrades = { ...upgrades };
  return new ProgressionManager(save);
}

test('ProgressionManager: clear credits go to the run ledger; bank moves them to the vault', () => {
  const p = freshManager();
  assert.equal(p.creditClear(0), 10);
  assert.equal(p.creditClear(1), 11, 'depth scaling bites immediately');
  assert.equal(p.runScrap, 21);
  assert.equal(p.scrap, 0, 'vault untouched until banked');

  assert.equal(p.bank(), 21);
  assert.equal(p.scrap, 21);
  assert.equal(p.runScrap, 0, 'ledger emptied');
  assert.equal(p.bank(), 0, 'banking nothing banks nothing');

  p.startRun();
  assert.equal(p.runScrap, 0);
  assert.equal(p.scrap, 21, 'banked is safe across runs');
});

test('ProgressionManager: death salvages half the ledger into the vault, then zeroes it', () => {
  const p = freshManager();
  p.creditClear(0); // +10 to the ledger
  const { earned, salvaged, destroyed } = p.endRunOnDeath();
  assert.deepEqual({ earned, salvaged, destroyed }, { earned: 10, salvaged: 5, destroyed: 5 });
  assert.equal(p.scrap, 5);
  assert.equal(p.runScrap, 0, 'nothing survives the death screen');
});

test('ProgressionManager: clearing records the deepest sector (save.depth = depth + 1)', () => {
  const p = freshManager();
  p.creditClear(0);
  assert.equal(p.save.depth, 1);
  p.creditClear(4);
  assert.equal(p.save.depth, 5);
});

test('ProgressionManager: purchases gate on affordability and level cap', () => {
  const p = freshManager(100);

  const tooExpensive = p.purchase('radar');
  assert.equal(tooExpensive.ok, false);
  assert.equal(tooExpensive.reason, 'affordable', '150 > 100');
  assert.equal(p.scrap, 100, 'failed purchase never charges');

  const bought = p.purchase('economy');
  assert.equal(bought.ok, true);
  assert.equal(bought.level, 1);
  assert.equal(bought.cost, 50, 'economy Lv1 = 50 (ruling 2026-10-27 curve 50/150/300/500)');
  assert.equal(p.scrap, 50);
  assert.equal(p.levels.economy, 1);
  assert.equal(p.canAfford('economy'), false, 'next economy level (150) > 50');

  p.save.scrap = 10_000;
  for (let lvl = 2; lvl <= 4; lvl++) assert.ok(p.purchase('economy').ok, `Lv${lvl}`);
  assert.equal(p.levels.economy, 4);
  const maxed = p.purchase('economy');
  assert.equal(maxed.ok, false);
  assert.equal(maxed.reason, 'maxed');
});

test('ProgressionManager: everythingMaxed only when all upgrades are maxed', () => {
  const p = freshManager();
  assert.equal(p.everythingMaxed, false);
  p.save.upgrades = { ...MAX_UPGRADES, economy: MAX_UPGRADES.economy - 1 };
  assert.equal(p.everythingMaxed, false);
  p.save.upgrades = { ...MAX_UPGRADES };
  assert.equal(p.everythingMaxed, true);
});