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/customization-sync.ts
import { openCustomizationCollection } from './armory-host.js';
import { customizationDigest, validateCustomization, type Customization } from './customization.js';
import { deadline } from './async.js';
import type { SaveStore } from './save-store.js';
import type { SyncState } from './armory-sync.js';

type GameAccess = { isSafe(): boolean; customization(): Customization; applyCustomization(value: Customization): void };
type Collection = Awaited<ReturnType<typeof openCustomizationCollection>>;
type Prepared = Awaited<ReturnType<Collection['prepare']>>;
type Preferences = { enabled: boolean; imported: boolean; dirty: boolean };

export function createCustomizationSync(store: SaveStore, game: () => GameAccess | undefined,
  render: (state: SyncState) => void, open = openCustomizationCollection) {
  let owner = '', generation = 0, busy = false;
  let collection: Collection | undefined;
  let supported = false, unsupported = false;
  let pendingRemote: Customization | undefined;
  let digest: string | undefined;
  let uncertain: { change: Prepared; digest: string } | undefined;
  let message = 'Sign in to sync your player choices';
  const prefs = (): Preferences => {
    const data = store.get<Partial<Preferences>>('blacksite.customizationSync', {});
    return { enabled: data?.enabled === true, imported: data?.imported === true, dirty: data?.dirty === true };
  };
  const setPrefs = (changes: Partial<Preferences>) => store.set('blacksite.customizationSync', { ...prefs(), ...changes });
  const valid = (version: number) => version === generation && !!owner && owner === store.owner();
  const current = () => validateCustomization(game()!.customization());
  const present = () => {
    const ready = !!owner && owner === store.owner() && !!game()?.isSafe();
    render({ supported, unsupported, message: message + (store.bucket().saveFailed ? ' · Device save unavailable' : ''), busy, enabled: prefs().enabled, canEnable: ready && !!collection && !busy && !prefs().enabled,
      canSave: ready && !!collection && !busy && prefs().enabled && (digest !== customizationDigest(current()) || !!uncertain),
      canLoad: ready && supported && !busy, canImport: ready && supported && !prefs().imported && !busy });
  };
  function reconcile() {
    if (!pendingRemote || !game()?.isSafe() || owner !== store.owner()) return;
    const choices = pendingRemote; pendingRemote = undefined;
    if (!prefs().dirty && customizationDigest(current()) !== customizationDigest(choices)) game()!.applyCustomization(choices);
  }
  async function read(version: number) {
    if (!collection) {
      const opened = await deadline(open);
      if (!valid(version)) return;
      collection = opened; supported = true; unsupported = false;
    }
    const result = await deadline(() => collection!.get(owner, 'choices'));
    if (!valid(version)) return;
    if (result.incomplete) throw new Error('customization-incomplete');
    if (result.record?.deleted) throw new Error('customization-deleted');
    supported = true; unsupported = false;
    digest = result.record?.data ? customizationDigest(result.record.data) : undefined;
    pendingRemote = result.record?.data ?? undefined;
    reconcile();
    return result.record;
  }
  const failed = (error: unknown) => {
    const reason = error instanceof Error ? error.message : '';
    return /unavailable|no-relays/.test(reason) ? 'This runtime does not support the features needed for sync.'
      : /invalid|incompatible|deleted/.test(reason) ? 'Account choices cannot be loaded · Device choices kept'
      : /stale|conflict/.test(reason) ? 'Choices changed on another device · Try saving again'
      : /denied|consent|permission|reject/i.test(reason) ? 'Account save not approved · Device choices kept'
      : 'Player choice sync failed · Device choices kept. Try again';
  };
  async function restore() {
    if (busy || !owner || owner !== store.owner()) return;
    busy = true; const version = generation; message = 'Loading player choices…'; present();
    try {
      await read(version);
      if (valid(version)) message = prefs().dirty ? 'Device choices ready to save to account'
        : pendingRemote ? 'Account choices ready · Applies when you return to the menu' : 'Player choices are up to date';
    } catch (error) { if (valid(version)) { unsupported = /app-data-(unavailable|no-relays)/.test(error instanceof Error ? error.message : ''); message = failed(error); } }
    finally { if (version === generation) { busy = false; present(); } }
  }
  return {
    async select(pubkey: string) {
      owner = pubkey; const version = ++generation; supported = false; unsupported = false; collection = undefined; pendingRemote = undefined; digest = undefined; uncertain = undefined; busy = false;
      message = pubkey ? 'Loading player choices…' : 'Sign in to sync your player choices'; present();
      if (pubkey) await restore();
      else {
        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 player choices';
        } catch { if (version === generation) { unsupported = true; message = 'This runtime does not support the features needed for sync.'; } }
        finally { if (version === generation) { busy = false; present(); } }
      }
    },
    suspend() { ++generation; owner = ''; supported = false; unsupported = false; collection = undefined; uncertain = undefined; pendingRemote = undefined; busy = false;
      message = 'Account changed · Player save switches after this mission'; present(); },
    changed() {
      if (owner && owner === store.owner()) { setPrefs({ dirty: true }); message = 'Choices saved on this device · Ready to save to account'; }
      present();
    },
    menu() { reconcile(); present(); }, restore,
    enable() {
      if (!owner || owner !== store.owner() || !game()?.isSafe() || !collection || busy) return;
      setPrefs({ enabled: true }); message = 'Player choice sync enabled · Ready to save to account'; present();
    },
    async importGuest() {
      if (!owner || busy || !game()?.isSafe() || prefs().imported) return;
      const version = generation;
      const choices = validateCustomization(await store.guestChoices());
      if (!valid(version) || !game()?.isSafe()) return;
      game()!.applyCustomization(choices); setPrefs({ imported: true, dirty: true });
      message = 'Device choices imported · Ready to save to account'; present();
    },
    async save() {
      if (busy || !owner || owner !== store.owner() || !game()?.isSafe() || !prefs().enabled) return;
      const version = generation; busy = true; message = 'Saving player choices…'; present();
      try {
        const base = await read(version);
        if (!valid(version) || !game()?.isSafe()) return;
        const snapshot = current(), snapshotDigest = customizationDigest(snapshot);
        if (snapshotDigest === digest) { uncertain = undefined; setPrefs({ dirty: false }); message = 'Player choices are up to date'; return; }
        const change = uncertain && uncertain.digest === snapshotDigest &&
          JSON.parse(uncertain.change.template.content).previous === (base?.revision ?? null) ? uncertain.change
          : await deadline(() => collection!.prepare({ id: 'choices', title: 'Black Site player choices', data: snapshot, base: base ?? null }));
        if (!valid(version) || !game()?.isSafe()) return;
        uncertain = { change, digest: snapshotDigest };
        await deadline(() => change.publish(), 60000);
        if (!valid(version)) return;
        digest = snapshotDigest; uncertain = undefined;
        const stillPending = customizationDigest(current()) !== snapshotDigest;
        setPrefs({ dirty: stillPending });
        message = stillPending ? 'Saved to account · Newer choices still on this device' : 'Choices saved to account';
      } catch (error) {
        if (valid(version)) {
          if (/stale|conflict|denied|consent|permission/i.test(error instanceof Error ? error.message : '')) uncertain = undefined;
          message = failed(error);
        }
      } finally { if (version === generation) { busy = false; present(); } }
    },
    close() { ++generation; owner = ''; collection = undefined; pendingRemote = undefined; uncertain = undefined; },
  };
}