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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 32 additions & 1 deletion scripts/mutate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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\.$/

Expand Down
125 changes: 125 additions & 0 deletions scripts/simcheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -7253,6 +7377,7 @@ testHullsComeAndGoByIdAndBadFramesDoNothing()
testAnIntentFrameEndsInAdmission()
testAMatchCrossesTheWire()
testABadWireIsSurvived()
testAnEmptySeatIsFlown()
testThePeerFliesItsOwnSeatOnly()
testTheStickIsAttachedToTheShip()
testAJitteryWireIsDrawnSteadily()
Expand Down
6 changes: 6 additions & 0 deletions src/core/rng.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
145 changes: 145 additions & 0 deletions src/game/autopilot.ts
Original file line number Diff line number Diff line change
@@ -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
},
}
}
Loading