Back to Conway's Game of Life
SOURCE / PINNED RELEASE

Made of little things.

Conway's Game of Life

Release
230955df1900…
Author-recorded commit
c08196c2b050…
License
LICENSE
Author’s source reference
nostr://npub16nzc4ql748r2kv9202rgzrt0fmtk4n3tzsjl86yf6ty9agqe9u0qjcc7hc/wss%3A%2F%2Fgit.napplet.soy%2F/n-644c45e22c7

Archive hash verified: 2627ce678935a213…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/savewire.ts
// Save wire (Phase 7, spec §7 / ratified default D6): the painted board,
// speed setting and generation counter persist through the host NAP-STORAGE
// capability. Browser storage APIs (the window-local kind) are banned for
// durable state by the platform contract — the host proxies storage over the
// napplet wire via `@napplet/sdk`.
//
// Degradation law (tested pair in tests/browser.test.mjs): every call is
// gated by runtimeHasDomain('storage') AND wrapped in try/catch, because a
// host can advertise the namespace without working methods (conformance
// harness behavior, docs/napplet-space.md). A missing or broken storage
// reads as "no save": the game keeps playing session-only and never throws
// at window level.
import { storage } from '@napplet/sdk';
import { decodeRle } from './engine.js';
import { runtimeHasDomain } from './domain-availability.js';

/** Save-key prefix per build plan Phase 7. */
export const SAVE_KEY_BOARD = 'game-of-life:board';
export const SAVE_KEY_SPEED = 'game-of-life:speed';
export const SAVE_KEY_GENERATION = 'game-of-life:generation';

/** Generation-rate range (D3) — clamps loaded speeds to the legal band. */
export const SPEED_MIN = 1;
export const SPEED_MAX = 30;

export interface SavedBoard {
  /** Full-board RLE exactly as encodeRle emits it (spec §7 wire shape). */
  boardRle: string;
  speed: number;
  generation: number;
}

/** Whether THIS load has a storage domain injected by the host. */
export function storageAvailable(): boolean {
  return runtimeHasDomain('storage');
}

/**
 * Read the save triple at boot. Returns null when there is nothing to
 * restore: no storage domain, no prior save, a failing read, or a corrupt
 * board encoding. Numeric fields clamp into their legal bands rather than
 * inheriting junk.
 */
export async function loadSavedBoard(
  w: number,
  h: number,
  defaultSpeed: number,
): Promise<SavedBoard | null> {
  if (!storageAvailable()) return null;
  try {
    const [boardRle, speedRaw, genRaw] = await Promise.all([
      storage.getItem(SAVE_KEY_BOARD),
      storage.getItem(SAVE_KEY_SPEED),
      storage.getItem(SAVE_KEY_GENERATION),
    ]);
    if (boardRle === null && speedRaw === null && genRaw === null) return null;
    if (typeof boardRle !== 'string') return null;
    const d = decodeRle(boardRle);
    // Wire-shape law: saved boards are full-board encodes. Anything that
    // does not decode to exactly the board dimensions is junk — defaults win.
    if (d.w !== w || d.h !== h) return null;
    const parsed = Math.round(Number(speedRaw));
    const speed = speedRaw === null || !Number.isFinite(parsed)
      ? defaultSpeed
      : Math.min(SPEED_MAX, Math.max(SPEED_MIN, parsed));
    const generation = Math.max(0, Math.trunc(Number(genRaw)) || 0);
    return { boardRle, speed, generation };
  } catch {
    return null; // a storage namespace that exists but cannot serve reads
  }
}

/** Write the save triple. Returns false when storage is absent or failed. */
export async function saveBoard(
  boardRle: string,
  speed: number,
  generation: number,
): Promise<boolean> {
  if (!storageAvailable()) return false;
  try {
    await storage.setItem(SAVE_KEY_BOARD, boardRle);
    await storage.setItem(SAVE_KEY_SPEED, String(speed));
    await storage.setItem(SAVE_KEY_GENERATION, String(generation));
    return true;
  } catch {
    return false; // host storage broke mid-play; session-only continues
  }
}