Back to Supersonic RC Revive
SOURCE / PINNED RELEASE

Made of little things.

Supersonic RC Revive

Release
bb8b570f3600…
Author-recorded commit
073e2d2c0e63…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/SuperSonicRCRevive

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

src/game/onlineSession.ts
import { cvm, webrtc, type WebrtcEvent, type WebrtcOpenRequest } from '@napplet/sdk';
import backend from '../../.napplet-space/soy-backend.json';
import { runtimeHasDomain } from '../domain-availability.js';
import { assetServers, currentLevelOverlay, currentPackBuffer } from './assets.js';
import { ProfileNames } from './onlineNames.js';
import { assetTag, decodeMessage, formatCode, HASH, mismatch, olderRoom, ONLINE_PROTOCOL, PHYSICS_VERSION, privateRoom, RemoteCar, ROOM_CAPACITY, type Descriptor, type Message, type Pose, type RoomDescriptor, type Snapshot } from './onlineProtocol.js';

export interface Room { room: string; name?: string; protocol: string; peers: string[]; capacity: number }
export type OnlineMode = { kind: 'public' } | { kind: 'private'; code: string };
export interface OnlineTransport {
  call(tool: string, args: Record<string, unknown>): Promise<unknown>;
  open(request: WebrtcOpenRequest): Promise<{ session: { id: string } }>;
  send(id: string, payload: unknown): Promise<void>;
  close(id: string, reason?: string): Promise<void>;
  events(handler: (event: WebrtcEvent) => void): { close(): void };
}
const transport: OnlineTransport = {
  async call(tool, args) {
    const result = backend.provider
      ? await cvm.callTool(backend.provider, tool, args)
      : await cvm.registry.call('soy.rooms.v1', tool, args);
    if (result.isError) throw new Error(result.content.filter(c => c.type === 'text').map(c => c.text).join('\n') || 'Online service unavailable.');
    return result.structuredContent;
  },
  open: request => webrtc.open(request),
  send: (id, payload) => webrtc.send(id, payload),
  close: (id, reason) => webrtc.close(id, reason),
  events: handler => webrtc.onEvent(handler),
};

/** Private rooms are pure NAP-WEBRTC; public rooms also need the CVM room directory. */
export function privateAvailable(): boolean { return runtimeHasDomain('webrtc'); }
export function publicAvailable(): boolean { return runtimeHasDomain('cvm') && runtimeHasDomain('webrtc'); }
export async function assetDescriptor(): Promise<Descriptor> {
  const pack = currentPackBuffer();
  if (!pack) throw new Error('Load an asset pack before playing online.');
  if (typeof crypto === 'undefined' || !crypto.subtle) throw new Error('This browser cannot verify asset hashes. Open the game in a secure napplet host to play online.');
  const hash = async (bytes: ArrayBuffer) => [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))].map(b => b.toString(16).padStart(2, '0')).join('');
  const overlay = currentLevelOverlay();
  const [packHash, levelHash] = await Promise.all([hash(pack), overlay ? hash(overlay) : Promise.resolve(null)]);
  return { pack: packHash, level: levelHash, physics: PHYSICS_VERSION, packServers: assetServers(pack), levelServers: overlay ? assetServers(overlay) : [] };
}
function roomSnapshot(v: unknown): Room {
  const r = v as Room;
  if (!r || typeof r.room !== 'string' || r.protocol !== ONLINE_PROTOCOL || r.capacity !== ROOM_CAPACITY || !Array.isArray(r.peers)
    || r.peers.length > ROOM_CAPACITY || !r.peers.every(p => typeof p === 'string' && HASH.test(p)) || new Set(r.peers).size !== r.peers.length) throw new Error('The room is incompatible with this game version.');
  return r;
}
const nonce = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('');
/** Stable per-player colour index 1–7; 0 is always the local car. */
export const remoteColor = (actor: string): number => 1 + parseInt(actor.slice(0, 8), 16) % 7;

/**
 * Host-less rendezvous and admission. Physics stays entirely in App's local world.
 * The first player founds the room's asset descriptor; everyone repeats it in beacons,
 * so the room continues after any player, including its founder, leaves.
 */
export class OnlineSession {
  actor = '';
  room: Room | null = null;
  roomDescriptor: RoomDescriptor | null = null;
  readonly remote = new Map<string, RemoteCar>();
  ready = false;
  /** Spawn offset index, chosen from the players already driving at admission. */
  lane = 0;
  /** This player's Nostr pubkey, shared so others can show a profile name; null stays anonymous. */
  profile: string | null = null;
  names = new ProfileNames();
  private readonly profiles = new Map<string, string>();
  status = 'Connecting…';
  onChange: () => void = () => {};
  onReady: () => void = () => {};
  onEnd: (reason: string) => void = () => {};
  onAssetsRequired: ((descriptor: Descriptor, isActive: () => boolean) => Promise<Descriptor>) | null = null;
  private syncingAssets = false;
  private active = true;
  private sessionId: string | null = null;
  private readonly connected = new Set<string>();
  /** Connected peers that beacon the same room descriptor as this player. */
  private readonly admitted = new Set<string>();
  private offered: RoomDescriptor | null = null;
  private offeredCount = 0;
  private polling = false;
  private renewedAt = 0;
  private polls = 0;
  private subscription: { close(): void } | null = null;
  private pollTimer: ReturnType<typeof setInterval> | null = null;
  private beaconTimer: ReturnType<typeof setInterval> | null = null;
  private waitTimer: ReturnType<typeof setTimeout> | null = null;
  private sendingState = false;
  private controlsInFlight = 0;
  private sequence = 0;
  private sendAt = 0;

  constructor(public descriptor: Descriptor, readonly mode: OnlineMode, private readonly io = transport, private readonly namespace = backend.napplet,
    private readonly foundingWait = mode.kind === 'private' ? 4000 : 10000) {}
  get code(): string { return this.mode.kind === 'private' ? formatCode(this.mode.code) : ''; }
  get players(): number { return this.admitted.size + 1; }

  async start(): Promise<void> {
    try {
      if (this.mode.kind === 'public') {
        const result = await this.io.call('soy_session', {}) as { actor: string };
        if (!this.active) return;
        if (!result || !HASH.test(result.actor)) throw new Error('The online service returned an invalid player identity.');
        this.actor = result.actor;
        const room = await this.findPublicRoom();
        // Cancellation can happen while create/join is awaiting the provider.
        if (!this.active) { await this.io.call('soy_room_leave', { room: room.room }); return; }
        this.room = room;
        this.renewedAt = Date.now();
      }
      this.subscription = this.io.events(e => this.event(e));
      await this.open();
      if (!this.active) return;
      if (this.room) this.pollTimer = setInterval(() => void this.poll(), 3000);
      this.beaconTimer = setInterval(() => this.beacon(), 2000);
      // A public room this player just created has nobody else to wait for.
      if (this.room?.peers.length === 1) this.found();
      else this.waitTimer = setTimeout(() => { if (!this.ready && !this.syncingAssets && !this.offered) this.found(); }, this.foundingWait);
      this.changed();
    } catch (error) {
      if (this.active) this.fail(error instanceof Error ? error.message : String(error));
      throw error;
    }
  }

  /** Listed public rooms with space, matching assets first, then fullest; null when the directory fails. */
  private async publicRooms(): Promise<Room[] | null> {
    const tag = assetTag(this.descriptor);
    let listed: { rooms?: unknown[] } | null;
    try { listed = await this.io.call('soy_room_list', { napplet: this.namespace, protocol: ONLINE_PROTOCOL }) as typeof listed; }
    catch (error) { console.log('[online] public room list unavailable', error); return null; }
    return (Array.isArray(listed?.rooms) ? listed.rooms : []).flatMap(r => { try { return [roomSnapshot(r)]; } catch { return []; } })
      .filter(r => r.peers.length < ROOM_CAPACITY && !r.peers.includes(this.actor))
      .sort((a, b) => Number(b.name?.endsWith(tag) ?? false) - Number(a.name?.endsWith(tag) ?? false) || b.peers.length - a.peers.length);
  }
  /**
   * Join an existing room whenever one has space: try every listed candidate, and list once more
   * when seats were taken meanwhile or the directory failed. Create a room only when none fits.
   */
  private async findPublicRoom(): Promise<Room> {
    for (let attempt = 0; attempt < 2 && this.active; ++attempt) {
      const rooms = await this.publicRooms();
      if (rooms && !rooms.length) break;
      for (const candidate of rooms ?? []) {
        if (!this.active) break;
        // Another player may take the last seat between listing and joining.
        try { return roomSnapshot(await this.io.call('soy_room_join', { room: candidate.room })); }
        catch (error) { console.log('[online] public room unavailable', error); }
      }
    }
    return roomSnapshot(await this.io.call('soy_room_create', { napplet: this.namespace, protocol: ONLINE_PROTOCOL, name: `Public · ${assetTag(this.descriptor)}`, capacity: ROOM_CAPACITY, listed: true }));
  }

  private changed(): void {
    const where = this.mode.kind === 'private' ? `Room ${this.code}` : 'Public';
    this.status = this.syncingAssets ? 'Downloading and loading the room’s game data…'
      : this.ready ? `${where} · ${this.players}/${ROOM_CAPACITY} players` : this.connected.size ? 'Joining players…' : 'Looking for players…';
    this.onChange();
  }
  /** Nobody answered: this player's assets become the room's. */
  private found(): void {
    if (!this.active || this.ready) return;
    this.roomDescriptor = { ...this.descriptor, founded: Date.now(), nonce: nonce() };
    this.admit();
  }
  private admit(players = 0): void {
    if (this.ready) return;
    this.ready = true;
    this.lane = Math.min(players, ROOM_CAPACITY - 1);
    if (this.waitTimer) clearTimeout(this.waitTimer);
    this.waitTimer = null;
    console.log('[online] player admitted', { players: this.players });
    this.onReady();
    this.beacon();
    this.changed();
  }
  private beacon(): void {
    if (!this.connected.size) return;
    if (this.ready && this.roomDescriptor) void this.control({ type: 'room', descriptor: this.roomDescriptor, count: this.players, ...this.profile ? { profile: this.profile } : {} });
    else void this.control({ type: 'hello' });
  }
  /** Beacons repeat, so a burst may drop them; "full" must always reach the overflow player. */
  private async control(message: Message): Promise<void> {
    const id = this.sessionId;
    if (!this.active || !id || !this.connected.size || this.controlsInFlight >= 4 && message.type !== 'full') return;
    ++this.controlsInFlight;
    try { await this.io.send(id, message); }
    catch (error) { console.log('[online] control delivery deferred', error); }
    finally { --this.controlsInFlight; }
  }

  /**
   * One open rendezvous per room: a private code, or the public room's CVM id. The host cannot
   * update a peer allowlist in place, and reopening the whole mesh on every join floods signaling,
   * so public membership is enforced per message from the renewed CVM roster instead.
   */
  private async open(): Promise<void> {
    const room = this.mode.kind === 'private' ? privateRoom(this.mode.code) : this.room!.room;
    const { session } = await this.io.open({ scope: { type: 'room', room }, channel: 'cars', protocol: ONLINE_PROTOCOL });
    if (!this.active) { await this.io.close(session.id, 'Cancelled'); return; }
    this.sessionId = session.id;
    console.log('[online] connecting', { mode: this.mode.kind });
  }
  private async poll(): Promise<void> {
    if (this.polling || !this.active || !this.room) return;
    this.polling = true;
    try {
      const next = roomSnapshot(await this.io.call('soy_room_status', { room: this.room.room }));
      if (!this.active) return;
      this.room = next;
      this.renewedAt = Date.now();
      for (const actor of [...this.admitted]) if (!next.peers.includes(actor)) this.admitted.delete(actor);
      for (const actor of this.remote.keys()) if (!this.admitted.has(actor)) this.remote.delete(actor);
      this.changed();
      if (++this.polls % 3 === 0 && this.alone()) await this.consolidate();
    } catch (error) {
      // Membership lasts 60 s: a slow or busy provider only ends play once renewal is about to lapse.
      const message = error instanceof Error ? error.message : String(error);
      if (!this.active) return;
      if (/expired|join this room/i.test(message) || Date.now() - this.renewedAt > 45000) this.fail(`Online connection lost: ${message}`);
      else console.log('[online] room renewal retrying', message);
    } finally { this.polling = false; }
  }
  private alone(): boolean {
    return this.ready && !this.syncingAssets && !this.connected.size && this.room?.peers.length === 1;
  }
  /**
   * Players who started together can each open an empty room. A lone public player moves into a
   * listed room with the same assets: any room with players, or the lower ID of two lone rooms so
   * exactly one side moves. Same assets means the move never downloads or interrupts driving.
   */
  private async consolidate(): Promise<void> {
    const current = this.room!, tag = assetTag(this.descriptor);
    const target = (await this.publicRooms())?.find(r => r.room !== current.room && r.name?.endsWith(tag)
      && (r.peers.length > 1 || r.room < current.room));
    if (!target || !this.active || !this.alone()) return;
    let next: Room;
    try { next = roomSnapshot(await this.io.call('soy_room_join', { room: target.room })); }
    catch (error) { console.log('[online] room move skipped', error); return; }
    // The player may have left, or someone may have connected, while the join was in flight.
    if (!this.active || !this.alone()) { void this.io.call('soy_room_leave', { room: next.room }).catch(() => {}); return; }
    const old = this.sessionId;
    this.sessionId = null;
    this.room = next;
    this.renewedAt = Date.now();
    console.log('[online] moving to a room with players', { players: next.peers.length });
    const results = await Promise.allSettled([old ? this.io.close(old, 'Moving room') : Promise.resolve(), this.io.call('soy_room_leave', { room: current.room })]);
    for (const r of results) if (r.status === 'rejected') console.log('[online] cleanup deferred to room expiry', r.reason);
    try { if (this.active) await this.open(); }
    catch (error) { if (this.active) this.fail(`Online connection lost: ${error instanceof Error ? error.message : String(error)}`); }
    this.changed();
  }
  private member(actor: string): boolean {
    return actor !== this.actor && this.connected.has(actor) && (!this.room || this.room.peers.includes(actor));
  }
  private event(e: WebrtcEvent): void {
    if (!this.active || e.sessionId !== this.sessionId) return;
    if (e.type === 'closed') { this.fail(e.reason || 'Online connection closed. Join again to reconnect.'); return; }
    if (e.type === 'peer') {
      // A new public member can connect before this player's next roster poll; messages wait for it.
      if (e.pubkey === this.actor) return;
      if (e.state === 'joined') { this.connected.add(e.pubkey); this.beacon(); }
      else { this.connected.delete(e.pubkey); this.admitted.delete(e.pubkey); this.remote.delete(e.pubkey); this.profiles.delete(e.pubkey); }
      console.log('[online] peer', { actor: e.pubkey, state: e.state });
      this.changed();
      return;
    }
    if (e.type !== 'message' || !this.member(e.from)) return;
    const m = decodeMessage(e.payload);
    if (!m) return;
    if (m.type === 'hello') {
      if (!this.ready) return;
      // The session keeps one spare peer slot so an overflow player still hears this.
      if (this.players >= ROOM_CAPACITY) void this.control({ type: 'full' });
      else this.beacon();
    } else if (m.type === 'room') {
      if (m.profile) this.profiles.set(e.from, m.profile);
      this.roomBeacon(e.from, m.descriptor, m.count);
    }
    else if (m.type === 'full') {
      if (!this.ready && !this.syncingAssets) this.fail(`This room is full (${ROOM_CAPACITY} players). Try again later.`);
    } else if (m.type === 'bye') {
      this.admitted.delete(e.from);
      this.remote.delete(e.from);
      this.changed();
    } else if (m.type === 'car' && this.ready && this.admitted.has(e.from)) {
      let car = this.remote.get(e.from);
      if (!car) { car = new RemoteCar(); this.remote.set(e.from, car); }
      car.push(m, performance.now());
    }
  }

  private roomBeacon(from: string, theirs: RoomDescriptor, count: number): void {
    if (theirs.physics !== this.descriptor.physics) { if (!this.ready) this.fail('Game versions differ. Open the same game release as the other players.'); return; }
    const ours = this.roomDescriptor;
    if (ours && ours.founded === theirs.founded && ours.nonce === theirs.nonce) {
      if (!this.admitted.has(from)) { this.admitted.add(from); this.changed(); }
      return;
    }
    if (!this.ready) {
      if (this.syncingAssets || this.offered && !olderRoom(theirs, this.offered)) return;
      if (count >= ROOM_CAPACITY) { this.fail(`This room is full (${ROOM_CAPACITY} players). Try again later.`); return; }
      this.offered = theirs;
      this.offeredCount = count;
      if (!mismatch(this.descriptor, theirs)) { this.roomDescriptor = theirs; this.admitted.add(from); this.admit(count); }
      else if (this.onAssetsRequired) void this.syncAssets(theirs, from);
      else this.fail(mismatch(this.descriptor, theirs)!);
      return;
    }
    // Two groups met (both founded before hearing each other): the older room wins.
    if (!ours || !olderRoom(theirs, ours)) { this.beacon(); return; }
    if (mismatch(this.descriptor, theirs)) { this.fail('Another group was already driving here with different game data. Join again to load it.'); return; }
    this.roomDescriptor = theirs;
    this.admitted.clear();
    this.admitted.add(from);
    this.beacon();
    this.changed();
  }

  private async syncAssets(target: RoomDescriptor, from: string): Promise<void> {
    this.syncingAssets = true;
    if (this.waitTimer) clearTimeout(this.waitTimer);
    this.waitTimer = setTimeout(() => this.fail('Downloading the room’s game data timed out. Try joining again.'), 120000);
    this.changed();
    try {
      const descriptor = await this.onAssetsRequired!(target, () => this.active);
      if (!this.active) return;
      const reason = mismatch(target, descriptor);
      if (reason) throw new Error(reason);
      this.descriptor = descriptor;
      this.syncingAssets = false;
      this.roomDescriptor = target;
      if (this.member(from)) this.admitted.add(from);
      this.admit(this.offeredCount);
    } catch (error) {
      if (this.active) this.fail(`Could not load the room’s game data: ${error instanceof Error ? error.message : String(error)}`);
    } finally { this.syncingAssets = false; }
  }

  /** One outstanding snapshot only: render frames never build an outbound backlog. */
  update(pose: Pose, now: number): void {
    if (!this.active || !this.ready || !this.sessionId || !this.admitted.size || this.sendingState || now < this.sendAt) return;
    this.sendAt = now + 50;
    const message: Snapshot = { ...pose, type: 'car', seq: this.sequence++, time: now };
    this.sendingState = true;
    void this.io.send(this.sessionId, message).catch(error => console.log('[online] snapshot dropped', error)).finally(() => { this.sendingState = false; });
  }
  remotePlayers(now: number): Array<{ actor: string; pose: Pose }> {
    return [...this.admitted].sort().flatMap(actor => {
      const pose = this.connected.has(actor) ? this.remote.get(actor)?.sample(now) : null;
      return pose ? [{ actor, pose }] : [];
    });
  }
  /** Profile name when the player shares one and NAP-COMMON finds it, else a short key tag. */
  label(actor: string): string {
    return this.names.name(this.profiles.get(actor)) ?? actor.slice(0, 4).toUpperCase();
  }
  private fail(reason: string): void {
    console.log('[online] session ended', reason);
    void this.close();
    this.onEnd(reason);
  }
  async close(): Promise<void> {
    if (!this.active) return;
    const id = this.sessionId, room = this.room;
    // Send departure before releasing the channel so others drop the car at once.
    const bye = id && this.connected.size ? this.io.send(id, { type: 'bye' }).catch(() => {}) : Promise.resolve();
    this.active = false;
    this.sessionId = null;
    if (this.pollTimer) clearInterval(this.pollTimer);
    if (this.beaconTimer) clearInterval(this.beaconTimer);
    if (this.waitTimer) clearTimeout(this.waitTimer);
    this.subscription?.close();
    this.remote.clear();
    // Closing must release RTC even if a departure message is buffered indefinitely.
    let timeout: ReturnType<typeof setTimeout> | undefined;
    await Promise.race([bye, new Promise<void>(resolve => { timeout = setTimeout(resolve, 500); })]);
    if (timeout) clearTimeout(timeout);
    const results = await Promise.allSettled([id ? this.io.close(id, 'Leaving game') : Promise.resolve(), room ? this.io.call('soy_room_leave', { room: room.room }) : Promise.resolve()]);
    for (const r of results) if (r.status === 'rejected') console.log('[online] cleanup deferred to room expiry', r.reason);
  }
}