Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
50 changes: 47 additions & 3 deletions scripts/mutate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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\.$/

Expand Down
121 changes: 119 additions & 2 deletions scripts/simcheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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"}')
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
6 changes: 3 additions & 3 deletions src/core/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
const keyHandlers = new Map<string, (() => void)[]>()
const lockLostHandlers: (() => void)[] = []
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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()
Expand Down
Loading