diff --git a/README.md b/README.md index 34fabcd..a033990 100644 --- a/README.md +++ b/README.md @@ -241,8 +241,9 @@ the client's world equals the host's at whatever tick it last applied). The brow policy: `webrtc.ts` (an unordered, no-retransmit `RTCDataChannel`) and `signal.ts` (offer/answer over Nostr ephemeral events on public relays, so there is nothing to run — SDP is plaintext there, which is named in the file rather than solved). **Try it:** open `?host` — the join code and a -COPY LINK button are on screen from the hangar onwards — and open the link on **another device**. -`?host=drone` picks the guest's hull. The join screen reports each stage (offer sent, answer +COPY LINK button are in the WING panel — and open the link on **another device**. +`?host=4` opens four seats (two by default); `?host=drone` retains the legacy two-seat AI hull default. +The joiner picks an airframe and presses JOIN WING before connecting. The join screen reports each stage (offer sent, answer received, ice checking, connected) and names the failing one. Once connected, the route the two browsers settled on (`host/udp 192.168.1.20:51234 ↔ srflx 203.0.113.9:3478` — host, srflx or relay at either end) is logged to the console on both machines, which is the first thing to read @@ -254,6 +255,27 @@ and offers RETRY — a fresh join on the same code — or PLAY SOLO. A host with policy, headless; `simcheck` walks it through a drop inside the grace, a recovery, a second outage that runs out, ICE failing, and the channel closing. +**Choose the wing before it flies.** `net/lobby.ts` reserves a seat only after a valid +protocol-v2 HELLO with a hull choice. Every waiting browser gets a versioned LOBBY roster: +seat number, hull, human/AI pilot, and which seat is yours. Host hull changes and departures +update everyone; revision numbers reject reordered old rosters, and the waiting client's +repeated HELLO repairs lost messages. Launch builds the match from those reservations and +keeps seat numbers even if somebody left a gap. AI fills the open seats. Once launched, a +late join takes the existing ship over without changing its hull. HUD/feed AI labels remain +unchanged. Different game versions prompt a reload. + +LEAVE WING closes the join and returns to solo hull selection. Returning the host to the +hangar or replaying closes the old wing and makes a fresh code; share that code for the next +match. Host launch does not wait for all seats to fill. Tab and Space remain available to +hangar controls; during flight Tab keeps switching targets. + +Browser check: open `?host=4`, copy its link, choose Drone in the joining browser and JOIN +WING. Both rosters should show P2 / Drone / JOINED, with YOU moving between them. Change the +host hull; both lists should update. Leave from the joiner: P2 becomes AI. Join again, then +launch from the host: the client enters flight in its reserved hull. A later join occupies an +AI seat in the already-running match. The headless suite also loses the initial HELLO, a +roster change and a launch WELCOME, reorders rosters, and launches with a gap at P2. + **A seat nobody is in is flown, not parked.** A host's match has a seat per hull, and a seat with no peer — before anyone has joined, or after a player dropped — used to fly a neutral stick in a straight line: a ghost the squadron shot for free. `game/autopilot.ts` flies it now, on the host diff --git a/scripts/mutate.mjs b/scripts/mutate.mjs index ce989f8..fb71289 100644 --- a/scripts/mutate.mjs +++ b/scripts/mutate.mjs @@ -29,6 +29,50 @@ import { readFileSync, writeFileSync } from 'node:fs' /** @type {{ name: string, file: string, from: string, to: string }[]} */ const MUTATIONS = [ + /* ---- Wing reservations and launch --------------------------------------- */ + { + name: 'lobby ignores the requested hull', + file: 'src/net/lobby.ts', + from: ' ships[seated] = ship', + to: " ships[seated] = 'wasp'", + }, + { + name: 'launch compacts reserved seats across a gap', + file: 'src/net/lobby.ts', + from: ' match.accept(channel, seated)', + to: ' match.accept(channel)', + }, + { + name: 'departed lobby peer stays human', + file: 'src/net/lobby.ts', + from: ' peers[seated] = null', + to: ' /* keep the old reservation */', + }, + { + name: 'hello does not repair a lost roster', + file: 'src/net/lobby.ts', + from: ' channel.send(encodeLobby(state(seated)))', + to: ' /* no roster resend */', + }, + { + name: 'older roster overwrites newer hull choice', + file: 'src/net/session.ts', + from: ' if (roster.revision >= lobbyRevision) {', + to: ' if (true) {', + }, + { + name: 'closing a lobby leaves its waiting channels open', + file: 'src/net/lobby.ts', + from: ' for (const channel of channels) channel.close()', + to: ' /* leave channels open */', + }, + { + name: 'host choice is not propagated to the wing', + file: 'src/net/lobby.ts', + from: ' ships[0] = ship', + to: ' /* ignore host selection */', + }, + /* ---- Intent routing: which seat flies which controls -------------------- */ { name: 'every seat flies intents[0]', @@ -283,8 +327,8 @@ const MUTATIONS = [ { name: 'the host seats a peer where there is no seat', file: 'src/net/session.ts', - from: ' const seat = peers.findIndex((p, i) => i > 0 && p === null)', - to: ' const seat = Math.max(1, peers.findIndex((p, i) => i > 0 && p === null))', + from: ' const seat = reservedSeat ?? peers.findIndex((p, i) => i > 0 && p === null)\n if (seat < 1 || seat >= seatCount || peers[seat]) {', + to: ' const seat = reservedSeat ?? Math.max(1, peers.findIndex((p, i) => i > 0 && p === null))\n if (seat < 1 || seat >= seatCount) {', }, { name: 'a repeated hello is ignored', @@ -897,7 +941,7 @@ function runSuite() { * If you added or removed checks on purpose, bump this in the same commit. If you did not, * something stopped running. */ -const EXPECTED_ASSERTIONS = 675 +const EXPECTED_ASSERTIONS = 699 const PASS_SUMMARY = 'All checks passed.' const SUMMARY = /check\(s\) failed\.$|All checks passed\.$/ diff --git a/scripts/simcheck.ts b/scripts/simcheck.ts index 92bbf99..7b0d6b6 100644 --- a/scripts/simcheck.ts +++ b/scripts/simcheck.ts @@ -42,6 +42,8 @@ import { } from '../src/net/snapshot' import { decodeIntent, encodeIntent, INTENT_FRAME_BYTES, INTENT_VERSION } from '../src/net/wire' import { createLoopback } from '../src/net/channel' +import { createMatchLobby } from '../src/net/lobby' +import { decodeHello, decodeLobby, encodeHello, encodeLobby, type LobbyState } from '../src/net/session' import { modeFromLocation } from '../src/net/browser' import { createLinkMonitor, type LinkReport } from '../src/net/link' import { createClient, createHost, decodeResult, decodeWelcome, encodeResult, encodeWelcome, FRAME, SNAPSHOT_DEPTH, SNAPSHOT_QUEUE } from '../src/net/session' @@ -4051,8 +4053,8 @@ function testThePeerFliesItsOwnSeatOnly(): void { /* The URL decides the mode, and solo is the default the shipped game takes. */ check('no query is solo', modeFromLocation('').kind === 'solo') - check('?host hosts with a wasp in seat 1', JSON.stringify(modeFromLocation('?host')) === '{"kind":"host","guest":"wasp"}') - check('?host=drone picks the guest hull', JSON.stringify(modeFromLocation('?host=drone')) === '{"kind":"host","guest":"drone"}') + check('?host hosts with a wasp in seat 1', JSON.stringify(modeFromLocation('?host')) === '{"kind":"host","guest":"wasp","seats":2}') + check('?host=drone picks the guest hull', JSON.stringify(modeFromLocation('?host=drone')) === '{"kind":"host","guest":"drone","seats":2}') check('?join=abc123 joins, upper-cased', JSON.stringify(modeFromLocation('?join=abc123')) === '{"kind":"join","code":"ABC123"}') } @@ -7684,6 +7686,120 @@ function testOneFrameDepictsOneInstant(): void { /* -------------------------------------------------------------------------- */ +function testTheWingLaunchesItsReservations(): void { + section('The wing reserves hulls, survives loss, and launches the same seats') + const game = newMatch() + const mirrors = [newMatch(), newMatch(), newMatch(), newMatch()] + const wing = createMatchLobby({ game, ships: ['hornet', 'wasp', 'wasp', 'wasp'], seed: 123 }) + const wires = [createLoopback(), createLoopback(), createLoopback()] + const states: (LobbyState | null)[] = [null, null, null] + const clients = wires.map((wire, i) => { + wing.accept(wire.a) + return createClient({ game: mirrors[i], channel: wire.b, ship: i === 0 ? 'drone' : 'hornet', + onLobby: (state) => { states[i] = state } }) + }) + for (let i = 0; i < 3; i++) wires.forEach(w => w.pump()) + check('the lobby does not start the game or send a welcome', !game.active && clients.every(c => c.seat === -1)) + check('each joiner sees its reserved seat and the shared hull choices', states.every((s, i) => + s?.seat === i + 1 && s.seats.map(x => x.ship).join() === 'hornet,drone,hornet,hornet')) + check('every occupied seat is human in the lobby', wing.state().seats.every(s => s.pilot === 'human')) + const old = states[2]! + wing.setShip('wasp') + wires.forEach(w => w.pump()) + check('host hull changes reach every waiting peer', states.every(s => s?.seats[0].ship === 'wasp')) + wires[2].a.send(encodeLobby(old)) + wires[2].pump() + check('an older reordered roster cannot undo the host hull change', states[2]?.seats[0].ship === 'wasp') + const overflow = createLoopback() + let refusal = '' + wing.accept(overflow.a) + createClient({ game: mirrors[3], channel: overflow.b, onRefused: r => { refusal = r } }) + overflow.pump() + check('a full lobby refuses before launch', refusal === 'full' && !overflow.a.open) + wires[0].a.close() + wires.forEach(w => w.pump()) + check('leaving returns a reservation to AI on every roster', wing.state().seats[1].pilot === 'ai' && + states[1]?.seats[1].pilot === 'ai' && states[2]?.seats[1].pilot === 'ai') + // Lose one initial welcome. Its repeated HELLO must recover the exact seat. + wires[1].setLoss(1) + const host = wing.launch() + wires[1].setLoss(0) + for (let i = 0; i < 35; i++) { + clients[1].tick(controls()); clients[2].tick(controls()) + wires.forEach(w => w.pump()) + } + check('launch preserves a gap instead of shifting the remaining reservations', clients[1].seat === 2 && clients[2].seat === 3) + check('lost launch is recovered while the client waits', wires[1].lost > 0 && mirrors[1].active) + check('launch uses the selected hulls on host and mirrors', [game, mirrors[1], mirrors[2]].every(g => + g.capture().seats.map(s => s.ship.hull).join() === '70,200,120,120')) + wing.setShip('drone') + check('the host cannot change a launched hull through the lobby', game.capture().seats[0].ship.hull === 70) + const late = createLoopback() + wing.accept(late.a) + const lateClient = createClient({ game: mirrors[0], channel: late.b, ship: 'wasp' }) + late.pump() + check('late join takes the free AI seat with its existing hull', lateClient.seat === 1 && mirrors[0].capture().seats[1].ship.hull === 200) + check('launch is idempotent and does not reset the match', wing.launch() === host) + wing.close() + check('closing the lobby closes every accepted channel', wires.every(w => !w.a.open) && !late.a.open && host.peers === 0) + const afterClose = createLoopback() + wing.accept(afterClose.a) + check('a connection completing after close cannot take a seat', !afterClose.a.open) + game.dispose(); mirrors.forEach(g => g.dispose()) + + const retryGame = newMatch(), retryMirror = newMatch() + const retryWing = createMatchLobby({ game: retryGame, ships: ['hornet', 'wasp'] }) + const lost = createLoopback({ loss: 1 }) + retryWing.accept(lost.a) + let roster: LobbyState | null = null + const retryClient = createClient({ game: retryMirror, channel: lost.b, ship: 'drone', onLobby: s => { roster = s } }) + lost.setLoss(0) + for (let i = 0; i < 35; i++) { retryClient.tick(controls()); lost.pump() } + check('a lost initial hello is retried from the hangar', (roster as LobbyState | null)?.seats[1].ship === 'drone' && lost.lost === 1) + const initial = roster + lost.setLoss(1) + retryWing.setShip('wasp') + lost.setLoss(0) + for (let i = 0; i < 35; i++) { retryClient.tick(controls()); lost.pump() } + check('a lost roster change is repaired by a repeated hello', (roster as LobbyState | null)?.seats[0].ship === 'wasp' && roster !== initial) + retryWing.close() + check('closing before launch releases a waiting reservation', !lost.a.open) + retryGame.dispose(); retryMirror.dispose() + + const badGame = newMatch() + const badWing = createMatchLobby({ game: badGame, ships: ['hornet', 'wasp'] }) + const bad = createLoopback() + let reason = -1 + bad.b.onMessage(bytes => { if (bytes[0] === FRAME.REFUSED) reason = bytes[1] }) + badWing.accept(bad.a) + bad.b.send(new Uint8Array([FRAME.HELLO, 1])) + bad.pump() + check('an old protocol is refused without reserving a seat', reason === 1 && !bad.a.open && badWing.state().seats[1].pilot === 'ai') + let malformed = 0 + for (const bytes of [new Uint8Array([FRAME.HELLO, 2, 99]), new Uint8Array([FRAME.HELLO, 2]), new Uint8Array([...encodeHello(), 0])]) { + try { decodeHello(bytes) } catch { malformed++ } + } + check('unknown, short, and trailing hull claims fail decoding', malformed === 3) + let badRosters = 0 + const valid = encodeLobby(badWing.state()) + for (const bytes of [valid.subarray(0, 4), new Uint8Array([...valid, 0]), encodeLobby({ ...badWing.state(), seat: 9 }), + encodeLobby({ ...badWing.state(), seats: [] })]) { + try { decodeLobby(bytes) } catch { badRosters++ } + } + check('malformed rosters fail before any UI state is applied', badRosters === 4) + const oldHost = createLoopback() + let oldRefused = '' + createClient({ game: badGame, channel: oldHost.b, onRefused: r => { oldRefused = r } }) + const oldWelcome = encodeWelcome(1, { ships: ['hornet', 'wasp'], seed: 1 }) + oldWelcome[1] = 1 + oldHost.a.send(oldWelcome); oldHost.pump() + check('a new client names an old host version instead of waiting forever', oldRefused === 'version' && !badGame.active) + badWing.close(); badGame.dispose() + check('?host=4 makes a four-seat wing', JSON.stringify(modeFromLocation('?host=4')) === '{"kind":"host","guest":"wasp","seats":4}') + check('invalid host values fall back to a valid two-seat wing', ['99', '-1', 'bogus', '2.5'].every(v => + JSON.stringify(modeFromLocation('?host=' + v)) === '{"kind":"host","guest":"wasp","seats":2}')) +} + console.log('NEON ORBIT — headless simulation checks') testPlayerBoltsKillEnemies() testHullBarFadeCurve() @@ -7748,6 +7864,7 @@ testTheStepClockNeverLosesTime() testARunMatchesItsRecordedBaseline() testOneFrameDepictsOneInstant() testALinkThatDropsIsNoticed() +testTheWingLaunchesItsReservations() console.log(failures === 0 ? '\nAll checks passed.' : `\n${failures} check(s) failed.`) process.exit(failures === 0 ? 0 : 1) diff --git a/src/core/input.ts b/src/core/input.ts index 65ec7cf..6fed215 100644 --- a/src/core/input.ts +++ b/src/core/input.ts @@ -47,7 +47,7 @@ export interface Input { dispose(): void } -export function createInput(canvas: HTMLCanvasElement): Input { +export function createInput(canvas: HTMLCanvasElement, captureControls: () => boolean = () => true): Input { const held = new Set() const keyHandlers = new Map void)[]>() const lockLostHandlers: (() => void)[] = [] @@ -81,7 +81,7 @@ export function createInput(canvas: HTMLCanvasElement): Input { if (handlers) for (const h of handlers) h() } - if (TRACKED.has(e.code)) { + if (TRACKED.has(e.code) && captureControls()) { held.add(e.code) e.preventDefault() } @@ -177,7 +177,7 @@ export function createInput(canvas: HTMLCanvasElement): Input { update, requestPointerLock() { // Chrome rejects the promise if lock is requested too soon after an exit. - void canvas.requestPointerLock() + void canvas.requestPointerLock()?.catch(() => {}) }, releasePointerLock() { if (document.pointerLockElement === canvas) document.exitPointerLock() diff --git a/src/main.ts b/src/main.ts index db562c4..e6079fa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,6 +23,7 @@ import type { ShipId } from './ships/specs' import { joinMatch, modeFromLocation, openLobby, startHosting, type Hosting, type Joining, type Lobby } from './net/browser' import { LINK_GRACE_MS } from './net/link' import { createHangar } from './ui/hangar' +import { createWing } from './ui/wing' import { createDebriefPanel, createPausePanel } from './ui/panels' import { createScreens } from './ui/screens' import { buildEnvironment } from './world/environment' @@ -62,7 +63,7 @@ function boot() { const environment = buildEnvironment() stage.scene.add(environment.group) - const input = createInput(canvas) + const input = createInput(canvas, () => screens.screen === 'flight') const pilot = createPilot() const audio = createAudio() const hud = createHud(overlay) @@ -77,6 +78,7 @@ function boot() { let hosting: Hosting | null = null let joining: Joining | null = null let lobby: Lobby | null = null + let joinAttempt = 0 /** The one line of network status on screen, in either mode. */ const netPanel = document.createElement('div') @@ -92,25 +94,6 @@ function boot() { netPanel.innerHTML = html } - if (mode.kind === 'host') { - // Listening from page load, so the code can be shared from the hangar and a - // peer can connect before the host has even picked a ship. - lobby = openLobby((stage) => console.log('[neon-orbit] lobby:', stage)) - const url = `${location.origin}${location.pathname}?join=${lobby.code}` - netStatus( - `JOIN CODE ${lobby.code}   ` + - `
${url}
`, - ) - console.log('[neon-orbit] join code', lobby.code, url) - document.getElementById('copyjoin')?.addEventListener('click', (ev) => { - ev.stopPropagation() - navigator.clipboard?.writeText(url).then( - () => ((ev.target as HTMLButtonElement).textContent = 'COPIED'), - () => ((ev.target as HTMLButtonElement).textContent = 'SELECT + COPY'), - ) - }) - } - /* ---- Screens ---------------------------------------------------------- */ const hangar = createHangar({ @@ -119,6 +102,16 @@ function boot() { camera: stage.camera, audio, onLaunch: (id) => startRun(id), + onSelect: (id) => lobby?.wing.setShip(id), + }) + + const wing = createWing(hangar.root.querySelector('.stage')!, () => { + joinAttempt++ + joining?.stop() + joining = null + mode = { kind: 'solo' } + netPanel.style.display = 'none' + openHangar() }) const pause = createPausePanel({ @@ -161,7 +154,32 @@ function boot() { /* ---- Transitions ------------------------------------------------------ */ + function prepareLobby() { + if (mode.kind !== 'host') return + hosting?.stop() + hosting = null + lobby?.close() + lobby = openLobby(game, [hangar.selected, ...Array(mode.seats - 1).fill(mode.guest)], + (state) => wing.update(state), + (seat) => hud.callout(`PLAYER ${seat + 1} JOINED`, '#6be6ff', 1.5), + (stage) => console.log('[neon-orbit] lobby:', stage)) + wing.show(lobby.code, true) + wing.update(lobby.wing.state()) + console.log('[neon-orbit] join code', lobby.code) + } + function openHangar() { + joinAttempt++ + joining?.stop() + joining = null + hosting?.stop() + hosting = null + lobby?.close() + lobby = null + game.abandon() + netPanel.style.display = 'none' + hangar.action(mode.kind === 'join' ? 'JOIN WING' : null) + wing.hide() screens.moveTo('hangar') pause.hide() debrief.hide() @@ -170,9 +188,15 @@ function boot() { input.releasePointerLock() audio.setMusic('hangar') hangar.open(pendingResult?.ship ?? lastShip() ?? 'hornet') + if (mode.kind === 'host') prepareLobby() + if (mode.kind === 'join') wing.show(mode.code, false) } function startRun(id: ShipId) { + if (mode.kind === 'join') { startJoining(mode.code); return } + if (mode.kind === 'host' && hosting) prepareLobby() + lobby?.wing.setShip(id) + wing.hide() hangar.close() pause.hide() debrief.hide() @@ -184,7 +208,9 @@ function boot() { pilot.reset() if (mode.kind === 'host' && lobby) { hosting?.stop() - hosting = startHosting(lobby, game, id, mode.guest, (seat) => hud.callout(`PLAYER ${seat + 1} JOINED`, '#6be6ff', 1.5)) + hosting = startHosting(lobby) + const url = `${location.origin}${location.pathname}?join=${lobby.code}` + netStatus(`JOIN CODE ${lobby.code} · JOIN LINK`) } else { // One seat, and elimination rather than respawn — the shipped game is a match // of one, and its lose condition is the run ending. `MatchSetup.respawn` in @@ -201,8 +227,10 @@ function boot() { * the match. */ function offerRetry(code: string, heading: string, reason: string, note = '') { + joinAttempt++ joining?.stop() joining = null + wing.status(reason) // Pointer lock would swallow the click on the button. document.exitPointerLock?.() netStatus( @@ -220,31 +248,53 @@ function boot() { } function startJoining(code: string) { + const attempt = ++joinAttempt joining?.stop() joining = null - hangar.close() + const current = () => attempt === joinAttempt + const ship = hangar.selected + rememberShip(ship) + game.abandon() + screens.moveTo('hangar') + hud.hide() + input.releasePointerLock() + hangar.action('JOIN WING') + hangar.open(ship) + hangar.action('CONNECTING…', true) pause.hide() debrief.hide() pilot.reset() - audio.setMusic('combat') + audio.setMusic('hangar') const waiting = 'connected — waiting for the host to launch' - netStatus(`JOINING ${code}
looking for the host…
`) - joinMatch(game, code, { - status: (stage) => netStatus(`JOINING ${code}
${stage}
`), + netPanel.style.display = 'none' + wing.show(code, false) + wing.status('Looking for the host…') + joinMatch(game, code, ship, { + active: current, + status: (stage) => { if (current()) wing.status(stage) }, + onLobby: (state) => { + if (!current()) return + wing.update(state) + hangar.action('WAITING FOR HOST', true) + }, onWelcome: (seat) => { + if (!current()) return + wing.hide() + hangar.close() + audio.setMusic('combat') netPanel.style.display = 'none' screens.moveTo('flight') hud.callout(`SEAT ${seat + 1}`, '#6be6ff', 1.5) input.requestPointerLock() }, - onRefused: () => - offerRetry( - code, - 'COULD NOT JOIN', - 'the host has no seat free', - 'if this is a reconnect, the old seat frees once the host notices the drop — give it a few seconds', - ), + onRefused: (reason) => { + if (!current()) return + offerRetry(code, 'COULD NOT JOIN', + reason === 'version' ? 'game versions differ — reload both pages' : 'the host has no seat free', + reason === 'full' ? 'A disconnected seat frees when the host notices the drop.' : '') + }, onLink: (link) => { + if (!current()) return const route = link.route ? `
route ${link.route}
` : '' if (link.state === 'degraded') { netStatus( @@ -259,9 +309,11 @@ function boot() { }, }) .then((j) => { - joining = j + if (current()) joining = j + else j.stop() }) .catch((error) => { + if (!current()) return console.error(error) offerRetry(code, 'COULD NOT JOIN', error instanceof Error ? error.message : String(error)) }) @@ -347,6 +399,8 @@ function boot() { // set of cards — so it runs straight off the frame. environment.update(frameSeconds, stage.camera) hangar.update(frameSeconds) + // Waiting peers still repeat HELLO to recover lost roster / launch frames. + for (let i = 0; i < ticks; i++) joining?.tick(intents[0]) } else { for (let i = 0; i < ticks; i++) { // Sampled per tick, not per frame: the virtual stick self-centres over @@ -376,8 +430,7 @@ function boot() { splashCleared = true splash.classList.add('done') window.setTimeout(() => splash.remove(), 600) - if (mode.kind === 'join') startJoining(mode.code) - else openHangar() + openHangar() } requestAnimationFrame(frame) diff --git a/src/net/browser.ts b/src/net/browser.ts index 513c327..a985474 100644 --- a/src/net/browser.ts +++ b/src/net/browser.ts @@ -17,16 +17,17 @@ * is exercised. */ -import type { Game, MatchSetup } from '../game/game' +import type { Game } from '../game/game' import type { Controls } from '../game/ship' -import type { ShipId } from '../ships/specs' +import { SHIP_ORDER, type ShipId } from '../ships/specs' import type { Channel } from './channel' +import { createMatchLobby } from './lobby' import { createLinkMonitor, LINK_GRACE_MS, type LinkReport } from './link' -import { createClient, createHost, type Client, type Host } from './session' +import { createClient, type Client, type Host, type LobbyState, type Refusal } from './session' import { createNostrSignal, newJoinCode, type Signal } from './signal' import { acceptAsHost, connectAsClient, type LinkHooks, type Status } from './webrtc' -export type NetMode = { kind: 'solo' } | { kind: 'host'; guest: ShipId } | { kind: 'join'; code: string } +export type NetMode = { kind: 'solo' } | { kind: 'host'; guest: ShipId; seats: number } | { kind: 'join'; code: string } /** Read the mode off the page URL: `?host[=wasp]` or `?join=CODE`. */ export function modeFromLocation(search: string): NetMode { @@ -34,31 +35,32 @@ export function modeFromLocation(search: string): NetMode { const join = params.get('join') if (join) return { kind: 'join', code: join.toUpperCase() } if (params.has('host')) { - const guest = (params.get('host') || 'wasp') as ShipId - return { kind: 'host', guest } + const value = params.get('host') ?? '' + const guest = SHIP_ORDER.includes(value as ShipId) ? value as ShipId : 'wasp' + const seats = /^[2-4]$/.test(value) ? Number(value) : 2 + return { kind: 'host', guest, seats } } return { kind: 'solo' } } export interface Lobby { readonly code: string - /** Channels that opened before the match started, waiting for a seat. */ - readonly waiting: number - /** Hand every open channel, now and later, to the callback. */ - onChannel(handler: (channel: Channel) => void): void + readonly wing: ReturnType close(): void } -/** Listen on a fresh join code from now on. Peers connect; seating waits for the match. */ -export function openLobby(status: Status = () => {}): Lobby { +/** Listen from the hangar; reservations and launch live in the headless lobby. */ +export function openLobby( + game: Game, ships: ShipId[], onChange: (state: LobbyState) => void, + onPeer: (seat: number) => void, status: Status = () => {}, +): Lobby { const code = newJoinCode() const signal: Signal = createNostrSignal(code) const answered = new Set() - const held: Channel[] = [] - let handler: ((channel: Channel) => void) | null = null - + const wing = createMatchLobby({ game, ships, onChange, onPeer }) + let closed = false const stop = signal.listen((message) => { - if (message.type !== 'offer' || answered.has(message.from)) return + if (closed || message.type !== 'offer' || answered.has(message.from)) return answered.add(message.from) const who = `peer ${message.from.slice(0, 6)}` status(`${who}: offer received`) @@ -67,25 +69,16 @@ export function openLobby(status: Status = () => {}): Lobby { onRoute: (route) => status(`${who}: route ${route}`), } acceptAsHost(signal, message, (stage) => status(`${who}: ${stage}`), hooks) - .then((channel) => { - if (handler) handler(channel) - else held.push(channel) - }) + .then((channel) => { if (closed) channel.close(); else wing.accept(channel) }) .catch((error) => status(`${who} failed: ${error instanceof Error ? error.message : error}`)) }) - return { - code, - get waiting() { - return held.length - }, - onChannel(h) { - handler = h - for (const channel of held.splice(0)) h(channel) - }, + code, wing, close() { + closed = true stop() signal.close() + wing.close() }, } } @@ -96,22 +89,9 @@ export interface Hosting { stop(): void } -/** Start a two-seat match on an open lobby; every peer that connects is seated. */ -export function startHosting(lobby: Lobby, game: Game, ship: ShipId, guest: ShipId, onPeer: (seat: number) => void): Hosting { - const setup: MatchSetup & { ships: ShipId[] } = { ships: [ship, guest], respawn: true } - const host = createHost({ game, setup }) - host.start() - lobby.onChannel((channel) => { - const seat = host.accept(channel) - if (seat >= 0) onPeer(seat) - }) - return { - host, - tick: (local) => host.tick(local), - stop() { - lobby.onChannel(() => {}) - }, - } +export function startHosting(lobby: Lobby): Hosting { + const host = lobby.wing.launch() + return { host, tick: (local) => host.tick(local), stop: () => lobby.close() } } export interface Joining { @@ -128,9 +108,11 @@ export interface LinkStatus extends LinkReport { export interface JoinHandlers { /** Each stage of the handshake, until the channel opens. */ status: Status + active(): boolean onWelcome(seat: number): void /** The host had no seat for us. The channel is closed before this is called. */ - onRefused(): void + onRefused(reason: Refusal): void + onLobby(state: LobbyState): void /** * The link after it opened: degraded when ICE drops, up again if it recovers * inside `LINK_GRACE_MS`, down — channel closed, seat freed at the host once @@ -140,15 +122,17 @@ export interface JoinHandlers { } /** Connect to a host by code. Resolves once the data channel is open; the welcome follows on it. */ -export async function joinMatch(game: Game, code: string, handlers: JoinHandlers): Promise { +export async function joinMatch(game: Game, code: string, ship: ShipId, handlers: JoinHandlers): Promise { const signal = createNostrSignal(code) let channel: Channel | null = null let route = '' let poll = 0 + let stopped = false const monitor = createLinkMonitor({ grace: LINK_GRACE_MS, now: () => performance.now(), onChange(report) { + if (stopped) return if (report.state === 'down') { window.clearInterval(poll) channel?.close() @@ -168,6 +152,7 @@ export async function joinMatch(game: Game, code: string, handlers: JoinHandlers } finally { signal.close() } + if (!handlers.active()) { channel.close(); throw new Error('join cancelled') } const open = channel open.onClose(() => monitor.closed()) poll = window.setInterval(() => monitor.poll(), 500) @@ -175,17 +160,21 @@ export async function joinMatch(game: Game, code: string, handlers: JoinHandlers const client = createClient({ game, channel: open, + ship, + onLobby: handlers.onLobby, onWelcome: handlers.onWelcome, - onRefused: () => { + onRefused: (reason) => { + stopped = true window.clearInterval(poll) open.close() - handlers.onRefused() + handlers.onRefused(reason) }, }) return { client, tick: (local) => client.tick(local), stop() { + stopped = true window.clearInterval(poll) open.close() }, diff --git a/src/net/lobby.ts b/src/net/lobby.ts new file mode 100644 index 0000000..1564790 --- /dev/null +++ b/src/net/lobby.ts @@ -0,0 +1,93 @@ +/** Host-owned reservations before launch. Transport is only a Channel. */ +import type { Game } from '../game/game' +import type { ShipId } from '../ships/specs' +import type { Channel } from './channel' +import { createHost, decodeHello, encodeLobby, FRAME, refuse, type Host, type LobbyState } from './session' + +export function createMatchLobby(options: { + game: Game + ships: ShipId[] + seed?: number + onChange?: (state: LobbyState) => void + onPeer?: (seat: number) => void +}) { + const ships = [...options.ships] + if (ships.length < 2 || ships.length > 4) throw new RangeError('a wing has two to four seats') + const peers: (Channel | null)[] = ships.map(() => null) + const channels = new Set() + const transfers = new WeakMap void>() + let revision = 0 + let host: Host | null = null + let closed = false + + function state(seat = 0): LobbyState { + return { revision, seat, seats: ships.map((ship, i) => ({ ship, pilot: i === 0 || peers[i] ? 'human' : 'ai' })) } + } + function changed() { + revision++ + options.onChange?.(state()) + for (let i = 1; i < peers.length; i++) peers[i]?.send(encodeLobby(state(i))) + } + + return { + state, + setShip(ship: ShipId) { + if (host || closed || ships[0] === ship) return + ships[0] = ship + changed() + }, + accept(channel: Channel) { + if (closed) { channel.close(); return } + channels.add(channel) + let seated = -1 + let handedOver = false + channel.onClose(() => { + channels.delete(channel) + if (seated >= 0 && peers[seated] === channel) { + peers[seated] = null + if (!host && !closed) changed() + } + }) + channel.onMessage((bytes) => { + if (closed || !channel.open || handedOver || bytes[0] !== FRAME.HELLO) return + let ship: ShipId + try { ship = decodeHello(bytes) } catch { refuse(channel, 'version'); return } + if (host) { + handedOver = true + const seat = host.accept(channel) + if (seat >= 0) options.onPeer?.(seat) + return + } + if (seated < 0) { + seated = peers.findIndex((p, i) => i > 0 && !p) + if (seated < 0) { refuse(channel, 'full'); return } + peers[seated] = channel + ships[seated] = ship + changed() + } else { + // A lost roster is recovered by the client's repeated HELLO. + channel.send(encodeLobby(state(seated))) + } + }) + // Launch transfers existing reservations once; this handler then stands down. + transfers.set(channel, (match) => { + if (seated < 0 || !channel.open) return + handedOver = true + match.accept(channel, seated) + }) + }, + launch(): Host { + if (closed) throw new Error('lobby is closed') + if (host) return host + host = createHost({ game: options.game, setup: { ships: [...ships], seed: options.seed, respawn: true } }) + host.start() + for (const channel of channels) transfers.get(channel)?.(host) + return host + }, + close() { + closed = true + for (const channel of channels) channel.close() + host?.close() + }, + } +} diff --git a/src/net/session.ts b/src/net/session.ts index de69084..a452602 100644 --- a/src/net/session.ts +++ b/src/net/session.ts @@ -41,7 +41,7 @@ import type { Channel } from './channel' import { decodeSnapshot, encodeSnapshot, type WorldSnapshot } from './snapshot' import { ByteReader, ByteWriter, decodeIntent, encodeIntent } from './wire' -export const PROTOCOL_VERSION = 1 +export const PROTOCOL_VERSION = 2 /** Ticks between repeated hellos while a client waits for its welcome. */ export const HELLO_EVERY = 30 @@ -53,6 +53,7 @@ export const FRAME = { SNAPSHOT: 4, REFUSED: 5, RESULT: 6, + LOBBY: 7, } as const /* ---- Frames --------------------------------------------------------------- */ @@ -64,8 +65,23 @@ function withType(type: number, payload: Uint8Array): Uint8Array { return out } -export function encodeHello(): Uint8Array { - return new Uint8Array([FRAME.HELLO, PROTOCOL_VERSION]) +export function encodeHello(ship: ShipId = 'hornet'): Uint8Array { + return new Uint8Array([FRAME.HELLO, PROTOCOL_VERSION, SHIP_ORDER.indexOf(ship)]) +} + +export function decodeHello(bytes: Uint8Array): ShipId { + if (bytes.length !== 3 || bytes[0] !== FRAME.HELLO || bytes[1] !== PROTOCOL_VERSION) { + throw new RangeError('incompatible hello') + } + const ship = SHIP_ORDER[bytes[2]] + if (!ship) throw new RangeError('unknown hull in hello') + return ship +} + +export type Refusal = 'full' | 'version' +export function refuse(channel: Channel, reason: Refusal): void { + channel.send(new Uint8Array([FRAME.REFUSED, reason === 'version' ? 1 : 0])) + channel.close() } export function encodeWelcome(seat: number, setup: Required> & MatchSetup): Uint8Array { @@ -103,6 +119,37 @@ export function decodeWelcome(bytes: Uint8Array): Welcome { return { seat, setup: { ships, seed, respawn, local: seat } } } +export interface LobbyState { + revision: number + seat: number + seats: { ship: ShipId; pilot: 'human' | 'ai' | 'empty' }[] +} + +export function encodeLobby(state: LobbyState): Uint8Array { + const w = new ByteWriter(32) + w.u8(FRAME.LOBBY).u8(PROTOCOL_VERSION).u32(state.revision).u8(state.seat).u8(state.seats.length) + for (const s of state.seats) w.u8(SHIP_ORDER.indexOf(s.ship)).u8(['human', 'ai', 'empty'].indexOf(s.pilot)) + return w.bytes() +} + +export function decodeLobby(bytes: Uint8Array): LobbyState { + const r = new ByteReader(bytes) + if (r.u8() !== FRAME.LOBBY || r.u8() !== PROTOCOL_VERSION) throw new RangeError('incompatible lobby') + const revision = r.u32() + const seat = r.u8() + const count = r.u8() + if (count < 2 || count > 4 || seat >= count) throw new RangeError('invalid lobby size or seat') + const seats: LobbyState['seats'] = [] + for (let i = 0; i < count; i++) { + const ship = SHIP_ORDER[r.u8()] + const pilot = (['human', 'ai', 'empty'] as const)[r.u8()] + if (!ship || !pilot) throw new RangeError('invalid lobby seat') + seats.push({ ship, pilot }) + } + r.finish() + return { revision, seat, seats } +} + export function encodeResult(result: MatchResult): Uint8Array { const w = new ByteWriter(64) w.u8(FRAME.RESULT).u8(PROTOCOL_VERSION).f32(result.time).bool(result.cleared).u8(result.lines.length) @@ -186,12 +233,13 @@ export interface Host { * Hand a connected channel a seat. Returns the seat, or -1 if there was none — * in which case the peer has been told and the channel closed. */ - accept(channel: Channel): number + accept(channel: Channel, reservedSeat?: number): number /** * Fly one tick: the host's own intent for seat 0, the latest admitted intent * (or a hold) for every remote seat, then a snapshot to every peer. */ tick(local: Controls): void + close(): void readonly stats: HostStats readonly peers: number readonly seed: number @@ -245,6 +293,11 @@ export function createHost(options: HostOptions): Host { } const type = bytes[0] if (type === FRAME.HELLO) { + try { decodeHello(bytes) } catch { + stats.malformed++ + refuse(peer.channel, 'version') + return + } // The welcome was lost; the peer is still asking. Say it again. peer.channel.send(encodeWelcome(peer.seat, setup)) return @@ -283,12 +336,11 @@ export function createHost(options: HostOptions): Host { resultSent = null }, - accept(channel) { - const seat = peers.findIndex((p, i) => i > 0 && p === null) - if (seat < 0) { + accept(channel, reservedSeat) { + const seat = reservedSeat ?? peers.findIndex((p, i) => i > 0 && p === null) + if (seat < 1 || seat >= seatCount || peers[seat]) { stats.refused++ - channel.send(new Uint8Array([FRAME.REFUSED])) - channel.close() + refuse(channel, 'full') return -1 } const peer: Peer = { channel, seat, lastTick: -1, flownTick: -1, pending: null, pendingTick: -1, held: holds[seat] } @@ -371,6 +423,9 @@ export function createHost(options: HostOptions): Host { } }, + close() { + for (const peer of peers) peer?.channel.close() + }, get stats() { return stats }, @@ -427,7 +482,9 @@ export interface ClientOptions { /** Called once the host has handed over a seat and the match has started. */ onWelcome?: (seat: number) => void /** Called if the host had no seat. */ - onRefused?: () => void + onRefused?: (reason: Refusal) => void + ship?: ShipId + onLobby?: (roster: LobbyState) => void /** * Apply snapshots one per tick of this client's own clock, holding what the * wire delivers early and coasting through what it delivers late. On by @@ -479,7 +536,8 @@ export function createClient(options: ClientOptions): Client { /** The host tick the client last coasted from, so a gap is coasted once, not forever. */ let coastedAt = -2 - channel.send(encodeHello()) + let lobbyRevision = -1 + channel.send(encodeHello(options.ship)) function applySnapshot(world: WorldSnapshot): void { // A snapshot that throws has changed nothing (`Game.apply`'s contract), and @@ -558,7 +616,19 @@ export function createClient(options: ClientOptions): Client { return } const type = bytes[0] + if (type === FRAME.LOBBY) { + if (seat >= 0) return + try { + const roster = decodeLobby(bytes) + if (roster.revision >= lobbyRevision) { + lobbyRevision = roster.revision + options.onLobby?.(roster) + } + } catch { stats.malformed++ } + return + } if (type === FRAME.WELCOME) { + if (bytes[1] !== PROTOCOL_VERSION) { options.onRefused?.('version'); return } if (seat >= 0) return let welcome: Welcome try { @@ -573,7 +643,7 @@ export function createClient(options: ClientOptions): Client { return } if (type === FRAME.REFUSED) { - options.onRefused?.() + options.onRefused?.(bytes[1] === 1 ? 'version' : 'full') return } if (type === FRAME.RESULT) { @@ -620,7 +690,7 @@ export function createClient(options: ClientOptions): Client { if (seat < 0) { // Still waiting: the hello, or the welcome, may have been lost. Ask again // every half second rather than every tick, so a slow host is not flooded. - if (++tick % HELLO_EVERY === 0) channel.send(encodeHello()) + if (++tick % HELLO_EVERY === 0) channel.send(encodeHello(options.ship)) return } // The world first, then this seat's own step on top of it. diff --git a/src/style.css b/src/style.css index 6884dc1..faa79e6 100644 --- a/src/style.css +++ b/src/style.css @@ -1242,3 +1242,50 @@ body { font-size: 10px; } } + +/* The wing shares the hangar's middle band with the live hull preview. */ +#select:has(.wing:not([hidden])) .stage { + grid-template-columns: minmax(240px, 320px) minmax(0, 1fr); + gap: 24px; + align-items: end; +} +#select:has(.wing:not([hidden])) .readout { grid-column: 2; grid-row: 1; } +.wing { + grid-column: 1; + grid-row: 1; + width: 100%; + padding: 16px; + color: #dff6ff; + border: 1px solid var(--hair); + background: var(--ink-solid); + pointer-events: auto; + text-align: left; +} +.wing[hidden] { display: none; } +.wing-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } +.wing h2 { margin: 0; font: 700 18px var(--font-display); color: var(--cyan); } +.wing-code { font-size: 12px; user-select: text; } +.wing-seats { margin: 16px 0; padding: 0; list-style: none; font-size: 13px; } +.wing-seats li { display: grid; grid-template-columns: 80px 1fr auto; gap: 8px; padding: 9px 0; border-bottom: 1px solid var(--hair); } +.wing-seats li.you { color: var(--cyan); } +.wing-seats strong { font-weight: 400; } +.wing-status { margin: 12px 0; font-size: 13px; line-height: 1.5; overflow-wrap: anywhere; } +.wing-actions { display: flex; gap: 8px; } +.wing .btn { padding: 10px 12px; min-height: 44px; font-size: 11px; letter-spacing: 0.06em; } +.wing button[hidden] { display: none; } +#select button:disabled { cursor: default; } +#select .btn-launch:disabled { opacity: 0.65; box-shadow: none !important; } +.wing button:focus-visible { outline: 2px solid var(--cyan); outline-offset: 3px; } +.wing ::selection { color: var(--bg); background: var(--cyan); } +@media (max-width: 760px), (max-height: 700px) { + #select:has(.wing:not([hidden])) { overflow-y: auto; gap: 20px; } + #select:has(.wing:not([hidden])) .stage { flex: none; padding-top: 24px; } + #select:has(.wing:not([hidden])) .best { display: none; } + #select:has(.wing:not([hidden])) .cards, + #select:has(.wing:not([hidden])) footer { flex-shrink: 0; } +} +@media (max-width: 600px) { + #select:has(.wing:not([hidden])) .stage { grid-template-columns: minmax(0, 1fr); } + #select:has(.wing:not([hidden])) .readout { grid-column: 1; grid-row: 1; padding: 16px; background: var(--ink-solid); } + .wing { grid-row: 2; } +} diff --git a/src/ui/hangar.ts b/src/ui/hangar.ts index 90fad74..a85f622 100644 --- a/src/ui/hangar.ts +++ b/src/ui/hangar.ts @@ -66,6 +66,7 @@ export interface Hangar { open(initial: ShipId): void close(): void update(dt: number): void + action(label: string | null, locked?: boolean): void readonly selected: ShipId dispose(): void } @@ -76,6 +77,7 @@ export interface HangarDeps { camera: THREE.PerspectiveCamera audio: Audio onLaunch: (id: ShipId) => void + onSelect?: (id: ShipId) => void } export function createHangar(deps: HangarDeps): Hangar { @@ -84,6 +86,8 @@ export function createHangar(deps: HangarDeps): Hangar { let selected: ShipId = 'hornet' let orbit = 0 let open = false + let locked = false + let actionLabel: string | null = null /* ---- Preview models --------------------------------------------------- */ @@ -188,6 +192,7 @@ export function createHangar(deps: HangarDeps): Hangar { } function select(id: ShipId, chime: boolean) { + if (locked) return selected = id const spec = SHIPS[id] @@ -203,7 +208,8 @@ export function createHangar(deps: HangarDeps): Hangar { launch.style.background = hex(spec.accent) launch.style.boxShadow = `0 0 34px ${hex(spec.accent)}aa` - launch.textContent = `Launch ${spec.name}` + launch.textContent = actionLabel ?? `Launch ${spec.name}` + deps.onSelect?.(id) if (chime) { audio.resume() @@ -212,6 +218,7 @@ export function createHangar(deps: HangarDeps): Hangar { } function doLaunch() { + if (locked) return audio.resume() audio.uiLaunch() deps.onLaunch(selected) @@ -220,7 +227,9 @@ export function createHangar(deps: HangarDeps): Hangar { launch.addEventListener('click', doLaunch) function onKey(e: KeyboardEvent) { - if (!open) return + if (!open || locked) return + // Focused controls own Enter, including copy/leave in the wing panel. + if ((e.target as HTMLElement)?.closest('button, input, a, select')) return const index = SHIP_ORDER.indexOf(selected) if (e.code === 'Enter' || e.code === 'NumpadEnter') { e.preventDefault() @@ -237,6 +246,13 @@ export function createHangar(deps: HangarDeps): Hangar { return { root, + action(label, lock = false) { + actionLabel = label + locked = lock + launch.disabled = lock + launch.textContent = label ?? `Launch ${SHIPS[selected].name}` + for (const node of cardNodes.values()) node.disabled = lock + }, get selected() { return selected diff --git a/src/ui/wing.ts b/src/ui/wing.ts new file mode 100644 index 0000000..30c1051 --- /dev/null +++ b/src/ui/wing.ts @@ -0,0 +1,63 @@ +import type { LobbyState } from '../net/session' +import { SHIPS } from '../ships/specs' + +/** One roster in the hangar, drawn from the host's reservations on either side. */ +export function createWing(parent: HTMLElement, onLeave: () => void) { + const root = document.createElement('section') + root.className = 'wing' + root.hidden = true + root.setAttribute('aria-label', 'Wing lobby') + root.innerHTML = `

WING

+
    +

    +
    +
    ` + const code = root.querySelector('.wing-code')! + const seats = root.querySelector('.wing-seats')! + const status = root.querySelector('.wing-status')! + const copy = root.querySelector('.wing-copy')! + const leave = root.querySelector('.wing-leave')! + let url = '' + copy.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(url) + copy.textContent = 'COPIED' + } catch { + status.textContent = `Copy this link: ${url}` + status.style.userSelect = 'text' + } + }) + leave.addEventListener('click', onLeave) + parent.append(root) + return { + show(joinCode: string, host: boolean) { + root.hidden = false + code.textContent = `JOIN CODE ${joinCode}` + url = `${location.origin}${location.pathname}?join=${joinCode}` + copy.textContent = 'COPY LINK' + copy.hidden = !host + leave.hidden = host + seats.replaceChildren() + status.textContent = host ? 'Launch when ready. Open seats fly on AI.' : 'Choose your hull, then join the wing.' + }, + update(state: LobbyState) { + seats.replaceChildren(...state.seats.map((seat, i) => { + const row = document.createElement('li') + row.className = i === state.seat ? 'you' : '' + const name = document.createElement('span') + name.textContent = `P${i + 1}${i === state.seat ? ' · YOU' : ''}` + const hull = document.createElement('strong') + hull.textContent = SHIPS[seat.ship].name + const pilot = document.createElement('span') + pilot.textContent = i === 0 ? 'HOST' : seat.pilot === 'human' ? 'JOINED' : seat.pilot.toUpperCase() + row.append(name, hull, pilot) + return row + })) + status.textContent = state.seat === 0 + ? 'Launch when ready. Open seats fly on AI.' + : 'Seat reserved. Waiting for the host to launch.' + }, + status(message: string) { status.textContent = message }, + hide() { root.hidden = true }, + } +}