Back to Browser Side Quest
SOURCE / PINNED RELEASE

Made of little things.

Browser Side Quest

Release
b9d42f5768e4…
Author-recorded commit
c267690145fa…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/browser-side-quest

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

src/main.ts
/**
 * Browser Side Quest — boot. Wires the ported game to the napplet host:
 * identity (character name), storage (save), common/resource (player profiles),
 * config (settings) and WebRTC (shared worlds). Every domain is optional; the
 * game always runs, falling back to a private solo world.
 */
import { WEBRTC_DOMAIN, type Subscription } from '@napplet/sdk';
import { App, type WorldProvider } from './client/app.js';
import { GameStorage } from './client/storage.js';
import { runtimeHasDomain } from './domain-availability.js';
import { InputLayer } from './input/input.js';
import { defaultSettings, watchSettings, type GameSettings } from './napplet-settings.js';
import { SoloWorlds } from './net/solo.js';
import { ShardNetwork } from './net/shard.js';
import { ProfileCache, onIdentityChanged, readIdentity, type NostrIdentity } from './social/profiles.js';
import './styles.css';

let settings: GameSettings = { ...defaultSettings };

/** Multiplayer when the host offers WebRTC and the player hasn't opted out. */
function createWorlds(): WorldProvider {
  if (settings.multiplayer && runtimeHasDomain(WEBRTC_DOMAIN)) return new ShardNetwork();
  return new SoloWorlds(runtimeHasDomain(WEBRTC_DOMAIN) ? 'Solo world (online play off)' : 'Solo world (no peer network)');
}

/** Retries (ms) for a profile name after a login that only yielded the npub fallback. */
const PROFILE_RETRY_MS = [2000, 5000, 10000];

async function boot(): Promise<void> {
  const storage = new GameStorage();
  const profiles = new ProfileCache();
  // The identity (and its profile name) can take a few seconds to resolve; don't
  // hold the intro for it. It is applied as soon as it arrives.
  const identityReady = readIdentity();
  await storage.load();

  const app = new App({ storage, profiles, worlds: createWorlds(), identity: { pubkey: '', name: '' } });

  const input = new InputLayer({
    playing: () => {
      const game = app.game;
      return game.started && game.player && !game.player.isDead ? game : null;
    },
    isTyping: () => app.isTyping(),
    toggleChat: () => app.toggleChat(),
    hideWindows: () => app.hideWindows(),
    toggleAchievements: () => app.toggleAchievements(),
    toggleMute: () => app.toggleMute(),
    confirm: () => app.confirm(),
  });
  input.bindTouchPad(document.querySelector<HTMLElement>('#touchpad')!);

  const stopSettings = watchSettings((next) => {
    settings = next;
    app.setMuted(!next.sound);
    document.querySelector('#app')?.classList.toggle('touchpad-off', !next.touchControls);
  });

  // Identity: the boot read, then every change (login, logout, account switch).
  // Right after a login the shell may not have the profile yet; while only the
  // npub fallback is known, retry a few times so the profile name replaces it.
  let identityGeneration = 0;
  const followIdentity = async (read: Promise<NostrIdentity>, changed: boolean): Promise<void> => {
    const generation = ++identityGeneration;
    let id = await read;
    if (generation !== identityGeneration) return;
    app.applyIdentity(id, changed);
    for (const delay of PROFILE_RETRY_MS) {
      if (!id.provisional) return;
      await new Promise((resolve) => window.setTimeout(resolve, delay));
      if (generation !== identityGeneration) return;
      const next = await readIdentity();
      if (generation !== identityGeneration || next.pubkey !== id.pubkey) return;
      if (!next.provisional) app.applyIdentity(next, changed);
      id = next;
    }
  };

  let identitySub: Subscription | null = onIdentityChanged(() => {
    void followIdentity(readIdentity(), true);
  });

  await app.load();
  // A change that already arrived during loading is newer than the boot read.
  if (identityGeneration === 0) void followIdentity(identityReady, false);

  window.addEventListener(
    'pagehide',
    () => {
      identitySub?.close();
      identitySub = null;
      stopSettings();
      input.dispose();
      app.dispose();
      profiles.dispose();
    },
    { once: true },
  );
}

boot().catch((error: unknown) => {
  const notice = document.querySelector('#identity-hint');
  if (notice) notice.textContent = `Browser Side Quest failed to start: ${error instanceof Error ? error.message : String(error)}`;
});