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/save.ts
/* Minesweeper DX — Phase 3 persistence: SaveManager + ProgressionManager.
 *
 * PLATFORM RULE (docs/boundaries.md): `localStorage`/`sessionStorage` are
 * FORBIDDEN for durable user data; the allowed pattern is NAP-STORAGE
 * (`storage.setItem/getItem` from @napplet/sdk), proxied through the shell and
 * isolated per napplet identity. There is NO durable fallback: when the host
 * injects no storage domain, saves are disabled and progress lives only for
 * the page session (AGENTS.md hard boundary; docs/napplet-data.md routes
 * device-local saves to NAP-STORAGE).
 *
 * Save schema (master spec §4.2A, updated 2026-10-27: flow_cap_lvl added —
 * Flow Capacitor, ruling flow_cap_ruling; 2026-10-24: anchor_radius_lvl
 * removed — flags only lock their own tile):
 *   { scrap, depth, upgrades: { radar_lvl, seeker_lvl, shield_lvl,
 *     stabilizer_lvl, economy_multiplier_lvl, flow_cap_lvl } }
 *
 * ProgressionManager owns the meta economy: banked Scrap, Workshop purchases
 * (tiered, exponential-ish costs — src/upgrades.ts), run scrap accrual and
 * the death salvage (half of the run's unbanked scrap survives, half is
 * destroyed). It is pure logic — persistence is a separate concern wired in
 * main.ts through SaveManager — so headless tests drive it directly.
 */

import { storage } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.ts';
import {
  MAX_UPGRADES,
  UPGRADE_DEFS,
  ZERO_UPGRADES,
  economyMultiplier,
  nextCost,
  type UpgradeId,
  type UpgradeLevels,
} from './upgrades.ts';

// ------------------------------- save data --------------------------------

export const SAVE_KEY = 'minesweeper-dx.save.v1';
/** Phase 4: UI settings (sound mute) ride the SAME NAP-STORAGE domain but a
 * separate key — the §4.2A save schema is frozen; settings are not save data. */
export const SETTINGS_KEY = 'minesweeper-dx.settings.v1';
export const SAVE_VERSION = 1;

export interface SaveData {
  version: number;
  scrap: number;
  /** Highest depth reached in a run — display stat on the Menu. */
  depth: number;
  /** Leaderboard handle for run-end posts (≤24 chars; '' = unnamed-runner).
   * Added with the depth-v1 board ruling 2026-10-28; absent in legacy saves. */
  name: string;
  /** True once the one-time first-run name gate has interrupted the player
   * (first-run name gate ruling 2026-10-29) — the gate never asks again, even
   * when they confirmed blank. Absent in legacy saves ⇒ false. */
  name_prompted: boolean;
  upgrades: UpgradeLevels;
}

export function initialSave(): SaveData {
  return { version: SAVE_VERSION, scrap: 0, depth: 0, name: '', name_prompted: false, upgrades: { ...ZERO_UPGRADES } };
}

/** Map save-file upgrade keys (spec §4.2A) ↔ internal UpgradeIds. */
const SAVE_LEVEL_KEYS: Record<UpgradeId, string> = {
  radar: 'radar_lvl',
  seeker: 'seeker_lvl',
  shield: 'shield_lvl',
  stabilizer: 'stabilizer_lvl',
  economy: 'economy_multiplier_lvl',
  flow_cap: 'flow_cap_lvl',
};

function clampLevel(value: unknown, max: number): number {
  const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0;
  return Math.max(0, Math.min(max, n));
}

/** Validate/repair an untrusted save blob. Never throws; unknown shapes and
 * legacy/removed keys (anchor_radius_lvl) are silently normalized. */
export function sanitizeSave(raw: unknown): SaveData {
  if (typeof raw !== 'object' || raw === null) return initialSave();
  const obj = raw as Record<string, unknown>;
  const up = (obj.upgrades ?? {}) as Record<string, unknown>;
  const upgrades = { ...ZERO_UPGRADES };
  for (const id of Object.keys(UPGRADE_DEFS) as UpgradeId[]) {
    upgrades[id] = clampLevel(up[SAVE_LEVEL_KEYS[id]], UPGRADE_DEFS[id].maxLevel);
  }
  const num = (v: unknown): number =>
    typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.floor(v)) : 0;
  return {
    version: SAVE_VERSION,
    scrap: num(obj.scrap),
    depth: num(obj.depth),
    name: typeof obj.name === 'string' ? obj.name.trim().slice(0, 24).trim() : '',
    name_prompted: obj.name_prompted === true,
    upgrades,
  };
}

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

export type SavePort = {
  getItem(key: string): Promise<string | null>;
  setItem(key: string, value: string): Promise<void>;
};

/**
 * Loads/saves the meta-progression through NAP-STORAGE when the host injects
 * the domain. Without the domain, `enabled` stays false: the game keeps an
 * in-memory SaveData for the session and silently skips persistence — the run
 * simply does not survive a reload (no forbidden fallback).
 */
export class SaveManager {
  readonly enabled: boolean;
  private readonly port: SavePort | null;
  private lastError: string | null = null;

  constructor(port: SavePort | null = null, enabled = port !== null) {
    this.port = port;
    this.enabled = enabled;
  }

  static fromRuntime(): SaveManager {
    if (!runtimeHasDomain('storage')) return new SaveManager(null, false);
    return new SaveManager(
      {
        getItem: (k) => storage.getItem(k),
        setItem: (k, v) => storage.setItem(k, v),
      },
      true,
    );
  }

  get error(): string | null {
    return this.lastError;
  }

  /** Load + validate. Any failure (no domain, read error, bad JSON, legacy
   * shape) yields a fresh initialSave() so a corrupt save never bricks boot. */
  async load(): Promise<SaveData> {
    if (!this.enabled || !this.port) return initialSave();
    try {
      const raw = await this.port.getItem(SAVE_KEY);
      if (raw === null) return initialSave();
      return sanitizeSave(JSON.parse(raw));
    } catch (err) {
      this.lastError = err instanceof Error ? err.message : String(err);
      return initialSave();
    }
  }

  /** UI settings read: missing/corrupt ⇒ defaults (mirrors load() policy). */
  async loadSettings(): Promise<{ muted: boolean }> {
    if (!this.enabled || !this.port) return { muted: false };
    try {
      const raw = await this.port.getItem(SETTINGS_KEY);
      if (raw === null) return { muted: false };
      const obj = JSON.parse(raw);
      return { muted: typeof obj?.muted === 'boolean' ? obj.muted : false };
    } catch {
      return { muted: false };
    }
  }

  async saveSettings(s: { muted: boolean }): Promise<void> {
    if (!this.enabled || !this.port) return;
    try {
      await this.port.setItem(SETTINGS_KEY, JSON.stringify({ muted: s.muted }));
    } catch (err) {
      this.lastError = err instanceof Error ? err.message : String(err);
    }
  }

  async save(data: SaveData): Promise<boolean> {
    if (!this.enabled || !this.port) return false;
    try {
      const wire: Record<string, unknown> = {
        version: SAVE_VERSION,
        scrap: data.scrap,
        depth: data.depth,
        name: data.name,
        name_prompted: data.name_prompted,
        upgrades: {
          [SAVE_LEVEL_KEYS.radar]: data.upgrades.radar,
          [SAVE_LEVEL_KEYS.seeker]: data.upgrades.seeker,
          [SAVE_LEVEL_KEYS.shield]: data.upgrades.shield,
          [SAVE_LEVEL_KEYS.stabilizer]: data.upgrades.stabilizer,
          [SAVE_LEVEL_KEYS.economy]: data.upgrades.economy,
          [SAVE_LEVEL_KEYS.flow_cap]: data.upgrades.flow_cap,
        },
      };
      await this.port.setItem(SAVE_KEY, JSON.stringify(wire));
      return true;
    } catch (err) {
      this.lastError = err instanceof Error ? err.message : String(err);
      return false;
    }
  }
}

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

/** Run scrap per board clear (player ruling 2026-10-24):
 *  BaseScrap(10) × 1.1^Depth, × economy multiplier (spec §5.2). */
export const BASE_SCRAP_PER_CLEAR = 10;

export function scrapForClear(depth: number, levels: UpgradeLevels): number {
  return Math.round(BASE_SCRAP_PER_CLEAR * Math.pow(1.1, depth) * economyMultiplier(levels));
}

/** Death salvage (spec §4.1 step 5): the run's UNBANKED scrap is halved —
 * half salvaged into the vault, half destroyed. Banked scrap never moves. */
export function salvageRunScrap(runScrap: number): { salvaged: number; destroyed: number } {
  const salvaged = Math.floor(runScrap / 2);
  return { salvaged, destroyed: runScrap - salvaged };
}

export interface PurchaseResult {
  ok: boolean;
  reason?: 'maxed' | 'affordable' | 'unknown';
  level?: number;
  cost?: number;
}

/**
 * Meta-economy brain: banked scrap, Workshop purchases, per-run scrap ledger.
 * The run ledger (`runScrap`) tracks scrap EARNED this run; Bank commits it to
 * the vault (safe), death salvages half of it. Clearing more boards after a
 * bank keeps accrueing into the same ledger — only fresh earnings are at risk.
 */
export class ProgressionManager {
  save: SaveData;

  /** Scrap earned during the CURRENT run (not yet banked). */
  runScrap = 0;
  /** Depth of the board currently being played (0-based). */
  runDepth = 0;
  /** Boards CLEARED this run (creditClear calls). Unlike engine.boardsCleared
   * (which counts advances, so banking the first clear reads 0), this counts
   * every clear the moment it's credited — the honest source for the run-end
   * submit gate and the summary "Boards cleared" row (depth-v1 ruling). */
  runClears = 0;

  constructor(save: SaveData = initialSave()) {
    this.save = save;
  }

  get scrap(): number {
    return this.save.scrap;
  }

  get levels(): UpgradeLevels {
    return this.save.upgrades;
  }

  /** Ability levels as the engine wants them (Radar/Seeker/Shield/Stabilizer
   * may be 0 = locked, rulings 2026-10-24 + 2026-10-27). Full record
   * pass-through; the engine reads the four ability levels. */
  abilityLevels(): UpgradeLevels {
    return { ...this.save.upgrades };
  }

  /** Begin a run (fresh ledger; depth resets to 0). */
  startRun(): void {
    this.runScrap = 0;
    this.runDepth = 0;
    this.runClears = 0;
  }

  /** Called when a board is cleared at depth d. Returns the scrap minted. */
  creditClear(depth: number): number {
    this.runDepth = depth;
    this.runClears += 1;
    const amount = scrapForClear(depth, this.levels);
    this.runScrap += amount;
    if (depth + 1 > this.save.depth) this.save.depth = depth + 1;
    return amount;
  }

  /** Bank the run: held run scrap moves into the vault, run continues deeper.
   * Returns the amount banked (and zeroes the ledger). */
  bank(): number {
    const amount = this.runScrap;
    this.save.scrap += amount;
    this.runScrap = 0;
    return amount;
  }

  /** Death: half the unbanked run scrap salvages into the vault. Returns the
   * SUMMARY numbers. The run is over — the ledger resets. */
  endRunOnDeath(): { earned: number; salvaged: number; destroyed: number } {
    const { salvaged, destroyed } = salvageRunScrap(this.runScrap);
    this.save.scrap += salvaged;
    const earned = this.runScrap;
    this.runScrap = 0;
    return { earned, salvaged, destroyed };
  }

  canAfford(id: UpgradeId): boolean {
    const cost = nextCost(id, this.levels[id]);
    return cost !== null && this.scrap >= cost;
  }

  purchase(id: UpgradeId): PurchaseResult {
    const def = UPGRADE_DEFS[id];
    if (!def) return { ok: false, reason: 'unknown' };
    // ?? 0 keeps QA-granted partial records safe: an absent level reads LOCKED.
    const level = this.levels[id] ?? 0;
    const cost = nextCost(id, level);
    if (cost === null) return { ok: false, reason: 'maxed', level };
    if (this.scrap < cost) return { ok: false, reason: 'affordable', level, cost };
    this.save.scrap -= cost;
    this.save.upgrades[id] = level + 1;
    return { ok: true, level: level + 1, cost };
  }

  /** True when every upgrade is maxed (Shop flourish). */
  get everythingMaxed(): boolean {
    return (Object.keys(MAX_UPGRADES) as UpgradeId[]).every(
      (id) => this.levels[id] >= MAX_UPGRADES[id],
    );
  }
}