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.
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; },
};
}
