Back to Napplet Machines V8
SOURCE / PINNED RELEASE

Made of little things.

Napplet Machines V8

Release
b3f614e739f0…
Author-recorded commit
534f19acb4ba…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-b5572362d4a

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

src/network.ts
import { cvm, webrtc, type Subscription } from '@napplet/sdk';
import backend from './backend-context.json';
import { validateTrack, type Track } from './track.js';
import { type World, type Input, type Driver } from './physics.js';
import { runtimeHasDomain } from './domain-availability.js';
export const PROTOCOL = 'pocket-circuit-v5';
export type Room = {
  room: string;
  name: string;
  capacity: number;
  peers: string[];
  authority?: string;
};
// Soy rooms have no creator field and sort peer keys. Carry the public creator in
// the application-owned label, then verify it against current CVM membership.
function roomLabel(name: string, actor: string) {
  return (
    name.slice(0, 30) +
    '|' +
    btoa(
      String.fromCharCode(...actor.match(/../g)!.map((h) => parseInt(h, 16))),
    ).replace(/=+$/, '')
  );
}
function decodeRoom(r: Room): Room {
  const split = r.name.lastIndexOf('|');
  let authority = '';
  try {
    authority = Array.from(atob(r.name.slice(split + 1)), (c) =>
      c.charCodeAt(0).toString(16).padStart(2, '0'),
    ).join('');
  } catch {}
  if (split < 0 || !key(authority) || !r.peers.includes(authority))
    throw new Error('The room host has left or the room is incompatible.');
  return { ...r, name: r.name.slice(0, split), authority };
}
export type Profile = { name: string; car: number; ready: boolean };
export type Command = { seq: number; input: Input };
export type Start = { type: 'start'; race: string; track: Track; world: World };
const record = (v: unknown): v is Record<string, unknown> =>
  !!v && typeof v === 'object' && !Array.isArray(v);
const key = (v: unknown): v is string =>
  typeof v === 'string' && /^[0-9a-f]{64}$/.test(v);
export function validRoom(v: unknown): v is Room {
  return (
    record(v) &&
    typeof v.room === 'string' &&
    v.room.length < 160 &&
    typeof v.name === 'string' &&
    v.name.length <= 80 &&
    Number.isInteger(v.capacity) &&
    Number(v.capacity) >= 2 &&
    Number(v.capacity) <= 6 &&
    Array.isArray(v.peers) &&
    v.peers.length <= 6 &&
    v.peers.every(key) &&
    new Set(v.peers).size === v.peers.length
  );
}
export function validInput(v: unknown): v is Input {
  return (
    record(v) &&
    typeof v.throttle === 'number' &&
    Number.isFinite(v.throttle) &&
    Math.abs(v.throttle) <= 1 &&
    typeof v.steer === 'number' &&
    Number.isFinite(v.steer) &&
    Math.abs(v.steer) <= 1 &&
    typeof v.brake === 'boolean' &&
    typeof v.reset === 'boolean'
  );
}
export function validWorld(v: unknown): v is World {
  if (
    !record(v) ||
    !Number.isSafeInteger(v.tick) ||
    Number(v.tick) < 0 ||
    Number(v.tick) > 1e8 ||
    typeof v.time !== 'number' ||
    v.time < 0 ||
    v.time > 1000 ||
    !Number.isFinite(v.time) ||
    typeof v.countdown !== 'number' ||
    v.countdown < 0 ||
    v.countdown > 4 ||
    !Number.isFinite(v.countdown) ||
    typeof v.ended !== 'boolean' ||
    !Array.isArray(v.bodies) ||
    v.bodies.length < 1 ||
    v.bodies.length > 6
  )
    return false;
  const ids = new Set();
  return v.bodies.every((b) => {
    if (
      !record(b) ||
      typeof b.id !== 'string' ||
      b.id.length > 80 ||
      ids.has(b.id) ||
      typeof b.name !== 'string' ||
      b.name.length > 18 ||
      !Number.isInteger(b.car) ||
      Number(b.car) < 0 ||
      Number(b.car) > 3 ||
      typeof b.ai !== 'boolean' ||
      typeof b.grounded !== 'boolean'
    )
      return false;
    ids.add(b.id);
    return (
      [
        'x',
        'y',
        'z',
        'vx',
        'vy',
        'vz',
        'angle',
        'spin',
        'steering',
        'handbrake',
        'progress',
        'lastDistance',
        'safeDistance',
        'lap',
        'finished',
        'falls',
        'respawn',
        'boost',
        'pitch',
        'roll',
        'ack',
        'nextGate',
        'stuck',
      ].every(
        (k) =>
          typeof b[k] === 'number' &&
          Number.isFinite(b[k]) &&
          Math.abs(Number(b[k])) < 1e7,
      ) &&
      Math.abs(Number(b.x)) < 256 &&
      Math.abs(Number(b.y)) < 256 &&
      Math.abs(Number(b.z)) < 100 &&
      Math.abs(Number(b.steering)) <= 1 &&
      Number(b.handbrake) >= 0 &&
      Number(b.handbrake) <= 1 &&
      Number.isSafeInteger(b.ack) &&
      Number(b.ack) >= 0
    );
  });
}
export class Multiplayer {
  actor = '';
  authority = '';
  room: Room | null = null;
  session = '';
  members = new Map<string, Profile>();
  connected = new Set<string>();
  profile: Profile = { name: 'Racer', car: 0, ready: false };
  start: Start | null = null;
  private sub: Subscription | null = null;
  private poll = 0;
  private heartbeat = 0;
  private revision = 0;
  private signature = '';
  private sending = false;
  private queued: unknown = null;
  private polling = false;
  onUpdate: () => void = () => {};
  onError: (m: string) => void = () => {};
  onStart: (s: Start) => void = () => {};
  onSnapshot: (w: World) => void = () => {};
  onInput: (from: string, c: Command[]) => void = () => {};
  onEnded: (reason: string) => void = () => {};
  get available() {
    return runtimeHasDomain('cvm') && runtimeHasDomain('webrtc');
  }
  get isHost() {
    return !!this.actor && this.actor === this.authority;
  }
  async call(
    tool: string,
    args: Record<string, unknown> = {},
  ): Promise<unknown> {
    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') || 'Room service unavailable',
      );
    if (!result.structuredContent)
      throw new Error('Room service returned no data');
    return result.structuredContent;
  }
  async identify() {
    if (!this.available)
      throw new Error(
        'Online rooms are unavailable in this host. Local AI racing still works.',
      );
    const s = await this.call('soy_session');
    if (!record(s) || !key(s.actor)) throw new Error('Invalid session');
    this.actor = s.actor;
  }
  async list(): Promise<Room[]> {
    await this.identify();
    const r = await this.call('soy_room_list', {
      napplet: backend.napplet,
      protocol: PROTOCOL,
    });
    if (!record(r) || !Array.isArray(r.rooms))
      throw new Error('Invalid room list');
    return r.rooms.filter(validRoom).flatMap((r) => {
      try {
        return [decodeRoom(r)];
      } catch {
        return [];
      }
    });
  }
  async host(name: string, capacity: number) {
    await this.identify();
    const r = await this.call('soy_room_create', {
      napplet: backend.napplet,
      protocol: PROTOCOL,
      name: roomLabel(name || 'Pocket race', this.actor),
      capacity,
      listed: true,
    });
    if (!validRoom(r)) throw new Error('Invalid room response');
    this.authority = this.actor;
    await this.enter(decodeRoom(r));
  }
  async join(id: string) {
    await this.identify();
    const r = await this.call('soy_room_join', { room: id.trim() });
    if (!validRoom(r)) throw new Error('Invalid room response');
    const decoded = decodeRoom(r);
    this.authority = decoded.authority!;
    await this.enter(decoded);
  }
  private async enter(room: Room) {
    this.room = room;
    this.profile.ready = this.isHost;
    this.members.set(this.actor, { ...this.profile });
    this.sub = webrtc.onEvent((e) => {
      if (e.sessionId !== this.session) return;
      if (e.type === 'peer') {
        if (!this.room?.peers.includes(e.pubkey)) return;
        if (e.state === 'joined') {
          this.connected.add(e.pubkey);
          void this.hello();
          if (this.isHost && this.start) void this.sendControl(this.start);
        } else this.connected.delete(e.pubkey);
        this.onUpdate();
      }
      if (e.type === 'message' && this.room?.peers.includes(e.from))
        this.receive(e.from, e.payload);
      if (e.type === 'closed') {
        this.connected.clear();
        this.signature = '';
        this.onError(e.reason || 'Connection closed. Use reconnect.');
        this.onUpdate();
      }
    });
    // A failed permission request keeps the room visible with an explicit retry.
    try {
      await this.reconnect();
    } catch (e) {
      this.onError(errorText(e));
    }
    this.poll = window.setInterval(() => void this.refresh(), 2500);
    this.heartbeat = window.setInterval(() => void this.hello(), 1500);
    this.onUpdate();
  }
  async refresh() {
    if (!this.room || this.polling) return;
    this.polling = true;
    const id = this.room.room;
    try {
      const r = await this.call('soy_room_status', { room: id });
      if (!validRoom(r)) throw new Error('Room expired. Leave and join again.');
      if (this.room?.room !== id) return;
      if (!r.peers.includes(this.authority)) {
        await this.leave();
        this.onEnded('The host left. Join or host a new race.');
        return;
      }
      const changed =
        [...r.peers].sort().join(',') !== [...this.room.peers].sort().join(',');
      this.room = decodeRoom(r);
      for (const k of this.members.keys())
        if (!r.peers.includes(k)) this.members.delete(k);
      if (changed) await this.reconnect();
      this.onUpdate();
    } catch (e) {
      this.onError(errorText(e));
    } finally {
      this.polling = false;
    }
  }
  async reconnect() {
    if (!this.room) return;
    const generation = ++this.revision;
    const peers = this.isHost
      ? this.room.peers.filter((k) => k !== this.actor)
      : [this.authority];
    const signature = peers.slice().sort().join(',');
    if (signature === this.signature && this.session) return;
    const old = this.session;
    this.session = '';
    this.connected.clear();
    if (old) await webrtc.close(old, 'Membership updated');
    if (!peers.length) {
      this.signature = signature;
      this.onUpdate();
      return;
    }
    const { session } = await webrtc.open({
      scope: { type: 'room', room: this.room.room, peers },
      channel: 'race',
      protocol: PROTOCOL,
    });
    if (generation !== this.revision || !this.room) {
      await webrtc.close(session.id, 'Cancelled');
      return;
    }
    this.session = session.id;
    this.signature = signature;
    this.onUpdate();
  }
  async retry() {
    this.signature = '';
    await this.reconnect();
  }
  async sendControl(payload: unknown) {
    if (!this.session || !this.connected.size) return;
    try {
      await webrtc.send(this.session, payload);
    } catch (e) {
      this.onError(errorText(e));
    }
  }
  // Only one pending send plus the newest replacement is retained under backpressure.
  async sendLatest(payload: unknown) {
    if (!this.session || !this.connected.size) return;
    this.queued = payload;
    if (this.sending) return;
    this.sending = true;
    try {
      while (this.queued && this.session) {
        const next = this.queued;
        this.queued = null;
        await webrtc.send(this.session, next);
      }
    } catch (e) {
      this.queued = null;
      this.onError(errorText(e));
    } finally {
      this.sending = false;
    }
  }
  async hello() {
    if (!this.room) return;
    this.members.set(this.actor, { ...this.profile });
    await this.sendControl({ type: 'profile', ...this.profile });
    if (this.isHost)
      await this.sendControl({
        type: 'lobby',
        members: Array.from(this.members, ([id, p]) => ({ id, ...p })),
      });
  }
  private receive(from: string, p: unknown) {
    if (!record(p) || typeof p.type !== 'string') return;
    if (
      p.type === 'profile' &&
      this.isHost &&
      typeof p.name === 'string' &&
      p.name.length <= 18 &&
      Number.isInteger(p.car) &&
      Number(p.car) >= 0 &&
      Number(p.car) < 4 &&
      typeof p.ready === 'boolean'
    ) {
      this.members.set(from, {
        name: p.name,
        car: Number(p.car),
        ready: p.ready,
      });
      this.onUpdate();
      return;
    }
    if (from === this.authority) {
      if (
        p.type === 'lobby' &&
        Array.isArray(p.members) &&
        p.members.length <= 6
      ) {
        for (const m of p.members)
          if (
            record(m) &&
            key(m.id) &&
            this.room?.peers.includes(m.id) &&
            typeof m.name === 'string' &&
            m.name.length <= 18 &&
            Number.isInteger(m.car) &&
            Number(m.car) >= 0 &&
            Number(m.car) < 4 &&
            typeof m.ready === 'boolean'
          )
            this.members.set(m.id, {
              name: m.name,
              car: Number(m.car),
              ready: m.ready,
            });
        this.onUpdate();
      }
      if (
        p.type === 'start' &&
        typeof p.race === 'string' &&
        p.race.length <= 64 &&
        validateTrack(p.track) &&
        validWorld(p.world)
      ) {
        if (
          p.world.bodies.some((b) => !b.ai && !this.room?.peers.includes(b.id))
        )
          return;
        if (this.start?.race !== p.race) {
          this.start = p as unknown as Start;
          this.onStart(this.start);
        }
        return;
      }
      if (
        p.type === 'world' &&
        p.race === this.start?.race &&
        validWorld(p.world)
      )
        this.onSnapshot(p.world);
      if (p.type === 'return') {
        this.start = null;
        this.onEnded('Back in the room. Ready for another race.');
      }
    }
    if (
      this.isHost &&
      p.type === 'input' &&
      p.race === this.start?.race &&
      Array.isArray(p.commands) &&
      p.commands.length <= 120
    ) {
      const commands = p.commands.filter(
        (c): c is Command =>
          record(c) &&
          Number.isSafeInteger(c.seq) &&
          Number(c.seq) > 0 &&
          Number(c.seq) < 1e8 &&
          validInput(c.input),
      );
      this.onInput(from, commands);
    }
  }
  async begin(track: Track, world: World) {
    if (!this.isHost) throw new Error('Only the host can start');
    if (!validateTrack(track)) {
      throw new Error(
        'Move crowded track points at least 3 m apart before hosting a race.',
      );
    }
    this.start = {
      type: 'start',
      race: crypto.randomUUID(),
      track: structuredClone(track),
      world: structuredClone(world),
    };
    this.onStart(this.start);
    await this.sendControl(this.start);
  }
  roster(): Driver[] {
    return (this.room?.peers || []).map((id) => ({
      id,
      name: this.members.get(id)?.name || 'Racer',
      car: this.members.get(id)?.car || 0,
      ai: false,
    }));
  }
  async leave() {
    const room = this.room,
      session = this.session;
    this.room = null;
    this.session = '';
    this.start = null;
    this.revision++;
    this.signature = '';
    this.queued = null;
    clearInterval(this.poll);
    clearInterval(this.heartbeat);
    this.sub?.close();
    this.sub = null;
    this.connected.clear();
    this.members.clear();
    this.onUpdate();
    const jobs = [];
    if (session) jobs.push(webrtc.close(session, 'Left room'));
    if (room) jobs.push(this.call('soy_room_leave', { room: room.room }));
    await Promise.allSettled(jobs);
  }
}
export const errorText = (e: unknown) =>
  e instanceof Error ? e.message : 'Operation failed. Please retry.';