Back to Black Site
SOURCE / PINNED RELEASE

Made of little things.

Black Site

Release
666bc7c74ebd…
Author-recorded commit
05e95af79676…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-d2225ddc391

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

src/armory-sync.ts
import { armoryDigest, emptyArmory, mergeArmory, profileArmory, type Armory, type GameProfile } from './armory.js';
import { openArmoryCollection, type ArmoryCollection } from './armory-host.js';
import { deadline } from './async.js';
import type { SaveStore } from './save-store.js';

export type SyncState = { supported: boolean; unsupported: boolean; message: string; busy: boolean; enabled: boolean; canEnable: boolean; canSave: boolean; canImport: boolean; canLoad: boolean };
type GameAccess = { isSafe(): boolean; profile(): GameProfile; applyArmory(value: Armory): void };
type Prepared = Awaited<ReturnType<ArmoryCollection['prepare']>>;

export function createArmorySync(store: SaveStore, game: () => GameAccess | undefined,
  render: (state: SyncState) => void, open = openArmoryCollection) {
  let owner = '';
  let generation = 0;
  let collection: ArmoryCollection | undefined;
  let supported = false, unsupported = false;
  let busy = false;
  let remote: Armory | undefined;
  let remoteDigest: string | undefined;
  let importAvailable = false;
  let message = 'Sign in to sync your armory';
  let uncertain: { change: Prepared; digest: string } | undefined;
  const current = () => profileArmory(game()?.profile() ?? store.get('blacksite.profile', {}));
  const preferences = () => store.preferences();
  const valid = (version: number) => version === generation && owner !== '' && store.owner() === owner;
  const present = () => {
    const ready = owner !== '' && store.owner() === owner && !!game()?.isSafe();
    let dirty = false;
    try { dirty = armoryDigest(current()) !== remoteDigest; } catch { /* Invalid local data cannot publish. */ }
    render({ supported, unsupported, message: message + (store.bucket().saveFailed ? ' · Device save unavailable' : ''), busy,
      enabled: preferences().enabled, canEnable: ready && !!collection && !busy && !preferences().enabled,
      canSave: ready && !!collection && !busy && preferences().enabled && (dirty || !!uncertain),
      canImport: ready && supported && importAvailable && !preferences().imported && !busy, canLoad: ready && supported && !busy });
  };
  function reconcile() {
    if (owner !== store.owner() || !game()?.isSafe() || !remote) return;
    const merged = mergeArmory(current(), remote);
    remote = undefined;
    if (armoryDigest(merged) !== armoryDigest(current())) game()!.applyArmory(merged);
  }
  const errorMessage = (error: unknown) => {
    const reason = error instanceof Error ? error.message : '';
    if (/app-data-(unavailable|no-relays)/.test(reason)) return 'This runtime does not support the features needed for sync.';
    if (/incompatible|invalid|deleted/.test(reason)) return 'Account armory cannot be loaded · Device progress kept';
    if (/denied|reject|consent|permission/i.test(reason)) return 'Account save not approved · Device progress kept';
    if (/stale|conflict/i.test(reason)) return 'Account changed on another device · Try saving again';
    return 'Armory sync failed · Device progress kept. Try again';
  };
  async function read(version: number) {
    if (!collection) {
      const opened = await deadline(() => open());
      if (!valid(version)) return;
      collection = opened; supported = true; unsupported = false;
    }
    if (!valid(version)) return;
    const result = await deadline(() => collection!.get(owner, 'progress'));
    if (!valid(version)) return;
    if (result.incomplete) throw new Error('armory-incomplete');
    if (result.record?.deleted) throw new Error('armory-deleted');
    supported = true; unsupported = false;
    remote = result.record?.data ?? emptyArmory();
    remoteDigest = armoryDigest(remote);
    reconcile();
    return result.record;
  }
  async function restore() {
    if (busy || !owner || store.owner() !== owner) return;
    const version = generation;
    busy = true;
    message = 'Loading account armory…'; present();
    try {
      await read(version);
      if (valid(version)) message = game()?.isSafe()
        ? (armoryDigest(current()) === remoteDigest ? 'Armory is up to date' : 'Changes saved on this device · Ready to save to account')
        : 'Account armory ready · Applies when you return to the menu';
    } catch (error) { if (valid(version)) { unsupported = /app-data-(unavailable|no-relays)/.test(error instanceof Error ? error.message : ''); message = errorMessage(error); } }
    finally { if (version === generation) { busy = false; present(); } }
  }
  return {
    async select(pubkey: string) {
      owner = pubkey; const version = ++generation;
      collection = undefined; supported = false; unsupported = false; remote = undefined; remoteDigest = undefined; uncertain = undefined; busy = false; importAvailable = false;
      message = pubkey ? 'Loading account armory…' : 'Sign in to sync your armory'; present();
      if (!pubkey) {
        busy = true; message = 'Checking sync support…'; present();
        try {
          const opened = await deadline(open);
          if (version !== generation) return;
          collection = opened; supported = true; message = 'Sign in to sync your armory';
        } catch {
          if (version === generation) { unsupported = true; message = 'This runtime does not support the features needed for sync.'; }
        } finally { if (version === generation) { busy = false; present(); } }
        return;
      }
      if (store.owner() !== pubkey) return;
      let available = false;
      try { available = armoryDigest(profileArmory(await store.guestProfile())) !== armoryDigest(emptyArmory()); } catch { /* Keep corrupt legacy data local. */ }
      if (!valid(version)) return;
      importAvailable = available;
      await restore();
    },
    suspend() {
      ++generation; owner = ''; supported = false; unsupported = false; remote = undefined; uncertain = undefined; collection = undefined; busy = false;
      message = 'Account changed · Player save switches after this mission'; present();
    },
    changed() {
      if (owner && owner === store.owner()) message = 'Changes saved on this device · Ready to save to account';
      present();
    },
    menu() { reconcile(); present(); },
    restore,
    enable() {
      if (!owner || store.owner() !== owner || !game()?.isSafe() || !collection || busy) return;
      store.set('blacksite.armorySync', { ...preferences(), enabled: true });
      message = 'Armory sync enabled · Ready to save to account'; present();
    },
    async importGuest() {
      if (!owner || busy || !game()?.isSafe() || preferences().imported) return;
      const version = generation;
      const source = profileArmory(await store.guestProfile());
      if (!valid(version) || !game()?.isSafe()) return;
      game()!.applyArmory(mergeArmory(current(), source));
      store.set('blacksite.armorySync', { ...preferences(), imported: true });
      message = 'Device armory imported · Ready to save to account'; present();
    },
    async save() {
      if (busy || !owner || store.owner() !== owner || !game()?.isSafe() || !preferences().enabled) return;
      const version = generation;
      const player = owner;
      busy = true; message = 'Saving armory to account…'; present();
      try {
        const base = await read(version);
        if (!valid(version) || !game()?.isSafe()) return;
        const snapshot = current();
        const digest = armoryDigest(snapshot);
        if (digest === remoteDigest) { uncertain = undefined; message = 'Armory is up to date'; return; }
        let change: Prepared;
        if (uncertain && uncertain.digest === digest && uncertain.change.template.content &&
            JSON.parse(uncertain.change.template.content).previous === (base?.revision ?? null)) {
          change = uncertain.change;
        } else {
          change = await deadline(() => collection!.prepare({ id: 'progress', title: 'Black Site armory', data: snapshot, base: base ?? null }));
        }
        if (!valid(version) || !game()?.isSafe()) return;
        uncertain = { change, digest };
        const saved = await deadline(() => change.publish(), 60000);
        if (!valid(version) || saved.author !== player) return;
        remoteDigest = digest;
        uncertain = undefined;
        message = armoryDigest(current()) === digest ? 'Saved to account' : 'Saved to account · Newer changes still on this device';
      } catch (error) {
        if (valid(version)) {
          if (/stale|conflict|denied|consent|permission/i.test(error instanceof Error ? error.message : '')) uncertain = undefined;
          message = errorMessage(error);
        }
      } finally { if (version === generation) { busy = false; present(); } }
    },
    close() { ++generation; owner = ''; collection = undefined; remote = undefined; uncertain = undefined; },
  };
}
export type ArmorySync = ReturnType<typeof createArmorySync>;