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 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],
);
}
}
