diff --git a/README.md b/README.md index 81948a8..4d60115 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,21 @@ 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. +**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 +only: a proportional controller with a seeded jink that steers from the same `RunSnapshot` a +scripted pilot gets (bearing and range to the lock, hull, heat), fires down its nose inside a cone, +detours to a repair pod when hurt, and turns hard off the star. It sees nothing a console cannot +and goes through the same seat admission a peer does. A peer that arrives takes the seat over +mid-flight — the host seeds the seat's throttle hold from what the autopilot was flying, so the +hull does not lurch on the tick a person takes the stick — and one that drops hands it back the +next tick. Seeded per seat (`STREAM.wingman`), so a seed replays the same match whether or not +anyone joined. `simcheck` flies a peer-less seat for twenty seconds and asks for hits, an arena +never left, the same match twice from one seed, a takeover with no shots from the autopilot after +and no jump in throttle or position across it, and the autopilot back on the stick after a drop. +`createHost({ backfill: false })` keeps the old hold for the wire checks that measure it. + **Two tabs on one machine will usually not connect, and that is WebRTC, not this code.** Chrome hides its LAN address behind an mDNS name and the only other route is back through your own router, which most do not allow; a plain two-tab WebRTC test with no relay in between sits in diff --git a/scripts/mutate.mjs b/scripts/mutate.mjs index 008682a..45ad3fb 100644 --- a/scripts/mutate.mjs +++ b/scripts/mutate.mjs @@ -223,6 +223,37 @@ const MUTATIONS = [ to: ' id: 0,\n spec: pilot.ship.spec.id,', }, + /* ---- Backfill: a seat with no peer is flown --------------------------- */ + { + name: 'an empty seat holds instead of being flown', + file: 'src/net/session.ts', + from: ' if (!peer && auto) {\n', + to: ' if (false && !peer && auto) {\n', + }, + { + name: 'the autopilot keeps flying a seat a peer has taken', + file: 'src/net/session.ts', + from: ' if (!peer && auto) {\n', + to: ' if (auto) {\n', + }, + { + name: 'a peer taking a seat over starts from launch throttle', + file: 'src/net/session.ts', + from: ' if (auto) holds[seat].throttle = auto.controls.throttle\n', + to: '', + }, + { + name: 'the seat autopilot never fires', + file: 'src/game/autopilot.ts', + from: ' controls.fire = t.range < FIRE_RANGE && Math.abs(t.pitch) < FIRE_CONE && Math.abs(t.yaw) < FIRE_CONE\n', + to: ' controls.fire = false\n', + }, + { + name: 'the seat autopilot steers away from its target', + file: 'src/game/autopilot.ts', + from: ' controls.pitch = clamp(t.pitch * STEER_GAIN + (closing ? jinkPitch : 0))\n controls.yaw = clamp(t.yaw * STEER_GAIN + (closing ? jinkYaw : 0))\n', + to: ' controls.pitch = clamp(-t.pitch * STEER_GAIN + (closing ? jinkPitch : 0))\n controls.yaw = clamp(-t.yaw * STEER_GAIN + (closing ? jinkYaw : 0))\n', + }, /* ---- Transport: the session protocol ------------------------------------ */ { name: 'the host flies whatever seat an intent claims', @@ -833,7 +864,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 = 618 +const EXPECTED_ASSERTIONS = 629 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 b775e82..4f29958 100644 --- a/scripts/simcheck.ts +++ b/scripts/simcheck.ts @@ -3357,6 +3357,130 @@ function testAMatchCrossesTheWire(): void { check('a seat nobody is flying flies differently', deaf.print !== live.print, 'the client\'s stick changed nothing on the host') } +/** + * An empty seat is flown. + * + * A host's seat with no peer in it used to fly a neutral stick in a straight + * line — before anyone joined, or after a player dropped, forever. Now the + * seat's autopilot (`game/autopilot.ts`) flies it from the same view a scripted + * pilot gets, a person who arrives takes it over mid-flight without a lurch, + * and one who drops hands it back. Seeded, so a seed replays the same match. + */ +function testAnEmptySeatIsFlown(): void { + section('An empty seat is flown') + + const SEED = 0x1e5a + const TICKS = Math.ceil(20 / STEP) + + function alone(backfill: boolean) { + const game = newMatch() + const host = createHost({ game, setup: { ships: ['hornet', 'wasp'], seed: SEED, respawn: true }, backfill }) + host.start() + const device = stubInput() + const pilot = createPilot() + let shots = 0 + let hits = 0 + let outside = 0 + let throttle = -1 + for (let i = 0; i < TICKS; i++) { + steer(device, game.snapshot(0)?.target ?? null) + host.tick(pilot.advance(device.state, STEP)) + const s = game.snapshot(1) + if (!s) break + shots = s.shotsFired + hits = s.hits + throttle = s.throttle + if (Math.hypot(s.position.x, s.position.y, s.position.z) > ARENA_RADIUS) outside++ + } + const print = matchPrint(game) + const stats = { ...host.stats } + game.dispose() + return { shots, hits, outside, throttle, print, stats } + } + + const flown = alone(true) + check('a seat with no peer fires and lands hits', flown.shots > 0 && flown.hits > 0, `${flown.shots} shots, ${flown.hits} hits`) + check('and never leaves the arena', flown.outside === 0, `${flown.outside} ticks outside`) + check('every one of its ticks was backfilled and none held', flown.stats.backfilled === TICKS && flown.stats.held === 0, JSON.stringify(flown.stats)) + const again = alone(true) + check('the same seed flies the same match with nobody in the seat', again.print === flown.print) + const held = alone(false) + check('with backfill off the seat holds: no shots, launch throttle', held.shots === 0 && held.throttle === 0.6 && held.stats.backfilled === 0, + `${held.shots} shots, throttle ${held.throttle}, ${JSON.stringify(held.stats)}`) + check('and that is a different match', held.print !== flown.print) + + /* A person arrives, then leaves. */ + const hostGame = newMatch() + const clientGame = newMatch() + const host = createHost({ game: hostGame, setup: { ships: ['hornet', 'wasp'], seed: 0x7a2e, respawn: true } }) + host.start() + const device = stubInput() + const pilot = createPilot() + function fly(): void { + steer(device, hostGame.snapshot(0)?.target ?? null) + host.tick(pilot.advance(device.state, STEP)) + } + // Hand over while the autopilot is flying, armed and at speed — a wrecked seat + // flies nothing, and a check against its stale throttle sees no hold at all + // (the first version handed over to a wreck and passed with the seeding removed). + let flownFor = 0 + for (; flownFor < Math.ceil(20 / STEP); flownFor++) { + fly() + const s = hostGame.snapshot(1)! + if (flownFor > 60 && s.phase === 'flying' && s.deaths === 0 && s.shotsFired > 0 && s.throttle > 0.9) break + } + const before = hostGame.snapshot(1)! + check('the autopilot was flying, armed and at speed when a peer arrived', + before.phase === 'flying' && before.shotsFired > 0 && before.throttle > 0.9, `after ${flownFor} ticks: ${before.phase}, ${before.shotsFired} shots, throttle ${before.throttle}`) + const wire = createLoopback() + const client = createClient({ game: clientGame, channel: wire.b, predict: false }) + const seat = host.accept(wire.a) + wire.pump() + // The person holds the stick still, trigger off, launch throttle asked for. + const still = controls({ throttle: 0.6 }) + let prev = before + let lowestThrottle = before.throttle + let widestThrottleStep = 0 + let widestStep = 0 + let widestAt = -1 + for (let i = 0; i < Math.ceil(5 / STEP); i++) { + client.tick(still) + fly() + wire.pump() + const s = hostGame.snapshot(1)! + lowestThrottle = Math.min(lowestThrottle, s.throttle) + // A hull that flew both ticks: a wreck tumbles and a respawn is a new place. + if (prev.phase === 'flying' && s.phase === 'flying' && s.deaths === prev.deaths) { + widestThrottleStep = Math.max(widestThrottleStep, Math.abs(s.throttle - prev.throttle)) + const moved = Math.hypot(s.position.x - prev.position.x, s.position.y - prev.position.y, s.position.z - prev.position.z) + const allowed = Math.max(prev.speed, s.speed) * STEP * 1.05 + 1e-6 + if (moved / allowed > widestStep) { + widestStep = moved / allowed + widestAt = i + } + } + prev = s + } + const taken = hostGame.snapshot(1)! + check('a peer that arrives takes the seat over and the autopilot lets go', + seat === 1 && client.seat === 1 && taken.shotsFired === before.shotsFired && before.shotsFired > 0, + `seat ${seat}, ${before.shotsFired} shots before, ${taken.shotsFired} after`) + check('the takeover ramps from the throttle the autopilot was flying, not from launch throttle', + widestThrottleStep <= THROTTLE_DOWN_RATE * STEP + 1e-9 && lowestThrottle <= 0.6 + 1e-6, + `autopilot at ${before.throttle}, widest throttle step ${widestThrottleStep.toFixed(4)} (rate allows ${(THROTTLE_DOWN_RATE * STEP).toFixed(4)}), lowest ${lowestThrottle}`) + check('and the hull never jumps across the handover', widestStep > 0 && widestStep <= 1, `widest step ${widestStep.toFixed(3)} of one tick's travel, at tick ${widestAt}`) + + const backfilledAtDrop = host.stats.backfilled + wire.a.close() + for (let i = 0; i < Math.ceil(10 / STEP); i++) fly() + const after = hostGame.snapshot(1)! + check('a peer that drops hands the seat back to the autopilot', + after.shotsFired > taken.shotsFired && host.stats.backfilled > backfilledAtDrop, + `${taken.shotsFired} shots at the drop, ${after.shotsFired} after; backfilled ${backfilledAtDrop} -> ${host.stats.backfilled}`) + hostGame.dispose() + clientGame.dispose() +} + /** * The wire is bad, and the match survives it. * @@ -7253,6 +7377,7 @@ testHullsComeAndGoByIdAndBadFramesDoNothing() testAnIntentFrameEndsInAdmission() testAMatchCrossesTheWire() testABadWireIsSurvived() +testAnEmptySeatIsFlown() testThePeerFliesItsOwnSeatOnly() testTheStickIsAttachedToTheShip() testAJitteryWireIsDrawnSteadily() diff --git a/src/core/rng.ts b/src/core/rng.ts index b1e91e0..4998e1f 100644 --- a/src/core/rng.ts +++ b/src/core/rng.ts @@ -122,4 +122,10 @@ export const STREAM = { * somebody does. */ respawn: 0xb0c4, + /** + * The autopilot flying a seat nobody has taken, offset by seat index. Seat 0 + * is never backfilled, so the bare label goes unused; clear of `pilot` and its + * offsets by thousands, for the reason `enemyGuns` gives. + */ + wingman: 0x7a70, } as const diff --git a/src/game/autopilot.ts b/src/game/autopilot.ts new file mode 100644 index 0000000..16a33ab --- /dev/null +++ b/src/game/autopilot.ts @@ -0,0 +1,145 @@ +/** + * The pilot of a seat nobody has taken. + * + * A host's match has a seat per hull, and until a person is in one it is flown + * by this: a peer that has not arrived yet, or one that dropped, leaves a seat + * that would otherwise fly a neutral stick in a straight line — a ghost that + * the squadron shoots for free and that a match rule counts as a participant. + * + * Two rules keep it honest, and both are the same rules a person is under: + * + * - **It flies the seat's view, not the ship.** Its whole input is the + * `RunSnapshot` a scripted pilot gets — body-frame bearing and range to the + * locked target, hull, solar exposure — so it can see nothing a console + * cannot, and it produces the same `Controls` a keyboard does. `aim` is null + * and `spread` is zero here and dropped by seat admission anyway, so it + * fires down its nose like anyone else. + * - **It draws from its own stream.** Wander comes from the `Rng` it is given, + * which the host seeds per seat, so a seed replays the same match whether or + * not anyone ever joined it. + * + * It is not `EnemyPilot`: that steers a hull it holds, leads its shots, and + * has a profile per airframe. This is a proportional controller with a jink, + * the same shape as the autopilot the headless suite flies every seat on, and + * it is meant to hold a seat rather than to win the match for whoever takes it. + */ + +import { rampThrottle } from './intent' +import type { RunSnapshot } from './game' +import type { Controls } from './ship' +import { SHIPS, type ShipId } from '../ships/specs' +import type { Rng } from '../core/rng' + +/** Stick deflection per radian of bearing error. */ +export const STEER_GAIN = 3 +/** Fire when the lead point is inside this cone, radians. */ +export const FIRE_CONE = 0.35 +/** And inside this range: bolts past it are heat spent on nothing. */ +export const FIRE_RANGE = 900 +/** Open the throttle beyond this range to the target. */ +export const CLOSE_IN = 260 +/** Back it off inside this range: a ram is a bad trade for both hulls. */ +export const BACK_OFF = 170 +/** Wander added to the stick while still closing, so two wingmen do not fly one line. */ +export const JINK = 0.35 +/** Seconds between wander changes. */ +export const JINK_EVERY: [number, number] = [0.7, 1.6] +/** Wander stops inside this range: the shot matters more than the line in. */ +export const JINK_BEYOND = 450 +/** Detour to a repair pod below this fraction of hull, if one is near. */ +export const REPAIR_BELOW = 0.4 +export const REPAIR_WITHIN = 1600 +/** Solar exposure at which the seat stops fighting and turns hard away. */ +export const SEAR_ESCAPE = 0.15 + +export interface SeatAutopilot { + /** + * Produce this tick's controls from the seat's view. `null` — the seat is + * wrecked, eliminated, or the match is not running — holds the stick still + * and the trigger off. The struct is reused; the game copies what it flies. + */ + advance(view: RunSnapshot | null, dt: number): Controls + /** What it last produced. A peer taking the seat over ramps from this throttle. */ + readonly controls: Controls +} + +function clamp(v: number): number { + return v < -1 ? -1 : v > 1 ? 1 : v +} + +export function createSeatAutopilot(ship: ShipId, rng: Rng): SeatAutopilot { + const maxHull = SHIPS[ship].maxHull + const controls: Controls = { + pitch: 0, + yaw: 0, + roll: 0, + throttle: 0.6, + fire: false, + dash: false, + aim: null, + spread: 0, + } + let jinkPitch = 0 + let jinkYaw = 0 + let jinkTimer = 0 + + function rejink(): void { + jinkTimer = rng.range(JINK_EVERY[0], JINK_EVERY[1]) + jinkPitch = rng.range(-JINK, JINK) + jinkYaw = rng.range(-JINK, JINK) + } + rejink() + + return { + controls, + advance(view, dt) { + controls.fire = false + controls.dash = false + if (!view) { + controls.pitch = 0 + controls.yaw = 0 + return controls + } + jinkTimer -= dt + if (jinkTimer <= 0) rejink() + + // Cooking: nothing else matters until the nose is off the star. A hard + // constant turn at full throttle is what a person does too — the view has + // no bearing to the sun, only the heat, and any hard turn changes heading. + if (view.solarExposure > SEAR_ESCAPE) { + controls.pitch = 1 + controls.yaw = 0 + controls.throttle = rampThrottle(controls.throttle, 1, dt) + return controls + } + + // Hurt with a repair pod near: go and get it rather than trade the last of the hull. + const repair = view.pickups.repair + if (view.hull < maxHull * REPAIR_BELOW && repair && repair.range < REPAIR_WITHIN) { + controls.pitch = clamp(repair.pitch * STEER_GAIN) + controls.yaw = clamp(repair.yaw * STEER_GAIN) + controls.throttle = rampThrottle(controls.throttle, 1, dt) + return controls + } + + const t = view.target + if (!t) { + // Nothing locked: a slow, wandering turn at cruise rather than a straight + // line, which is the difference between circling inside the arena until + // something arrives and flying out of it. + controls.pitch = jinkPitch * 0.5 + controls.yaw = 0.25 + jinkYaw * 0.5 + controls.throttle = rampThrottle(controls.throttle, 0.6, dt) + return controls + } + + const closing = t.range > JINK_BEYOND + controls.pitch = clamp(t.pitch * STEER_GAIN + (closing ? jinkPitch : 0)) + controls.yaw = clamp(t.yaw * STEER_GAIN + (closing ? jinkYaw : 0)) + controls.fire = t.range < FIRE_RANGE && Math.abs(t.pitch) < FIRE_CONE && Math.abs(t.yaw) < FIRE_CONE + if (t.range > CLOSE_IN) controls.throttle = rampThrottle(controls.throttle, 1, dt) + else if (t.range < BACK_OFF) controls.throttle = rampThrottle(controls.throttle, 0, dt) + return controls + }, + } +} diff --git a/src/net/session.ts b/src/net/session.ts index 3e30e99..a4f1358 100644 --- a/src/net/session.ts +++ b/src/net/session.ts @@ -30,7 +30,9 @@ * this layer never constructs a `Controls` by hand. */ +import { STREAM, subRng } from '../core/rng' import type { MatchResult, SeatLine } from '../core/scores' +import { createSeatAutopilot, type SeatAutopilot } from '../game/autopilot' import { admitIntent } from '../game/intent' import { STEP, type Game, type MatchSetup } from '../game/game' import type { Controls } from '../game/ship' @@ -157,6 +159,8 @@ export interface HostStats { refused: number /** Result frames sent: one per peer when the match resolved, and every half second after. */ results: number + /** Ticks a seat with no peer was flown by its autopilot. */ + backfilled: number } export interface HostOptions { @@ -164,6 +168,15 @@ export interface HostOptions { setup: MatchSetup & { ships: ShipId[] } /** Send a snapshot every this many ticks. 1 is every tick. */ snapshotEvery?: number + /** + * Fly every seat that has no peer on an autopilot (`game/autopilot.ts`). On by + * default: a seat nobody has taken is otherwise a ghost — a neutral stick in a + * straight line before anyone joins, or a dropped player's last stick forever + * after. A peer that arrives takes the seat over mid-flight; one that drops + * hands it back. Off, an empty seat holds, which is what the wire tests that + * measure a held seat need. + */ + backfill?: boolean } export interface Host { @@ -213,7 +226,12 @@ export function createHost(options: HostOptions): Host { const intents: Controls[] = Array.from({ length: seatCount }, () => neutral()) const holds: Controls[] = Array.from({ length: seatCount }, () => neutral()) const scratch: Controls = neutral() - const stats: HostStats = { wrongSeat: 0, stale: 0, malformed: 0, held: 0, admitted: 0, refused: 0, results: 0 } + // One per seat that can be empty, seeded per seat so two wingmen do not fly + // the same line and a seed replays the same match. Seat 0 is the host's own. + const autopilots: (SeatAutopilot | null)[] = setup.ships.map((ship, seat) => + seat > 0 && (options.backfill ?? true) ? createSeatAutopilot(ship, subRng(seed, STREAM.wingman + seat)) : null, + ) + const stats: HostStats = { wrongSeat: 0, stale: 0, malformed: 0, held: 0, admitted: 0, refused: 0, results: 0, backfilled: 0 } let tick = 0 let sinceSnapshot = 0 /** The result the peers are being told, and how long since it was last said. */ @@ -274,6 +292,11 @@ export function createHost(options: HostOptions): Host { return -1 } const peer: Peer = { channel, seat, lastTick: -1, flownTick: -1, pending: null, pendingTick: -1, held: holds[seat] } + // The seat is taken over mid-flight: the peer's first intents ramp from the + // throttle the autopilot was flying, not from launch throttle, so the hull + // does not lurch on the tick a person takes the stick. + const auto = autopilots[seat] + if (auto) holds[seat].throttle = auto.controls.throttle peers[seat] = peer channel.onMessage((bytes) => onFrame(peer, bytes)) channel.onClose(() => { @@ -312,6 +335,15 @@ export function createHost(options: HostOptions): Host { for (let seat = 1; seat < seatCount; seat++) { const peer = peers[seat] const held = holds[seat] + const auto = autopilots[seat] + if (!peer && auto) { + // Nobody in the seat: the autopilot flies it, from the same per-seat + // view a scripted pilot gets, through the same admission a peer gets. + intents[seat] = auto.advance(game.snapshot(seat), STEP) + stats.backfilled++ + game.acknowledge(seat, -1) + continue + } if (peer && peer.pending) { Object.assign(held, peer.pending) peer.flownTick = peer.pendingTick