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 { common, identity, resource } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
export interface CurrentUser {
pubkey: string;
name: string;
picture: string;
status: string;
}
async function bounded<T>(read: () => Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
read(),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('timeout')), 5000);
}),
]);
} finally {
clearTimeout(timer);
}
}
/** Read-only viewer state; generations prevent old account responses from being displayed. */
export function watchCurrentUser(render: (user: CurrentUser) => void) {
let generation = 0;
let closed = false;
let subscription: { close(): void } | undefined;
const show = (pubkey: string, name: string, status: string, picture = '') => {
if (!closed) render({ pubkey, name, picture, status });
};
async function loadProfile(pubkey: string, version: number) {
try {
const profile = await bounded(async () => {
if (runtimeHasDomain('common')) {
const result = await common.getProfile(pubkey);
if (!result.ok || result.pubkey !== pubkey) throw new Error('profile unavailable');
return result.profile;
}
return await identity.getProfile();
});
if (version !== generation || closed) return;
const metadata = profile as Record<string, unknown> | null | undefined;
const name = [metadata?.display_name, metadata?.displayName, metadata?.name]
.find((value) => typeof value === 'string' && value.trim());
show(pubkey, typeof name === 'string' ? name.trim().slice(0, 120) : '',
profile ? 'Signed in' : 'Signed in · Player profile not set',
typeof metadata?.picture === 'string' ? metadata.picture : '');
} catch {
if (version === generation) show(pubkey, '', 'Signed in · Player profile unavailable');
}
}
function changed(pubkey: string) {
const version = ++generation;
if (closed) return;
if (!pubkey) {
show('', '', 'Playing as guest');
return;
}
if (!/^[a-f0-9]{64}$/i.test(pubkey)) {
show('', '', 'Player account unavailable');
return;
}
show(pubkey, '', 'Signed in · Loading player profile…');
void loadProfile(pubkey, version);
}
async function refresh() {
const version = ++generation;
if (closed) return;
if (!runtimeHasDomain('identity')) {
show('', '', 'Player account unavailable');
return;
}
show('', '', 'Checking player account…');
try {
const pubkey = await bounded(() => identity.getPublicKey());
if (version === generation && !closed) changed(pubkey);
} catch {
if (version === generation) show('', '', 'Could not load player account');
}
}
if (runtimeHasDomain('identity')) {
try {
subscription = identity.onChanged(changed);
} catch {
// Hosts without push support still get an initial identity snapshot.
}
}
void refresh();
return {
refresh,
close() {
closed = true;
++generation;
try { subscription?.close(); } catch { /* Optional host teardown may fail. */ }
},
};
}
/** Static help explaining account sync and why a host may not support it. */
function syncHelp() {
const section = document.createElement('section');
section.className = 'help-sync';
section.setAttribute('aria-labelledby', 'helpSyncHeading');
const add = (tag: 'h3' | 'h4' | 'p', text: string) => {
const element = document.createElement(tag);
element.textContent = text;
section.append(element);
return element;
};
const list = (items: string[]) => {
const element = document.createElement('ul');
for (const text of items) {
const item = document.createElement('li');
item.textContent = text;
element.append(item);
}
section.append(element);
};
add('h3', 'ACCOUNT SYNC').id = 'helpSyncHeading';
add('p', 'Your progress always saves on this device first. Sync is optional: it copies your progress to your player account so you can continue on another device.');
list([
'Armory sync saves your permanent unlocks and upgrades.',
'Player choice sync saves your character, loadout, equipped armor, difficulty and map.',
'Turn sync on from PLAYER ACCOUNT or ARMORY while signed in. Each save asks for your approval, and saved account data is public.',
'Loading your account armory combines it with this device, so unlocks are never lost. If another device saved in the meantime, save again.',
]);
add('h4', 'WHY SYNC MAY BE UNAVAILABLE');
add('p', 'Black Site runs inside a host app (the runtime). Sync only works when that host provides everything it needs. Otherwise the game shows "This runtime does not support the features needed for sync." and keeps playing with device saves only.');
list([
'A signed-in player account (identity).',
'Reading and publishing your account events (outbox).',
'App data storage (soy.app-data/1) with a storage relay chosen in the host’s Network settings.',
]);
add('p', 'To use sync, open Black Site in a host that supports these features, sign in, and make sure a storage relay is set. Nothing on this device is removed while sync is unavailable.');
return section;
}
/** Keep napplet-only menus outside the synced game's HTML. Mount before bootGame. */
export function mountCurrentUser(onIdentity: (pubkey: string) => void = () => {}) {
const menu = document.querySelector('#menu .col');
if (!menu) return;
const createScreen = (id: string, title: string) => {
const screen = document.createElement('section');
screen.id = id;
screen.className = 'screen account-screen';
const bar = document.createElement('div');
bar.className = 'lbar';
const heading = document.createElement('h2');
heading.textContent = title;
const back = document.createElement('button');
back.type = 'button';
back.dataset.go = 'menu';
back.textContent = 'BACK';
bar.append(heading, back);
screen.append(bar);
document.body.append(screen);
return { screen, back };
};
const open = (screen: HTMLElement, focus: HTMLElement) => {
for (const item of document.querySelectorAll('.screen')) {
item.classList.toggle('on', item === screen);
}
focus.focus();
};
const help = createScreen('howToPlay', 'HELP & HOW TO PLAY');
const instructions = menu.querySelector('.hint');
if (instructions) help.screen.append(instructions);
help.screen.append(syncHelp());
const helpButton = document.createElement('button');
helpButton.type = 'button';
helpButton.id = 'helpButton';
helpButton.textContent = 'HELP & HOW TO PLAY';
helpButton.addEventListener('click', () => open(help.screen, help.back));
menu.append(helpButton);
const player = createScreen('playerAccount', 'PLAYER ACCOUNT');
const details = document.createElement('div');
details.className = 'player-details';
details.setAttribute('role', 'status');
const name = document.createElement('div');
name.className = 'user-name';
const status = document.createElement('div');
status.className = 'user-status';
const profileAvatar = document.createElement('span');
profileAvatar.className = 'player-avatar';
details.append(profileAvatar, name, status);
player.screen.append(details);
const playerButton = document.createElement('button');
playerButton.type = 'button';
playerButton.id = 'currentUser';
playerButton.setAttribute('aria-controls', 'playerAccount');
playerButton.addEventListener('click', () => open(player.screen, player.back));
const menuAvatar = document.createElement('span');
menuAvatar.className = 'player-avatar';
const playerName = document.createElement('span');
playerName.className = 'player-label';
playerButton.append(menuAvatar, playerName);
menu.append(playerButton);
let avatarVersion = 0;
let avatarUrl: string | undefined;
let avatarRequest: AbortController | undefined;
const clearAvatar = () => {
++avatarVersion;
avatarRequest?.abort();
avatarRequest = undefined;
if (avatarUrl) URL.revokeObjectURL(avatarUrl);
avatarUrl = undefined;
};
const loadAvatar = async (picture: string, version: number) => {
const request = new AbortController();
avatarRequest = request;
try {
const blob = await bounded(() => resource.bytes(picture, { signal: request.signal }));
if (version !== avatarVersion || !/^image\/(png|jpeg|webp|gif)$/.test(blob.type)) return;
avatarUrl = URL.createObjectURL(blob);
for (const avatar of [menuAvatar, profileAvatar]) {
const image = document.createElement('img');
image.alt = '';
image.src = avatarUrl;
image.addEventListener('load', () => {
if (version === avatarVersion) avatar.replaceChildren(image);
}, { once: true });
}
} catch {
// Keep initials when an optional avatar is absent, denied, or unreadable.
} finally {
request.abort();
if (avatarRequest === request) avatarRequest = undefined;
}
};
const start = () => watchCurrentUser((user) => {
if (user.status !== 'Checking player account…') onIdentity(user.pubkey);
const label = user.name || (user.pubkey ? 'Player' : 'Guest player');
playerName.textContent = label;
clearAvatar();
const initials = user.name ? user.name.split(/\s+/).slice(0, 2).map(part => part[0]).join('').toUpperCase() : 'P';
for (const avatar of [menuAvatar, profileAvatar]) avatar.textContent = initials;
if (user.picture && runtimeHasDomain('resource')) void loadAvatar(user.picture, avatarVersion);
playerButton.setAttribute('aria-label', `Player account: ${label}`);
name.textContent = label;
status.textContent = user.status;
});
let controller = start();
window.addEventListener('pagehide', () => { controller.close(); clearAvatar(); });
window.addEventListener('pageshow', (event) => {
if (event.persisted) controller = start();
});
for (const [view, trigger] of [[help, helpButton], [player, playerButton]] as const) {
view.back.addEventListener('click', () => trigger.focus());
view.screen.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.preventDefault();
view.back.click();
}
});
}
}
