From 81fcb2b0f340edf8e53239980b71607049cf3319 Mon Sep 17 00:00:00 2001
From: Chris Greening LAUNCH THE ESCAPE POD? YOU LOSE THE SHIP AND EVERYTHING IN THE HOLD.MAX_MISS_STRETCH | 3 | The most that geometry may stretch the aim, in multiples of the intended pass. | passaim.maxMissStretch | [pass-aim.ts:31](./pass-aim.ts#L31) |
| planet | WITCHPOINT_RADII | 16 | How far out of the planet you drop from witch-space, in planet radii. | planet.witchpointRadii | [planet.ts:15](./planet.ts#L15) |
| planet | PLANET_CRASH_ALTITUDE | 80 | The altitude above the surface at which the ship is destroyed. | | [planet.ts:24](./planet.ts#L24) |
-| player-flight | PLAYER_FLIGHT | { maxSpeed: 400, accel: 220, maxRoll: 2.5, maxPitch: 1.45, rateRamp: 4.1396, rateDecay: 13.3886, } as const | The player's flight envelope, in one place that a harness can read. | flight.player.maxSpeedflight.player.rateRamp | [player-flight.ts:21](./player-flight.ts#L21) |
+| player-flight | PLAYER_FLIGHT | { maxSpeed: 400, accel: 220, throttleBand: 12, maxRoll: 2.5, maxPitch: 1.45, rateRamp: 4.1396, rateDecay: 13.3886, } as const | The player's flight envelope, in one place that a harness can read. | flight.player.maxSpeedflight.player.throttleBandflight.player.rateRamp | [player-flight.ts:21](./player-flight.ts#L21) |
| player-gun | LASER_RANGE | 3500 | How far the player's laser reaches, in world units. | | [player-gun.ts:14](./player-gun.ts#L14) |
| player-gun | LASER_PACING | { pulse: { cooldown: 0.24, heat: 0.067 }, beam: { cooldown: 0.09, heat: 0.05 }, military: { cooldown: 0.09, heat: 0.05 }, } as const | The cadence and the heat of each fitted laser. | | [player-gun.ts:29](./player-gun.ts#L29) |
| player-gun | LASER_CUTOUT | 0.98 | The laser cuts out at this temperature. | | [player-gun.ts:36](./player-gun.ts#L36) |
diff --git a/src/constants/player-flight.ts b/src/constants/player-flight.ts
index 7961b8fb..65ac1dc8 100644
--- a/src/constants/player-flight.ts
+++ b/src/constants/player-flight.ts
@@ -29,6 +29,16 @@ export const PLAYER_FLIGHT = {
/** Thrust, world units per second per second, in both directions. */
accel: 220,
+ /**
+ * How far the ship's speed may sit from a wanted speed before the throttle
+ * moves, in world units per second (docs/TODO/204 M1). Twelve is about
+ * one twentieth of a second of thrust, so the ship settles on the slider's
+ * speed and does not hunt round it.
+ *
+ * @rule flight.player.throttleBand
+ */
+ throttleBand: 12,
+
/**
* The player's Cobra turns at these. They are Harmless numbers, not released
* ones. They are set so that you out-turn a pirate Cobra and a Krait, match a
diff --git a/src/engine/flight-controls.ts b/src/engine/flight-controls.ts
index e96bbb35..f68538b6 100644
--- a/src/engine/flight-controls.ts
+++ b/src/engine/flight-controls.ts
@@ -29,12 +29,19 @@ export interface FlightControls {
readonly mouseX: number;
readonly mouseY: number;
readonly mouseFire: boolean;
+ /** the speed a throttle slider asks for, as a fraction of top speed, or null (docs/TODO/204) */
+ readonly wantedSpeed?: number | null;
}
-/** The ramped rates the demand continues from — the ship's own, in practice. */
+/**
+ * The ramped rates the demand continues from — the ship's own, in practice.
+ * `speed` is the ship's, for the wanted-speed rule, and a caller with no
+ * throttle slider may leave it out.
+ */
export interface TurnRates {
rollRate: number;
pitchRate: number;
+ speed?: number;
}
/** The active bindings the flight controls need, supplied by their owner. */
@@ -70,11 +77,28 @@ export function flightDemand(
// slash only decelerates unshifted — ? opens the controls guide
const decelHeld = keys.decel.some((k) =>
c.held(k) && (k !== 'Slash' || !c.held('ShiftLeft', 'ShiftRight')));
+ const accelHeld = c.held(...keys.accel);
return {
rollRate: rampFlightRate(from.rollRate, rollIn * PLAYER_FLIGHT.maxRoll, rollIn !== 0, dt),
pitchRate: rampFlightRate(from.pitchRate, pitchIn * PLAYER_FLIGHT.maxPitch, pitchIn !== 0, dt),
- throttle: (c.held(...keys.accel) ? 1 : 0) - (decelHeld ? 1 : 0),
+ throttle: accelHeld || decelHeld
+ ? (accelHeld ? 1 : 0) - (decelHeld ? 1 : 0)
+ : throttleToward(c.wantedSpeed ?? null, from.speed),
fire: c.held(...keys.fire) || c.mouseFire,
};
}
+
+/**
+ * The throttle a wanted speed asks for: open below it, brake above it, and
+ * coast inside `PLAYER_FLIGHT.throttleBand` of it (docs/TODO/204 M1). A
+ * touch slider sets the wanted speed. A keyboard never does, so with no
+ * wanted speed and no key the ship coasts, as it always did.
+ */
+export function throttleToward(wanted: number | null, speed: number | undefined): number {
+ if (wanted === null || speed === undefined) return 0;
+ const target = wanted * PLAYER_FLIGHT.maxSpeed;
+ if (speed < target - PLAYER_FLIGHT.throttleBand) return 1;
+ if (speed > target + PLAYER_FLIGHT.throttleBand) return -1;
+ return 0;
+}
diff --git a/src/engine/input.ts b/src/engine/input.ts
index ab9a2bb0..2c458e0c 100644
--- a/src/engine/input.ts
+++ b/src/engine/input.ts
@@ -67,6 +67,12 @@ export class Input {
mouseX = 0;
mouseY = 0;
mouseFire = false;
+ /**
+ * The speed a throttle slider asks for, as a fraction of the ship's top
+ * speed, or null when no slider is set (docs/TODO/204 M1). A speed key
+ * held overrides it, and `flightDemand` ramps toward it otherwise.
+ */
+ wantedSpeed: number | null = null;
private readonly canvas: HTMLElement | null;
constructor() {
@@ -137,6 +143,20 @@ export class Input {
if (code === 'Slash') this.down.delete('Question');
}
+ /**
+ * Hold a key down, as a finger on a button does, until `release`
+ * (docs/TODO/204 M1). It is a held key and never a tap: `held` answers
+ * for it, `pressed` does not, and the carry rule never sees it. So a FIRE
+ * button holds the trigger exactly as the A key does.
+ */
+ press(code: string): void {
+ this.down.add(code);
+ }
+
+ release(code: string): void {
+ this.down.delete(code);
+ }
+
/**
* Queue a press as though a finger struck the key. So a clickable UI reuses
* the keyboard handlers. That covers a virtual code like 'VirtBuyMax', which
diff --git a/test/flight.test.ts b/test/flight.test.ts
index c4bebaed..2c11cf7f 100644
--- a/test/flight.test.ts
+++ b/test/flight.test.ts
@@ -15,14 +15,14 @@ import {
type FlightDemand,
} from '../src/player.ts';
import { PLAYER_FLIGHT } from '../src/constants/player-flight.ts';
-import { flightDemand, type FlightControls } from '../src/engine/flight-controls.ts';
+import { flightDemand, throttleToward, type FlightControls } from '../src/engine/flight-controls.ts';
import { keymap } from '../src/engine/keymap.ts';
import { CombatComputer } from '../src/game/combat-computer.ts';
import {
CC_ACCEL, CC_MAX_PITCH, CC_MAX_ROLL, CC_MAX_SPEED,
} from '../src/constants/combat-computer.ts';
import { freshSystems } from '../src/game/systems.ts';
-import { check } from './harness.ts';
+import { check, eq } from './harness.ts';
// --- flight demands: what the pilot wants, and who wanted it ----------------
//
@@ -54,6 +54,7 @@ console.log('\nflight demands');
mouseX = 0;
mouseY = 0;
mouseFire = false;
+ wantedSpeed: number | null = null;
constructor(down: string[] = []) { this.down = new Set(down); }
held(...codes: string[]): boolean { return codes.some((c) => this.down.has(c)); }
/** Input's own self-centring, copied because Input itself needs a document. */
@@ -374,3 +375,27 @@ console.log('\nturn ramp');
check('a released rate still snaps to exactly zero',
rampFlightRate(0.0005, 0, false, 1 / 60) === 0);
}
+
+console.log('\na wanted speed drives the throttle, and a key overrides it (docs/TODO/204 M1)');
+{
+ // The hands, as the block above shapes them, with a slider on the side.
+ const at = (speed: number, wanted: number | null, ...keys: string[]) => {
+ const down = new Set(keys);
+ const h: FlightControls = {
+ held: (...codes) => codes.some((c) => down.has(c)),
+ mouseFlight: false, mouseX: 0, mouseY: 0, mouseFire: false, wantedSpeed: wanted,
+ };
+ return flightDemand(h, keymap(), { rollRate: 0, pitchRate: 0, speed }, 1 / 60).throttle;
+ };
+ const max = PLAYER_FLIGHT.maxSpeed;
+ const band = PLAYER_FLIGHT.throttleBand;
+ eq('below the wanted speed the throttle opens', at(100, 0.5), 1);
+ eq('above it the throttle brakes', at(300, 0.5), -1);
+ eq('at it the ship coasts', at(max * 0.5, 0.5), 0);
+ eq('...and inside the band, so it does not hunt', at(max * 0.5 - band + 1, 0.5), 0);
+ eq('...but just outside the band it moves', at(max * 0.5 - band - 1, 0.5), 1);
+ eq('with no wanted speed and no key, the ship coasts as it always did', at(100, null), 0);
+ eq('a held speed key overrides the slider', at(100, 0.5, 'Slash'), -1);
+ eq('...and so does the other one', at(300, 0.5, 'Space'), 1);
+ eq('a caller with no speed gets no throttle from the slider', throttleToward(0.5, undefined), 0);
+}
diff --git a/test/input.test.ts b/test/input.test.ts
index 353723ad..c26cf972 100644
--- a/test/input.test.ts
+++ b/test/input.test.ts
@@ -151,6 +151,19 @@ const readPerFrame = (i: Input, code: string, frames: number): number => {
eq('...and drains the carry with it', i.drainPresses().length, 0);
}
+// --- a button holds a key, and is never a tap (docs/TODO/204 M1) ------------
+{
+ const i = new Input();
+ i.press('KeyA');
+ check('a pressed key is held', i.held('KeyA'));
+ check('...and it is not a tap', !i.pressed('KeyA'));
+ i.endFrame();
+ check('...and the frame boundary does not let go of it', i.held('KeyA'));
+ i.release('KeyA');
+ check('a released key is let go', !i.held('KeyA'));
+ check('...and left no tap behind', !i.pressed('KeyA'));
+}
+
// --- a real key carries its own modifier (docs/TODO/202 M1) -----------------
//
// The keydown that says shiftKey is the whole evidence. A Shift keydown the
From e05dea6d5ba2400ad08a69724b764c4287876b7e Mon Sep 17 00:00:00 2001
From: Chris Greening MASS_LOCK_STATION | 5000 | How near the station holds the drive down. | | [torus.ts:29](./torus.ts#L29) |
| torus | MASS_LOCK_PLANET_ALTITUDE | 4000 | ...and how near the planet, as an ALTITUDE above the surface. | | [torus.ts:37](./torus.ts#L37) |
| torus | MASS_LOCK_SHIP | 4500 | ...and how near another ship — any live one that is not a rock. | torus.massLockShip | [torus.ts:46](./torus.ts#L46) |
+| touch | TOUCH_STICK_TRAVEL | 125 | How far a steering finger travels from where it landed for full deflection, in CSS pixels: 125, which is about a thumb's comfortable reach on a phone held in one hand. | touch.stickTravel | [touch.ts:13](./touch.ts#L13) |
| trumbles | TRUMBLE_PURGE_TEMP | 0.55 | The cabin heat that drives them out. | | [trumbles.ts:12](./trumbles.ts#L12) |
| trumbles | BREED_INTERVAL | 20 | Seconds between broods. | trumbles.breedInterval | [trumbles.ts:20](./trumbles.ts#L20) |
| trumbles | BREED_RATE | 1.6 | They multiply by this, plus one, every brood. | | [trumbles.ts:23](./trumbles.ts#L23) |
diff --git a/src/constants/touch.ts b/src/constants/touch.ts
new file mode 100644
index 00000000..79f8c5de
--- /dev/null
+++ b/src/constants/touch.ts
@@ -0,0 +1,13 @@
+// Flying by touch: the one number the finger's stick needs (docs/TODO/204).
+
+/**
+ * How far a steering finger travels from where it landed for full
+ * deflection, in CSS pixels. It is 125, which is about a thumb's
+ * comfortable reach on a phone held in one hand. The mouse stick uses 450 pixels of
+ * travel, because a mouse crosses a desk. A thumb crosses a third of a
+ * phone's width.
+ *
+ * @domain touch
+ * @rule touch.stickTravel
+ */
+export const TOUCH_STICK_TRAVEL = 125;
diff --git a/src/engine/input.ts b/src/engine/input.ts
index 2c458e0c..19eb6bf9 100644
--- a/src/engine/input.ts
+++ b/src/engine/input.ts
@@ -1,4 +1,5 @@
import { CARRY_LIMIT } from '../constants/world-clock.ts';
+import { attachTouch } from './touch.ts';
// Keyboard state with frame-oriented semantics:
// - held(codes): live keydown state — every continuous control, the trigger
@@ -73,6 +74,12 @@ export class Input {
* held overrides it, and `flightDemand` ramps toward it otherwise.
*/
wantedSpeed: number | null = null;
+ /**
+ * A steering finger is down, so the stick holds its deflection and
+ * `decayMouse` waits (docs/TODO/204 M2). A still mouse decays; a still
+ * finger does not.
+ */
+ stickHeld = false;
private readonly canvas: HTMLElement | null;
constructor() {
@@ -85,6 +92,10 @@ export class Input {
return;
}
this.canvas = document.getElementById('scene');
+ // The touch overlay, where the page has one (docs/TODO/204 M2). It is the
+ // same bargain as the listeners below: platform wiring, here and nowhere
+ // else, and nothing when the page has no overlay.
+ attachTouch(this, document);
document.addEventListener('pointerlockchange', () => {
this.mouseFlight = document.pointerLockElement === this.canvas;
if (!this.mouseFlight) {
@@ -232,8 +243,9 @@ export class Input {
if (this.mouseFlight) document.exitPointerLock();
}
- /** The stick centres itself: with no input, it eases back to neutral. */
+ /** The stick centres itself: with no input, it eases back to neutral. A held finger stops it. */
decayMouse(dt: number): void {
+ if (this.stickHeld) return;
const k = Math.max(0, 1 - dt * 1.5);
this.mouseX *= k;
this.mouseY *= k;
diff --git a/src/engine/touch.ts b/src/engine/touch.ts
new file mode 100644
index 00000000..0da1111c
--- /dev/null
+++ b/src/engine/touch.ts
@@ -0,0 +1,152 @@
+// Flying by touch: drag anywhere on the view to steer, hold FIRE, and slide
+// the throttle (docs/TODO/204 M2).
+//
+// TWO HALVES. `TouchTracker` is pure. It takes a finger's down, move and up,
+// with a pointer id and a place. It writes the stick, the trigger and the
+// wanted speed into a small target that `Input` satisfies. A test drives it
+// with plain numbers. `attachTouch` is the platform half. It binds the
+// browser's pointer events on the overlay to the tracker. It is the only
+// code here that reads the DOM. `Input` calls it when the page has the overlay.
+//
+// THE STICK IS THE MOUSE STICK. A finger's offset from where it landed is the
+// same -1..1 pair the mouse fills, and `flightDemand` reads nothing new. Two
+// things differ. A held finger holds its deflection, where a still mouse
+// decays: `stickHeld` tells `Input.decayMouse` to wait. And a lifted finger
+// decays to centre through the same decay, so the ship settles.
+//
+// A FINGER IS TRACKED BY ITS POINTER ID. One steers while another holds FIRE,
+// and lifting either one leaves the other where it is.
+
+import { TOUCH_STICK_TRAVEL } from '../constants/touch.ts';
+import { keymap } from './keymap.ts';
+
+/** What the tracker writes into: the parts of `Input` a finger can move. */
+export interface TouchTarget {
+ mouseFlight: boolean;
+ mouseX: number;
+ mouseY: number;
+ stickHeld: boolean;
+ wantedSpeed: number | null;
+ press(code: string): void;
+ release(code: string): void;
+}
+
+/** Where a finger landed: the view, the FIRE button, or the throttle slider. */
+export type TouchPlace = 'view' | 'fire' | 'throttle';
+
+/** The stick a drag asks for: the offset from where the finger landed, clamped to -1..1. */
+export function stickFromDrag(x0: number, y0: number, x: number, y: number, travel = TOUCH_STICK_TRAVEL): { x: number; y: number } {
+ const clamp = (v: number): number => Math.max(-1, Math.min(1, v));
+ return { x: clamp((x - x0) / travel), y: clamp((y - y0) / travel) };
+}
+
+/** The speed a slider asks for: 1 at its top, 0 at its bottom, as a fraction of top speed. */
+export function sliderFraction(y: number, top: number, height: number): number {
+ if (height <= 0) return 0;
+ return Math.max(0, Math.min(1, 1 - (y - top) / height));
+}
+
+export class TouchTracker {
+ private steer: { id: number; x0: number; y0: number } | null = null;
+ private fire: number | null = null;
+ private throttle: number | null = null;
+ private readonly target: TouchTarget;
+ /** the fire key of the active layout, read at each press so a layout switch is honoured */
+ private readonly fireKey: () => string;
+ /** the slider's box, read at each touch so a resize is honoured */
+ private readonly slider: () => { top: number; height: number };
+
+ constructor(
+ target: TouchTarget,
+ fireKey: () => string = () => keymap().fire[0],
+ slider: () => { top: number; height: number } = () => ({ top: 0, height: 1 }),
+ ) {
+ this.target = target;
+ this.fireKey = fireKey;
+ this.slider = slider;
+ }
+
+ down(id: number, place: TouchPlace, x: number, y: number): void {
+ if (place === 'fire') {
+ if (this.fire === null) { this.fire = id; this.target.press(this.fireKey()); }
+ return;
+ }
+ if (place === 'throttle') {
+ this.throttle = id;
+ this.setThrottle(y);
+ return;
+ }
+ if (this.steer !== null) return; // one finger steers; a second on the view is ignored
+ this.steer = { id, x0: x, y0: y };
+ this.target.mouseFlight = true;
+ this.target.stickHeld = true;
+ this.target.mouseX = 0;
+ this.target.mouseY = 0;
+ }
+
+ move(id: number, x: number, y: number): void {
+ if (this.steer?.id === id) {
+ const s = stickFromDrag(this.steer.x0, this.steer.y0, x, y);
+ this.target.mouseX = s.x;
+ this.target.mouseY = s.y;
+ } else if (this.throttle === id) {
+ this.setThrottle(y);
+ }
+ }
+
+ up(id: number): void {
+ if (this.steer?.id === id) {
+ this.steer = null;
+ this.target.stickHeld = false; // the stick decays to centre from here
+ } else if (this.fire === id) {
+ this.fire = null;
+ this.target.release(this.fireKey());
+ } else if (this.throttle === id) {
+ this.throttle = null;
+ }
+ }
+
+ private setThrottle(y: number): void {
+ const box = this.slider();
+ this.target.wantedSpeed = sliderFraction(y, box.top, box.height);
+ }
+}
+
+/**
+ * Bind the overlay's pointer events to a tracker. The overlay is `#touch`,
+ * the button `#touch-fire` and the slider `#touch-throttle`. A page without
+ * them, or a headless run, attaches nothing.
+ *
+ * @returns the tracker, so a caller can read the slider back
+ */
+export function attachTouch(target: TouchTarget, doc: Document): TouchTracker | null {
+ const view = doc.getElementById('touch');
+ const fire = doc.getElementById('touch-fire');
+ const throttle = doc.getElementById('touch-throttle');
+ if (!view || !fire || !throttle) return null;
+ const tracker = new TouchTracker(target, undefined, () => {
+ const r = throttle.getBoundingClientRect();
+ return { top: r.top, height: r.height };
+ });
+ const placeOf = (el: EventTarget | null): TouchPlace =>
+ (el === fire || (el instanceof Node && fire.contains(el))) ? 'fire'
+ : (el === throttle || (el instanceof Node && throttle.contains(el))) ? 'throttle'
+ : 'view';
+ view.addEventListener('pointerdown', (e) => {
+ e.preventDefault();
+ const place = placeOf(e.target);
+ tracker.down(e.pointerId, place, e.clientX, e.clientY);
+ if (place === 'throttle') throttle.style.setProperty('--throttle', String(target.wantedSpeed ?? 0));
+ // capture, so a drag that leaves the overlay still steers; a synthetic
+ // pointer has no capture to give, and that is not an error
+ try { view.setPointerCapture(e.pointerId); } catch { /* no such pointer */ }
+ });
+ view.addEventListener('pointermove', (e) => {
+ tracker.move(e.pointerId, e.clientX, e.clientY);
+ if (target.wantedSpeed !== null) throttle.style.setProperty('--throttle', String(target.wantedSpeed));
+ });
+ for (const type of ['pointerup', 'pointercancel'] as const) {
+ view.addEventListener(type, (e) => tracker.up(e.pointerId));
+ }
+ return tracker;
+}
diff --git a/src/game/flight-instruments.ts b/src/game/flight-instruments.ts
index a379d8b3..b91909f6 100644
--- a/src/game/flight-instruments.ts
+++ b/src/game/flight-instruments.ts
@@ -130,6 +130,7 @@ export class Instruments {
}
toggleMouseFlight(): void {
+ if (this.input.stickHeld) return; // a finger has the stick, and there is no lock to ask for
if (this.input.mouseFlight) {
this.input.releaseMouseFlight();
this.host.showMessage('MOUSE FLIGHT OFF', 2);
diff --git a/src/style.css b/src/style.css
index 7f171f12..ec7be49c 100644
--- a/src/style.css
+++ b/src/style.css
@@ -976,3 +976,53 @@ body.screen-open #exercise { display: none; }
#screen .buttons button { padding: 15px 18px; }
#screen table button { padding: 15px 12px; }
}
+
+/*
+ * FLYING BY TOUCH (docs/TODO/204 M2). The overlay covers the view above the
+ * console. A drag on it steers. FIRE in its bottom right corner holds the
+ * trigger. The slider at its left edge sets the speed, and the knob follows
+ * `--throttle`, which engine/touch.ts writes. It shows on a coarse pointer
+ * only, and never under a screen, where the console hides too. `touch-action`
+ * is none, or the browser would scroll and zoom the page under a thumb.
+ */
+#touch {
+ display: none;
+ position: absolute;
+ left: 0; right: 0; top: 0;
+ bottom: var(--console-h, 0px);
+ z-index: 8;
+ touch-action: none;
+ user-select: none;
+ -webkit-user-select: none;
+}
+@media (pointer: coarse) { #touch { display: block; } }
+body.screen-open #touch { display: none; }
+#touch-fire {
+ position: absolute;
+ right: 14px; bottom: 14px;
+ width: 78px; height: 78px;
+ border: 1px solid var(--hud-amber);
+ border-radius: 50%;
+ color: var(--hud-amber);
+ background: rgba(0, 14, 2, 0.55);
+ font-size: 13px;
+ letter-spacing: 2px;
+ display: flex; align-items: center; justify-content: center;
+}
+#touch-fire:active { background: rgba(var(--hud-amber-rgb), 0.35); }
+#touch-throttle {
+ --throttle: 0;
+ position: absolute;
+ left: 14px; bottom: 14px;
+ width: 44px; height: 42%;
+ border: 1px solid var(--hud-dim);
+ background: rgba(0, 14, 2, 0.55);
+}
+#touch-throttle-knob {
+ position: absolute;
+ left: 4px; right: 4px;
+ height: 18px;
+ bottom: calc(var(--throttle) * (100% - 18px));
+ background: var(--hud-amber);
+ box-shadow: 0 0 8px var(--hud-amber);
+}
diff --git a/test/run.ts b/test/run.ts
index fa68c888..38755f13 100644
--- a/test/run.ts
+++ b/test/run.ts
@@ -169,6 +169,7 @@ import './screen-buttons.test.ts';
import './key-prose.test.ts';
import './site-footer.test.ts';
import './input.test.ts';
+import './touch.test.ts';
import './hud-binding.test.ts';
import './console-plate.test.ts';
import './elapsed-day.test.ts';
diff --git a/test/touch.test.ts b/test/touch.test.ts
new file mode 100644
index 00000000..d30c84b5
--- /dev/null
+++ b/test/touch.test.ts
@@ -0,0 +1,86 @@
+// Flying by touch, with no browser (docs/TODO/204 M2).
+//
+// The tracker is pure: a finger's down, move and up, by pointer id, and the
+// stick, the trigger and the wanted speed it writes. The overlay's listeners
+// are the platform half, and Chrome drives those.
+
+import { TouchTracker, sliderFraction, stickFromDrag, type TouchTarget } from '../src/engine/touch.ts';
+import { TOUCH_STICK_TRAVEL } from '../src/constants/touch.ts';
+import { Input } from '../src/engine/input.ts';
+import { check, eq } from './harness.ts';
+
+/** A target that records what the tracker writes, and the keys it holds. */
+function target(): TouchTarget & { held: SetMAX_MISS_STRETCH | 3 | The most that geometry may stretch the aim, in multiples of the intended pass. | passaim.maxMissStretch | [pass-aim.ts:31](./pass-aim.ts#L31) |
| planet | WITCHPOINT_RADII | 16 | How far out of the planet you drop from witch-space, in planet radii. | planet.witchpointRadii | [planet.ts:15](./planet.ts#L15) |
| planet | PLANET_CRASH_ALTITUDE | 80 | The altitude above the surface at which the ship is destroyed. | | [planet.ts:24](./planet.ts#L24) |
-| player-flight | PLAYER_FLIGHT | { maxSpeed: 400, accel: 220, throttleBand: 12, maxRoll: 2.5, maxPitch: 1.45, rateRamp: 4.1396, rateDecay: 13.3886, } as const | The player's flight envelope, in one place that a harness can read. | flight.player.maxSpeedflight.player.throttleBandflight.player.rateRamp | [player-flight.ts:21](./player-flight.ts#L21) |
+| player-flight | PLAYER_FLIGHT | { maxSpeed: 400, accel: 220, maxRoll: 2.5, maxPitch: 1.45, rateRamp: 4.1396, rateDecay: 13.3886, } as const | The player's flight envelope, in one place that a harness can read. | flight.player.maxSpeedflight.player.rateRamp | [player-flight.ts:21](./player-flight.ts#L21) |
| player-gun | LASER_RANGE | 3500 | How far the player's laser reaches, in world units. | | [player-gun.ts:14](./player-gun.ts#L14) |
| player-gun | LASER_PACING | { pulse: { cooldown: 0.24, heat: 0.067 }, beam: { cooldown: 0.09, heat: 0.05 }, military: { cooldown: 0.09, heat: 0.05 }, } as const | The cadence and the heat of each fitted laser. | | [player-gun.ts:29](./player-gun.ts#L29) |
| player-gun | LASER_CUTOUT | 0.98 | The laser cuts out at this temperature. | | [player-gun.ts:36](./player-gun.ts#L36) |
@@ -402,7 +402,6 @@ search names, meanings and values with `npm run constants:find -- "MASS_LOCK_STATION | 5000 | How near the station holds the drive down. | | [torus.ts:29](./torus.ts#L29) |
| torus | MASS_LOCK_PLANET_ALTITUDE | 4000 | ...and how near the planet, as an ALTITUDE above the surface. | | [torus.ts:37](./torus.ts#L37) |
| torus | MASS_LOCK_SHIP | 4500 | ...and how near another ship — any live one that is not a rock. | torus.massLockShip | [torus.ts:46](./torus.ts#L46) |
-| touch | TOUCH_STICK_TRAVEL | 125 | How far a steering finger travels from where it landed for full deflection, in CSS pixels. | touch.stickTravel | [touch.ts:13](./touch.ts#L13) |
| trumbles | TRUMBLE_PURGE_TEMP | 0.55 | The cabin heat that drives them out. | | [trumbles.ts:12](./trumbles.ts#L12) |
| trumbles | BREED_INTERVAL | 20 | Seconds between broods. | trumbles.breedInterval | [trumbles.ts:20](./trumbles.ts#L20) |
| trumbles | BREED_RATE | 1.6 | They multiply by this, plus one, every brood. | | [trumbles.ts:23](./trumbles.ts#L23) |
diff --git a/src/constants/player-flight.ts b/src/constants/player-flight.ts
index 65ac1dc8..7961b8fb 100644
--- a/src/constants/player-flight.ts
+++ b/src/constants/player-flight.ts
@@ -29,16 +29,6 @@ export const PLAYER_FLIGHT = {
/** Thrust, world units per second per second, in both directions. */
accel: 220,
- /**
- * How far the ship's speed may sit from a wanted speed before the throttle
- * moves, in world units per second (docs/TODO/204 M1). Twelve is about
- * one twentieth of a second of thrust, so the ship settles on the slider's
- * speed and does not hunt round it.
- *
- * @rule flight.player.throttleBand
- */
- throttleBand: 12,
-
/**
* The player's Cobra turns at these. They are Harmless numbers, not released
* ones. They are set so that you out-turn a pirate Cobra and a Krait, match a
diff --git a/src/constants/touch.ts b/src/constants/touch.ts
deleted file mode 100644
index 79f8c5de..00000000
--- a/src/constants/touch.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-// Flying by touch: the one number the finger's stick needs (docs/TODO/204).
-
-/**
- * How far a steering finger travels from where it landed for full
- * deflection, in CSS pixels. It is 125, which is about a thumb's
- * comfortable reach on a phone held in one hand. The mouse stick uses 450 pixels of
- * travel, because a mouse crosses a desk. A thumb crosses a third of a
- * phone's width.
- *
- * @domain touch
- * @rule touch.stickTravel
- */
-export const TOUCH_STICK_TRAVEL = 125;
diff --git a/src/engine/browser-shell.ts b/src/engine/browser-shell.ts
index a9324364..db6fbb8a 100644
--- a/src/engine/browser-shell.ts
+++ b/src/engine/browser-shell.ts
@@ -46,16 +46,11 @@ export function browserShell(canvas: HTMLCanvasElement, scene: THREE.Scene): She
// was a listener on `#screen` in the constructor. The listener lives on the
// persistent overlay container, since screen contents are re-rendered
// wholesale, and it passes the closest element carrying data-key/data-row.
- //
- // Since docs/TODO/204 M3 it listens on the touch command row and the
- // prompt line too. Their buttons carry a data-key, as a menu row does.
onScreenClick: (fn) => {
- for (const id of ['screen', 'touch-commands', 'touch-menu', 'prompts']) {
- document.getElementById(id)?.addEventListener('click', (e) => {
- const el = (e.target as HTMLElement).closest('[data-key],[data-row]');
- fn(el ?? e.target, e);
- });
- }
+ document.getElementById('screen')!.addEventListener('click', (e) => {
+ const el = (e.target as HTMLElement).closest('[data-key],[data-row]');
+ fn(el ?? e.target, e);
+ });
},
// The pointer's twin of the above, on the same persistent container. No
diff --git a/src/engine/flight-controls.ts b/src/engine/flight-controls.ts
index f68538b6..e96bbb35 100644
--- a/src/engine/flight-controls.ts
+++ b/src/engine/flight-controls.ts
@@ -29,19 +29,12 @@ export interface FlightControls {
readonly mouseX: number;
readonly mouseY: number;
readonly mouseFire: boolean;
- /** the speed a throttle slider asks for, as a fraction of top speed, or null (docs/TODO/204) */
- readonly wantedSpeed?: number | null;
}
-/**
- * The ramped rates the demand continues from — the ship's own, in practice.
- * `speed` is the ship's, for the wanted-speed rule, and a caller with no
- * throttle slider may leave it out.
- */
+/** The ramped rates the demand continues from — the ship's own, in practice. */
export interface TurnRates {
rollRate: number;
pitchRate: number;
- speed?: number;
}
/** The active bindings the flight controls need, supplied by their owner. */
@@ -77,28 +70,11 @@ export function flightDemand(
// slash only decelerates unshifted — ? opens the controls guide
const decelHeld = keys.decel.some((k) =>
c.held(k) && (k !== 'Slash' || !c.held('ShiftLeft', 'ShiftRight')));
- const accelHeld = c.held(...keys.accel);
return {
rollRate: rampFlightRate(from.rollRate, rollIn * PLAYER_FLIGHT.maxRoll, rollIn !== 0, dt),
pitchRate: rampFlightRate(from.pitchRate, pitchIn * PLAYER_FLIGHT.maxPitch, pitchIn !== 0, dt),
- throttle: accelHeld || decelHeld
- ? (accelHeld ? 1 : 0) - (decelHeld ? 1 : 0)
- : throttleToward(c.wantedSpeed ?? null, from.speed),
+ throttle: (c.held(...keys.accel) ? 1 : 0) - (decelHeld ? 1 : 0),
fire: c.held(...keys.fire) || c.mouseFire,
};
}
-
-/**
- * The throttle a wanted speed asks for: open below it, brake above it, and
- * coast inside `PLAYER_FLIGHT.throttleBand` of it (docs/TODO/204 M1). A
- * touch slider sets the wanted speed. A keyboard never does, so with no
- * wanted speed and no key the ship coasts, as it always did.
- */
-export function throttleToward(wanted: number | null, speed: number | undefined): number {
- if (wanted === null || speed === undefined) return 0;
- const target = wanted * PLAYER_FLIGHT.maxSpeed;
- if (speed < target - PLAYER_FLIGHT.throttleBand) return 1;
- if (speed > target + PLAYER_FLIGHT.throttleBand) return -1;
- return 0;
-}
diff --git a/src/engine/input.ts b/src/engine/input.ts
index 19eb6bf9..44b712bf 100644
--- a/src/engine/input.ts
+++ b/src/engine/input.ts
@@ -1,5 +1,4 @@
import { CARRY_LIMIT } from '../constants/world-clock.ts';
-import { attachTouch } from './touch.ts';
// Keyboard state with frame-oriented semantics:
// - held(codes): live keydown state — every continuous control, the trigger
@@ -68,18 +67,6 @@ export class Input {
mouseX = 0;
mouseY = 0;
mouseFire = false;
- /**
- * The speed a throttle slider asks for, as a fraction of the ship's top
- * speed, or null when no slider is set (docs/TODO/204 M1). A speed key
- * held overrides it, and `flightDemand` ramps toward it otherwise.
- */
- wantedSpeed: number | null = null;
- /**
- * A steering finger is down, so the stick holds its deflection and
- * `decayMouse` waits (docs/TODO/204 M2). A still mouse decays; a still
- * finger does not.
- */
- stickHeld = false;
private readonly canvas: HTMLElement | null;
constructor() {
@@ -92,10 +79,6 @@ export class Input {
return;
}
this.canvas = document.getElementById('scene');
- // The touch overlay, where the page has one (docs/TODO/204 M2). It is the
- // same bargain as the listeners below: platform wiring, here and nowhere
- // else, and nothing when the page has no overlay.
- attachTouch(this, document);
document.addEventListener('pointerlockchange', () => {
this.mouseFlight = document.pointerLockElement === this.canvas;
if (!this.mouseFlight) {
@@ -155,10 +138,14 @@ export class Input {
}
/**
- * Hold a key down, as a finger on a button does, until `release`
- * (docs/TODO/204 M1). It is a held key and never a tap: `held` answers
- * for it, `pressed` does not, and the carry rule never sees it. So a FIRE
- * button holds the trigger exactly as the A key does.
+ * Hold a key down, as a finger on a button does, until `release`. It is a
+ * held key and never a tap: `held` answers for it, `pressed` does not, and
+ * the carry rule never sees it. So a laser button holds the trigger exactly
+ * as the A key does.
+ *
+ * docs/TODO/204 M1 added this pair. 205 M1 took the rest of 204 out, and it
+ * kept the pair. 206 holds the laser with it, and 207 holds THRUST and BRAKE
+ * with it.
*/
press(code: string): void {
this.down.add(code);
@@ -243,9 +230,8 @@ export class Input {
if (this.mouseFlight) document.exitPointerLock();
}
- /** The stick centres itself: with no input, it eases back to neutral. A held finger stops it. */
+ /** The stick centres itself: with no input, it eases back to neutral. */
decayMouse(dt: number): void {
- if (this.stickHeld) return;
const k = Math.max(0, 1 - dt * 1.5);
this.mouseX *= k;
this.mouseY *= k;
diff --git a/src/engine/touch.ts b/src/engine/touch.ts
deleted file mode 100644
index 63903dc3..00000000
--- a/src/engine/touch.ts
+++ /dev/null
@@ -1,193 +0,0 @@
-// Flying by touch: drag anywhere on the view to steer, hold FIRE, and slide
-// the throttle (docs/TODO/204 M2).
-//
-// TWO HALVES. `TouchTracker` is pure. It takes a finger's down, move and up,
-// with a pointer id and a place. It writes the stick, the trigger and the
-// wanted speed into a small target that `Input` satisfies. A test drives it
-// with plain numbers. `attachTouch` is the platform half. It binds the
-// browser's pointer events on the overlay to the tracker. It is the only
-// code here that reads the DOM. `Input` calls it when the page has the overlay.
-//
-// THE STICK IS THE MOUSE STICK. A finger's offset from where it landed is the
-// same -1..1 pair the mouse fills, and `flightDemand` reads nothing new. Three
-// things differ. A held finger holds its deflection, where a still mouse
-// decays: `stickHeld` tells `Input.decayMouse` to wait. A lifted finger
-// decays to centre through the same decay, so the ship settles. And a finger
-// points where it wants to go: a drag up raises the nose. A mouse pulls back
-// to climb, as a stick does, and Chris found that upside down under a thumb
-// on 2026-09-11. The sign flips here, and nowhere else.
-//
-// A FINGER IS TRACKED BY ITS POINTER ID. One steers while another holds FIRE,
-// and lifting either one leaves the other where it is.
-
-import { TOUCH_STICK_TRAVEL } from '../constants/touch.ts';
-import { keymap } from './keymap.ts';
-
-/** What the tracker writes into: the parts of `Input` a finger can move. */
-export interface TouchTarget {
- mouseFlight: boolean;
- mouseX: number;
- mouseY: number;
- stickHeld: boolean;
- wantedSpeed: number | null;
- press(code: string): void;
- release(code: string): void;
-}
-
-/** Where a finger landed: the view, the FIRE button, the throttle slider, or a command button. */
-export type TouchPlace = 'view' | 'fire' | 'throttle' | 'command';
-
-/** The stick a drag asks for: the offset from where the finger landed, clamped to -1..1. */
-export function stickFromDrag(x0: number, y0: number, x: number, y: number, travel = TOUCH_STICK_TRAVEL): { x: number; y: number } {
- const clamp = (v: number): number => Math.max(-1, Math.min(1, v));
- return { x: clamp((x - x0) / travel), y: clamp((y - y0) / travel) };
-}
-
-/** The speed a slider asks for: 1 at its top, 0 at its bottom, as a fraction of top speed. */
-export function sliderFraction(y: number, top: number, height: number): number {
- if (height <= 0) return 0;
- return Math.max(0, Math.min(1, 1 - (y - top) / height));
-}
-
-export class TouchTracker {
- private steer: { id: number; x0: number; y0: number } | null = null;
- private fire: number | null = null;
- private throttle: number | null = null;
- private readonly target: TouchTarget;
- /** the fire key of the active layout, read at each press so a layout switch is honoured */
- private readonly fireKey: () => string;
- /** the slider's box, read at each touch so a resize is honoured */
- private readonly slider: () => { top: number; height: number };
-
- constructor(
- target: TouchTarget,
- fireKey: () => string = () => keymap().fire[0],
- slider: () => { top: number; height: number } = () => ({ top: 0, height: 1 }),
- ) {
- this.target = target;
- this.fireKey = fireKey;
- this.slider = slider;
- }
-
- down(id: number, place: TouchPlace, x: number, y: number): void {
- if (place === 'fire') {
- if (this.fire === null) { this.fire = id; this.target.press(this.fireKey()); }
- return;
- }
- if (place === 'throttle') {
- this.throttle = id;
- this.setThrottle(y);
- return;
- }
- if (place === 'command') return; // a tap, and the click seam answers it
- if (this.steer !== null) return; // one finger steers; a second on the view is ignored
- this.steer = { id, x0: x, y0: y };
- this.target.mouseFlight = true;
- this.target.stickHeld = true;
- this.target.mouseX = 0;
- this.target.mouseY = 0;
- }
-
- move(id: number, x: number, y: number): void {
- if (this.steer?.id === id) {
- const s = stickFromDrag(this.steer.x0, this.steer.y0, x, y);
- this.target.mouseX = s.x;
- this.target.mouseY = -s.y; // up is up, under a finger
- } else if (this.throttle === id) {
- this.setThrottle(y);
- }
- }
-
- up(id: number): void {
- if (this.steer?.id === id) {
- this.steer = null;
- this.target.stickHeld = false; // the stick decays to centre from here
- } else if (this.fire === id) {
- this.fire = null;
- this.target.release(this.fireKey());
- } else if (this.throttle === id) {
- this.throttle = null;
- }
- }
-
- private setThrottle(y: number): void {
- const box = this.slider();
- this.target.wantedSpeed = sliderFraction(y, box.top, box.height);
- }
-}
-
-/**
- * Bind the overlay's pointer events to a tracker. The overlay is `#touch`,
- * the button `#touch-fire` and the slider `#touch-throttle`. A page without
- * them, or a headless run, attaches nothing.
- *
- * @returns the tracker, so a caller can read the slider back
- */
-export function attachTouch(target: TouchTarget, doc: Document): TouchTracker | null {
- const view = doc.getElementById('touch');
- const fire = doc.getElementById('touch-fire');
- const throttle = doc.getElementById('touch-throttle');
- const commands = doc.getElementById('touch-commands');
- const menu = doc.getElementById('touch-menu');
- if (!view || !fire || !throttle) return null;
- // The flight menu (docs/TODO/204 M4). MENU shows it. A row on it presses
- // its key through the click seam, and hides it. The escape pod row opens
- // the confirmation inside it first. All of that is show and hide. The keys
- // travel the same path a station row's do.
- if (menu) {
- const show = (on: boolean): void => {
- menu.classList.toggle('hidden', !on);
- if (!on) for (const ask of menu.querySelectorAll
- On a phone, drag anywhere on the screen to steer, up to climb, hold
- FIRE to fire, and slide the bar at the left edge to set your
- speed. MENU opens the charts and the rest.`,
+ whenever you want it back.`,
},
{
title: 'MAKE SOME MONEY',
diff --git a/src/ui/key-help.ts b/src/ui/key-help.ts
index d0e12b8c..5032012c 100644
--- a/src/ui/key-help.ts
+++ b/src/ui/key-help.ts
@@ -89,88 +89,6 @@ export const isVirtualKey = (code: string): boolean => code.startsWith('Virt');
export const STATION_MENU_NOTE =
'At a station, every command is a row on the menu. Tap a row, or move to it with \u2191 \u2193 and press ENTER. ESC goes back.';
-/**
- * The code a button injects for `command` in `mode`'s table, with its
- * modifier, or null when the mode binds none (docs/TODO/204 M3). A prompt
- * button and the touch command row press this, as a menu row presses its
- * own `data-key`. A virtual code is a row already, and answers null.
- */
-export function keyCodeIfBound(mode: ControlMode, command: Command): { code: string; shift: boolean } | null {
- const b = [...BINDINGS[mode], ...GLOBAL_BINDINGS].find((x) => x.command === command);
- return b && !isVirtualKey(b.key) ? { code: b.key, shift: b.shift === true } : null;
-}
-
-/**
- * The five commands a thumb needs in a fight, as buttons along the top of
- * the console (docs/TODO/204 M3). Each carries the flight key it presses.
- * MISSILE carries both of its keys. The arm key is `data-key`, and the
- * launch key is `data-launch`. The HUD swaps the launch key in once a
- * missile is armed. So the button does what the two keys do, one tap each.
- */
-export const TOUCH_COMMANDS: readonly { command: Command; label: string }[] = [
- { command: 'armMissile', label: 'MISSILE' },
- { command: 'fireEcm', label: 'E.C.M.' },
- { command: 'startHyperspace', label: 'JUMP' },
- { command: 'toggleTorus', label: 'TORUS' },
- { command: 'toggleDockingComputer', label: 'DOCK' },
-];
-
-export function touchCommandsHtml(): string {
- const launch = keyCodeIfBound('flight', 'launchMissile');
- return TOUCH_COMMANDS.map(({ command, label }) => {
- const key = keyCodeIfBound('flight', command);
- if (!key) return '';
- const extra = command === 'armMissile' && launch ? ` data-launch="${launch.code}" data-arm="${key.code}"` : '';
- return `CONTRACT_RANGE | MAX_FUEL | How far away a contract may send you, in tenths of a light year: exactly as far as a full tank reaches. | | [contracts.ts:27](./contracts.ts#L27) |
| contracts | PASSENGER_BERTH_TONNES | 2 | What one passenger costs the hold, in tonnes. | contracts.passengerBerthTonnes | [contracts.ts:51](./contracts.ts#L51) |
| contracts | SMUGGLE_DELIVERY_NOTORIETY | 0.06 | How loudly a delivered smuggling run is talked about, per tonne landed. | contracts.smuggleDeliveryNotoriety | [contracts.ts:81](./contracts.ts#L81) |
+| course | COURSE_TORUS_CONE | 0.1 | How far off the nose the target may sit, in radians, before the course pilot engages the torus drive. | course.torusCone | [course.ts:26](./course.ts#L26) |
| docking | GATE_HALF_WIDTHS | 5 | How far out the approach gate sits, in multiples of the station half-width. | docking.gateHalfWidths | [docking.ts:25](./docking.ts#L25) |
| docking | TURN_IN | Math.PI / 4 | How far round from the slot axis an approach turns in, in radians. | | [docking.ts:49](./docking.ts#L49) |
| docking | RUN_IN_WIDTHS | GATE_HALF_WIDTHS * 0.6 | Where the approach stops the curve and flies straight, in station half-widths. | | [docking.ts:87](./docking.ts#L87) |
diff --git a/src/constants/course.ts b/src/constants/course.ts
new file mode 100644
index 00000000..b4ce5ece
--- /dev/null
+++ b/src/constants/course.ts
@@ -0,0 +1,26 @@
+// How a course flies the ship (docs/TODO/205 M3).
+//
+// A course is one thing the ship does next with no hand on the stick.
+// `game/course-pilot.ts` flies it, and it spends these values.
+
+/**
+ * How far off the nose the target may sit, in radians, before the course
+ * pilot engages the torus drive.
+ *
+ * The drive multiplies travel by eight (`TORUS_MULTIPLIER`). A drive engaged
+ * with the target well off the nose carries the ship a long way off the line
+ * before the turn finishes. So the pilot turns first, and then engages. The
+ * pilot still steers while the drive runs, so a small error inside the cone
+ * closes on the way.
+ *
+ * It equals `DC_TURN_FADE_ANGLE`, and the two rules are independent. That one
+ * fades the docking computer's turn near its heading. This one gates a drive.
+ *
+ * It belongs here, and not with the spawn cones in `spawn-placement.ts`. Those
+ * place a ship when it appears. This one gates a drive while the commander's
+ * own ship flies, and only the course pilot reads it.
+ *
+ * @rule course.torusCone
+ * @domain course
+ */
+export const COURSE_TORUS_CONE = 0.1;
diff --git a/src/game/autopilot.ts b/src/game/autopilot.ts
index 3b395fc5..401caefc 100644
--- a/src/game/autopilot.ts
+++ b/src/game/autopilot.ts
@@ -118,6 +118,26 @@ export class Autopilot {
return events;
}
+ /**
+ * A course hands the ship to the docking computer (docs/TODO/205 M3).
+ *
+ * It needs no fitted computer, and that is a stopgap. Until docs/TODO/207
+ * gives the pilot a trial at the slot, a course to the station ends in a
+ * free dock. The course asks only inside `DOCK_COMPUTER_RANGE`, so the range
+ * refusal of `toggleDocking` cannot arise here.
+ */
+ handOverToDock(): AutopilotEvent[] {
+ const s = this.state;
+ if (s.session.dcEngaged) return [];
+ s.session.dcEngaged = true;
+ s.dockPlan.phase = 'gate';
+ return [
+ say('DOCKING COMPUTER ENGAGED', 2),
+ { kind: 'sound', name: 'dockingComputerEngaged' },
+ { kind: 'dockingMusic', on: true },
+ ];
+ }
+
/**
* How far the commander is from the station, for the station's truce
* (`truceHolds`, law.ts).
diff --git a/src/game/course-pilot.ts b/src/game/course-pilot.ts
new file mode 100644
index 00000000..fb3202dc
--- /dev/null
+++ b/src/game/course-pilot.ts
@@ -0,0 +1,110 @@
+// How the ship flies the course that the pilot picked (docs/TODO/205 M3).
+//
+// `courses.ts` decides what is on the list. This file flies one course for one
+// frame. It DECIDES and reports a `CourseStep`, as the two computers in
+// `autopilot.ts` do. The demand in it is a `FlightDemand`, the same thing a
+// pair of hands makes, and `PlayerShip.update` flies it.
+//
+// It APPLIES nothing. The switches are `flight-instruments.ts`'s: the torus
+// drive, and the hand-over to the docking computer. A step asks for them, and
+// the instruments throw them. It draws nothing from the seeded stream, so it
+// has no order to preserve.
+//
+// THE STEERING IS THE CO-PILOT'S. `bankToTurn` in `pitch-roll-steer.ts` points
+// the nose with pitch and roll alone, at the commander's own caps and ramp.
+// The ship has no yaw axis, so there is no other way to point it.
+//
+// THE STEER MEMORY IS NOT SAVED. It holds which vertical a bank takes, and a
+// restore that starts it fresh costs one bank at most. The scripted co-pilot
+// makes the same bargain.
+//
+// Two courses fly today. The station course points at the station and runs
+// the torus until the mass lock. It then hands the ship to the docking
+// computer, at the range where that computer takes a job. The jump course
+// flies straight while the countdown runs. The rest wait for their
+// milestones, and a course with no flight yet returns no demand.
+
+import * as THREE from 'three';
+import { rampFlightRate, type FlightDemand } from '../player.ts';
+import { bankToTurn, freshSteerMemory, type SteerMemory } from './pitch-roll-steer.ts';
+import type { CourseKind } from './courses.ts';
+import { PLAYER_FLIGHT } from '../constants/player-flight.ts';
+import { DOCK_COMPUTER_RANGE } from '../constants/docking-computer.ts';
+import { COURSE_TORUS_CONE } from '../constants/course.ts';
+
+/** What the course pilot reads for one frame. A flat view, so a test needs no world. */
+export interface CourseView {
+ readonly course: CourseKind;
+ readonly position: THREE.Vector3;
+ readonly quaternion: THREE.Quaternion;
+ readonly pitchRate: number;
+ readonly rollRate: number;
+ readonly stationPos: THREE.Vector3;
+ /** the docking computer already has the ship */
+ readonly dcEngaged: boolean;
+}
+
+/** What the course pilot asks for this frame. */
+export interface CourseStep {
+ /** what it wants flown, or null where something else flies the ship */
+ readonly demand: FlightDemand | null;
+ /** whether it wants the torus drive on. The mass lock still decides */
+ readonly torus: boolean;
+ /** hand the ship to the docking computer now */
+ readonly handOver: boolean;
+}
+
+const IDLE: CourseStep = { demand: null, torus: false, handOver: false };
+
+export class CoursePilot {
+ private mem: SteerMemory = freshSteerMemory();
+ private readonly dir = new THREE.Vector3();
+ private readonly fwd = new THREE.Vector3();
+
+ /** Forget the bank, for a new course. */
+ reset(): void { this.mem = freshSteerMemory(); }
+
+ step(v: CourseView, dt: number): CourseStep {
+ if (v.course === 'station') return this.toStation(v, dt);
+ if (v.course === 'jump') return { demand: straight(v, dt), torus: false, handOver: false };
+ return IDLE;
+ }
+
+ /**
+ * Point at the station, and run the torus while the nose is on it. Inside
+ * the docking computer's range, hand over. The docking computer then flies
+ * the approach and the slot, and this course asks for nothing more.
+ */
+ private toStation(v: CourseView, dt: number): CourseStep {
+ if (v.dcEngaged) return IDLE;
+ this.dir.subVectors(v.stationPos, v.position);
+ if (this.dir.length() <= DOCK_COMPUTER_RANGE) {
+ return { demand: null, torus: false, handOver: true };
+ }
+ const stick = bankToTurn(v.quaternion, this.dir, this.mem);
+ this.fwd.set(0, 0, -1).applyQuaternion(v.quaternion);
+ const offNose = this.fwd.angleTo(this.dir);
+ return {
+ demand: {
+ pitchRate: rampFlightRate(
+ v.pitchRate, stick.pitch * PLAYER_FLIGHT.maxPitch, stick.pitch !== 0, dt),
+ rollRate: rampFlightRate(
+ v.rollRate, stick.roll * PLAYER_FLIGHT.maxRoll, stick.roll !== 0, dt),
+ throttle: 1,
+ fire: false,
+ },
+ torus: offNose < COURSE_TORUS_CONE,
+ handOver: false,
+ };
+ }
+}
+
+/** Level the sticks and open the throttle: the ship flies on as it points. */
+function straight(v: CourseView, dt: number): FlightDemand {
+ return {
+ pitchRate: rampFlightRate(v.pitchRate, 0, false, dt),
+ rollRate: rampFlightRate(v.rollRate, 0, false, dt),
+ throttle: 1,
+ fire: false,
+ };
+}
diff --git a/src/game/flight-instruments.ts b/src/game/flight-instruments.ts
index a379d8b3..51a05a77 100644
--- a/src/game/flight-instruments.ts
+++ b/src/game/flight-instruments.ts
@@ -5,9 +5,10 @@
// `tools/sizes.mjs` calls its 400-line ceiling a detector rather than
// a rule, and this is what it detected.
//
-// ONE RESPONSIBILITY: the instruments a pilot switches on. Five of them:
+// ONE RESPONSIBILITY: the instruments a pilot switches on. Six of them:
//
// - the two computers that fly the ship for her;
+// - the course she picked, which flies the ship too (docs/TODO/205);
// - the drive that crosses the system;
// - the mouse she flies with;
// - the view she flies by.
@@ -20,6 +21,7 @@
import { sfx } from '../audio.ts';
import { Autopilot, type AutopilotEvent } from './autopilot.ts';
+import { CoursePilot } from './course-pilot.ts';
import { massLocked } from './world-step.ts';
import { boundKey } from '../ui/key-help.ts';
import { defenceBrain } from './brains.ts';
@@ -68,6 +70,8 @@ export class Instruments {
* seat.
*/
private readonly autopilot: Autopilot;
+ /** the seat that flies a picked course (docs/TODO/205 M3) */
+ private readonly coursePilot = new CoursePilot();
private readonly host: InstrumentHost;
constructor(
@@ -102,6 +106,39 @@ export class Instruments {
return { demand: auto.demand ?? null, ecm: auto.ecm };
}
+ /**
+ * The course the pilot picked, flown for one frame, or null for none
+ * (docs/TODO/205 M3).
+ *
+ * `course-pilot.ts` decides. This throws the two switches that a step asks
+ * for: the torus drive, and the hand-over to the docking computer. A flight
+ * key suspends the course, as it drops the two computers. The course list
+ * then offers the course again (Chris, 2026-09-11: *"keep the keys"*).
+ */
+ course(dt: number, handsOn: boolean): FlightDemand | null {
+ const s = this.state.session;
+ if (s.course === null) return null;
+ if (handsOn) {
+ s.course = null;
+ this.coursePilot.reset();
+ this.host.showMessage('COURSE SUSPENDED — MANUAL CONTROL', 2);
+ return null;
+ }
+ const p = this.state.player;
+ const step = this.coursePilot.step({
+ course: s.course,
+ position: p.position,
+ quaternion: p.quaternion,
+ pitchRate: p.pitchRate,
+ rollRate: p.rollRate,
+ stationPos: this.state.world.station.position,
+ dcEngaged: s.dcEngaged,
+ }, dt);
+ if (step.handOver) this.applyAutopilot(this.autopilot.handOverToDock());
+ if (step.torus !== s.torusEngaged && (!step.torus || !this.massLocked())) this.toggleTorus();
+ return step.demand;
+ }
+
/** A hit worth a break: the co-pilot keeps its own record
* (scripted-co-pilot.ts). */
noteUnderFire(): void { this.autopilot.noteUnderFire(); }
diff --git a/src/game/flight.ts b/src/game/flight.ts
index 1ed41cab..95afcc6e 100644
--- a/src/game/flight.ts
+++ b/src/game/flight.ts
@@ -310,8 +310,8 @@ export class Flight {
/**
* Who flies the ship, and what they want.
*
- * ONE producer per frame: the hands at the keyboard, or the combat computer
- * when it is engaged and still holds the ship. The trigger is the union of
+ * ONE producer per frame: the hands at the keyboard, the combat computer
+ * when it is engaged and still holds the ship, or a picked course. The trigger is the union of
* the two. A fitted combat computer flies the ship. It does not take your gun
* off you.
*/
@@ -320,7 +320,12 @@ export class Flight {
// the virtual stick self-centres; the producer is pure, so the mutation
// is ours to do, immediately after the read
if (this.input.mouseFlight) this.input.decayMouse(dt);
- if (!this.state.session.ccEngaged) return hands;
+ // A picked course flies when no co-pilot does (docs/TODO/205 M3). The
+ // trigger stays the pilot's, as it does under the co-pilot.
+ if (!this.state.session.ccEngaged) {
+ const course = this.instruments.course(dt, this.handsOn());
+ return course ? { ...course, fire: hands.fire } : hands;
+ }
// WHICH co-pilot is the brain selection's answer. Under the shipped
// 'attack-run' name it is the scripted PURE-PURSUIT co-pilot. Otherwise it
// is the trained defence seat, which is dormant: defenceBrain() is null and
diff --git a/src/game/hyperspace-actions.ts b/src/game/hyperspace-actions.ts
index ce7642cd..be07b5cf 100644
--- a/src/game/hyperspace-actions.ts
+++ b/src/game/hyperspace-actions.ts
@@ -38,6 +38,7 @@ import { runMissions } from './mission-bridge.ts';
import { arrivalLines, type Sighting } from './mission-arrival.ts';
import type { WorldBuild } from './world-build.ts';
import type { GameState } from './state.ts';
+import { endVisit } from './session.ts';
import { COUNTDOWN, WITCHSPACE_ESCAPE_COST } from '../constants/jump.ts';
import { WITCHPOINT_RADII } from '../constants/planet.ts';
@@ -174,6 +175,7 @@ export class HyperspaceActions {
seedWorld(this.state.commander.galaxy * 0x9e3779b1
^ (this.state.commander.systemIndex << 8) ^ this.state.commander.day);
this.state.session.witchspace = false; // any arrival leaves witch-space (incl. galactic jump)
+ endVisit(this.state.session); // The course and the finished work were the last system's.
// Before the world is built, because the roster it is built with is this.
this.world.chooseBlueprintSet();
this.world.buildWorld();
diff --git a/src/game/session.ts b/src/game/session.ts
index f2840c27..6834ca6b 100644
--- a/src/game/session.ts
+++ b/src/game/session.ts
@@ -5,6 +5,8 @@
// walks it generically. So a new field here saves itself, and there is no list
// to keep in step.
+import type { CourseKind } from './courses.ts';
+
/**
* The flight session: every flag and timer that describes the moment.
*
@@ -87,6 +89,23 @@ export interface SessionState {
ccEngaged: boolean;
beamTimer: number;
dcEngaged: boolean;
+ /**
+ * The course the pilot picked, or null for none (docs/TODO/205 M3). It is
+ * saved state, because it decides what flies the ship. The list it came
+ * from is derived, and nothing saves that (courses.ts).
+ */
+ course: CourseKind | null;
+ /** the courses this visit already finished, so the list leaves them out */
+ coursesDone: CourseKind[];
+}
+
+/**
+ * A visit to a system ends: at a dock, and at an arrival somewhere else. The
+ * picked course and the finished ones belong to the visit, so both go.
+ */
+export function endVisit(state: SessionState): void {
+ state.course = null;
+ state.coursesDone = [];
}
/** Put a message in canonical state; the HUD only paints these fields. */
diff --git a/src/game/state.ts b/src/game/state.ts
index f337201e..ba07b013 100644
--- a/src/game/state.ts
+++ b/src/game/state.ts
@@ -156,6 +156,8 @@ export function freshSession(): SessionState {
ccEngaged: false,
beamTimer: 0,
dcEngaged: false,
+ course: null,
+ coursesDone: [],
};
}
diff --git a/src/game/station.ts b/src/game/station.ts
index f72ebf9c..49bf0372 100644
--- a/src/game/station.ts
+++ b/src/game/station.ts
@@ -42,6 +42,7 @@ import { ordersSummary, standingOrders } from './orders.ts';
import type { Command } from './controls.ts';
import type { Ordnance } from './ordnance.ts';
import { repairAtStation } from './systems.ts';
+import { endVisit } from './session.ts';
import type { GameState } from './state.ts';
import type { SoundEvent, SoundName } from './sounds.ts';
@@ -190,6 +191,7 @@ export class Station {
s.session.hyperCountdown = -1;
s.session.torusEngaged = false;
s.session.ccEngaged = false;
+ endVisit(s.session);
this.ordnance.armed = false;
effects.push({ kind: 'presentation', action: 'releaseMouseFlight' });
// ANYONE YOU PULLED OUT OF A CAPSULE IS NOT RESOLVED HERE any more. A dock
diff --git a/test/constants.test.ts b/test/constants.test.ts
index 91f35c36..cb648777 100644
--- a/test/constants.test.ts
+++ b/test/constants.test.ts
@@ -390,6 +390,16 @@ const OUTSIDE: readonly Group[] = [
},
},
+ {
+ why: 'STAYS: not a number at all. It is the step a course pilot returns when'
+ + ' it asks for nothing, one ready-made object so a frame allocates none'
+ + ' (docs/TODO/205 M3). The one tunable, the torus cone, is'
+ + ' constants/course.ts',
+ files: {
+ 'game/course-pilot.ts': ['IDLE'],
+ },
+ },
+
{
why: 'STAYS: how the launch/docking tunnel effect LOOKS — the ellipse squash that'
+ ' reads as a bay mouth, and two fractions of the effect\'s own timeline. Pure'
diff --git a/test/course-pilot.test.ts b/test/course-pilot.test.ts
new file mode 100644
index 00000000..430e432a
--- /dev/null
+++ b/test/course-pilot.test.ts
@@ -0,0 +1,119 @@
+// How the ship flies a picked course (docs/TODO/205 M3).
+//
+// Two halves. The pure half asks the course pilot for one frame, with no world
+// behind it. The flown half picks the station course in a real headless game,
+// on the real world step, and waits for the dock. That half is the claim that
+// matters: a pilot with no hand on the stick gets from the witchpoint into
+// the slot.
+
+import * as THREE from 'three';
+import { Game } from '../src/game/game.ts';
+import { headlessShell } from '../src/engine/shell.ts';
+import { withoutSaving } from '../src/game/storage.ts';
+import { seedWorld } from '../src/game/rng.ts';
+import { CoursePilot, type CourseView } from '../src/game/course-pilot.ts';
+import { DOCK_COMPUTER_RANGE } from '../src/constants/docking-computer.ts';
+import { MASS_LOCK_STATION } from '../src/constants/torus.ts';
+import { check, dismissBriefing, eq } from './harness.ts';
+
+console.log('\nthe course pilot, one frame at a time');
+
+/** A ship at the origin, nose down −Z, and a station somewhere. */
+const view = (station: THREE.Vector3, over: PartialSIM_LOG_LIMIT | 20 | How many exercise records the in-memory ring keeps. | combatrecord.simLogLimit | [combat-record.ts:50](./combat-record.ts#L50) |
| combat-record | MAX_SAMPLES | 12_000 | Samples kept before the buffer closes. | record.maxSamples | [combat-record.ts:63](./combat-record.ts#L63) |
| commander | DEFAULT_NAME | 'JAMESON' | The original's own commander, and the default here. | | [commander.ts:10](./commander.ts#L10) |
-| commander | STARTING_CREDITS | 1000 | The grubstake, in tenths of a credit — the classic 100.0 Cr. | | [commander.ts:14](./commander.ts#L14) |
-| commander | CHEAT_CREDIT_GRANT | 100_000 | What test mode's GRANT CREDITS row hands over per press, in tenths: 10,000 Cr, which is a hundred grubstakes (`game/screens/test-mode.ts`). | | [commander.ts:36](./commander.ts#L36) |
-| commander | MAX_FUEL | 70 | The tank, in tenths of a light year — the classic 7.0 LY range. | | [commander.ts:45](./commander.ts#L45) |
-| commander | MAX_MISSILES | 4 | The missile rails: four, as the original's Cobra carried. | commander.maxMissiles | [commander.ts:52](./commander.ts#L52) |
-| commander | BRIEFING_VERSION | 1 | Which edition of the docked briefing a commander is up to date with. | onboarding.briefingVersion | [commander.ts:69](./commander.ts#L69) |
-| commander | HOLD_TONNES | 20 | What the hold carries, in tonnes, without and with the Large Cargo Bay. | commander.holdTonnes | [commander.ts:81](./commander.ts#L81) |
-| commander | LARGE_BAY_TONNES | 35 | | | [commander.ts:82](./commander.ts#L82) |
+| commander | STARTING_CREDITS | 1000 | The grubstake, in tenths of a credit — the classic 100.0 Cr. | commander.startingCredits | [commander.ts:18](./commander.ts#L18) |
+| commander | CHEAT_CREDIT_GRANT | 100_000 | What test mode's GRANT CREDITS row hands over per press, in tenths: 10,000 Cr, which is a hundred grubstakes (`game/screens/test-mode.ts`). | | [commander.ts:40](./commander.ts#L40) |
+| commander | MAX_FUEL | 70 | The tank, in tenths of a light year — the classic 7.0 LY range. | | [commander.ts:49](./commander.ts#L49) |
+| commander | MAX_MISSILES | 4 | The missile rails: four, as the original's Cobra carried. | commander.maxMissiles | [commander.ts:56](./commander.ts#L56) |
+| commander | BRIEFING_VERSION | 1 | Which edition of the docked briefing a commander is up to date with. | onboarding.briefingVersion | [commander.ts:73](./commander.ts#L73) |
+| commander | HOLD_TONNES | 20 | What the hold carries, in tonnes, without and with the Large Cargo Bay. | commander.holdTonnes | [commander.ts:85](./commander.ts#L85) |
+| commander | LARGE_BAY_TONNES | 35 | | | [commander.ts:86](./commander.ts#L86) |
| commodities | ORDINARY_GOODS | [0, 1, 4, 8, 9, 12] | Ordinary goods: the unremarkable legal cargo that plain trade is made of — food, textiles, liquor, machinery, alloys, minerals. | | [commodities.ts:19](./commodities.ts#L19) |
| commodities | ALIEN_ITEMS | 16 | Alien Items: the row a dead Thargon is scooped as (docs/TODO/196). | commodities.alienItems | [commodities.ts:36](./commodities.ts#L36) |
| commodities | SLAVES | 3 | Slaves: the row that a rescued survivor is sold on, and the only commodity index named on its own (docs/TODO/127). | commodities.slaves | [commodities.ts:55](./commodities.ts#L55) |
@@ -114,7 +114,15 @@ search names, meanings and values with `npm run constants:find -- "CONTRACT_RANGE | MAX_FUEL | How far away a contract may send you, in tenths of a light year: exactly as far as a full tank reaches. | | [contracts.ts:27](./contracts.ts#L27) |
| contracts | PASSENGER_BERTH_TONNES | 2 | What one passenger costs the hold, in tonnes. | contracts.passengerBerthTonnes | [contracts.ts:51](./contracts.ts#L51) |
| contracts | SMUGGLE_DELIVERY_NOTORIETY | 0.06 | How loudly a delivered smuggling run is talked about, per tonne landed. | contracts.smuggleDeliveryNotoriety | [contracts.ts:81](./contracts.ts#L81) |
-| course | COURSE_TORUS_CONE | 0.1 | How far off the nose the target may sit, in radians, before the course pilot engages the torus drive. | course.torusCone | [course.ts:26](./course.ts#L26) |
+| course | COURSE_TORUS_CONE | 0.1 | How far off the nose the target may sit, in radians, before the course pilot engages the torus drive. | course.torusCone | [course.ts:31](./course.ts#L31) |
+| course | COURSE_TORUS_DROP | TORUS_MULTIPLIER * PLAYER_FLIGHT.maxSpeed * 2.5 | How far from its target the course pilot drops the torus drive, in world units, before an arrival. | | [course.ts:44](./course.ts#L44) |
+| course | COURSE_ARRIVE_BRAKE | 0.7 | The share of the ship's thrust that an arrival plans to brake with. | course.arriveBrake | [course.ts:56](./course.ts#L56) |
+| course | COURSE_ARRIVE_TOLERANCE | 75 | How near its standoff the ship must be to count as arrived, in world units. | course.arriveTolerance | [course.ts:66](./course.ts#L66) |
+| course | COURSE_DERELICT_STANDOFF | GENERATION_CARGO_SCATTER + 400 | Where the derelict course stops, as a distance from the generation ship's centre, in world units. | | [course.ts:79](./course.ts#L79) |
+| course | COURSE_HERMIT_STANDOFF | 240 | Where the hermit course stops, as a distance from the rock's centre, in world units. | course.hermitStandoff | [course.ts:92](./course.ts#L92) |
+| course | COURSE_SKIM_DISTANCE | 65_000 | The hold distance of the skim course, from the centre of the star, in world units. | course.skimDistance | [course.ts:107](./course.ts#L107) |
+| course | COURSE_PLANET_CLEARANCE | MASS_LOCK_PLANET_ALTITUDE * 1.25 | How high above the planet's surface a course keeps its line, in world units. | | [course.ts:119](./course.ts#L119) |
+| course | COURSE_HERMIT_SPEED | HERMIT_DOCK_SPEED / 2 | The speed at which the hermit course arrives, in world units a second. | | [course.ts:129](./course.ts#L129) |
| docking | GATE_HALF_WIDTHS | 5 | How far out the approach gate sits, in multiples of the station half-width. | docking.gateHalfWidths | [docking.ts:25](./docking.ts#L25) |
| docking | TURN_IN | Math.PI / 4 | How far round from the slot axis an approach turns in, in radians. | | [docking.ts:49](./docking.ts#L49) |
| docking | RUN_IN_WIDTHS | GATE_HALF_WIDTHS * 0.6 | Where the approach stops the curve and flies straight, in station half-widths. | | [docking.ts:87](./docking.ts#L87) |
@@ -358,6 +366,7 @@ search names, meanings and values with `npm run constants:find -- "STATION_DEFENCE_JITTER | 80 | ...and the random nudge on each, so a second launch does not look like the first. | | [spawn-placement.ts:293](./spawn-placement.ts#L293) |
| spawn-placement | TRADER_ARRIVED | 900 | How near the station an arriving trader has to be to start trading. | spawn.traderArrived | [spawn-placement.ts:308](./spawn-placement.ts#L308) |
| spawn-placement | TRADER_JUMP_OUT | 2500 | How near its waypoint a departing trader has to be to jump out. | spawn.traderJumpOut | [spawn-placement.ts:324](./spawn-placement.ts#L324) |
+| spawn-placement | SPAWN_PLANET_ALTITUDE | 1000 | The lowest a ship may appear above the planet's surface, measured to the ship's centre, in world units. | spawn.planetAltitude | [spawn-placement.ts:343](./spawn-placement.ts#L343) |
| station | STATION_SPIN | 0.26 | How fast the station spins about its slot axis, in radians a second. | | [station.ts:19](./station.ts#L19) |
| station | DODO_TECH_LEVEL | 10 | The tech level at which a system's station is the dodecahedral Dodo rather than the Coriolis, in SHOWN one-based units. | | [station.ts:32](./station.ts#L32) |
| station | BOUNCE_STANDOFF | 420 | Where a fluffed docking bounces you to. | | [station.ts:48](./station.ts#L48) |
diff --git a/src/constants/commander.ts b/src/constants/commander.ts
index 925b5e4f..ce7e7147 100644
--- a/src/constants/commander.ts
+++ b/src/constants/commander.ts
@@ -9,8 +9,12 @@
* every career under it, and the save screens fall back to it. */
export const DEFAULT_NAME = 'JAMESON';
-/** The grubstake, in tenths of a credit — the classic 100.0 Cr. The briefing
- * interpolates this, so its prose cannot drift from the credits you get. */
+/**
+ * The grubstake, in tenths of a credit — the classic 100.0 Cr. The briefing
+ * interpolates this, so its prose cannot drift from the credits you get.
+ *
+ * @rule commander.startingCredits
+ */
export const STARTING_CREDITS = 1000;
/**
diff --git a/src/constants/course.ts b/src/constants/course.ts
index b4ce5ece..87b3238b 100644
--- a/src/constants/course.ts
+++ b/src/constants/course.ts
@@ -3,6 +3,11 @@
// A course is one thing the ship does next with no hand on the stick.
// `game/course-pilot.ts` flies it, and it spends these values.
+import { HERMIT_DOCK_SPEED } from './hermit-market.ts';
+import { PLAYER_FLIGHT } from './player-flight.ts';
+import { MASS_LOCK_PLANET_ALTITUDE, TORUS_MULTIPLIER } from './torus.ts';
+import { GENERATION_CARGO_SCATTER } from './spawn-placement.ts';
+
/**
* How far off the nose the target may sit, in radians, before the course
* pilot engages the torus drive.
@@ -24,3 +29,101 @@
* @domain course
*/
export const COURSE_TORUS_CONE = 0.1;
+
+/**
+ * How far from its target the course pilot drops the torus drive, in world
+ * units, before an arrival.
+ *
+ * It is two and a half seconds of torus travel. The drive carries the ship
+ * 3,200 units a second, which is 53 units in one frame. The ship then brakes
+ * from its top speed of 400, which takes about 360 units at full thrust. So
+ * the margin covers both, with room to spare, and it grows with the drive.
+ *
+ * @domain course
+ */
+export const COURSE_TORUS_DROP = TORUS_MULTIPLIER * PLAYER_FLIGHT.maxSpeed * 2.5;
+
+/**
+ * The share of the ship's thrust that an arrival plans to brake with.
+ *
+ * The approach asks for the speed from which this share of thrust stops the
+ * ship at the standoff. The rest is a margin for the turn and for one frame
+ * of lag in the throttle.
+ *
+ * @rule course.arriveBrake
+ * @domain course
+ */
+export const COURSE_ARRIVE_BRAKE = 0.7;
+
+/**
+ * How near its standoff the ship must be to count as arrived, in world units.
+ * The brake plan leaves the ship within a few units of the standoff. This is
+ * room for the turn, and for a target that moves.
+ *
+ * @rule course.arriveTolerance
+ * @domain course
+ */
+export const COURSE_ARRIVE_TOLERANCE = 75;
+
+/**
+ * Where the derelict course stops, as a distance from the generation ship's
+ * centre, in world units.
+ *
+ * The hull is 340 units across the radius (`GENERATION_SHIP_RADIUS`). Its
+ * canisters drift within `GENERATION_CARGO_SCATTER` of the centre. So the ship
+ * stops 400 units outside the canisters, clear of the hull and of every
+ * canister, where the pilot can see them all.
+ *
+ * @domain course
+ */
+export const COURSE_DERELICT_STANDOFF = GENERATION_CARGO_SCATTER + 400;
+
+/**
+ * Where the hermit course stops, as a distance from the rock's centre, in
+ * world units.
+ *
+ * The trade opens inside `HERMIT_DOCK_RANGE`, which is 320. The rock is 120
+ * units across the radius. So the ship stops between the two, clear of the
+ * rock and inside the range.
+ *
+ * @rule course.hermitStandoff
+ * @domain course
+ */
+export const COURSE_HERMIT_STANDOFF = 240;
+
+/**
+ * The hold distance of the skim course, from the centre of the star, in world
+ * units.
+ *
+ * The scoops take fuel inside `SUN_SCOOP_RANGE`, which is 80,000. The cabin
+ * heads toward a temperature set by the distance. It is fatal at
+ * `CABIN_TEMP_FATAL`, which is reached near 26,800 units. At this distance
+ * the cabin settles near 0.54, well short of fatal. The margin inside the
+ * scoop range keeps an overshoot of the brake inside it too.
+ *
+ * @rule course.skimDistance
+ * @domain course
+ */
+export const COURSE_SKIM_DISTANCE = 65_000;
+
+/**
+ * How high above the planet's surface a course keeps its line, in world
+ * units.
+ *
+ * It is a quarter above `MASS_LOCK_PLANET_ALTITUDE`, so the detour does not
+ * hold the torus drive down. A line to the target that dips below it goes
+ * round the planet instead.
+ *
+ * @domain course
+ */
+export const COURSE_PLANET_CLEARANCE = MASS_LOCK_PLANET_ALTITUDE * 1.25;
+
+/**
+ * The speed at which the hermit course arrives, in world units a second.
+ *
+ * The trade opens only below `HERMIT_DOCK_SPEED`. Half of it arrives well
+ * inside that rule, and it is still a real approach.
+ *
+ * @domain course
+ */
+export const COURSE_HERMIT_SPEED = HERMIT_DOCK_SPEED / 2;
diff --git a/src/constants/spawn-placement.ts b/src/constants/spawn-placement.ts
index 20be1f1e..4fcbfca4 100644
--- a/src/constants/spawn-placement.ts
+++ b/src/constants/spawn-placement.ts
@@ -322,3 +322,22 @@ export const TRADER_ARRIVED = 900;
* @domain spawn-placement
*/
export const TRADER_JUMP_OUT = 2500;
+
+/**
+ * The lowest a ship may appear above the planet's surface, measured to the
+ * ship's centre, in world units.
+ *
+ * The scatter round the station has no idea where the planet is. So a hermit
+ * or a police ship could appear inside it: docs/TODO/205 M3 counted two of
+ * about 2,300 ships over 128 systems. A ship inside the planet then crashed
+ * into it the moment it moved. `aboveGround` in `game/spawning.ts` lifts such
+ * a ship straight out from the planet's centre to this height. The lift draws
+ * nothing from the seeded stream, so no other outcome moves.
+ *
+ * It is far above `PLANET_CRASH_ALTITUDE`, and above the largest hull that
+ * appears near the station.
+ *
+ * @rule spawn.planetAltitude
+ * @domain spawn-placement
+ */
+export const SPAWN_PLANET_ALTITUDE = 1000;
diff --git a/src/game/course-pilot.ts b/src/game/course-pilot.ts
index fb3202dc..64f78ba2 100644
--- a/src/game/course-pilot.ts
+++ b/src/game/course-pilot.ts
@@ -6,9 +6,9 @@
// pair of hands makes, and `PlayerShip.update` flies it.
//
// It APPLIES nothing. The switches are `flight-instruments.ts`'s: the torus
-// drive, and the hand-over to the docking computer. A step asks for them, and
-// the instruments throw them. It draws nothing from the seeded stream, so it
-// has no order to preserve.
+// drive, the hand-over to the docking computer, and the end of a course. A
+// step asks for them, and the instruments throw them. It draws nothing from
+// the seeded stream, so it has no order to preserve.
//
// THE STEERING IS THE CO-PILOT'S. `bankToTurn` in `pitch-roll-steer.ts` points
// the nose with pitch and roll alone, at the commander's own caps and ramp.
@@ -18,11 +18,19 @@
// restore that starts it fresh costs one bank at most. The scripted co-pilot
// makes the same bargain.
//
-// Two courses fly today. The station course points at the station and runs
-// the torus until the mass lock. It then hands the ship to the docking
-// computer, at the range where that computer takes a job. The jump course
-// flies straight while the countdown runs. The rest wait for their
-// milestones, and a course with no flight yet returns no demand.
+// THREE SHAPES OF COURSE fly today:
+//
+// - the station course points at the station and runs the torus. Inside the
+// docking computer's range it hands the ship over;
+// - an ARRIVAL flies to a standoff from a target and stops there. The
+// derelict, the hermit and the star are arrivals. The star course then
+// holds until the tank is full;
+// - the jump course flies straight while the countdown runs.
+//
+// EVERY LINE GOES ROUND THE PLANET. A line that dips below the clearance
+// altitude aims at a point beside the planet instead, until the line clears.
+// The arrival at the witchpoint already sits on the station's side of the
+// planet. A launch, the star and a rock far out have no such promise.
import * as THREE from 'three';
import { rampFlightRate, type FlightDemand } from '../player.ts';
@@ -30,7 +38,11 @@ import { bankToTurn, freshSteerMemory, type SteerMemory } from './pitch-roll-ste
import type { CourseKind } from './courses.ts';
import { PLAYER_FLIGHT } from '../constants/player-flight.ts';
import { DOCK_COMPUTER_RANGE } from '../constants/docking-computer.ts';
-import { COURSE_TORUS_CONE } from '../constants/course.ts';
+import {
+ COURSE_ARRIVE_BRAKE, COURSE_ARRIVE_TOLERANCE, COURSE_DERELICT_STANDOFF,
+ COURSE_HERMIT_SPEED, COURSE_HERMIT_STANDOFF, COURSE_PLANET_CLEARANCE,
+ COURSE_SKIM_DISTANCE, COURSE_TORUS_CONE, COURSE_TORUS_DROP,
+} from '../constants/course.ts';
/** What the course pilot reads for one frame. A flat view, so a test needs no world. */
export interface CourseView {
@@ -39,7 +51,19 @@ export interface CourseView {
readonly quaternion: THREE.Quaternion;
readonly pitchRate: number;
readonly rollRate: number;
+ readonly speed: number;
readonly stationPos: THREE.Vector3;
+ readonly planetPos: THREE.Vector3;
+ readonly planetRadius: number;
+ readonly sunPos: THREE.Vector3;
+ /** the live generation ship, or null for none */
+ readonly derelictPos: THREE.Vector3 | null;
+ /** how fast the generation ship drifts, because the course matches it */
+ readonly derelictSpeed: number;
+ /** the live rock hermit, or null for none */
+ readonly hermitPos: THREE.Vector3 | null;
+ /** the tank holds all it can */
+ readonly tankFull: boolean;
/** the docking computer already has the ship */
readonly dcEngaged: boolean;
}
@@ -52,22 +76,55 @@ export interface CourseStep {
readonly torus: boolean;
/** hand the ship to the docking computer now */
readonly handOver: boolean;
+ /** the course is finished, and it leaves the ship */
+ readonly done: boolean;
}
-const IDLE: CourseStep = { demand: null, torus: false, handOver: false };
+const IDLE: CourseStep = { demand: null, torus: false, handOver: false, done: false };
+
+/**
+ * One arrival: where it goes, how far out it stops, and how fast it arrives.
+ *
+ * A target that moves sets the arrival speed to its own. The generation ship
+ * drifts at 25 u/s. A course that asked for a stop beside it trailed it at
+ * about 19 u/s for ever, and never counted as arrived (docs/TODO/205 M3).
+ */
+interface Arrival {
+ readonly target: THREE.Vector3;
+ readonly standoff: number;
+ readonly speed: number;
+}
export class CoursePilot {
private mem: SteerMemory = freshSteerMemory();
private readonly dir = new THREE.Vector3();
private readonly fwd = new THREE.Vector3();
+ private readonly aim = new THREE.Vector3();
/** Forget the bank, for a new course. */
reset(): void { this.mem = freshSteerMemory(); }
step(v: CourseView, dt: number): CourseStep {
- if (v.course === 'station') return this.toStation(v, dt);
- if (v.course === 'jump') return { demand: straight(v, dt), torus: false, handOver: false };
- return IDLE;
+ switch (v.course) {
+ case 'station': return this.toStation(v, dt);
+ case 'jump': return { demand: straight(v, dt), torus: false, handOver: false, done: false };
+ case 'derelict':
+ return v.derelictPos === null ? ended()
+ : this.arrive(v, {
+ target: v.derelictPos, standoff: COURSE_DERELICT_STANDOFF, speed: v.derelictSpeed,
+ }, dt);
+ case 'hermit':
+ return v.hermitPos === null ? ended()
+ : this.arrive(v, { target: v.hermitPos, standoff: COURSE_HERMIT_STANDOFF, speed: COURSE_HERMIT_SPEED }, dt);
+ case 'skim': {
+ if (v.tankFull) return ended();
+ const s = this.arrive(v, { target: v.sunPos, standoff: COURSE_SKIM_DISTANCE, speed: 0 }, dt);
+ // An arrival at the star is not the end. The ship holds there, and the
+ // scoops work, until the tank is full.
+ return { ...s, done: false };
+ }
+ default: return IDLE;
+ }
}
/**
@@ -77,28 +134,61 @@ export class CoursePilot {
*/
private toStation(v: CourseView, dt: number): CourseStep {
if (v.dcEngaged) return IDLE;
- this.dir.subVectors(v.stationPos, v.position);
- if (this.dir.length() <= DOCK_COMPUTER_RANGE) {
- return { demand: null, torus: false, handOver: true };
+ if (v.position.distanceTo(v.stationPos) <= DOCK_COMPUTER_RANGE) {
+ return { demand: null, torus: false, handOver: true, done: false };
+ }
+ const aim = clearOfPlanet(v.position, v.stationPos, v.planetPos, v.planetRadius, this.aim);
+ return { ...this.pointAt(v, aim, 1, dt), handOver: false, done: false };
+ }
+
+ /**
+ * Fly to a standoff from a target, and stop there.
+ *
+ * The speed follows the distance that is left. It is the speed from which
+ * `COURSE_ARRIVE_BRAKE` of the ship's thrust stops the ship at the standoff,
+ * plus the speed the arrival asks for. The torus runs until
+ * `COURSE_TORUS_DROP` is left, and only while the nose is on the line.
+ */
+ private arrive(v: CourseView, a: Arrival, dt: number): CourseStep {
+ const left = v.position.distanceTo(a.target) - a.standoff;
+ if (Math.abs(left) <= COURSE_ARRIVE_TOLERANCE && v.speed <= a.speed + PLAYER_FLIGHT.accel * dt) {
+ return { demand: hold(v, dt), torus: false, handOver: false, done: true };
}
+ const wanted = Math.min(PLAYER_FLIGHT.maxSpeed,
+ a.speed + Math.sqrt(2 * COURSE_ARRIVE_BRAKE * PLAYER_FLIGHT.accel * Math.max(0, left)));
+ const band = PLAYER_FLIGHT.accel * dt;
+ const throttle = v.speed < wanted - band ? 1 : v.speed > wanted + band ? -1 : 0;
+ const aim = clearOfPlanet(v.position, a.target, v.planetPos, v.planetRadius, this.aim);
+ const p = this.pointAt(v, aim, throttle, dt);
+ return { ...p, torus: p.torus && left > COURSE_TORUS_DROP, handOver: false, done: false };
+ }
+
+ /** Bank and pull the nose onto a point, with this throttle. */
+ private pointAt(
+ v: CourseView, point: THREE.Vector3, throttle: number, dt: number,
+ ): { demand: FlightDemand; torus: boolean } {
+ this.dir.subVectors(point, v.position);
const stick = bankToTurn(v.quaternion, this.dir, this.mem);
this.fwd.set(0, 0, -1).applyQuaternion(v.quaternion);
- const offNose = this.fwd.angleTo(this.dir);
return {
demand: {
pitchRate: rampFlightRate(
v.pitchRate, stick.pitch * PLAYER_FLIGHT.maxPitch, stick.pitch !== 0, dt),
rollRate: rampFlightRate(
v.rollRate, stick.roll * PLAYER_FLIGHT.maxRoll, stick.roll !== 0, dt),
- throttle: 1,
+ throttle,
fire: false,
},
- torus: offNose < COURSE_TORUS_CONE,
- handOver: false,
+ torus: this.fwd.angleTo(this.dir) < COURSE_TORUS_CONE,
};
}
}
+/** The course has nothing left to do: its target is gone, or its work is done. */
+function ended(): CourseStep {
+ return { demand: null, torus: false, handOver: false, done: true };
+}
+
/** Level the sticks and open the throttle: the ship flies on as it points. */
function straight(v: CourseView, dt: number): FlightDemand {
return {
@@ -108,3 +198,47 @@ function straight(v: CourseView, dt: number): FlightDemand {
fire: false,
};
}
+
+/** Level the sticks and brake to a stop. */
+function hold(v: CourseView, dt: number): FlightDemand {
+ return { ...straight(v, dt), throttle: v.speed > PLAYER_FLIGHT.accel * dt ? -1 : 0 };
+}
+
+const seg = new THREE.Vector3();
+const off = new THREE.Vector3();
+
+/**
+ * Where to aim, so that the line to the target clears the planet.
+ *
+ * It finds the nearest point of the line to the planet's centre. That point
+ * can be below `COURSE_PLANET_CLEARANCE`. Then it aims beside the planet, on
+ * the same side as the line, and half as high again. The line from there
+ * clears the planet, and the ship then turns onto the target.
+ *
+ * A target that is itself the nearest point needs no detour. docs/TODO/205 M3
+ * found a hermit low over the planet. The line to it always counted as
+ * blocked, and the ship circled the detour point for ever.
+ *
+ * @returns `out`, holding the point to aim at.
+ */
+export function clearOfPlanet(
+ from: THREE.Vector3, to: THREE.Vector3, planet: THREE.Vector3, radius: number,
+ out: THREE.Vector3,
+): THREE.Vector3 {
+ seg.subVectors(to, from);
+ const len2 = seg.lengthSq();
+ const t = len2 > 0 ? Math.max(0, Math.min(1, off.subVectors(planet, from).dot(seg) / len2)) : 0;
+ // The nearest point is the target itself: the planet is not between. A
+ // target low over the planet is still reached, because the last of the
+ // line runs down to it rather than through the planet.
+ if (t >= 1) return out.copy(to);
+ off.copy(from).addScaledVector(seg, t).sub(planet);
+ const clear = radius + COURSE_PLANET_CLEARANCE;
+ if (off.length() >= clear) return out.copy(to);
+ // A line through the centre has no side. Any direction square to it will do.
+ if (off.lengthSq() < 1e-6) {
+ off.set(seg.y, -seg.x, 0);
+ if (off.lengthSq() < 1e-6) off.set(0, seg.z, -seg.y);
+ }
+ return out.copy(planet).addScaledVector(off.normalize(), radius + COURSE_PLANET_CLEARANCE * 1.5);
+}
diff --git a/src/game/flight-instruments.ts b/src/game/flight-instruments.ts
index 51a05a77..b8b59654 100644
--- a/src/game/flight-instruments.ts
+++ b/src/game/flight-instruments.ts
@@ -22,6 +22,17 @@
import { sfx } from '../audio.ts';
import { Autopilot, type AutopilotEvent } from './autopilot.ts';
import { CoursePilot } from './course-pilot.ts';
+import type { CourseKind } from './courses.ts';
+import { MAX_FUEL } from '../constants/commander.ts';
+
+/**
+ * What the console says when a course finishes its work. The hermit course
+ * says nothing, because the hermit's own trade screen opens on arrival.
+ */
+const COURSE_ENDS: PartialLAUNCH
+
+ LOCAL_CANVAS | 560 | Square, so a light year is the same number of pixels whichever way you go. | | [chart-metric.ts:54](./chart-metric.ts#L54) |
| chart-metric | CHART_CANVAS_W | 780 | The galactic chart's canvas, in px. | | [chart-metric.ts:66](./chart-metric.ts#L66) |
| chart-metric | CHART_CANVAS_H | 400 | Its height. | | [chart-metric.ts:74](./chart-metric.ts#L74) |
-| chart-metric | LANE_PICK_PX | 8 | How near the pointer must come to a trade lane to be pointing AT it, in canvas pixels. | | [chart-metric.ts:85](./chart-metric.ts#L85) |
+| chart-metric | LANE_PICK_PX | 8 | How near the pointer must come to a trade lane to be pointing AT it, in canvas pixels. | chart.lanePickPx | [chart-metric.ts:87](./chart-metric.ts#L87) |
| chart-overlay | LANE_FADE_FLOOR | 0.35 | The alpha that the quietest drawn trade lane keeps, with the busiest at 1. | chart.laneFadeFloor | [chart-overlay.ts:25](./chart-overlay.ts#L25) |
| chart-overlay | LANE_CARGO_NAMED | 3 | How many of a lane's commodities the detail line names before it counts the rest ("+2"). | chart.laneCargoNamed | [chart-overlay.ts:37](./chart-overlay.ts#L37) |
| collision | PLAYER_SPEED_KEPT | 0.3 | Speed kept after a collision. | | [collision.ts:10](./collision.ts#L10) |
@@ -123,6 +123,7 @@ search names, meanings and values with `npm run constants:find -- "COURSE_SKIM_DISTANCE | 65_000 | The hold distance of the skim course, from the centre of the star, in world units. | course.skimDistance | [course.ts:107](./course.ts#L107) |
| course | COURSE_PLANET_CLEARANCE | MASS_LOCK_PLANET_ALTITUDE * 1.25 | How high above the planet's surface a course keeps its line, in world units. | | [course.ts:119](./course.ts#L119) |
| course | COURSE_HERMIT_SPEED | HERMIT_DOCK_SPEED / 2 | The speed at which the hermit course arrives, in world units a second. | | [course.ts:129](./course.ts#L129) |
+| course | SKIP_SPEED | 8 | How much faster the world runs under the fast forward button, as a count of fixed steps in one screen frame (docs/TODO/205 M7). | course.skipSpeed | [course.ts:145](./course.ts#L145) |
| docking | GATE_HALF_WIDTHS | 5 | How far out the approach gate sits, in multiples of the station half-width. | docking.gateHalfWidths | [docking.ts:25](./docking.ts#L25) |
| docking | TURN_IN | Math.PI / 4 | How far round from the slot axis an approach turns in, in radians. | | [docking.ts:49](./docking.ts#L49) |
| docking | RUN_IN_WIDTHS | GATE_HALF_WIDTHS * 0.6 | Where the approach stops the curve and flies straight, in station half-widths. | | [docking.ts:87](./docking.ts#L87) |
@@ -154,12 +155,12 @@ search names, meanings and values with `npm run constants:find -- "OPENING_RANGE | 4500 | The opening range for a fight that you are meant to see coming. | exercise.openingRange | [exercise.ts:33](./exercise.ts#L33) |
| exercise | AMBUSH_RANGE | 2400 | An ambush opens INSIDE their gun, because that is what an ambush is. | | [exercise.ts:40](./exercise.ts#L40) |
| exercise | MIN_OPENING_RANGE | 2 * PASS_FAR | No opening may be closer than this. | | [exercise.ts:49](./exercise.ts#L49) |
-| exercise | OPENING_CONE_DEG | 8 | The cone that a visible opening is scattered through, as a half-angle in degrees. | | [exercise.ts:58](./exercise.ts#L58) |
-| exercise | AMBUSH_CONE_DEG | 30 | An ambush spreads wide behind you: 16 to 43 degrees off your tail. | exercise.ambushCone | [exercise.ts:69](./exercise.ts#L69) |
-| exercise | IN_VIEW_DEG | 20 | How far off the nose still counts as "the pilot can see it". | exercise.inViewDeg | [exercise.ts:80](./exercise.ts#L80) |
-| exercise | ENTRY_THROTTLE | 0.25 | Where the exercise starts you, as a fraction of the ship's top speed. | | [exercise.ts:83](./exercise.ts#L83) |
-| exercise | SCENARIO_TIMEOUT | 120 | Seconds a scenario exercise may run before it times out. | | [exercise.ts:86](./exercise.ts#L86) |
-| exercise | NO_AMBIENT_TRAFFIC | 1e9 | How far out the encounter timers are pushed while an exercise runs. | | [exercise.ts:96](./exercise.ts#L96) |
+| exercise | OPENING_CONE_DEG | 8 | The cone that a visible opening is scattered through, as a half-angle in degrees. | exercise.openingConeDeg | [exercise.ts:60](./exercise.ts#L60) |
+| exercise | AMBUSH_CONE_DEG | 30 | An ambush spreads wide behind you: 16 to 43 degrees off your tail. | exercise.ambushCone | [exercise.ts:71](./exercise.ts#L71) |
+| exercise | IN_VIEW_DEG | 20 | How far off the nose still counts as "the pilot can see it". | exercise.inViewDeg | [exercise.ts:82](./exercise.ts#L82) |
+| exercise | ENTRY_THROTTLE | 0.25 | Where the exercise starts you, as a fraction of the ship's top speed. | | [exercise.ts:85](./exercise.ts#L85) |
+| exercise | SCENARIO_TIMEOUT | 120 | Seconds a scenario exercise may run before it times out. | | [exercise.ts:88](./exercise.ts#L88) |
+| exercise | NO_AMBIENT_TRAFFIC | 1e9 | How far out the encounter timers are pushed while an exercise runs. | | [exercise.ts:98](./exercise.ts#L98) |
| extend-arc | EXTEND_ARC_ANGLE | (60 * Math.PI) / 180 | The angle that the run-out holds off the OUTWARD radial, at its tightest. | | [extend-arc.ts:13](./extend-arc.ts#L13) |
| extend-arc | CLEAR_RANGE | 340 | How far out the ship gets before it starts to curve at all. | | [extend-arc.ts:22](./extend-arc.ts#L22) |
| hermit-market | HERMIT_ORE | new Set(['Minerals', 'Gold', 'Platinum', 'Gem-Stones']) | What a hermit sits on: whatever they dug up. | | [hermit-market.ts:16](./hermit-market.ts#L16) |
@@ -401,23 +402,23 @@ search names, meanings and values with `npm run constants:find -- "COURTESY_RATE | 0.15 | Professional courtesy: the share of receptions that never form at all, because somebody recognised a commander they would rather not cross. | | [threat.ts:110](./threat.ts#L110) |
| threat | PRIZE_SATURATION | 25000 | The cargo value at which the prize term saturates, in tenths of a credit: 25,000, which is 2,500 Cr. | | [threat.ts:118](./threat.ts#L118) |
| threat | DEFENCE_WEIGHT | 12 | The weights on the three fields of `sourceThreatScore`: 1. how much fire a hull survives (`maxEnergy`, weight 1, the base); 2. how much of each hit it shrugs off (`perHitDefence`, a subtraction); 3. how hard it hits back (`laserPower`). | | [threat.ts:131](./threat.ts#L131) |
-| threat | LASER_WEIGHT | 8 | | | [threat.ts:132](./threat.ts#L132) |
-| threat | PROFESSIONAL_SCORE | 110 | The tier ladder over `sourceThreatScore`. | | [threat.ts:138](./threat.ts#L138) |
-| threat | GANG_SCORE | 160 | | | [threat.ts:139](./threat.ts#L139) |
-| threat | MAX_TIER | 2 | The ladder's top rung. | threat.maxTier | [threat.ts:150](./threat.ts#L150) |
-| threat | CURATED_TIER | { 'elite-a:design:17': 0, } | Hulls held at a tier that the score alone would not give them. | | [threat.ts:160](./threat.ts#L160) |
+| threat | LASER_WEIGHT | 8 | The weight on how hard a hull hits back (`laserPower`), the third field of `sourceThreatScore`. | threat.laserWeight | [threat.ts:139](./threat.ts#L139) |
+| threat | PROFESSIONAL_SCORE | 110 | The tier ladder over `sourceThreatScore`. | | [threat.ts:145](./threat.ts#L145) |
+| threat | GANG_SCORE | 160 | | | [threat.ts:146](./threat.ts#L146) |
+| threat | MAX_TIER | 2 | The ladder's top rung. | threat.maxTier | [threat.ts:157](./threat.ts#L157) |
+| threat | CURATED_TIER | { 'elite-a:design:17': 0, } | Hulls held at a tier that the score alone would not give them. | | [threat.ts:167](./threat.ts#L167) |
| threat-lock | THREAT_SWITCH_MARGIN | 2.0 | A rival threat must be this much NEARER than the threat under fire before the defender may switch to it. | threat.switchMargin | [threat-lock.ts:14](./threat-lock.ts#L14) |
| threat-lock | THREAT_MIN_HOLD | 5 | Seconds that the defender fights a threat before it considers a rival. | threat.minHold | [threat-lock.ts:25](./threat-lock.ts#L25) |
-| torus | TORUS_MULTIPLIER | 8 | How much faster the torus drive travels than ordinary flight. | | [torus.ts:17](./torus.ts#L17) |
-| torus | MASS_LOCK_STATION | 5000 | How near the station holds the drive down. | | [torus.ts:29](./torus.ts#L29) |
-| torus | MASS_LOCK_PLANET_ALTITUDE | 4000 | ...and how near the planet, as an ALTITUDE above the surface. | | [torus.ts:37](./torus.ts#L37) |
-| torus | MASS_LOCK_SHIP | 4500 | ...and how near another ship — any live one that is not a rock. | torus.massLockShip | [torus.ts:46](./torus.ts#L46) |
+| torus | TORUS_MULTIPLIER | 8 | How much faster the torus drive travels than ordinary flight. | torus.multiplier | [torus.ts:19](./torus.ts#L19) |
+| torus | MASS_LOCK_STATION | 5000 | How near the station holds the drive down. | | [torus.ts:31](./torus.ts#L31) |
+| torus | MASS_LOCK_PLANET_ALTITUDE | 4000 | ...and how near the planet, as an ALTITUDE above the surface. | | [torus.ts:39](./torus.ts#L39) |
+| torus | MASS_LOCK_SHIP | 4500 | ...and how near another ship — any live one that is not a rock. | torus.massLockShip | [torus.ts:48](./torus.ts#L48) |
| trumbles | TRUMBLE_PURGE_TEMP | 0.55 | The cabin heat that drives them out. | | [trumbles.ts:12](./trumbles.ts#L12) |
| trumbles | BREED_INTERVAL | 20 | Seconds between broods. | trumbles.breedInterval | [trumbles.ts:20](./trumbles.ts#L20) |
| trumbles | BREED_RATE | 1.6 | They multiply by this, plus one, every brood. | | [trumbles.ts:23](./trumbles.ts#L23) |
| trumbles | MAX_TRUMBLES | 999 | No more than this many, or the hold report becomes a novel. | | [trumbles.ts:26](./trumbles.ts#L26) |
-| trumbles | APPETITE_DIVISOR | 8 | One tonne eaten per this many trumbles, per brood. | | [trumbles.ts:29](./trumbles.ts#L29) |
-| trumbles | NOTICEABLE | 4 | Below this many, they are not worth a word. | trumbles.noticeable | [trumbles.ts:37](./trumbles.ts#L37) |
+| trumbles | APPETITE_DIVISOR | 8 | One tonne eaten per this many trumbles, per brood. | trumbles.appetiteDivisor | [trumbles.ts:33](./trumbles.ts#L33) |
+| trumbles | NOTICEABLE | 4 | Below this many, they are not worth a word. | trumbles.noticeable | [trumbles.ts:41](./trumbles.ts#L41) |
| waves | WAVE_MAX_COUNT | 6 | The most ships a wave ever holds — the ceiling the ramp exists to have. | waves.waveMaxCount | [waves.ts:21](./waves.ts#L21) |
| waves | WAVE_COUNT_EVERY | 2 | The count grows by one every this many waves... | waves.countEvery | [waves.ts:31](./waves.ts#L31) |
| waves | WAVE_TIER_EVERY | 3 | ...and the tier climbs a rung every this many. | waves.tierEvery | [waves.ts:38](./waves.ts#L38) |
diff --git a/src/constants/chart-metric.ts b/src/constants/chart-metric.ts
index abb8c4eb..8e249340 100644
--- a/src/constants/chart-metric.ts
+++ b/src/constants/chart-metric.ts
@@ -81,5 +81,7 @@ export const CHART_CANVAS_H = 400;
* It is smaller than the 28 px a click snaps to a system by. Lanes are long
* targets, and dozens of them are on screen. A generous radius would pick a
* neighbouring lane while the pointer sat on a star.
+ *
+ * @rule chart.lanePickPx
*/
export const LANE_PICK_PX = 8;
diff --git a/src/constants/course.ts b/src/constants/course.ts
index 87b3238b..6be02d75 100644
--- a/src/constants/course.ts
+++ b/src/constants/course.ts
@@ -127,3 +127,19 @@ export const COURSE_PLANET_CLEARANCE = MASS_LOCK_PLANET_ALTITUDE * 1.25;
* @domain course
*/
export const COURSE_HERMIT_SPEED = HERMIT_DOCK_SPEED / 2;
+
+/**
+ * How much faster the world runs under the fast forward button, as a count of
+ * fixed steps in one screen frame (docs/TODO/205 M7).
+ *
+ * Chris chose one fixed speed on 2026-09-11: *"Fixed speed - let's keep it
+ * simple."* Eight turns the median trip to the station, 148 s, into about
+ * 19 s of the player's time. The world step ran about 2,000 times faster than
+ * real time with no graphics, so eight costs little.
+ *
+ * It equals `TORUS_MULTIPLIER`, and the two rules are independent. That one is
+ * how far the drive carries the ship. This one is how fast time passes.
+ *
+ * @rule course.skipSpeed
+ */
+export const SKIP_SPEED = 8;
diff --git a/src/constants/exercise.ts b/src/constants/exercise.ts
index d7376f65..acef4f48 100644
--- a/src/constants/exercise.ts
+++ b/src/constants/exercise.ts
@@ -54,6 +54,8 @@ export const MIN_OPENING_RANGE = 2 * PASS_FAR;
* nose, and the nearest is 4.4. That is inside the canopy, and off-centre enough
* that a gang is a spread rather than a stack. The 1.45 is
* `OPPOSITION_CONE_FAR`, the spawner's widest fraction.
+ *
+ * @rule exercise.openingConeDeg
*/
export const OPENING_CONE_DEG = 8;
diff --git a/src/constants/threat.ts b/src/constants/threat.ts
index 20fcb693..f112f32a 100644
--- a/src/constants/threat.ts
+++ b/src/constants/threat.ts
@@ -129,6 +129,13 @@ export const PRIZE_SATURATION = 25000;
* the source's.
*/
export const DEFENCE_WEIGHT = 12;
+/**
+ * The weight on how hard a hull hits back (`laserPower`), the third field of
+ * `sourceThreatScore`. The comment on `DEFENCE_WEIGHT` above explains all
+ * three weights.
+ *
+ * @rule threat.laserWeight
+ */
export const LASER_WEIGHT = 8;
/**
diff --git a/src/constants/torus.ts b/src/constants/torus.ts
index a409d057..fb207057 100644
--- a/src/constants/torus.ts
+++ b/src/constants/torus.ts
@@ -13,6 +13,8 @@
* At the commander's 400 this is 3,200 units/s. That is the figure the
* starfield's streaks are faded against, and roughly a 28-second run from the
* witchpoint.
+ *
+ * @rule torus.multiplier
*/
export const TORUS_MULTIPLIER = 8;
diff --git a/src/constants/trumbles.ts b/src/constants/trumbles.ts
index a6a742d7..09d7403f 100644
--- a/src/constants/trumbles.ts
+++ b/src/constants/trumbles.ts
@@ -25,7 +25,11 @@ export const BREED_RATE = 1.6;
/** No more than this many, or the hold report becomes a novel. */
export const MAX_TRUMBLES = 999;
-/** One tonne eaten per this many trumbles, per brood. */
+/**
+ * One tonne eaten per this many trumbles, per brood.
+ *
+ * @rule trumbles.appetiteDivisor
+ */
export const APPETITE_DIVISOR = 8;
/**
diff --git a/src/game/bindings.ts b/src/game/bindings.ts
index 2a425da7..33d93283 100644
--- a/src/game/bindings.ts
+++ b/src/game/bindings.ts
@@ -65,6 +65,9 @@ export const COURSE_CHART_KEY = 'VirtCourseChart';
*/
export const COURSE_TOGGLE_KEY = 'VirtCourseToggle';
+/** The fast forward button in flight (docs/TODO/205 M7). It has no key either. */
+export const COURSE_SKIP_KEY = 'VirtCourseSkip';
+
export const GLOBAL_BINDINGS: readonly Binding[] = [
// ? toggles the controls guide (plain / is the classic decelerate key)
{ key: 'Question', command: 'toggleHelp' },
diff --git a/src/game/cockpit-view.ts b/src/game/cockpit-view.ts
index fd9dc576..b61814ca 100644
--- a/src/game/cockpit-view.ts
+++ b/src/game/cockpit-view.ts
@@ -35,7 +35,8 @@ import { keyCodeIfBound, keyIfBound } from '../ui/key-help.ts';
import type { HudButton } from '../hud/hud-buttons.ts';
import type { CoursePanel } from './course-actions.ts';
import { COURSE_NAMES } from './courses.ts';
-import { COURSE_KEYS, COURSE_TOGGLE_KEY } from './bindings.ts';
+import { SKIP_SPEED } from '../constants/course.ts';
+import { COURSE_KEYS, COURSE_SKIP_KEY, COURSE_TOGGLE_KEY } from './bindings.ts';
import type { ControlMode } from './controls.ts';
import type { ExerciseStrip } from './combat-sim-strip.ts';
import type { Ordnance } from './ordnance.ts';
@@ -88,8 +89,10 @@ export interface CockpitHost {
*/
export function courseButtonsFor(p: CoursePanel, chart: string | null): HudButton[] {
if (p.rows === null) {
- return p.current === null ? []
- : [{ code: COURSE_TOGGLE_KEY, label: COURSE_NAMES[p.current], lit: true, hint: 'CHOOSE SOMEWHERE ELSE' }];
+ return p.current === null ? [] : [
+ { code: COURSE_TOGGLE_KEY, label: COURSE_NAMES[p.current], lit: true, hint: 'CHOOSE SOMEWHERE ELSE' },
+ skipButton(p),
+ ].filter((b): b is HudButton => b !== null);
}
const out: HudButton[] = p.rows.map((c) => ({
code: COURSE_KEYS[c.kind], label: c.what, ...(c.why === null ? {} : { note: c.why }),
@@ -99,6 +102,18 @@ export function courseButtonsFor(p: CoursePanel, chart: string | null): HudButto
return out;
}
+/** The fast forward button, while a course flies (docs/TODO/205 M7). */
+function skipButton(p: CoursePanel): HudButton | null {
+ if (!p.skip) return null;
+ if (p.skip.on) {
+ return { code: COURSE_SKIP_KEY, label: 'FAST FORWARD IS ON', lit: true, hint: 'BACK TO NORMAL SPEED' };
+ }
+ return {
+ code: COURSE_SKIP_KEY, label: 'FAST FORWARD',
+ ...(p.skip.block === null ? { hint: `TIME RUNS ${SKIP_SPEED} TIMES FASTER` } : { note: p.skip.block }),
+ };
+}
+
export class CockpitView {
private readonly state: GameState;
private readonly ordnance: Ordnance;
diff --git a/src/game/course-actions.ts b/src/game/course-actions.ts
index dd7ea6d4..ece8a3cb 100644
--- a/src/game/course-actions.ts
+++ b/src/game/course-actions.ts
@@ -20,7 +20,9 @@ import { courseList, type Course, type CourseKind, type CourseSituation, type Co
import type { checkJump } from './hyperspace.ts';
import type { GameState } from './state.ts';
import type { Input } from '../engine/input.ts';
-import { COURSE_KEYS, COURSE_TOGGLE_KEY } from './bindings.ts';
+import { COURSE_KEYS, COURSE_SKIP_KEY, COURSE_TOGGLE_KEY } from './bindings.ts';
+import { hostilesNear } from './hostility.ts';
+import { SKIP_SPEED } from '../constants/course.ts';
/**
* What the course buttons show in flight: the list, or the course under way.
@@ -29,6 +31,12 @@ import { COURSE_KEYS, COURSE_TOGGLE_KEY } from './bindings.ts';
export interface CoursePanel {
readonly rows: readonly Course[] | null;
readonly current: CourseKind | null;
+ /**
+ * The fast forward button, while a course flies: whether it is on, and why
+ * it cannot start, or null when it can. Null for the whole field with no
+ * course.
+ */
+ readonly skip: { readonly on: boolean; readonly block: string | null } | null;
}
const KINDS = Object.keys(COURSE_KEYS) as CourseKind[];
@@ -54,6 +62,12 @@ export class CourseActions {
* show, and never what the ship does, so no save carries it.
*/
private opened = false;
+ /**
+ * The fast forward button is on (docs/TODO/205 M7). Like `opened`, it is
+ * how fast time passes for the player, and never what the world does. So no
+ * save carries it, and a restore starts at normal speed.
+ */
+ private skipping = false;
constructor(state: () => GameState, host: CourseHost) {
this.state = state;
@@ -69,9 +83,49 @@ export class CourseActions {
return {
rows: current === null || this.opened ? this.list('flight') : null,
current,
+ skip: current === null ? null : { on: this.skipping, block: this.skipBlock() },
};
}
+ /**
+ * How many fixed steps one screen frame runs: `SKIP_SPEED` under fast
+ * forward, and one otherwise. The Game's loop multiplies its time by it.
+ */
+ get speed(): number {
+ return this.skipping ? SKIP_SPEED : 1;
+ }
+
+ /**
+ * Why fast forward cannot run now, or null when it can. It runs only while a
+ * course flies, and only while the condition light is not red. So it
+ * shortens a wait, and it never flies a fight.
+ */
+ private skipBlock(): string | null {
+ const s = this.state();
+ if (s.session.course === null) return 'CHOOSE WHERE TO GO FIRST';
+ if (hostilesNear(s.world.npcs, s.player.position, s.commander.legalStatus,
+ s.player.position.distanceTo(s.world.station.position))) {
+ return 'NOT WITH A HOSTILE SHIP NEARBY';
+ }
+ return null;
+ }
+
+ /**
+ * After each step: fast forward stops by itself when it may no longer run.
+ * The console says why when a hostile ship is the reason.
+ *
+ * @internal — driven by src/game/game.ts, once per fixed step.
+ */
+ watchSkip(): void {
+ if (!this.skipping) return;
+ const block = this.skipBlock();
+ if (block === null) return;
+ this.skipping = false;
+ if (this.state().session.course !== null) {
+ this.host.showMessage('HOSTILE SHIP NEARBY — BACK TO NORMAL SPEED', 3);
+ }
+ }
+
/**
* A course button was pressed in flight. The codes are `COURSE_KEYS` and
@@ -85,6 +139,14 @@ export class CourseActions {
this.opened = !this.opened;
return;
}
+ // The fast forward button. A second press stops it.
+ if (i.pressed(COURSE_SKIP_KEY)) {
+ const block = this.skipBlock();
+ if (this.skipping) this.skipping = false;
+ else if (block === null) this.skipping = true;
+ else { this.host.showMessage(block, 3); this.host.refused(); }
+ return;
+ }
for (const kind of KINDS) {
if (i.pressed(COURSE_KEYS[kind])) {
if (this.pick(kind, 'flight')) this.opened = false;
diff --git a/src/game/game.ts b/src/game/game.ts
index 1d770761..c808caef 100644
--- a/src/game/game.ts
+++ b/src/game/game.ts
@@ -792,16 +792,19 @@ export class Game {
let accumulator = 0;
let simTime = 0;
this.shell.runLoop((now: number): void => {
- accumulator += Math.min((now - last) / 1000, MAX_FRAME_TIME);
+ // Fast forward runs more steps in each frame, and changes no step
+ // (docs/TODO/205 M7). A device that cannot keep up runs slower.
+ const speed = this.courses_.speed;
+ accumulator += Math.min((now - last) / 1000, MAX_FRAME_TIME) * speed;
last = now;
let steps = 0;
- while (accumulator >= FIXED_DT && steps < MAX_STEPS_PER_FRAME) {
+ while (accumulator >= FIXED_DT && steps < MAX_STEPS_PER_FRAME * speed) {
simTime += FIXED_DT;
this.step(FIXED_DT, simTime);
accumulator -= FIXED_DT;
steps += 1;
}
- if (steps === MAX_STEPS_PER_FRAME) accumulator = 0; // gave up catching up
+ if (steps === MAX_STEPS_PER_FRAME * speed) accumulator = 0; // gave up catching up
this.draw(FIXED_DT);
});
}
@@ -1023,7 +1026,9 @@ export class Game {
* whatever the frame rate.
*/
step(dt: number, elapsed: number): void {
- tickMessage(this.state.session, dt);
+ // A console line keeps its time in the player's seconds under fast
+ // forward, so it can still be read (docs/TODO/205 M7).
+ tickMessage(this.state.session, dt / this.courses_.speed);
// Flight is the only state that can be paused. While it is paused, route
// input through the same command table as any other frame, but apply only
// what a paused cockpit answers — controls.ts's WHILE_PAUSED.
@@ -1045,6 +1050,7 @@ export class Game {
}
this.tunnel.update(dt);
if (this.mode === 'flight') this.flight_.update(dt, elapsed);
+ this.courses_.watchSkip();
this.finishStep(dt);
}
diff --git a/test/constants.test.ts b/test/constants.test.ts
index 578bf931..9f47deb9 100644
--- a/test/constants.test.ts
+++ b/test/constants.test.ts
@@ -465,7 +465,7 @@ const OUTSIDE: readonly Group[] = [
// ...and the codes a course row sends, on both of its surfaces (docs/TODO/205 M4)
'game/bindings.ts': [
'GLOBAL_BINDINGS', 'FLIGHT_BINDINGS', 'NOT_IN_THE_SIMULATOR', 'BINDINGS',
- 'WHILE_PAUSED', 'COURSE_KEYS', 'COURSE_CHART_KEY', 'COURSE_TOGGLE_KEY',
+ 'WHILE_PAUSED', 'COURSE_KEYS', 'COURSE_CHART_KEY', 'COURSE_TOGGLE_KEY', 'COURSE_SKIP_KEY',
],
'game/screens/save-transfer.ts': ['NOT_A_SAVE', 'WRONG_VERSION', 'STORE_FULL'],
'engine/keymap.ts': ['LAYOUTS', 'STORAGE_KEY'],
diff --git a/test/course-buttons.test.ts b/test/course-buttons.test.ts
index d40cd887..e739ee9e 100644
--- a/test/course-buttons.test.ts
+++ b/test/course-buttons.test.ts
@@ -55,8 +55,8 @@ function press(g: Game, code: string): void {
press(g, COURSE_KEYS.station);
eq('the station button picks the station course', g.state.session.course, 'station');
eq('...and the list folds away', g.coursePanel()?.rows ?? null, null);
- eq('...leaving one button that says what the ship is doing',
- courseButtonsFor(g.coursePanel()!, null).map((b) => b.label).join(), 'HEADING TO THE STATION');
+ eq('...leaving a button that says what the ship is doing, and fast forward',
+ courseButtonsFor(g.coursePanel()!, null).map((b) => b.label).join(), 'HEADING TO THE STATION,FAST FORWARD');
press(g, COURSE_TOGGLE_KEY);
check('that button opens the list again over the course', (g.coursePanel()?.rows?.length ?? 0) > 0);
diff --git a/test/elite-a-live-combat.test.ts b/test/elite-a-live-combat.test.ts
index b6418bb1..184cca4b 100644
--- a/test/elite-a-live-combat.test.ts
+++ b/test/elite-a-live-combat.test.ts
@@ -326,12 +326,14 @@ console.log('\nlive combat — regeneration');
// A backgrounded tab hands the loop one enormous frame. It never reaches a
// ship, because game.ts clamps the accumulator and caps the catch-up steps —
// asserted here against the source, since a regeneration rule that IS
- // per-frame can only be as safe as the loop that feeds it.
+ // per-frame can only be as safe as the loop that feeds it. Fast forward
+ // (docs/TODO/205 M7) scales the clamp and the cap by one fixed speed, so a
+ // frame still banks a bounded amount of world time.
const loop = readFileSync(new URL('../src/game/game.ts', import.meta.url), 'utf8');
check('the frame loop clamps a long frame and gives up catching up',
- /accumulator \+= Math\.min\(\(now - last\) \/ 1000, MAX_FRAME_TIME\)/.test(loop)
- && /steps < MAX_STEPS_PER_FRAME/.test(loop)
- && /if \(steps === MAX_STEPS_PER_FRAME\) accumulator = 0/.test(loop));
+ /accumulator \+= Math\.min\(\(now - last\) \/ 1000, MAX_FRAME_TIME\) \* speed/.test(loop)
+ && /steps < MAX_STEPS_PER_FRAME \* speed/.test(loop)
+ && /if \(steps === MAX_STEPS_PER_FRAME \* speed\) accumulator = 0/.test(loop));
const twoSteps = ship(60);
twoSteps.n.regenerate(1 / 60);
twoSteps.n.regenerate(1 / 60);
diff --git a/test/run.ts b/test/run.ts
index b43da323..b601acd4 100644
--- a/test/run.ts
+++ b/test/run.ts
@@ -75,6 +75,7 @@ import './courses.test.ts';
import './course-pilot.test.ts';
import './course-launch.test.ts';
import './course-buttons.test.ts';
+import './skip.test.ts';
import './world.test.ts';
import './docking.test.ts';
import './docking-computer.test.ts';
diff --git a/test/skip.test.ts b/test/skip.test.ts
new file mode 100644
index 00000000..12704709
--- /dev/null
+++ b/test/skip.test.ts
@@ -0,0 +1,123 @@
+// The fast forward button (docs/TODO/205 M7).
+//
+// Chris's words of 2026-09-11: *"instead of being able to cheat the mass lock
+// we just skip time forward."* So fast forward runs more fixed steps in each
+// screen frame, and it changes no step. The first block proves that claim: a
+// trip under fast forward and a trip at normal speed end in the same world,
+// step for step. The rest prove each way that fast forward stops by itself.
+
+import * as THREE from 'three';
+import { Game } from '../src/game/game.ts';
+import { headlessShell } from '../src/engine/shell.ts';
+import { withoutSaving } from '../src/game/storage.ts';
+import { restoreRng, rngState, seedWorld } from '../src/game/rng.ts';
+import { COURSE_SKIP_KEY } from '../src/game/bindings.ts';
+import { SKIP_SPEED } from '../src/constants/course.ts';
+import { check, dismissBriefing, eq } from './harness.ts';
+
+console.log('\nfast forward');
+
+/** A commander at the witchpoint on the station course, past both tunnels. */
+function onCourse(seed: number): Game {
+ const g = withoutSaving(() => {
+ seedWorld(seed);
+ const game = new Game(() => headlessShell());
+ dismissBriefing(game);
+ game.launch();
+ return game;
+ }).value;
+ const settle = (): void => {
+ withoutSaving(() => { for (let f = 0, at = 0; f < 400; f++) g.step(1 / 60, at += 1 / 60); });
+ };
+ settle();
+ g.arriveInSystem();
+ settle();
+ g.state.session.course = 'station';
+ return g;
+}
+
+function run(g: Game, steps: number, from = 10): void {
+ withoutSaving(() => { for (let f = 0; f < steps; f++) g.step(1 / 60, from + f / 60); });
+}
+
+function press(g: Game, code: string): void {
+ g.input.injectPress(code);
+ run(g, 1, 5);
+}
+
+/** The world a step leaves, without the console, which counts the player's time. */
+function world(g: Game): string {
+ const r = (v: THREE.Vector3 | THREE.Quaternion) => v.toArray().map((x) => x.toFixed(6)).join();
+ const { messageText, messageTimer, queued, ...session } = g.state.session;
+ void messageText; void messageTimer; void queued;
+ return JSON.stringify({
+ player: [r(g.state.player.position), r(g.state.player.quaternion), g.state.player.speed.toFixed(6)],
+ npcs: g.state.world.npcs.map((n) => [n.role, n.state.alive, r(n.object.position)]),
+ commander: [g.state.commander.fuel, g.state.commander.credits],
+ session,
+ });
+}
+
+{
+ // Both games draw from ONE seeded stream (game/rng.ts). So each trip starts
+ // from the same point of it, or the second trip sees other numbers.
+ const quiet = (n: Game): void => {
+ for (const s of n.state.world.npcs) if (s.role === 'pirate' || s.role === 'hunter') s.state.alive = false;
+ };
+ const normal = onCourse(20_260_919);
+ quiet(normal);
+ const start = rngState();
+ press(normal, 'Unbound');
+ run(normal, 1200);
+
+ const fast = onCourse(20_260_919);
+ quiet(fast);
+ restoreRng(start);
+ press(fast, COURSE_SKIP_KEY);
+ eq('the button turns fast forward on', fast.coursePanel()?.skip?.on, true);
+ eq('...and the loop runs the fixed step that many times in a frame', (fast as unknown as {
+ courses_: { speed: number } }).courses_.speed, SKIP_SPEED);
+ run(fast, 1200);
+ eq('a trip under fast forward and a trip at normal speed reach the same world, step for step',
+ world(fast), world(normal));
+ run(fast, 1, 40);
+ check('...where one step more is a different world (the control)', world(fast) !== world(normal));
+}
+
+{
+ const g = onCourse(20_260_920);
+ for (const s of g.state.world.npcs) if (s.role === 'pirate' || s.role === 'hunter') s.state.alive = false;
+ press(g, COURSE_SKIP_KEY);
+ press(g, COURSE_SKIP_KEY);
+ eq('a second press stops fast forward', g.coursePanel()?.skip?.on, false);
+
+ press(g, COURSE_SKIP_KEY);
+ g.input.press('ArrowUp');
+ run(g, 1, 6);
+ g.input.release('ArrowUp');
+ eq('a flight key takes the ship, and fast forward stops with the course', g.coursePanel()?.skip ?? null, null);
+}
+
+{
+ const g = onCourse(20_260_921);
+ for (const s of g.state.world.npcs) if (s.role === 'pirate' || s.role === 'hunter') s.state.alive = false;
+ press(g, COURSE_SKIP_KEY);
+ // A pirate appears beside the ship, as the encounters put one.
+ const pirate = g.state.world.spawn('pirate',
+ g.state.player.position.clone().add(new THREE.Vector3(0, 0, -1500)), 7);
+ pirate.state.provokedByPlayer = true;
+ run(g, 2, 7);
+ eq('a hostile ship nearby stops fast forward by itself', g.coursePanel()?.skip?.on, false);
+ eq('...and the console says why', g.state.session.messageText, 'HOSTILE SHIP NEARBY — BACK TO NORMAL SPEED');
+
+ press(g, COURSE_SKIP_KEY);
+ eq('...and with the hostile ship still there, the button refuses', g.coursePanel()?.skip?.on, false);
+ eq('...and says why', g.state.session.messageText, 'NOT WITH A HOSTILE SHIP NEARBY');
+}
+
+{
+ const g = onCourse(20_260_922);
+ g.state.session.course = null;
+ press(g, COURSE_SKIP_KEY);
+ check('with no course there is no fast forward button', g.coursePanel()?.skip === null);
+}
From e696336a1267f0f221d66c780d8b646dade0190a Mon Sep 17 00:00:00 2001
From: Chris Greening Your First Flight
Controls
station menu.
Arrow keys fly in both, flight-style, so pull back to climb.
+ You do not need the keys to play. In flight, the buttons at the top + right of the screen show where the ship can go next: the station, a + derelict, the hermit, the star, the next jump. Click one, or tap it + on a phone, and the ship flies there by itself. A dimmed button says + what you need before you can use it. FAST FORWARD makes the + trip quicker while nothing hostile is near. Touch a flight key at any + time and you have the controls back. +
Loading bindings…
You arrive at the witch-point, a long way out from the planet, and the - journey in is part of the game. That is what the torus drive - (J) is for. It disengages near anything with mass: a planet, a - station, or somebody who wants your cargo. + journey in is part of the game. Choose FLY TO THE STATION and + the ship takes you in on the torus drive (J if you fly by + hand). It disengages near anything with mass: a planet, a station, or + somebody who wants your cargo. When the space around you is clear + again, the ship starts the drive again by itself.
diff --git a/src/game/courses.ts b/src/game/courses.ts index f84c6d88..a2ad578e 100644 --- a/src/game/courses.ts +++ b/src/game/courses.ts @@ -130,7 +130,7 @@ function jumpRow(w: CourseWorld, launch: boolean): Course | null { if (!launch && !w.jump.ok && w.jump.reason === 'noTarget') return null; return { kind: 'jump', - what: w.targetName === null ? 'JUMP' : `JUMP TO ${w.targetName.toUpperCase()}`, + what: w.targetName === null ? 'JUMP TO ANOTHER SYSTEM' : `JUMP TO ${w.targetName.toUpperCase()}`, why: w.jump.ok ? null : JUMP_WHY[w.jump.reason], }; } diff --git a/src/game/game.ts b/src/game/game.ts index c808caef..5c7dfc98 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -451,8 +451,10 @@ export class Game { setSightLit: (on) => this.shell.setSightLit(on), view: () => this.render, // The course buttons, in career flight only. An exercise is a room at - // the station, and it has nowhere to go (docs/TODO/205 M5). - coursePanel: () => (this.flight_.inSimulator() ? null : this.courses_.panel()), + // the station, and it has nowhere to go (docs/TODO/205 M5). During a + // tunnel the cockpit reads no button, so it shows none. + coursePanel: () => (this.flight_.inSimulator() || this.tunnel.active + ? null : this.courses_.panel()), } satisfies CockpitHost); /** diff --git a/src/style.css b/src/style.css index 1753adeb..6e558887 100644 --- a/src/style.css +++ b/src/style.css @@ -866,6 +866,15 @@ body.screen-open #exercise { display: none; } #screen .menu div[data-key] { cursor: pointer; padding: 0 8px; border-left: 2px solid transparent; } #screen .menu div[data-key]:hover { background: rgba(var(--hud-green-rgb), 0.14); border-left-color: var(--hud-green); } #screen .menu div[data-key].sel { background: rgba(var(--hud-green-rgb), 0.2); border-left-color: var(--hud-green); } +/* + * The launch list (docs/TODO/205 M4): a short list, so one column even where + * the station menu takes two. A row the ship cannot fly is dimmed, and its + * reason sits on a line of its own under it. + */ +#screen .menu.course-list { column-count: 1; margin-left: 14%; margin-right: 14%; line-height: 1.6; } +#screen .menu.course-list div[data-key] { padding-top: 6px; padding-bottom: 6px; } +#screen .menu.course-list div.blocked { color: rgba(var(--hud-green-rgb), 0.55); } +#screen .menu.course-list .why { display: block; font-size: 11px; color: var(--hud-amber); letter-spacing: 1px; } #screen tr.pick { cursor: pointer; } #screen tr.pick:hover td { background: rgba(var(--hud-green-rgb), 0.14); } #screen tr.sel.pick:hover td { background: var(--hud-green); } diff --git a/src/ui/briefing.ts b/src/ui/briefing.ts index 1de9cd7c..284d72ba 100644 --- a/src/ui/briefing.ts +++ b/src/ui/briefing.ts @@ -40,8 +40,6 @@ import { COMMAND_HELP } from '../game/command-help.ts'; // consequences. The complete key map is the `?` guide and the manual. const KEY = { help: boundKey('docked', 'toggleHelp'), - jump: boundKey('flight', 'startHyperspace'), - torus: boundKey('flight', 'toggleTorus'), dockingComputer: boundKey('flight', 'toggleDockingComputer'), jettison: boundKey('flight', 'jettison1'), ecm: boundKey('flight', 'fireEcm'), @@ -89,22 +87,27 @@ export const BRIEFING: { title: string; body: string }[] = [ body: `Open ${ROW.localChart} on the station menu.COURSE_PLANET_CLEARANCE | MASS_LOCK_PLANET_ALTITUDE * 1.25 | How high above the planet's surface a course keeps its line, in world units. | | [course.ts:119](./course.ts#L119) |
| course | COURSE_HERMIT_SPEED | HERMIT_DOCK_SPEED / 2 | The speed at which the hermit course arrives, in world units a second. | | [course.ts:129](./course.ts#L129) |
| course | SKIP_SPEED | 8 | How much faster the world runs under the fast forward button, as a count of fixed steps in one screen frame (docs/TODO/205 M7). | course.skipSpeed | [course.ts:145](./course.ts#L145) |
+| course | RUN_CLOSE_MARGIN | 50 | Below this lead in top speed, in world units a second, the run row says the ship is only a little faster (docs/TODO/206 M5). | course.runCloseMargin | [course.ts:159](./course.ts#L159) |
+| course | COURSE_RUN_REACH | 50_000 | How far ahead the run course aims, in world units, on the line away from the hostile ships (docs/TODO/206 M5). | course.runReach | [course.ts:169](./course.ts#L169) |
| docking | GATE_HALF_WIDTHS | 5 | How far out the approach gate sits, in multiples of the station half-width. | docking.gateHalfWidths | [docking.ts:25](./docking.ts#L25) |
| docking | TURN_IN | Math.PI / 4 | How far round from the slot axis an approach turns in, in radians. | | [docking.ts:49](./docking.ts#L49) |
| docking | RUN_IN_WIDTHS | GATE_HALF_WIDTHS * 0.6 | Where the approach stops the curve and flies straight, in station half-widths. | | [docking.ts:87](./docking.ts#L87) |
| docking | LINED_UP_LATERAL | 45 | The off-axis error we insist on before we commit to the run in, in world units. | | [docking.ts:100](./docking.ts#L100) |
-| docking | HULL_BOX_MARGIN | 50 | The bounding cube around the station: a margin over the half-width, in world units, a little larger than the hull. | | [docking.ts:112](./docking.ts#L112) |
-| docking | NPC_HULL_BOX_MARGIN | HULL_BOX_MARGIN | The same cube for every NPC. | | [docking.ts:117](./docking.ts#L117) |
-| docking | SLOT_HALF_ACROSS | 26 | The slot channel, as half-extents ACROSS the slot and ALONG it, in station-local world units. | | [docking.ts:125](./docking.ts#L125) |
-| docking | SLOT_HALF_ALONG | 62 | | | [docking.ts:126](./docking.ts#L126) |
-| docking | SLOT_DEPTH | 60 | How far into the -Z face puts a ship in the channel, in world units. | | [docking.ts:129](./docking.ts#L129) |
-| docking | ROLL_TOLERANCE | 0.65 | The wings against the slot's long axis, in radians: how badly you may be rolled and still fit through the letterbox. | | [docking.ts:140](./docking.ts#L140) |
+| docking | HULL_BOX_MARGIN | 50 | The bounding cube around the station: a margin over the half-width, in world units, a little larger than the hull. | docking.hullBoxMargin | [docking.ts:114](./docking.ts#L114) |
+| docking | NPC_HULL_BOX_MARGIN | HULL_BOX_MARGIN | The same cube for every NPC. | | [docking.ts:119](./docking.ts#L119) |
+| docking | SLOT_HALF_ACROSS | 26 | The slot channel, as half-extents ACROSS the slot and ALONG it, in station-local world units. | | [docking.ts:127](./docking.ts#L127) |
+| docking | SLOT_HALF_ALONG | 62 | | | [docking.ts:128](./docking.ts#L128) |
+| docking | SLOT_DEPTH | 60 | How far into the -Z face puts a ship in the channel, in world units. | | [docking.ts:131](./docking.ts#L131) |
+| docking | ROLL_TOLERANCE | 0.65 | The wings against the slot's long axis, in radians: how badly you may be rolled and still fit through the letterbox. | | [docking.ts:142](./docking.ts#L142) |
| docking-computer | DOCK_COMPUTER_RANGE | 3500 | How close to the station the docking computer will take the job, in world units. | | [docking-computer.ts:46](./docking-computer.ts#L46) |
| docking-computer | DC_SLOT_MARGIN | 0.30 | How much of the slot's roll tolerance the TURN may spend, as a fraction. | docking.slotMargin | [docking-computer.ts:97](./docking-computer.ts#L97) |
| docking-computer | DC_TURN_FADE_ANGLE | 0.10 | The off-nose angle, in radians, over which the TURN's claim on the roll axis ramps in. | docking.turnFadeAngle | [docking-computer.ts:151](./docking-computer.ts#L151) |
@@ -141,17 +143,17 @@ search names, meanings and values with `npm run constants:find -- "DC_PATH_LOOKAHEAD | 1.5 | How far ahead ALONG THE PATH the follower aims, in station half-widths. | docking.pathLookahead | [docking-computer.ts:272](./docking-computer.ts#L272) |
| encounters | TRADER_GAP | 100 | The gap between trader arrivals in a system with no economy to speak of. | | [encounters.ts:20](./encounters.ts#L20) |
| encounters | TRADER_GAP_JITTER | 60 | ...and the jitter on top, drawn flat, so the lane never runs to a metronome. | | [encounters.ts:23](./encounters.ts#L23) |
-| encounters | TRADER_GAP_BUSY_MAX | 50 | The most that a busy economy can discount off `TRADER_GAP`. | | [encounters.ts:36](./encounters.ts#L36) |
-| encounters | PRODUCTIVITY_PER_SECOND | 1200 | How much 1984 productivity buys one second off that gap. | | [encounters.ts:47](./encounters.ts#L47) |
-| encounters | TRADER_GAP_FIRST | 20 | How long after a system's clocks start the first trader may appear. | encounters.traderGapFirst | [encounters.ts:60](./encounters.ts#L60) |
-| encounters | TRADER_GAP_FIRST_JITTER | 40 | ...and its jitter, so two arrivals in one system are not the same arrival. * | encounters.traderGapFirstJitter | [encounters.ts:65](./encounters.ts#L65) |
-| encounters | PIRATE_WAVE_GAP | 60 | The gap between pirate waves in the most organised system that still breeds them. | | [encounters.ts:73](./encounters.ts#L73) |
-| encounters | PIRATE_WAVE_GAP_PER_GOVERNMENT | 40 | ...and how much longer the wait grows for every step up the government ladder. | encounters.pirateWaveGapPerGovernment | [encounters.ts:86](./encounters.ts#L86) |
-| encounters | PIRATE_WAVE_GAP_JITTER | 90 | ...and the jitter. | | [encounters.ts:90](./encounters.ts#L90) |
-| encounters | LAWLESS_GOVERNMENT | 3 | A government at or below this breeds pirate waves at all. 3 is a dictatorship on the 1984 ladder, so waves stop at communist (4) and above. | encounters.lawlessGovernment | [encounters.ts:102](./encounters.ts#L102) |
-| encounters | ANARCHY_GOVERNMENT | 1 | ...and a government at or below THIS sends them two at a time: anarchy (0) and feudal (1). | encounters.anarchyGovernment | [encounters.ts:118](./encounters.ts#L118) |
-| encounters | MAX_THARGONS | 2 | How many drones the Thargoids keep in the sky at once, across every mothership. | encounters.maxThargons | [encounters.ts:138](./encounters.ts#L138) |
-| encounters | THARGON_REDEPLOY | 5 | Seconds between one drone and the next, and the wait for the first. | encounters.thargonRedeploy | [encounters.ts:153](./encounters.ts#L153) |
+| encounters | TRADER_GAP_BUSY_MAX | 50 | The most that a busy economy can discount off `TRADER_GAP`. | encounters.traderGapBusyMax | [encounters.ts:38](./encounters.ts#L38) |
+| encounters | PRODUCTIVITY_PER_SECOND | 1200 | How much 1984 productivity buys one second off that gap. | | [encounters.ts:49](./encounters.ts#L49) |
+| encounters | TRADER_GAP_FIRST | 20 | How long after a system's clocks start the first trader may appear. | encounters.traderGapFirst | [encounters.ts:62](./encounters.ts#L62) |
+| encounters | TRADER_GAP_FIRST_JITTER | 40 | ...and its jitter, so two arrivals in one system are not the same arrival. * | encounters.traderGapFirstJitter | [encounters.ts:67](./encounters.ts#L67) |
+| encounters | PIRATE_WAVE_GAP | 60 | The gap between pirate waves in the most organised system that still breeds them. | | [encounters.ts:75](./encounters.ts#L75) |
+| encounters | PIRATE_WAVE_GAP_PER_GOVERNMENT | 40 | ...and how much longer the wait grows for every step up the government ladder. | encounters.pirateWaveGapPerGovernment | [encounters.ts:88](./encounters.ts#L88) |
+| encounters | PIRATE_WAVE_GAP_JITTER | 90 | ...and the jitter. | | [encounters.ts:92](./encounters.ts#L92) |
+| encounters | LAWLESS_GOVERNMENT | 3 | A government at or below this breeds pirate waves at all. 3 is a dictatorship on the 1984 ladder, so waves stop at communist (4) and above. | encounters.lawlessGovernment | [encounters.ts:104](./encounters.ts#L104) |
+| encounters | ANARCHY_GOVERNMENT | 1 | ...and a government at or below THIS sends them two at a time: anarchy (0) and feudal (1). | encounters.anarchyGovernment | [encounters.ts:120](./encounters.ts#L120) |
+| encounters | MAX_THARGONS | 2 | How many drones the Thargoids keep in the sky at once, across every mothership. | encounters.maxThargons | [encounters.ts:140](./encounters.ts#L140) |
+| encounters | THARGON_REDEPLOY | 5 | Seconds between one drone and the next, and the wait for the first. | encounters.thargonRedeploy | [encounters.ts:155](./encounters.ts#L155) |
| exercise | OPENING_RANGE | 4500 | The opening range for a fight that you are meant to see coming. | exercise.openingRange | [exercise.ts:33](./exercise.ts#L33) |
| exercise | AMBUSH_RANGE | 2400 | An ambush opens INSIDE their gun, because that is what an ambush is. | | [exercise.ts:40](./exercise.ts#L40) |
| exercise | MIN_OPENING_RANGE | 2 * PASS_FAR | No opening may be closer than this. | | [exercise.ts:49](./exercise.ts#L49) |
@@ -273,19 +275,19 @@ search names, meanings and values with `npm run constants:find -- "MISSILE_LIFE | 25 | How long a missile lives before it gives up and detonates. | | [ordnance.ts:16](./ordnance.ts#L16) |
| ordnance | HOSTILE_MISSILE_LIFE | 30 | A hostile missile lives longer, because it has further to come. | ordnance.hostileMissileLife | [ordnance.ts:26](./ordnance.ts#L26) |
| ordnance | MISSILE_TURN | 2.5 | Turn rate while it homes, radians per second. | | [ordnance.ts:28](./ordnance.ts#L28) |
-| ordnance | MISSILE_HIT_RANGE | 50 | Close enough to detonate. | | [ordnance.ts:30](./ordnance.ts#L30) |
-| ordnance | LOCK_CONE | 0.09 | Lock cone: how near the crosshair a ship must be to be locked. | | [ordnance.ts:33](./ordnance.ts#L33) |
-| ordnance | LOCK_RANGE | 5500 | ...and how far away it may be. | | [ordnance.ts:35](./ordnance.ts#L35) |
-| ordnance | MISSILE_MAX_RANGE | 3200 | The far edge of the seeker's envelope. | | [ordnance.ts:40](./ordnance.ts#L40) |
-| ordnance | MISSILE_LAST_STAND_HULL | 0.4 | The hull fraction below which a ship stops saving its missiles for later. | ordnance.missileLastStandHull | [ordnance.ts:51](./ordnance.ts#L51) |
-| ordnance | MISSILE_LAST_STAND_GATE | Math.PI / 2 | ...and it launches on a bearing rather than on a firing line. | | [ordnance.ts:56](./ordnance.ts#L56) |
-| ordnance | MISSILE_LAST_STAND_MIN_RANGE | 250 | Desperation widens the envelope INWARD, but not all the way. | | [ordnance.ts:62](./ordnance.ts#L62) |
-| ordnance | MISSILE_RELOAD | 2 | Gap between launches, so a Python does not empty both rails in one frame. | ordnance.missileReload | [ordnance.ts:71](./ordnance.ts#L71) |
-| ordnance | MISSILE_COMMIT_PASSES | 2 | How many passes a ship makes before it accepts that this is not going its way. | ordnance.missileCommitPasses | [ordnance.ts:83](./ordnance.ts#L83) |
-| ordnance | ECM_RANGE | 2800 | A target with an E.C.M. fries an incoming missile inside this. | | [ordnance.ts:88](./ordnance.ts#L88) |
-| ordnance | ECM_RATE | 0.45 | ...at this chance per second. | | [ordnance.ts:90](./ordnance.ts#L90) |
-| ordnance | ECM_ENERGY_COST | ENERGY_BANK_POINTS | A shot of the E.C.M. costs one bank of energy. | | [ordnance.ts:96](./ordnance.ts#L96) |
-| ordnance | ENERGY_BOMB_RANGE | 8000 | The energy bomb reaches this far. | | [ordnance.ts:99](./ordnance.ts#L99) |
+| ordnance | MISSILE_HIT_RANGE | 50 | Close enough to detonate. * | ordnance.missileHitRange | [ordnance.ts:32](./ordnance.ts#L32) |
+| ordnance | LOCK_CONE | 0.09 | Lock cone: how near the crosshair a ship must be to be locked. | | [ordnance.ts:35](./ordnance.ts#L35) |
+| ordnance | LOCK_RANGE | 5500 | ...and how far away it may be. | | [ordnance.ts:37](./ordnance.ts#L37) |
+| ordnance | MISSILE_MAX_RANGE | 3200 | The far edge of the seeker's envelope. | | [ordnance.ts:42](./ordnance.ts#L42) |
+| ordnance | MISSILE_LAST_STAND_HULL | 0.4 | The hull fraction below which a ship stops saving its missiles for later. | ordnance.missileLastStandHull | [ordnance.ts:53](./ordnance.ts#L53) |
+| ordnance | MISSILE_LAST_STAND_GATE | Math.PI / 2 | ...and it launches on a bearing rather than on a firing line. | | [ordnance.ts:58](./ordnance.ts#L58) |
+| ordnance | MISSILE_LAST_STAND_MIN_RANGE | 250 | Desperation widens the envelope INWARD, but not all the way. | | [ordnance.ts:64](./ordnance.ts#L64) |
+| ordnance | MISSILE_RELOAD | 2 | Gap between launches, so a Python does not empty both rails in one frame. | ordnance.missileReload | [ordnance.ts:73](./ordnance.ts#L73) |
+| ordnance | MISSILE_COMMIT_PASSES | 2 | How many passes a ship makes before it accepts that this is not going its way. | ordnance.missileCommitPasses | [ordnance.ts:85](./ordnance.ts#L85) |
+| ordnance | ECM_RANGE | 2800 | A target with an E.C.M. fries an incoming missile inside this. | | [ordnance.ts:90](./ordnance.ts#L90) |
+| ordnance | ECM_RATE | 0.45 | ...at this chance per second. | | [ordnance.ts:92](./ordnance.ts#L92) |
+| ordnance | ECM_ENERGY_COST | ENERGY_BANK_POINTS | A shot of the E.C.M. costs one bank of energy. | | [ordnance.ts:98](./ordnance.ts#L98) |
+| ordnance | ENERGY_BOMB_RANGE | 8000 | The energy bomb reaches this far. | | [ordnance.ts:101](./ordnance.ts#L101) |
| pass-aim | PASS_MISS_DISTANCE | 110 | How far to the SIDE of its target a ship aims its attack run. 110 clears the largest pirate hull, plus the commander's radius, twice over. | | [pass-aim.ts:13](./pass-aim.ts#L13) |
| pass-aim | MAX_LEAD_SECONDS | 0.5 | The furthest ahead of a target that a ship will aim, in seconds. | | [pass-aim.ts:21](./pass-aim.ts#L21) |
| pass-aim | MAX_MISS_STRETCH | 3 | The most that geometry may stretch the aim, in multiples of the intended pass. | passaim.maxMissStretch | [pass-aim.ts:31](./pass-aim.ts#L31) |
diff --git a/src/constants/course.ts b/src/constants/course.ts
index 6be02d75..40887ecb 100644
--- a/src/constants/course.ts
+++ b/src/constants/course.ts
@@ -143,3 +143,27 @@ export const COURSE_HERMIT_SPEED = HERMIT_DOCK_SPEED / 2;
* @rule course.skipSpeed
*/
export const SKIP_SPEED = 8;
+
+/**
+ * Below this lead in top speed, in world units a second, the run row says the
+ * ship is only a little faster (docs/TODO/206 M5).
+ *
+ * The player's Cobra tops out at 400. The fastest pirate and the fastest
+ * bounty hunter reach 381. A run from one gains 19 u/s. The lasers of both
+ * reach 3,500 units. A lead under 50 u/s takes more than a minute to
+ * open that range, under fire. A police Viper, at 320, falls behind at 80.
+ *
+ * @rule course.runCloseMargin
+ * @domain course
+ */
+export const RUN_CLOSE_MARGIN = 50;
+
+/**
+ * How far ahead the run course aims, in world units, on the line away from
+ * the hostile ships (docs/TODO/206 M5). The point only gives the line a
+ * direction, so it sits far beyond scanner range.
+ *
+ * @rule course.runReach
+ * @domain course
+ */
+export const COURSE_RUN_REACH = 50_000;
diff --git a/src/constants/docking.ts b/src/constants/docking.ts
index dfd5174c..9ebfcded 100644
--- a/src/constants/docking.ts
+++ b/src/constants/docking.ts
@@ -108,7 +108,9 @@ export const LINED_UP_LATERAL = 45;
* scale. The Coriolis reaches 160 against a 160 slot plane. The Dodo's five
* tallest vertices reach 243 against a 196 one. 50 clears both, and it does not
* let a ship slip past a vertex and be reported clear.
- */
+ *
+ * @rule docking.hullBoxMargin
+*/
export const HULL_BOX_MARGIN = 50;
/** The same cube for every NPC. It is the SAME RULE as the player's, so NPC
diff --git a/src/constants/encounters.ts b/src/constants/encounters.ts
index b1cdedf7..b1af1bc5 100644
--- a/src/constants/encounters.ts
+++ b/src/constants/encounters.ts
@@ -32,7 +32,9 @@ export const TRADER_GAP_JITTER = 60;
* continuous trader stream, held back only by `MAX_TRADERS`. Live, a median
* system runs its lane at about 90s plus jitter. The richest runs at about 53s
* plus jitter.
- */
+ *
+ * @rule encounters.traderGapBusyMax
+*/
export const TRADER_GAP_BUSY_MAX = 50;
/**
diff --git a/src/constants/ordnance.ts b/src/constants/ordnance.ts
index a148addb..bc1c5550 100644
--- a/src/constants/ordnance.ts
+++ b/src/constants/ordnance.ts
@@ -26,7 +26,9 @@ export const MISSILE_LIFE = 25;
export const HOSTILE_MISSILE_LIFE = 30;
/** Turn rate while it homes, radians per second. */
export const MISSILE_TURN = 2.5;
-/** Close enough to detonate. */
+/** Close enough to detonate. *
+ * @rule ordnance.missileHitRange
+*/
export const MISSILE_HIT_RANGE = 50;
/** Lock cone: how near the crosshair a ship must be to be locked. */
diff --git a/src/constants/witchspace.ts b/src/constants/witchspace.ts
index a2ba0529..7408bf8f 100644
--- a/src/constants/witchspace.ts
+++ b/src/constants/witchspace.ts
@@ -60,7 +60,8 @@ export const THARGOID_AMBUSH_RANGE_SPAN = 2500;
// Two constants used to end this file: STRANDED_HINT_FIRST and
// STRANDED_HINT_REPEAT. They were the cadence of a console message that told
-// you to press B, and they went with that message (docs/TODO/128). To be stranded is a situation, not an event. The cockpit's
-// prompt line now carries the offer for as long as it is true. So there is no
+// you to press B, and they went with that message (docs/TODO/128). To be
+// stranded is a situation, not an event. The cockpit now offers the beacon as
+// a button for as long as it is true (docs/TODO/206 M5). So there is no
// repeat to time, and no letter to hard-code. The condition itself
// (`WITCHSPACE_ESCAPE_COST` in the tank) is `game/prompts.ts`.
diff --git a/src/game/autopilot.ts b/src/game/autopilot.ts
index d9d76e66..e5faac5d 100644
--- a/src/game/autopilot.ts
+++ b/src/game/autopilot.ts
@@ -175,6 +175,8 @@ export class Autopilot {
autoEngage(): AutopilotEvent[] {
const s = this.state;
if (s.session.ccEngaged || s.session.handFlown || !this.fightOn()) return [];
+ // A ship that runs does not turn to fight (docs/TODO/206 M5).
+ if (s.session.course === 'run') return [];
// The LIVE BRAINS row can set the co-pilot to NONE outright.
if (defenceBrainNameFor(s.brains) === 'scripted') return [];
s.session.ccEngaged = true;
diff --git a/src/game/bindings.ts b/src/game/bindings.ts
index a08930a9..1f9ff239 100644
--- a/src/game/bindings.ts
+++ b/src/game/bindings.ts
@@ -53,6 +53,7 @@ export const COURSE_KEYS: ReadonlyBRAIN_RATE_DECAY | 5.2207 | | | [brain-flight.ts:25](./brain-flight.ts#L25) |
| brain-flight | DECISION_INTERVAL | 0.1 | How long a brain holds a decision before it takes another: 10 Hz. | flight.brain.decisionInterval | [brain-flight.ts:38](./brain-flight.ts#L38) |
| brain-flight | OBS_SPEED_SCALE | 400 | The speed scale that normalizes every observation, in world units a second. | flight.brain.observationSpeed | [brain-flight.ts:52](./brain-flight.ts#L52) |
-| camera | CAMERA_FOV | 60 | Vertical field of view, in degrees. | | [camera.ts:10](./camera.ts#L10) |
-| camera | CAMERA_NEAR | 1 | Near plane — 1 unit, about a wingtip. | camera.near | [camera.ts:20](./camera.ts#L20) |
-| camera | CAMERA_FAR | 1_000_000 | Far plane — a million units. | | [camera.ts:26](./camera.ts#L26) |
-| camera | HEADLESS_WIDTH | 1280 | The viewport that a run with no window pretends to have. | | [camera.ts:34](./camera.ts#L34) |
-| camera | HEADLESS_HEIGHT | 720 | | | [camera.ts:35](./camera.ts#L35) |
+| camera | CAMERA_FOV | 60 | Vertical field of view, in degrees. | camera.fov | [camera.ts:12](./camera.ts#L12) |
+| camera | CAMERA_NEAR | 1 | Near plane — 1 unit, about a wingtip. | camera.near | [camera.ts:22](./camera.ts#L22) |
+| camera | CAMERA_FAR | 1_000_000 | Far plane — a million units. | | [camera.ts:28](./camera.ts#L28) |
+| camera | HEADLESS_WIDTH | 1280 | The viewport that a run with no window pretends to have. | | [camera.ts:36](./camera.ts#L36) |
+| camera | HEADLESS_HEIGHT | 720 | | | [camera.ts:37](./camera.ts#L37) |
| character | CHARACTER | [ [0, 'Honest'], [10, 'Dubious'], [25, 'Dodgy'], [50, 'Shady'], [80, 'Notorious'], [120, 'Cutthroat'], ] | The character ladder: the disrepute score, lowest first, and the rung that it earns. | | [character.ts:19](./character.ts#L19) |
| character | DISREPUTE_HERMIT_KILL | 40 | What each deed adds to disrepute, tuned in play. | character.hermitKill | [character.ts:35](./character.ts#L35) |
| character | HERMIT_HIT_LINE | 'ROCK HERMIT HIT — SOMEBODY LIVES IN THAT ROCK' | What the console says on the first hit that lands on a rock hermit. | | [character.ts:52](./character.ts#L52) |
@@ -126,6 +126,7 @@ search names, meanings and values with `npm run constants:find -- "SKIP_SPEED | 8 | How much faster the world runs under the fast forward button, as a count of fixed steps in one screen frame (docs/TODO/205 M7). | course.skipSpeed | [course.ts:145](./course.ts#L145) |
| course | RUN_CLOSE_MARGIN | 50 | Below this lead in top speed, in world units a second, the run row says the ship is only a little faster (docs/TODO/206 M5). | course.runCloseMargin | [course.ts:159](./course.ts#L159) |
| course | COURSE_RUN_REACH | 50_000 | How far ahead the run course aims, in world units, on the line away from the hostile ships (docs/TODO/206 M5). | course.runReach | [course.ts:169](./course.ts#L169) |
+| course | COURSE_COLLECT_SPEED | 60 | How fast the collect course flies onto a canister, in world units a second (docs/TODO/206 M6). | course.collectSpeed | [course.ts:183](./course.ts#L183) |
| docking | GATE_HALF_WIDTHS | 5 | How far out the approach gate sits, in multiples of the station half-width. | docking.gateHalfWidths | [docking.ts:25](./docking.ts#L25) |
| docking | TURN_IN | Math.PI / 4 | How far round from the slot axis an approach turns in, in radians. | | [docking.ts:49](./docking.ts#L49) |
| docking | RUN_IN_WIDTHS | GATE_HALF_WIDTHS * 0.6 | Where the approach stops the curve and flies straight, in station half-widths. | | [docking.ts:87](./docking.ts#L87) |
@@ -134,26 +135,26 @@ search names, meanings and values with `npm run constants:find -- "NPC_HULL_BOX_MARGIN | HULL_BOX_MARGIN | The same cube for every NPC. | | [docking.ts:119](./docking.ts#L119) |
| docking | SLOT_HALF_ACROSS | 26 | The slot channel, as half-extents ACROSS the slot and ALONG it, in station-local world units. | | [docking.ts:127](./docking.ts#L127) |
| docking | SLOT_HALF_ALONG | 62 | | | [docking.ts:128](./docking.ts#L128) |
-| docking | SLOT_DEPTH | 60 | How far into the -Z face puts a ship in the channel, in world units. | | [docking.ts:131](./docking.ts#L131) |
-| docking | ROLL_TOLERANCE | 0.65 | The wings against the slot's long axis, in radians: how badly you may be rolled and still fit through the letterbox. | | [docking.ts:142](./docking.ts#L142) |
+| docking | SLOT_DEPTH | 60 | How far into the -Z face puts a ship in the channel, in world units. * | docking.slotDepth | [docking.ts:133](./docking.ts#L133) |
+| docking | ROLL_TOLERANCE | 0.65 | The wings against the slot's long axis, in radians: how badly you may be rolled and still fit through the letterbox. | | [docking.ts:144](./docking.ts#L144) |
| docking-computer | DOCK_COMPUTER_RANGE | 3500 | How close to the station the docking computer will take the job, in world units. | | [docking-computer.ts:46](./docking-computer.ts#L46) |
| docking-computer | DC_SLOT_MARGIN | 0.30 | How much of the slot's roll tolerance the TURN may spend, as a fraction. | docking.slotMargin | [docking-computer.ts:97](./docking-computer.ts#L97) |
| docking-computer | DC_TURN_FADE_ANGLE | 0.10 | The off-nose angle, in radians, over which the TURN's claim on the roll axis ramps in. | docking.turnFadeAngle | [docking-computer.ts:151](./docking-computer.ts#L151) |
| docking-computer | DC_ROLL_LEAD | 0.10 | How far ahead IN TIME the roll ask reads the rate it already rolls at, in seconds. | docking.rollLead | [docking-computer.ts:211](./docking-computer.ts#L211) |
| docking-computer | DC_PATH_LOOKAHEAD | 1.5 | How far ahead ALONG THE PATH the follower aims, in station half-widths. | docking.pathLookahead | [docking-computer.ts:272](./docking-computer.ts#L272) |
| encounters | TRADER_GAP | 100 | The gap between trader arrivals in a system with no economy to speak of. | | [encounters.ts:20](./encounters.ts#L20) |
-| encounters | TRADER_GAP_JITTER | 60 | ...and the jitter on top, drawn flat, so the lane never runs to a metronome. | | [encounters.ts:23](./encounters.ts#L23) |
-| encounters | TRADER_GAP_BUSY_MAX | 50 | The most that a busy economy can discount off `TRADER_GAP`. | encounters.traderGapBusyMax | [encounters.ts:38](./encounters.ts#L38) |
-| encounters | PRODUCTIVITY_PER_SECOND | 1200 | How much 1984 productivity buys one second off that gap. | | [encounters.ts:49](./encounters.ts#L49) |
-| encounters | TRADER_GAP_FIRST | 20 | How long after a system's clocks start the first trader may appear. | encounters.traderGapFirst | [encounters.ts:62](./encounters.ts#L62) |
-| encounters | TRADER_GAP_FIRST_JITTER | 40 | ...and its jitter, so two arrivals in one system are not the same arrival. * | encounters.traderGapFirstJitter | [encounters.ts:67](./encounters.ts#L67) |
-| encounters | PIRATE_WAVE_GAP | 60 | The gap between pirate waves in the most organised system that still breeds them. | | [encounters.ts:75](./encounters.ts#L75) |
-| encounters | PIRATE_WAVE_GAP_PER_GOVERNMENT | 40 | ...and how much longer the wait grows for every step up the government ladder. | encounters.pirateWaveGapPerGovernment | [encounters.ts:88](./encounters.ts#L88) |
-| encounters | PIRATE_WAVE_GAP_JITTER | 90 | ...and the jitter. | | [encounters.ts:92](./encounters.ts#L92) |
-| encounters | LAWLESS_GOVERNMENT | 3 | A government at or below this breeds pirate waves at all. 3 is a dictatorship on the 1984 ladder, so waves stop at communist (4) and above. | encounters.lawlessGovernment | [encounters.ts:104](./encounters.ts#L104) |
-| encounters | ANARCHY_GOVERNMENT | 1 | ...and a government at or below THIS sends them two at a time: anarchy (0) and feudal (1). | encounters.anarchyGovernment | [encounters.ts:120](./encounters.ts#L120) |
-| encounters | MAX_THARGONS | 2 | How many drones the Thargoids keep in the sky at once, across every mothership. | encounters.maxThargons | [encounters.ts:140](./encounters.ts#L140) |
-| encounters | THARGON_REDEPLOY | 5 | Seconds between one drone and the next, and the wait for the first. | encounters.thargonRedeploy | [encounters.ts:155](./encounters.ts#L155) |
+| encounters | TRADER_GAP_JITTER | 60 | ...and the jitter on top, drawn flat, so the lane never runs to a metronome. * | encounters.traderGapJitter | [encounters.ts:25](./encounters.ts#L25) |
+| encounters | TRADER_GAP_BUSY_MAX | 50 | The most that a busy economy can discount off `TRADER_GAP`. | encounters.traderGapBusyMax | [encounters.ts:40](./encounters.ts#L40) |
+| encounters | PRODUCTIVITY_PER_SECOND | 1200 | How much 1984 productivity buys one second off that gap. | | [encounters.ts:51](./encounters.ts#L51) |
+| encounters | TRADER_GAP_FIRST | 20 | How long after a system's clocks start the first trader may appear. | encounters.traderGapFirst | [encounters.ts:64](./encounters.ts#L64) |
+| encounters | TRADER_GAP_FIRST_JITTER | 40 | ...and its jitter, so two arrivals in one system are not the same arrival. * | encounters.traderGapFirstJitter | [encounters.ts:69](./encounters.ts#L69) |
+| encounters | PIRATE_WAVE_GAP | 60 | The gap between pirate waves in the most organised system that still breeds them. | encounters.pirateWaveGap | [encounters.ts:79](./encounters.ts#L79) |
+| encounters | PIRATE_WAVE_GAP_PER_GOVERNMENT | 40 | ...and how much longer the wait grows for every step up the government ladder. | encounters.pirateWaveGapPerGovernment | [encounters.ts:92](./encounters.ts#L92) |
+| encounters | PIRATE_WAVE_GAP_JITTER | 90 | ...and the jitter. | | [encounters.ts:96](./encounters.ts#L96) |
+| encounters | LAWLESS_GOVERNMENT | 3 | A government at or below this breeds pirate waves at all. 3 is a dictatorship on the 1984 ladder, so waves stop at communist (4) and above. | encounters.lawlessGovernment | [encounters.ts:108](./encounters.ts#L108) |
+| encounters | ANARCHY_GOVERNMENT | 1 | ...and a government at or below THIS sends them two at a time: anarchy (0) and feudal (1). | encounters.anarchyGovernment | [encounters.ts:124](./encounters.ts#L124) |
+| encounters | MAX_THARGONS | 2 | How many drones the Thargoids keep in the sky at once, across every mothership. | encounters.maxThargons | [encounters.ts:144](./encounters.ts#L144) |
+| encounters | THARGON_REDEPLOY | 5 | Seconds between one drone and the next, and the wait for the first. | encounters.thargonRedeploy | [encounters.ts:159](./encounters.ts#L159) |
| exercise | OPENING_RANGE | 4500 | The opening range for a fight that you are meant to see coming. | exercise.openingRange | [exercise.ts:33](./exercise.ts#L33) |
| exercise | AMBUSH_RANGE | 2400 | An ambush opens INSIDE their gun, because that is what an ambush is. | | [exercise.ts:40](./exercise.ts#L40) |
| exercise | MIN_OPENING_RANGE | 2 * PASS_FAR | No opening may be closer than this. | | [exercise.ts:49](./exercise.ts#L49) |
diff --git a/src/constants/camera.ts b/src/constants/camera.ts
index 10e4236d..faed934f 100644
--- a/src/constants/camera.ts
+++ b/src/constants/camera.ts
@@ -6,7 +6,9 @@
* Vertical field of view, in degrees. It is load-bearing beyond the looks. The
* trainer's `IN_VIEW_DEG` arc is argued from this, so a change moves what "the
* pilot can see it" means.
- */
+ *
+ * @rule camera.fov
+*/
export const CAMERA_FOV = 60;
/**
diff --git a/src/constants/course.ts b/src/constants/course.ts
index 40887ecb..a6f28368 100644
--- a/src/constants/course.ts
+++ b/src/constants/course.ts
@@ -167,3 +167,17 @@ export const RUN_CLOSE_MARGIN = 50;
* @domain course
*/
export const COURSE_RUN_REACH = 50_000;
+
+/**
+ * How fast the collect course flies onto a canister, in world units a second
+ * (docs/TODO/206 M6).
+ *
+ * The scoop takes a canister inside `SCOOP_RANGE`, which is 45 units. At this
+ * speed the ship covers that in more than half a second, so the scoop has
+ * frames to catch it. A canister drifts, and the course arrives at the speed
+ * the drift needs rather than at a stop.
+ *
+ * @rule course.collectSpeed
+ * @domain course
+ */
+export const COURSE_COLLECT_SPEED = 60;
diff --git a/src/constants/docking.ts b/src/constants/docking.ts
index 9ebfcded..6cfdf67a 100644
--- a/src/constants/docking.ts
+++ b/src/constants/docking.ts
@@ -127,7 +127,9 @@ export const NPC_HULL_BOX_MARGIN = HULL_BOX_MARGIN;
export const SLOT_HALF_ACROSS = 26;
export const SLOT_HALF_ALONG = 62;
-/** How far into the -Z face puts a ship in the channel, in world units. */
+/** How far into the -Z face puts a ship in the channel, in world units. *
+ * @rule docking.slotDepth
+*/
export const SLOT_DEPTH = 60;
/**
diff --git a/src/constants/encounters.ts b/src/constants/encounters.ts
index b1af1bc5..d7bde30a 100644
--- a/src/constants/encounters.ts
+++ b/src/constants/encounters.ts
@@ -19,7 +19,9 @@
*/
export const TRADER_GAP = 100;
-/** ...and the jitter on top, drawn flat, so the lane never runs to a metronome. */
+/** ...and the jitter on top, drawn flat, so the lane never runs to a metronome. *
+ * @rule encounters.traderGapJitter
+*/
export const TRADER_GAP_JITTER = 60;
/**
@@ -71,7 +73,9 @@ export const TRADER_GAP_FIRST_JITTER = 40;
* them. It is also the first wave's countdown when you arrive. It is one number,
* because the first wave is the ladder's bottom rung, with no government term and
* no jitter.
- */
+ *
+ * @rule encounters.pirateWaveGap
+*/
export const PIRATE_WAVE_GAP = 60;
/**
diff --git a/src/game/bindings.ts b/src/game/bindings.ts
index 1f9ff239..01bfd155 100644
--- a/src/game/bindings.ts
+++ b/src/game/bindings.ts
@@ -54,6 +54,7 @@ export const COURSE_KEYS: Readonly+ When somebody opens fire, the computer takes the stick and lines the + ship up on them. The shooting stays yours. FIRE LASER at the + bottom right fires while you hold it, ARM A MISSILE arms one + and then fires it, and E.C.M. answers a missile coming at + you. TARGETS lists every ship, rock and derelict on the + scanner, and a row sends the computer after that one. It also says + when the law protects a ship, before you shoot it. +
++ A missile costs money, so the computer never fires one for you. Buy + a combat computer and it will do the rest of the fight while + you watch. Press a steering key at any time and you have the ship + back. +
++ If the fight is going badly, the buttons on the left offer the ways + out: RUN FOR IT, which says whether you are faster, and the + offers that fit the moment, such as paying a patrol off or throwing + cargo to a pirate. +
RUN_CLOSE_MARGIN | 50 | Below this lead in top speed, in world units a second, the run row says the ship is only a little faster (docs/TODO/206 M5). | course.runCloseMargin | [course.ts:159](./course.ts#L159) |
| course | COURSE_RUN_REACH | 50_000 | How far ahead the run course aims, in world units, on the line away from the hostile ships (docs/TODO/206 M5). | course.runReach | [course.ts:169](./course.ts#L169) |
| course | COURSE_COLLECT_SPEED | 60 | How fast the collect course flies onto a canister, in world units a second (docs/TODO/206 M6). | course.collectSpeed | [course.ts:183](./course.ts#L183) |
+| course | COURSE_DOCK_HANDOVER | 1500 | How far from the station the station course hands the ship to the pilot, in world units (docs/TODO/207 M1). | course.dockHandover | [course.ts:197](./course.ts#L197) |
| docking | GATE_HALF_WIDTHS | 5 | How far out the approach gate sits, in multiples of the station half-width. | docking.gateHalfWidths | [docking.ts:25](./docking.ts#L25) |
| docking | TURN_IN | Math.PI / 4 | How far round from the slot axis an approach turns in, in radians. | | [docking.ts:49](./docking.ts#L49) |
| docking | RUN_IN_WIDTHS | GATE_HALF_WIDTHS * 0.6 | Where the approach stops the curve and flies straight, in station half-widths. | | [docking.ts:87](./docking.ts#L87) |
@@ -137,6 +138,7 @@ search names, meanings and values with `npm run constants:find -- "SLOT_HALF_ALONG | 62 | | | [docking.ts:128](./docking.ts#L128) |
| docking | SLOT_DEPTH | 60 | How far into the -Z face puts a ship in the channel, in world units. * | docking.slotDepth | [docking.ts:133](./docking.ts#L133) |
| docking | ROLL_TOLERANCE | 0.65 | The wings against the slot's long axis, in radians: how badly you may be rolled and still fit through the letterbox. | | [docking.ts:144](./docking.ts#L144) |
+| docking | SLOT_SPEED_LIMIT | 120 | How fast a ship may be going when it reaches the slot, in world units a second (docs/TODO/207 M3). | docking.slotSpeedLimit | [docking.ts:165](./docking.ts#L165) |
| docking-computer | DOCK_COMPUTER_RANGE | 3500 | How close to the station the docking computer will take the job, in world units. | | [docking-computer.ts:46](./docking-computer.ts#L46) |
| docking-computer | DC_SLOT_MARGIN | 0.30 | How much of the slot's roll tolerance the TURN may spend, as a fraction. | docking.slotMargin | [docking-computer.ts:97](./docking-computer.ts#L97) |
| docking-computer | DC_TURN_FADE_ANGLE | 0.10 | The off-nose angle, in radians, over which the TURN's claim on the roll axis ramps in. | docking.turnFadeAngle | [docking-computer.ts:151](./docking-computer.ts#L151) |
@@ -162,8 +164,8 @@ search names, meanings and values with `npm run constants:find -- "AMBUSH_CONE_DEG | 30 | An ambush spreads wide behind you: 16 to 43 degrees off your tail. | exercise.ambushCone | [exercise.ts:71](./exercise.ts#L71) |
| exercise | IN_VIEW_DEG | 20 | How far off the nose still counts as "the pilot can see it". | exercise.inViewDeg | [exercise.ts:82](./exercise.ts#L82) |
| exercise | ENTRY_THROTTLE | 0.25 | Where the exercise starts you, as a fraction of the ship's top speed. | | [exercise.ts:85](./exercise.ts#L85) |
-| exercise | SCENARIO_TIMEOUT | 120 | Seconds a scenario exercise may run before it times out. | | [exercise.ts:88](./exercise.ts#L88) |
-| exercise | NO_AMBIENT_TRAFFIC | 1e9 | How far out the encounter timers are pushed while an exercise runs. | | [exercise.ts:98](./exercise.ts#L98) |
+| exercise | SCENARIO_TIMEOUT | 120 | Seconds a scenario exercise may run before it times out. * | exercise.scenarioTimeout | [exercise.ts:90](./exercise.ts#L90) |
+| exercise | NO_AMBIENT_TRAFFIC | 1e9 | How far out the encounter timers are pushed while an exercise runs. | | [exercise.ts:100](./exercise.ts#L100) |
| extend-arc | EXTEND_ARC_ANGLE | (60 * Math.PI) / 180 | The angle that the run-out holds off the OUTWARD radial, at its tightest. | | [extend-arc.ts:13](./extend-arc.ts#L13) |
| extend-arc | CLEAR_RANGE | 340 | How far out the ship gets before it starts to curve at all. | | [extend-arc.ts:22](./extend-arc.ts#L22) |
| hermit-market | HERMIT_ORE | new Set(['Minerals', 'Gold', 'Platinum', 'Gem-Stones']) | What a hermit sits on: whatever they dug up. | | [hermit-market.ts:16](./hermit-market.ts#L16) |
@@ -336,7 +338,7 @@ search names, meanings and values with `npm run constants:find -- "MAX_SAVE_NAME | 16 | The longest name a player may type. 16, because that is what the list column holds without a wrap, and what keeps an id short. | saves.maxSaveName | [saves.ts:68](./saves.ts#L68) |
| scoop | SCOOP_RANGE | 45 | How close the commander must fly to scoop a drifting object, in world units. | | [scoop.ts:17](./scoop.ts#L17) |
| separation | SEPARATION_RANGE | 200 | How near a wingman has to be before a ship cares, in world units. | separation.range | [separation.ts:11](./separation.ts#L11) |
-| separation | SEPARATION_PUSH | 120 | How hard a ship that closes bends its aim to avoid a mate, in units of offset. | | [separation.ts:17](./separation.ts#L17) |
+| separation | SEPARATION_PUSH | 120 | How hard a ship that closes bends its aim to avoid a mate, in units of offset. | separation.push | [separation.ts:19](./separation.ts#L19) |
| shop | FUEL_PRICE | 0.4 | What a refuel costs, in tenths of a credit per tenth of a LY. | shop.fuelPrice | [shop.ts:19](./shop.ts#L19) |
| shop | PULSE_LASER_PRICE | 4000 | What a pulse laser costs, wherever it is mounted. | | [shop.ts:27](./shop.ts#L27) |
| shop | BEAM_LASER_PRICE | 10000 | The beam laser's price. | | [shop.ts:34](./shop.ts#L34) |
@@ -366,17 +368,17 @@ search names, meanings and values with `npm run constants:find -- "STATION_DEFENCE_MIN | 1 | The fewest Vipers the station launches after you shoot at something you should not. | spawn.stationDefenceMin | [spawn-placement.ts:260](./spawn-placement.ts#L260) |
| spawn-placement | STATION_DEFENCE_SPAN | 2 | ...and the width of that draw: one or two of them. | spawn.stationDefenceSpan | [spawn-placement.ts:271](./spawn-placement.ts#L271) |
| spawn-placement | STATION_DEFENCE_STANDOFF | 500 | How far out of the slot the first one launches. | | [spawn-placement.ts:277](./spawn-placement.ts#L277) |
-| spawn-placement | STATION_DEFENCE_STACK | 120 | ...and how much further out each one after it starts, so a pair does not arrive inside each other. | | [spawn-placement.ts:283](./spawn-placement.ts#L283) |
-| spawn-placement | STATION_DEFENCE_JITTER | 80 | ...and the random nudge on each, so a second launch does not look like the first. | | [spawn-placement.ts:293](./spawn-placement.ts#L293) |
-| spawn-placement | TRADER_ARRIVED | 900 | How near the station an arriving trader has to be to start trading. | spawn.traderArrived | [spawn-placement.ts:308](./spawn-placement.ts#L308) |
-| spawn-placement | TRADER_JUMP_OUT | 2500 | How near its waypoint a departing trader has to be to jump out. | spawn.traderJumpOut | [spawn-placement.ts:324](./spawn-placement.ts#L324) |
-| spawn-placement | SPAWN_PLANET_ALTITUDE | 1000 | The lowest a ship may appear above the planet's surface, measured to the ship's centre, in world units. | spawn.planetAltitude | [spawn-placement.ts:343](./spawn-placement.ts#L343) |
+| spawn-placement | STATION_DEFENCE_STACK | 120 | ...and how much further out each one after it starts, so a pair does not arrive inside each other. | spawn.stationDefenceStack | [spawn-placement.ts:285](./spawn-placement.ts#L285) |
+| spawn-placement | STATION_DEFENCE_JITTER | 80 | ...and the random nudge on each, so a second launch does not look like the first. | | [spawn-placement.ts:295](./spawn-placement.ts#L295) |
+| spawn-placement | TRADER_ARRIVED | 900 | How near the station an arriving trader has to be to start trading. | spawn.traderArrived | [spawn-placement.ts:310](./spawn-placement.ts#L310) |
+| spawn-placement | TRADER_JUMP_OUT | 2500 | How near its waypoint a departing trader has to be to jump out. | spawn.traderJumpOut | [spawn-placement.ts:326](./spawn-placement.ts#L326) |
+| spawn-placement | SPAWN_PLANET_ALTITUDE | 1000 | The lowest a ship may appear above the planet's surface, measured to the ship's centre, in world units. | spawn.planetAltitude | [spawn-placement.ts:345](./spawn-placement.ts#L345) |
| station | STATION_SPIN | 0.26 | How fast the station spins about its slot axis, in radians a second. | | [station.ts:19](./station.ts#L19) |
| station | DODO_TECH_LEVEL | 10 | The tech level at which a system's station is the dodecahedral Dodo rather than the Coriolis, in SHOWN one-based units. | | [station.ts:32](./station.ts#L32) |
| station | BOUNCE_STANDOFF | 420 | Where a fluffed docking bounces you to. | | [station.ts:48](./station.ts#L48) |
| station | LAUNCH_STANDOFF | 450 | How far off the slot you sit when the bay spits you out, in world units. | | [station.ts:51](./station.ts#L51) |
-| station | LAUNCH_SPEED | 120 | ...and how fast, in world units a second — a firm push, not a cruise. | | [station.ts:54](./station.ts#L54) |
-| station | DOCKED_BACKDROP_DISTANCE | 900 | Where the docked menu parks your ship, along the slot normal, in world units. | station.backdropDistance | [station.ts:63](./station.ts#L63) |
+| station | LAUNCH_SPEED | 120 | ...and how fast, in world units a second — a firm push, not a cruise. | station.launchSpeed | [station.ts:58](./station.ts#L58) |
+| station | DOCKED_BACKDROP_DISTANCE | 900 | Where the docked menu parks your ship, along the slot normal, in world units. | station.backdropDistance | [station.ts:67](./station.ts#L67) |
| sun | SUN_HEAT_START | 110_000 | Closer than this, and the cabin starts to warm. | | [sun.ts:22](./sun.ts#L22) |
| sun | SUN_SCOOP_RANGE | 80_000 | Close enough to scoop fuel, if you have the scoops. | | [sun.ts:30](./sun.ts#L30) |
| sun | SUN_HEAT_MAX | 26_000 | The bottom of the temperature ramp. | | [sun.ts:38](./sun.ts#L38) |
diff --git a/src/constants/course.ts b/src/constants/course.ts
index a6f28368..fbefe853 100644
--- a/src/constants/course.ts
+++ b/src/constants/course.ts
@@ -181,3 +181,17 @@ export const COURSE_RUN_REACH = 50_000;
* @domain course
*/
export const COURSE_COLLECT_SPEED = 60;
+
+/**
+ * How far from the station the station course hands the ship to the pilot,
+ * in world units (docs/TODO/207 M1).
+ *
+ * A ship with a docking computer is handed to that computer further out, at
+ * `DOCK_COMPUTER_RANGE`. A pilot flies the last stretch, and 1,500 units is
+ * about four seconds at the speed the approach arrives with. The concepts
+ * page of 2026-09-11 started its trial here, and Chris picked one of them.
+ *
+ * @rule course.dockHandover
+ * @domain course
+ */
+export const COURSE_DOCK_HANDOVER = 1500;
diff --git a/src/constants/docking.ts b/src/constants/docking.ts
index 6cfdf67a..24f4439d 100644
--- a/src/constants/docking.ts
+++ b/src/constants/docking.ts
@@ -142,3 +142,24 @@ export const SLOT_DEPTH = 60;
* measure again if this tolerance or the half-widths above ever move.
*/
export const ROLL_TOLERANCE = 0.65;
+
+/**
+ * How fast a ship may be going when it reaches the slot, in world units a
+ * second (docs/TODO/207 M3).
+ *
+ * The slot took any speed at all until now, and the dock was a test of the
+ * roll alone. A pilot flies the last stretch since 207, so the speed is the
+ * second half of the manoeuvre. The docking computer settles at 110 on its
+ * own approach (`planDocking`), so it keeps 10 units a second of room.
+ *
+ * `LAUNCH_SPEED` is also 120, and the two rules are independent. That one is
+ * the push a station gives a ship on the way out.
+ *
+ * It belongs here, with the rest of the slot's rules, and not with the flight
+ * envelopes. The slot decides what it will take, and `dockingOutcome` next
+ * door is the one reader.
+ *
+ * @rule docking.slotSpeedLimit
+ * @domain docking
+ */
+export const SLOT_SPEED_LIMIT = 120;
diff --git a/src/constants/exercise.ts b/src/constants/exercise.ts
index acef4f48..029c3807 100644
--- a/src/constants/exercise.ts
+++ b/src/constants/exercise.ts
@@ -84,7 +84,9 @@ export const IN_VIEW_DEG = 20;
/** Where the exercise starts you, as a fraction of the ship's top speed. */
export const ENTRY_THROTTLE = 0.25;
-/** Seconds a scenario exercise may run before it times out. */
+/** Seconds a scenario exercise may run before it times out. *
+ * @rule exercise.scenarioTimeout
+*/
export const SCENARIO_TIMEOUT = 120;
/**
diff --git a/src/constants/separation.ts b/src/constants/separation.ts
index 1e82a531..178410bb 100644
--- a/src/constants/separation.ts
+++ b/src/constants/separation.ts
@@ -13,5 +13,7 @@ export const SEPARATION_RANGE = 200;
/**
* How hard a ship that closes bends its aim to avoid a mate, in units of offset.
* How close the mate is scales it. It sits a shade above `PASS_MISS_DISTANCE`.
- */
+ *
+ * @rule separation.push
+*/
export const SEPARATION_PUSH = 120;
diff --git a/src/constants/spawn-placement.ts b/src/constants/spawn-placement.ts
index 4fcbfca4..1ea751ef 100644
--- a/src/constants/spawn-placement.ts
+++ b/src/constants/spawn-placement.ts
@@ -279,7 +279,9 @@ export const STATION_DEFENCE_STANDOFF = 500;
/**
* ...and how much further out each one after it starts, so a pair does not arrive
* inside each other. It is three times a Viper's 18.75 contact radius.
- */
+ *
+ * @rule spawn.stationDefenceStack
+*/
export const STATION_DEFENCE_STACK = 120;
/**
diff --git a/src/constants/station.ts b/src/constants/station.ts
index 2daae854..087edc6f 100644
--- a/src/constants/station.ts
+++ b/src/constants/station.ts
@@ -50,7 +50,11 @@ export const BOUNCE_STANDOFF = 420;
/** How far off the slot you sit when the bay spits you out, in world units. */
export const LAUNCH_STANDOFF = 450;
-/** ...and how fast, in world units a second — a firm push, not a cruise. */
+/**
+ * ...and how fast, in world units a second — a firm push, not a cruise.
+ *
+ * @rule station.launchSpeed
+ */
export const LAUNCH_SPEED = 120;
/**
diff --git a/src/game/course-pilot.ts b/src/game/course-pilot.ts
index 62bb8e8c..ee61c63a 100644
--- a/src/game/course-pilot.ts
+++ b/src/game/course-pilot.ts
@@ -37,7 +37,6 @@ import { rampFlightRate, type FlightDemand } from '../player.ts';
import { bankToTurn, freshSteerMemory, type SteerMemory } from './pitch-roll-steer.ts';
import type { CourseKind } from './courses.ts';
import { PLAYER_FLIGHT } from '../constants/player-flight.ts';
-import { DOCK_COMPUTER_RANGE } from '../constants/docking-computer.ts';
import {
COURSE_ARRIVE_BRAKE, COURSE_ARRIVE_TOLERANCE, COURSE_DERELICT_STANDOFF,
COURSE_HERMIT_SPEED, COURSE_HERMIT_STANDOFF, COURSE_PLANET_CLEARANCE,
@@ -71,6 +70,11 @@ export interface CourseView {
readonly loot: readonly THREE.Vector3[];
/** the docking computer already has the ship */
readonly dcEngaged: boolean;
+ /**
+ * How near the station the station course hands the ship over. A fitted
+ * docking computer takes the job from further out than a pilot does.
+ */
+ readonly handOverRange: number;
}
/** What the course pilot asks for this frame. */
@@ -166,7 +170,7 @@ export class CoursePilot {
*/
private toStation(v: CourseView, dt: number): CourseStep {
if (v.dcEngaged) return IDLE;
- if (v.position.distanceTo(v.stationPos) <= DOCK_COMPUTER_RANGE) {
+ if (v.position.distanceTo(v.stationPos) <= v.handOverRange) {
return { demand: null, torus: false, handOver: true, done: false };
}
const aim = clearOfPlanet(v.position, v.stationPos, v.planetPos, v.planetRadius, this.aim);
diff --git a/src/game/docking.ts b/src/game/docking.ts
index ddb30aa3..43a01760 100644
--- a/src/game/docking.ts
+++ b/src/game/docking.ts
@@ -32,7 +32,7 @@ import * as THREE from 'three';
import {
GATE_HALF_WIDTHS, LINED_UP_LATERAL, HULL_BOX_MARGIN,
- SLOT_HALF_ACROSS, SLOT_HALF_ALONG, SLOT_DEPTH, ROLL_TOLERANCE,
+ SLOT_HALF_ACROSS, SLOT_HALF_ALONG, SLOT_DEPTH, ROLL_TOLERANCE, SLOT_SPEED_LIMIT,
} from '../constants/docking.ts';
import { slotNormal } from '../world/slot.ts';
import { dockPath, makeDockPath } from './dock-path.ts';
@@ -229,12 +229,19 @@ export type DockingOutcome =
| 'docked'
/** in the channel but rolled wrong */
| 'slotMiss'
+ /** in the channel, lined up, and going too fast to be taken (docs/TODO/207) */
+ | 'tooFast'
/** flew into the hull */
| 'hull';
/**
- * Where a ship is relative to the slot.
+ * Where a ship is relative to the slot, and whether the slot will take it.
*
+ * The slot asks two things of a ship in the channel: the roll, and the speed
+ * (docs/TODO/207 M3). It is one answer, so the caller cannot hold half of the
+ * rule.
+ *
+ * @param speed how fast the ship is going, against `SLOT_SPEED_LIMIT`
* @param scratch a Vector3 and a Quaternion to work in; this runs every frame.
*/
export function dockingOutcome(
@@ -242,6 +249,7 @@ export function dockingOutcome(
quat: THREE.Quaternion,
station: THREE.Object3D,
dockZ: number,
+ speed: number,
scratch: { v: THREE.Vector3; q: THREE.Quaternion; r: THREE.Vector3 },
): DockingOutcome {
const box = dockZ + HULL_BOX_MARGIN;
@@ -256,5 +264,6 @@ export function dockingOutcome(
scratch.q.copy(station.quaternion).invert().multiply(quat);
const right = scratch.r.set(1, 0, 0).applyQuaternion(scratch.q);
- return rollAlignedWithSlot(right.x, right.y) ? 'docked' : 'slotMiss';
+ if (!rollAlignedWithSlot(right.x, right.y)) return 'slotMiss';
+ return speed > SLOT_SPEED_LIMIT ? 'tooFast' : 'docked';
}
diff --git a/src/game/flight-instruments.ts b/src/game/flight-instruments.ts
index d3a22d69..3a0d5e46 100644
--- a/src/game/flight-instruments.ts
+++ b/src/game/flight-instruments.ts
@@ -27,6 +27,8 @@ import { MAX_FUEL } from '../constants/commander.ts';
import { hostilesOnScanner } from './hostility.ts';
import { pickTarget, pickedTarget } from './targets.ts';
import { SCANNER_RANGE } from '../constants/console.ts';
+import { DOCK_COMPUTER_RANGE } from '../constants/docking-computer.ts';
+import { COURSE_DOCK_HANDOVER } from '../constants/course.ts';
/**
* What the console says when a course finishes its work. The hermit course
@@ -174,13 +176,53 @@ export class Instruments {
threats: hostilesOnScanner(w.npcs, p.position, this.state.commander.legalStatus,
p.position.distanceTo(w.station.position)).map((n) => n.object.position),
dcEngaged: s.dcEngaged,
+ handOverRange: this.state.commander.equipment.dockingComputer
+ ? DOCK_COMPUTER_RANGE : COURSE_DOCK_HANDOVER,
}, dt);
- if (step.handOver) this.applyAutopilot(this.autopilot.handOverToDock());
+ if (step.handOver) this.handOver();
if (step.torus !== s.torusEngaged && (!step.torus || !this.massLocked())) this.toggleTorus();
if (step.done) this.endCourse(s.course);
return step.demand;
}
+ /**
+ * The station course reaches the hand-over, and the ship changes hands
+ * (docs/TODO/207 M1).
+ *
+ * With a docking computer fitted, that computer flies the slot, as it does
+ * today. Without one, the pilot flies the last stretch. The computer holds
+ * the ship on the slot axis. The pilot matches the station's spin, and the
+ * speed.
+ */
+ private handOver(): void {
+ const s = this.state.session;
+ if (this.state.commander.equipment.dockingComputer) {
+ this.applyAutopilot(this.autopilot.handOverToDock());
+ return;
+ }
+ s.dockTrial = true;
+ s.course = null;
+ this.coursePilot.reset();
+ this.host.showMessage('YOU HAVE THE SLOT — MATCH ITS SPIN AND GO IN SLOWLY', 5);
+ }
+
+ /**
+ * The pilot's stretch ends when the ship leaves the docking computer's
+ * range, which is the width of the whole approach. The course list then
+ * offers the station again.
+ *
+ * @internal — driven by src/game/flight.ts, once per fixed step.
+ */
+ watchDockTrial(): void {
+ const s = this.state.session;
+ if (!s.dockTrial) return;
+ const out = this.state.player.position
+ .distanceTo(this.state.world.station.position) > DOCK_COMPUTER_RANGE;
+ if (!out) return;
+ s.dockTrial = false;
+ this.host.showMessage('THE STATION IS BEHIND YOU', 3);
+ }
+
/**
* The mining course keeps a rock picked as the target, so the computer aims
* at it and the pilot fires.
diff --git a/src/game/flight.ts b/src/game/flight.ts
index 70f9f659..19be1b7e 100644
--- a/src/game/flight.ts
+++ b/src/game/flight.ts
@@ -322,6 +322,7 @@ export class Flight {
if (this.input.mouseFlight) this.input.decayMouse(dt);
// A fight gives every pilot the computer's aim (docs/TODO/206 M2).
this.instruments.autoEngage();
+ this.instruments.watchDockTrial();
// A picked course flies when no co-pilot does (docs/TODO/205 M3). The
// trigger stays the pilot's, as it does under the co-pilot.
if (!this.state.session.ccEngaged) {
diff --git a/src/game/session.ts b/src/game/session.ts
index 459e7a3c..354256d2 100644
--- a/src/game/session.ts
+++ b/src/game/session.ts
@@ -104,6 +104,12 @@ export interface SessionState {
* decides who flies.
*/
handFlown: boolean;
+ /**
+ * The pilot flies the last of the approach (docs/TODO/207). The computer
+ * holds the ship on the slot axis, and the pilot matches the station's spin
+ * and the speed. It is saved, because it decides who flies.
+ */
+ dockTrial: boolean;
}
/**
@@ -114,6 +120,7 @@ export function endVisit(state: SessionState): void {
state.course = null;
state.coursesDone = [];
state.handFlown = false;
+ state.dockTrial = false;
}
/** Put a message in canonical state; the HUD only paints these fields. */
diff --git a/src/game/state.ts b/src/game/state.ts
index 9afea6ec..39e9f45f 100644
--- a/src/game/state.ts
+++ b/src/game/state.ts
@@ -159,6 +159,7 @@ export function freshSession(): SessionState {
course: null,
coursesDone: [],
handFlown: false,
+ dockTrial: false,
};
}
diff --git a/src/game/world-step.ts b/src/game/world-step.ts
index 189f423e..a18ab0d1 100644
--- a/src/game/world-step.ts
+++ b/src/game/world-step.ts
@@ -50,7 +50,7 @@ import {
PIRATE_WAVE_RANGE, PIRATE_WAVE_RANGE_SPAN, THARGON_DEPLOY_RANGE,
TRADER_ARRIVAL_RANGE,
} from '../constants/spawn-placement.ts';
-import { planDocking, dockingOutcome } from './docking.ts';
+import { planDocking, dockingOutcome, type DockingOutcome } from './docking.ts';
import { dockingSticks } from './docking-sticks.ts';
import { NPC_HULL_BOX_MARGIN } from '../constants/docking.ts';
import { BOUNCE_STANDOFF } from '../constants/station.ts';
@@ -230,6 +230,13 @@ export interface PilotInput {
* Holds the state, the missiles and the host — and its own scratch vectors, so
* stepping at 60Hz allocates nothing.
*/
+/** What the console says about a dock that did not take (docs/TODO/207 M3). */
+const SCRAPE_SAID: Partial- Get it wrong and you bounce off the hull with a bang and most of a - shield gone. Get it wrong on a shield that is already down and it - reaches you. + Arrive rolled wrong, or too fast, and you bounce off the hull with a + bang and most of a shield gone. The station puts you back outside, and + you can try again. Get it wrong on a shield that is already down and + it reaches you.
When you have the money, buy a docking computer. It is the
diff --git a/src/game/course-pilot.ts b/src/game/course-pilot.ts
index ee61c63a..273802f7 100644
--- a/src/game/course-pilot.ts
+++ b/src/game/course-pilot.ts
@@ -37,6 +37,7 @@ import { rampFlightRate, type FlightDemand } from '../player.ts';
import { bankToTurn, freshSteerMemory, type SteerMemory } from './pitch-roll-steer.ts';
import type { CourseKind } from './courses.ts';
import { PLAYER_FLIGHT } from '../constants/player-flight.ts';
+import { SLOT_SPEED_LIMIT } from '../constants/docking.ts';
import {
COURSE_ARRIVE_BRAKE, COURSE_ARRIVE_TOLERANCE, COURSE_DERELICT_STANDOFF,
COURSE_HERMIT_SPEED, COURSE_HERMIT_STANDOFF, COURSE_PLANET_CLEARANCE,
@@ -170,11 +171,18 @@ export class CoursePilot {
*/
private toStation(v: CourseView, dt: number): CourseStep {
if (v.dcEngaged) return IDLE;
+ // It ARRIVES at the hand-over, at the speed the slot will take
+ // (docs/TODO/207 M1). A ship handed over at full speed has less than four
+ // seconds to lose 280 units a second. That is no way to meet a pilot.
+ // The hand-over is a RANGE rather than an arrival. An arrival also asks
+ // for the speed to settle. A ship a few units a second over it would sail
+ // past the station while it waited.
if (v.position.distanceTo(v.stationPos) <= v.handOverRange) {
return { demand: null, torus: false, handOver: true, done: false };
}
- const aim = clearOfPlanet(v.position, v.stationPos, v.planetPos, v.planetRadius, this.aim);
- return { ...this.pointAt(v, aim, 1, dt), handOver: false, done: false };
+ return this.arrive(v, {
+ target: v.stationPos, standoff: v.handOverRange, speed: SLOT_SPEED_LIMIT,
+ }, dt);
}
/**
diff --git a/src/game/game.ts b/src/game/game.ts
index 33145f67..bb18b184 100644
--- a/src/game/game.ts
+++ b/src/game/game.ts
@@ -457,8 +457,11 @@ export class Game {
// The course buttons, in career flight only. An exercise is a room at
// the station, and it has nowhere to go (docs/TODO/205 M5). During a
// tunnel the cockpit reads no button, so it shows none.
+ // It shows none while the pilot flies the slot either. The ship is in
+ // the station's mouth then. The only things to fly are the roll and the
+ // speed (docs/TODO/207 M2).
coursePanel: () => (this.flight_.inSimulator() || this.tunnel.active
- ? null : this.courses_.panel()),
+ || this.state.session.dockTrial ? null : this.courses_.panel()),
// The target buttons, in career flight and in an exercise alike: a
// training fight is a real fight (docs/TODO/206 M3).
targetPanel: () => (this.tunnel.active ? null : this.targets_.panel()),
diff --git a/src/style.css b/src/style.css
index a58a3bd2..59b38c28 100644
--- a/src/style.css
+++ b/src/style.css
@@ -504,10 +504,11 @@ body.screen-open #courses { visibility: hidden; }
}
.bar .limit {
position: absolute;
- top: -2px; bottom: -2px;
+ top: -3px; bottom: -3px;
width: 2px;
- background: var(--hud-amber);
- opacity: 0.85;
+ z-index: 1;
+ background: var(--hud-green);
+ box-shadow: 0 0 6px rgba(var(--hud-green-rgb), 0.9);
}
#actions .hud-strip .label {
diff --git a/src/ui/briefing.ts b/src/ui/briefing.ts
index 9102569f..1516f5ee 100644
--- a/src/ui/briefing.ts
+++ b/src/ui/briefing.ts
@@ -130,15 +130,18 @@ export const BRIEFING: { title: string; body: string }[] = [
{
title: 'DOCKING',
body: `The hard part, and everybody finds it hard at first.
- The station rotates, and so does its docking port. An amber marker
- shows where the port is, with an arrow at the edge of the screen when it
- is behind you.
- Get onto the axis straight out from the port, then roll until you match
- its rotation — the opening is a letterbox and you must be the same way
- up as it. Then go in slowly. The marker turns green when you are lined
- up.
- When you can afford one, buy a docking computer and press
- ${KEY.dockingComputer}.`,
+ The station rotates, and so does its docking port. Choose
+ FLY TO THE STATION and the ship flies the approach for you. Near
+ the port it hands you the last stretch, and two things are then
+ yours.
+ The first is the roll: the opening is a letterbox, and you must be
+ the same way up as it. Drag the strip at the bottom right, or use your
+ roll keys. The second is the speed: hold THRUST or
+ BRAKE, and cross the mark on the speed bar before you go in. The
+ port marker turns green when you are lined up.
+ Get it wrong and you scrape the hull, bounce clear and try again. When
+ you can afford one, buy a docking computer and press
+ ${KEY.dockingComputer}: it flies the slot for you.`,
},
{
title: 'STAYING ALIVE',
From fc11b1041f85b0669a46edf0f85514aebae9d450 Mon Sep 17 00:00:00 2001
From: Chris Greening SAMPLE_HZ | 10 | How often the code samples the geometry, in Hz. | | [combat-record.ts:12](./combat-record.ts#L12) |
| combat-record | SIX_CONE | Math.PI / 3 | The rear cone that counts as somebody's six, as a half-angle from directly astern. | | [combat-record.ts:20](./combat-record.ts#L20) |
| combat-record | PASS_CLOSE | 400 | What an attack run is, in ranges. | | [combat-record.ts:42](./combat-record.ts#L42) |
-| combat-record | PASS_FAR | 600 | | | [combat-record.ts:43](./combat-record.ts#L43) |
-| combat-record | SIM_LOG_LIMIT | 20 | How many exercise records the in-memory ring keeps. | combatrecord.simLogLimit | [combat-record.ts:50](./combat-record.ts#L50) |
-| combat-record | MAX_SAMPLES | 12_000 | Samples kept before the buffer closes. | record.maxSamples | [combat-record.ts:63](./combat-record.ts#L63) |
+| combat-record | PASS_FAR | 600 | ...and the far end of that pair: the range a ship must break back out to for the pass to count. | combatrecord.passFar | [combat-record.ts:49](./combat-record.ts#L49) |
+| combat-record | SIM_LOG_LIMIT | 20 | How many exercise records the in-memory ring keeps. | combatrecord.simLogLimit | [combat-record.ts:56](./combat-record.ts#L56) |
+| combat-record | MAX_SAMPLES | 12_000 | Samples kept before the buffer closes. | record.maxSamples | [combat-record.ts:69](./combat-record.ts#L69) |
| commander | DEFAULT_NAME | 'JAMESON' | The original's own commander, and the default here. | | [commander.ts:10](./commander.ts#L10) |
| commander | STARTING_CREDITS | 1000 | The grubstake, in tenths of a credit — the classic 100.0 Cr. | commander.startingCredits | [commander.ts:18](./commander.ts#L18) |
| commander | CHEAT_CREDIT_GRANT | 100_000 | What test mode's GRANT CREDITS row hands over per press, in tenths: 10,000 Cr, which is a hundred grubstakes (`game/screens/test-mode.ts`). | | [commander.ts:40](./commander.ts#L40) |
@@ -128,6 +128,8 @@ search names, meanings and values with `npm run constants:find -- "COURSE_RUN_REACH | 50_000 | How far ahead the run course aims, in world units, on the line away from the hostile ships (docs/TODO/206 M5). | course.runReach | [course.ts:169](./course.ts#L169) |
| course | COURSE_COLLECT_SPEED | 60 | How fast the collect course flies onto a canister, in world units a second (docs/TODO/206 M6). | course.collectSpeed | [course.ts:183](./course.ts#L183) |
| course | COURSE_DOCK_HANDOVER | 1500 | How far from the station the station course hands the ship to the pilot, in world units (docs/TODO/207 M1). | course.dockHandover | [course.ts:197](./course.ts#L197) |
+| course | COURSE_WATCH_STANDOFF | 1200 | How far from a ship the scan course holds, in world units (docs/TODO/208 M2). | course.watchStandoff | [course.ts:211](./course.ts#L211) |
+| course | COURSE_ESCORT_STANDOFF | 600 | How far from its charge the escort course flies, in world units (docs/TODO/208 M2). | course.escortStandoff | [course.ts:224](./course.ts#L224) |
| docking | GATE_HALF_WIDTHS | 5 | How far out the approach gate sits, in multiples of the station half-width. | docking.gateHalfWidths | [docking.ts:25](./docking.ts#L25) |
| docking | TURN_IN | Math.PI / 4 | How far round from the slot axis an approach turns in, in radians. | | [docking.ts:49](./docking.ts#L49) |
| docking | RUN_IN_WIDTHS | GATE_HALF_WIDTHS * 0.6 | Where the approach stops the curve and flies straight, in station half-widths. | | [docking.ts:87](./docking.ts#L87) |
@@ -147,16 +149,16 @@ search names, meanings and values with `npm run constants:find -- "TRADER_GAP | 100 | The gap between trader arrivals in a system with no economy to speak of. | | [encounters.ts:20](./encounters.ts#L20) |
| encounters | TRADER_GAP_JITTER | 60 | ...and the jitter on top, drawn flat, so the lane never runs to a metronome. * | encounters.traderGapJitter | [encounters.ts:25](./encounters.ts#L25) |
| encounters | TRADER_GAP_BUSY_MAX | 50 | The most that a busy economy can discount off `TRADER_GAP`. | encounters.traderGapBusyMax | [encounters.ts:40](./encounters.ts#L40) |
-| encounters | PRODUCTIVITY_PER_SECOND | 1200 | How much 1984 productivity buys one second off that gap. | | [encounters.ts:51](./encounters.ts#L51) |
-| encounters | TRADER_GAP_FIRST | 20 | How long after a system's clocks start the first trader may appear. | encounters.traderGapFirst | [encounters.ts:64](./encounters.ts#L64) |
-| encounters | TRADER_GAP_FIRST_JITTER | 40 | ...and its jitter, so two arrivals in one system are not the same arrival. * | encounters.traderGapFirstJitter | [encounters.ts:69](./encounters.ts#L69) |
-| encounters | PIRATE_WAVE_GAP | 60 | The gap between pirate waves in the most organised system that still breeds them. | encounters.pirateWaveGap | [encounters.ts:79](./encounters.ts#L79) |
-| encounters | PIRATE_WAVE_GAP_PER_GOVERNMENT | 40 | ...and how much longer the wait grows for every step up the government ladder. | encounters.pirateWaveGapPerGovernment | [encounters.ts:92](./encounters.ts#L92) |
-| encounters | PIRATE_WAVE_GAP_JITTER | 90 | ...and the jitter. | | [encounters.ts:96](./encounters.ts#L96) |
-| encounters | LAWLESS_GOVERNMENT | 3 | A government at or below this breeds pirate waves at all. 3 is a dictatorship on the 1984 ladder, so waves stop at communist (4) and above. | encounters.lawlessGovernment | [encounters.ts:108](./encounters.ts#L108) |
-| encounters | ANARCHY_GOVERNMENT | 1 | ...and a government at or below THIS sends them two at a time: anarchy (0) and feudal (1). | encounters.anarchyGovernment | [encounters.ts:124](./encounters.ts#L124) |
-| encounters | MAX_THARGONS | 2 | How many drones the Thargoids keep in the sky at once, across every mothership. | encounters.maxThargons | [encounters.ts:144](./encounters.ts#L144) |
-| encounters | THARGON_REDEPLOY | 5 | Seconds between one drone and the next, and the wait for the first. | encounters.thargonRedeploy | [encounters.ts:159](./encounters.ts#L159) |
+| encounters | PRODUCTIVITY_PER_SECOND | 1200 | How much 1984 productivity buys one second off that gap. | encounters.productivityPerSecond | [encounters.ts:53](./encounters.ts#L53) |
+| encounters | TRADER_GAP_FIRST | 20 | How long after a system's clocks start the first trader may appear. | encounters.traderGapFirst | [encounters.ts:66](./encounters.ts#L66) |
+| encounters | TRADER_GAP_FIRST_JITTER | 40 | ...and its jitter, so two arrivals in one system are not the same arrival. * | encounters.traderGapFirstJitter | [encounters.ts:71](./encounters.ts#L71) |
+| encounters | PIRATE_WAVE_GAP | 60 | The gap between pirate waves in the most organised system that still breeds them. | encounters.pirateWaveGap | [encounters.ts:81](./encounters.ts#L81) |
+| encounters | PIRATE_WAVE_GAP_PER_GOVERNMENT | 40 | ...and how much longer the wait grows for every step up the government ladder. | encounters.pirateWaveGapPerGovernment | [encounters.ts:94](./encounters.ts#L94) |
+| encounters | PIRATE_WAVE_GAP_JITTER | 90 | ...and the jitter. | | [encounters.ts:98](./encounters.ts#L98) |
+| encounters | LAWLESS_GOVERNMENT | 3 | A government at or below this breeds pirate waves at all. 3 is a dictatorship on the 1984 ladder, so waves stop at communist (4) and above. | encounters.lawlessGovernment | [encounters.ts:110](./encounters.ts#L110) |
+| encounters | ANARCHY_GOVERNMENT | 1 | ...and a government at or below THIS sends them two at a time: anarchy (0) and feudal (1). | encounters.anarchyGovernment | [encounters.ts:126](./encounters.ts#L126) |
+| encounters | MAX_THARGONS | 2 | How many drones the Thargoids keep in the sky at once, across every mothership. | encounters.maxThargons | [encounters.ts:146](./encounters.ts#L146) |
+| encounters | THARGON_REDEPLOY | 5 | Seconds between one drone and the next, and the wait for the first. | encounters.thargonRedeploy | [encounters.ts:161](./encounters.ts#L161) |
| exercise | OPENING_RANGE | 4500 | The opening range for a fight that you are meant to see coming. | exercise.openingRange | [exercise.ts:33](./exercise.ts#L33) |
| exercise | AMBUSH_RANGE | 2400 | An ambush opens INSIDE their gun, because that is what an ambush is. | | [exercise.ts:40](./exercise.ts#L40) |
| exercise | MIN_OPENING_RANGE | 2 * PASS_FAR | No opening may be closer than this. | | [exercise.ts:49](./exercise.ts#L49) |
@@ -344,35 +346,35 @@ search names, meanings and values with `npm run constants:find -- "BEAM_LASER_PRICE | 10000 | The beam laser's price. | | [shop.ts:34](./shop.ts#L34) |
| shop | EQUIPMENT_CATALOGUE | [ { id: 'missile', name: 'Missile', price: 300, minTL: 1 }, { id: 'largeBay', name: `Large Cargo Bay (${LARGE_BAY_TONNES}t)`, price: 4000, minTL: 1 }, { id: 'ecm', name: 'E.C.M. System', price: 6000, minTL: 2 }, { id: 'rearLaser', name: 'Rear Pulse Laser', price: PULSE_LASER_PRICE, minTL: 3 }, { id: 'leftLaser', name: 'Left Pulse Laser', price: PULSE_LASER_PRICE, minTL: 3 }, { id: 'rightLaser', name: 'Right Pulse Laser', price: PULSE_LASER_PRICE, minTL: 3 }, { id: 'beam', name: 'Beam Laser', price: BEAM_LASER_PRICE, minTL: 4 }, { id: 'scoops', name: 'Fuel Scoops', price: 5250, minTL: 5 }, { id: 'escapePod', name: 'Escape Pod', price: 10000, minTL: 6 }, { id: 'energyBomb', name: 'Energy Bomb', price: 9000, minTL: 7 }, { id: 'energyUnit', name: 'Extra Energy Unit', price: 15000, minTL: 8 }, { id: 'dockingComputer', name: 'Docking Computer', price: 15000, minTL: 9 }, { id: 'miningLaser', name: 'Mining Laser', price: 8000, minTL: 10 }, { id: 'combatComputer', name: 'Combat Computer', price: 20000, minTL: 9 }, { id: 'trumble', name: 'Trumble (adorable, harmless*)', price: 20, minTL: 1 }, { id: 'military', name: 'Military Laser', price: 60000, minTL: 10 }, { id: 'galacticDrive', name: 'Galactic Hyperdrive', price: 50000, minTL: 10 }, ] | The outfitter's shelf, in the order the screen lists it. | | [shop.ts:49](./shop.ts#L49) |
| spawn-placement | TRADER_SCATTER | 1800 | How far from the station a trader on its run loiters. | | [spawn-placement.ts:25](./spawn-placement.ts#L25) |
-| spawn-placement | POLICE_SCATTER | 1200 | How far off the arrival corridor the police scatter. | | [spawn-placement.ts:33](./spawn-placement.ts#L33) |
-| spawn-placement | POLICE_PATROL_RANGE | 18_000 | How far a launch scatters the patrol across the system. | | [spawn-placement.ts:41](./spawn-placement.ts#L41) |
-| spawn-placement | ASTEROID_SCATTER | 5000 | How far the rocks scatter round the station. | | [spawn-placement.ts:55](./spawn-placement.ts#L55) |
-| spawn-placement | ASTEROID_LANE_SCATTER | SCANNER_RANGE / 1.5 | How far off the arrival corridor each rock sits. | | [spawn-placement.ts:73](./spawn-placement.ts#L73) |
-| spawn-placement | HUNTER_SCATTER | 6000 | How far out a bounty hunter starts, at work on the whole system. | spawn.hunterScatter | [spawn-placement.ts:83](./spawn-placement.ts#L83) |
-| spawn-placement | HERMIT_SCATTER | 14_000 | How far out the rock hermit hides. | | [spawn-placement.ts:97](./spawn-placement.ts#L97) |
-| spawn-placement | CORRIDOR_START | 0.1 | Where along the route from you to the station the nearest pirate can be, as a fraction of that route. | spawn.corridorStart | [spawn-placement.ts:111](./spawn-placement.ts#L111) |
-| spawn-placement | CORRIDOR_SPAN | 0.75 | How much of the route the rest are spread across. 0.1 + 0.75 leaves the last 15% clear, which is the approach to the station. | | [spawn-placement.ts:117](./spawn-placement.ts#L117) |
-| spawn-placement | PIRATE_SCATTER | 2500 | How far off the corridor's line each pirate sits. | spawn.pirateScatter | [spawn-placement.ts:130](./spawn-placement.ts#L130) |
-| spawn-placement | TRADER_ARRIVAL_RANGE | 22_000 | How far out a fresh trader warps in. | | [spawn-placement.ts:137](./spawn-placement.ts#L137) |
-| spawn-placement | DEEP_TRADER_RANGE | 12_000 | How far AHEAD OF THE COMMANDER a trader warps in, out in deep space. | spawn.deepTraderRange | [spawn-placement.ts:150](./spawn-placement.ts#L150) |
-| spawn-placement | DEEP_TRADER_CONE | Math.asin(MASS_LOCK_SHIP / DEEP_TRADER_RANGE) | The half-angle of the cone it warps into, about the commander's own heading, in RADIANS. 0.5 is about 29 degrees. | spawn.deepTraderCone | [spawn-placement.ts:177](./spawn-placement.ts#L177) |
-| spawn-placement | DEEP_TRADER_RUN | 30_000 | How far it runs before it jumps out. | | [spawn-placement.ts:196](./spawn-placement.ts#L196) |
-| spawn-placement | PIRATE_WAVE_RANGE | 9000 | How far from the commander a pirate wave warps in. | | [spawn-placement.ts:204](./spawn-placement.ts#L204) |
-| spawn-placement | PIRATE_WAVE_RANGE_SPAN | 4000 | ...and how much further out than that they may be. | | [spawn-placement.ts:207](./spawn-placement.ts#L207) |
-| spawn-placement | GENERATION_SHIP_RANGE | 14_000 | How far from the commander a generation ship crosses. | | [spawn-placement.ts:213](./spawn-placement.ts#L213) |
-| spawn-placement | GENERATION_SHIP_RANGE_SPAN | 8000 | ...and the width of that band. | | [spawn-placement.ts:216](./spawn-placement.ts#L216) |
-| spawn-placement | GENERATION_CARGO_SCATTER | 700 | How far from a generation ship its shed cargo drifts. | | [spawn-placement.ts:223](./spawn-placement.ts#L223) |
-| spawn-placement | MISSION_TARGET_RANGE | 2500 | How far from the commander a mission's target waits on arrival. | spawn.missionTargetRange | [spawn-placement.ts:238](./spawn-placement.ts#L238) |
-| spawn-placement | MISSION_TARGET_RANGE_SPAN | 2000 | ...and the width of that band, so a target sits between 2,500 and 4,500 out. | | [spawn-placement.ts:241](./spawn-placement.ts#L241) |
-| spawn-placement | THARGON_DEPLOY_RANGE | 150 | How far from its mother a Thargon drone appears. | | [spawn-placement.ts:247](./spawn-placement.ts#L247) |
-| spawn-placement | STATION_DEFENCE_MIN | 1 | The fewest Vipers the station launches after you shoot at something you should not. | spawn.stationDefenceMin | [spawn-placement.ts:260](./spawn-placement.ts#L260) |
-| spawn-placement | STATION_DEFENCE_SPAN | 2 | ...and the width of that draw: one or two of them. | spawn.stationDefenceSpan | [spawn-placement.ts:271](./spawn-placement.ts#L271) |
-| spawn-placement | STATION_DEFENCE_STANDOFF | 500 | How far out of the slot the first one launches. | | [spawn-placement.ts:277](./spawn-placement.ts#L277) |
-| spawn-placement | STATION_DEFENCE_STACK | 120 | ...and how much further out each one after it starts, so a pair does not arrive inside each other. | spawn.stationDefenceStack | [spawn-placement.ts:285](./spawn-placement.ts#L285) |
-| spawn-placement | STATION_DEFENCE_JITTER | 80 | ...and the random nudge on each, so a second launch does not look like the first. | | [spawn-placement.ts:295](./spawn-placement.ts#L295) |
-| spawn-placement | TRADER_ARRIVED | 900 | How near the station an arriving trader has to be to start trading. | spawn.traderArrived | [spawn-placement.ts:310](./spawn-placement.ts#L310) |
-| spawn-placement | TRADER_JUMP_OUT | 2500 | How near its waypoint a departing trader has to be to jump out. | spawn.traderJumpOut | [spawn-placement.ts:326](./spawn-placement.ts#L326) |
-| spawn-placement | SPAWN_PLANET_ALTITUDE | 1000 | The lowest a ship may appear above the planet's surface, measured to the ship's centre, in world units. | spawn.planetAltitude | [spawn-placement.ts:345](./spawn-placement.ts#L345) |
+| spawn-placement | POLICE_SCATTER | 1200 | How far off the arrival corridor the police scatter. | spawn.policeScatter | [spawn-placement.ts:35](./spawn-placement.ts#L35) |
+| spawn-placement | POLICE_PATROL_RANGE | 18_000 | How far a launch scatters the patrol across the system. | | [spawn-placement.ts:43](./spawn-placement.ts#L43) |
+| spawn-placement | ASTEROID_SCATTER | 5000 | How far the rocks scatter round the station. | | [spawn-placement.ts:57](./spawn-placement.ts#L57) |
+| spawn-placement | ASTEROID_LANE_SCATTER | SCANNER_RANGE / 1.5 | How far off the arrival corridor each rock sits. | | [spawn-placement.ts:75](./spawn-placement.ts#L75) |
+| spawn-placement | HUNTER_SCATTER | 6000 | How far out a bounty hunter starts, at work on the whole system. | spawn.hunterScatter | [spawn-placement.ts:85](./spawn-placement.ts#L85) |
+| spawn-placement | HERMIT_SCATTER | 14_000 | How far out the rock hermit hides. | | [spawn-placement.ts:99](./spawn-placement.ts#L99) |
+| spawn-placement | CORRIDOR_START | 0.1 | Where along the route from you to the station the nearest pirate can be, as a fraction of that route. | spawn.corridorStart | [spawn-placement.ts:113](./spawn-placement.ts#L113) |
+| spawn-placement | CORRIDOR_SPAN | 0.75 | How much of the route the rest are spread across. 0.1 + 0.75 leaves the last 15% clear, which is the approach to the station. | | [spawn-placement.ts:119](./spawn-placement.ts#L119) |
+| spawn-placement | PIRATE_SCATTER | 2500 | How far off the corridor's line each pirate sits. | spawn.pirateScatter | [spawn-placement.ts:132](./spawn-placement.ts#L132) |
+| spawn-placement | TRADER_ARRIVAL_RANGE | 22_000 | How far out a fresh trader warps in. | | [spawn-placement.ts:139](./spawn-placement.ts#L139) |
+| spawn-placement | DEEP_TRADER_RANGE | 12_000 | How far AHEAD OF THE COMMANDER a trader warps in, out in deep space. | spawn.deepTraderRange | [spawn-placement.ts:152](./spawn-placement.ts#L152) |
+| spawn-placement | DEEP_TRADER_CONE | Math.asin(MASS_LOCK_SHIP / DEEP_TRADER_RANGE) | The half-angle of the cone it warps into, about the commander's own heading, in RADIANS. 0.5 is about 29 degrees. | spawn.deepTraderCone | [spawn-placement.ts:179](./spawn-placement.ts#L179) |
+| spawn-placement | DEEP_TRADER_RUN | 30_000 | How far it runs before it jumps out. | | [spawn-placement.ts:198](./spawn-placement.ts#L198) |
+| spawn-placement | PIRATE_WAVE_RANGE | 9000 | How far from the commander a pirate wave warps in. | | [spawn-placement.ts:206](./spawn-placement.ts#L206) |
+| spawn-placement | PIRATE_WAVE_RANGE_SPAN | 4000 | ...and how much further out than that they may be. | | [spawn-placement.ts:209](./spawn-placement.ts#L209) |
+| spawn-placement | GENERATION_SHIP_RANGE | 14_000 | How far from the commander a generation ship crosses. | | [spawn-placement.ts:215](./spawn-placement.ts#L215) |
+| spawn-placement | GENERATION_SHIP_RANGE_SPAN | 8000 | ...and the width of that band. | | [spawn-placement.ts:218](./spawn-placement.ts#L218) |
+| spawn-placement | GENERATION_CARGO_SCATTER | 700 | How far from a generation ship its shed cargo drifts. | | [spawn-placement.ts:225](./spawn-placement.ts#L225) |
+| spawn-placement | MISSION_TARGET_RANGE | 2500 | How far from the commander a mission's target waits on arrival. | spawn.missionTargetRange | [spawn-placement.ts:240](./spawn-placement.ts#L240) |
+| spawn-placement | MISSION_TARGET_RANGE_SPAN | 2000 | ...and the width of that band, so a target sits between 2,500 and 4,500 out. | | [spawn-placement.ts:243](./spawn-placement.ts#L243) |
+| spawn-placement | THARGON_DEPLOY_RANGE | 150 | How far from its mother a Thargon drone appears. | | [spawn-placement.ts:249](./spawn-placement.ts#L249) |
+| spawn-placement | STATION_DEFENCE_MIN | 1 | The fewest Vipers the station launches after you shoot at something you should not. | spawn.stationDefenceMin | [spawn-placement.ts:262](./spawn-placement.ts#L262) |
+| spawn-placement | STATION_DEFENCE_SPAN | 2 | ...and the width of that draw: one or two of them. | spawn.stationDefenceSpan | [spawn-placement.ts:273](./spawn-placement.ts#L273) |
+| spawn-placement | STATION_DEFENCE_STANDOFF | 500 | How far out of the slot the first one launches. | | [spawn-placement.ts:279](./spawn-placement.ts#L279) |
+| spawn-placement | STATION_DEFENCE_STACK | 120 | ...and how much further out each one after it starts, so a pair does not arrive inside each other. | spawn.stationDefenceStack | [spawn-placement.ts:287](./spawn-placement.ts#L287) |
+| spawn-placement | STATION_DEFENCE_JITTER | 80 | ...and the random nudge on each, so a second launch does not look like the first. | | [spawn-placement.ts:297](./spawn-placement.ts#L297) |
+| spawn-placement | TRADER_ARRIVED | 900 | How near the station an arriving trader has to be to start trading. | spawn.traderArrived | [spawn-placement.ts:312](./spawn-placement.ts#L312) |
+| spawn-placement | TRADER_JUMP_OUT | 2500 | How near its waypoint a departing trader has to be to jump out. | spawn.traderJumpOut | [spawn-placement.ts:328](./spawn-placement.ts#L328) |
+| spawn-placement | SPAWN_PLANET_ALTITUDE | 1000 | The lowest a ship may appear above the planet's surface, measured to the ship's centre, in world units. | spawn.planetAltitude | [spawn-placement.ts:347](./spawn-placement.ts#L347) |
| station | STATION_SPIN | 0.26 | How fast the station spins about its slot axis, in radians a second. | | [station.ts:19](./station.ts#L19) |
| station | DODO_TECH_LEVEL | 10 | The tech level at which a system's station is the dodecahedral Dodo rather than the Coriolis, in SHOWN one-based units. | | [station.ts:32](./station.ts#L32) |
| station | BOUNCE_STANDOFF | 420 | Where a fluffed docking bounces you to. | | [station.ts:48](./station.ts#L48) |
diff --git a/src/constants/combat-record.ts b/src/constants/combat-record.ts
index 33c8761d..3c6d6e63 100644
--- a/src/constants/combat-record.ts
+++ b/src/constants/combat-record.ts
@@ -40,6 +40,12 @@ export const SIX_CONE = Math.PI / 3;
* passes at this threshold.
*/
export const PASS_CLOSE = 400;
+/**
+ * ...and the far end of that pair: the range a ship must break back out to
+ * for the pass to count. The comment above explains both.
+ *
+ * @rule combatrecord.passFar
+ */
export const PASS_FAR = 600;
/**
diff --git a/src/constants/course.ts b/src/constants/course.ts
index fbefe853..8e901cfc 100644
--- a/src/constants/course.ts
+++ b/src/constants/course.ts
@@ -195,3 +195,30 @@ export const COURSE_COLLECT_SPEED = 60;
* @domain course
*/
export const COURSE_DOCK_HANDOVER = 1500;
+
+/**
+ * How far from a ship the scan course holds, in world units
+ * (docs/TODO/208 M2).
+ *
+ * A scan counts seconds while the ship is inside `SCANNER_RANGE`, which is
+ * 6,000, and within `WATCH_CONE` of the nose. So the hold sits well inside
+ * the range, and near enough that the ship fills a useful part of the cone.
+ * It is far enough out that a trader's own wandering does not shake it off.
+ *
+ * @rule course.watchStandoff
+ * @domain course
+ */
+export const COURSE_WATCH_STANDOFF = 1200;
+
+/**
+ * How far from its charge the escort course flies, in world units
+ * (docs/TODO/208 M2).
+ *
+ * The escort is safe when no hostile ship is within 3,500 units of the
+ * charge. A pilot who flies this close is inside that ring, and the fight
+ * comes to the pilot rather than to the charge.
+ *
+ * @rule course.escortStandoff
+ * @domain course
+ */
+export const COURSE_ESCORT_STANDOFF = 600;
diff --git a/src/constants/encounters.ts b/src/constants/encounters.ts
index d7bde30a..6ea3fd9c 100644
--- a/src/constants/encounters.ts
+++ b/src/constants/encounters.ts
@@ -47,7 +47,9 @@ export const TRADER_GAP_BUSY_MAX = 50;
* This is therefore the exchange rate between the 1984 figure and a Harmless
* clock. It is the only place the two scales meet, and that is why neither can be
* re-based without the other.
- */
+ *
+ * @rule encounters.productivityPerSecond
+*/
export const PRODUCTIVITY_PER_SECOND = 1200;
/**
diff --git a/src/constants/spawn-placement.ts b/src/constants/spawn-placement.ts
index 1ea751ef..4b71402c 100644
--- a/src/constants/spawn-placement.ts
+++ b/src/constants/spawn-placement.ts
@@ -29,7 +29,9 @@ export const TRADER_SCATTER = 1800;
* `PIRATE_SCATTER` plays for a gang. The police patrol the lane rather than guard
* the slot, so a fugitive can still reach the slot to pay a fine (game/law.ts,
* station.ts).
- */
+ *
+ * @rule spawn.policeScatter
+*/
export const POLICE_SCATTER = 1200;
/**
diff --git a/src/game/course-actions.ts b/src/game/course-actions.ts
index e9229c55..87c7ced4 100644
--- a/src/game/course-actions.ts
+++ b/src/game/course-actions.ts
@@ -24,6 +24,7 @@ import { COURSE_KEYS, COURSE_SKIP_KEY, COURSE_TOGGLE_KEY } from './bindings.ts';
import { hostilesNear, hostilesOnScanner } from './hostility.ts';
import { SKIP_SPEED } from '../constants/course.ts';
import { SCANNER_RANGE } from '../constants/console.ts';
+import { missionCourse } from './mission-course.ts';
/**
* What the course buttons show in flight: the list, or the course under way.
@@ -200,7 +201,9 @@ export class CourseActions {
// The sky is cleared while the ship is docked (courses.ts).
sky: situation === 'launch' ? []
: s.world.npcs.filter((n) => n.state.alive).map((n) => n.role),
- mission: null,
+ mission: situation === 'launch' ? null
+ : missionCourse(s.commander.missions, s.commander.systemIndex,
+ s.world.npcs, s.world.cargo.items)?.what ?? null,
done: new Set(s.session.coursesDone),
threat: situation === 'launch' ? null : this.threat(),
loot: situation === 'launch' ? 0 : s.world.cargo.items
diff --git a/src/game/course-pilot.ts b/src/game/course-pilot.ts
index 273802f7..bd747f2a 100644
--- a/src/game/course-pilot.ts
+++ b/src/game/course-pilot.ts
@@ -41,9 +41,10 @@ import { SLOT_SPEED_LIMIT } from '../constants/docking.ts';
import {
COURSE_ARRIVE_BRAKE, COURSE_ARRIVE_TOLERANCE, COURSE_DERELICT_STANDOFF,
COURSE_HERMIT_SPEED, COURSE_HERMIT_STANDOFF, COURSE_PLANET_CLEARANCE,
- COURSE_COLLECT_SPEED, COURSE_RUN_REACH, COURSE_SKIM_DISTANCE, COURSE_TORUS_CONE,
- COURSE_TORUS_DROP,
+ COURSE_COLLECT_SPEED, COURSE_ESCORT_STANDOFF, COURSE_RUN_REACH, COURSE_SKIM_DISTANCE,
+ COURSE_TORUS_CONE, COURSE_TORUS_DROP, COURSE_WATCH_STANDOFF,
} from '../constants/course.ts';
+import type { MissionHow } from './mission-course.ts';
/** What the course pilot reads for one frame. A flat view, so a test needs no world. */
export interface CourseView {
@@ -69,6 +70,12 @@ export interface CourseView {
readonly threats: readonly THREE.Vector3[];
/** where the cargo adrift within scanner range is, nearest first */
readonly loot: readonly THREE.Vector3[];
+ /**
+ * What the mission asks for here (docs/TODO/208 M1): where its target is,
+ * how fast it moves, and what the ship does about it. Null when no live leg
+ * has work in this system.
+ */
+ readonly mission: { readonly at: THREE.Vector3; readonly speed: number; readonly how: MissionHow } | null;
/** the docking computer already has the ship */
readonly dcEngaged: boolean;
/**
@@ -145,6 +152,17 @@ export class CoursePilot {
// A rock is fought, not flown to: `flight-instruments.ts` picks the next
// one as the target, and the computer's aim lines the ship up on it.
case 'mine': return IDLE;
+ case 'mission': {
+ const m = v.mission;
+ if (m === null) return ended();
+ // A hunt is a fight: `flight-instruments.ts` picks the ship, and the
+ // computer's aim flies it, as it does for a rock.
+ if (m.how === 'fight') return IDLE;
+ const standoff = m.how === 'hold' ? COURSE_WATCH_STANDOFF
+ : m.how === 'escort' ? COURSE_ESCORT_STANDOFF : 0;
+ const speed = m.how === 'scoop' ? COURSE_COLLECT_SPEED : m.speed;
+ return { ...this.arrive(v, { target: m.at, standoff, speed }, dt), done: false };
+ }
default: return IDLE;
}
}
diff --git a/src/game/flight-instruments.ts b/src/game/flight-instruments.ts
index 3a0d5e46..e4574e40 100644
--- a/src/game/flight-instruments.ts
+++ b/src/game/flight-instruments.ts
@@ -26,6 +26,7 @@ import type { CourseKind } from './courses.ts';
import { MAX_FUEL } from '../constants/commander.ts';
import { hostilesOnScanner } from './hostility.ts';
import { pickTarget, pickedTarget } from './targets.ts';
+import { missionCourse } from './mission-course.ts';
import { SCANNER_RANGE } from '../constants/console.ts';
import { DOCK_COMPUTER_RANGE } from '../constants/docking-computer.ts';
import { COURSE_DOCK_HANDOVER } from '../constants/course.ts';
@@ -152,6 +153,17 @@ export class Instruments {
this.endCourse('mine');
return null;
}
+ // A mission's own work here, and what the ship does about it
+ // (docs/TODO/208 M1). A hunt is a fight, so the ship it names is picked
+ // as the target, exactly as a rock is.
+ const mission = s.course !== 'mission' ? null
+ : missionCourse(this.state.commander.missions, this.state.commander.systemIndex,
+ w.npcs, w.cargo.items);
+ if (mission?.how === 'fight' && mission.ship !== null
+ && pickedTarget(w.npcs) !== mission.ship) {
+ pickTarget(w.npcs, mission.ship);
+ s.handFlown = false;
+ }
const live = (role: string) => w.npcs.find((n) => n.state.alive && n.role === role) ?? null;
const derelict = live('generation');
const step = this.coursePilot.step({
@@ -176,6 +188,8 @@ export class Instruments {
threats: hostilesOnScanner(w.npcs, p.position, this.state.commander.legalStatus,
p.position.distanceTo(w.station.position)).map((n) => n.object.position),
dcEngaged: s.dcEngaged,
+ mission: mission === null ? null
+ : { at: mission.at, speed: mission.speed, how: mission.how },
handOverRange: this.state.commander.equipment.dockingComputer
? DOCK_COMPUTER_RANGE : COURSE_DOCK_HANDOVER,
}, dt);
diff --git a/src/game/mission-course.ts b/src/game/mission-course.ts
new file mode 100644
index 00000000..449ec59e
--- /dev/null
+++ b/src/game/mission-course.ts
@@ -0,0 +1,96 @@
+// What a mission asks the ship to do here, as a course (docs/TODO/208 M1).
+//
+// A mission was a line on a screen and a line on the console. It is a button
+// over the view now: the first row of the course list, in the words of the
+// job. Chris asked for that on 2026-09-11: *"the missions are becoming much
+// more important - they need to feel part of the game"*.
+//
+// It reads the live legs (`missions/queries.ts`) and the sky. It matches the
+// leg's tag to the ship or the canister that the world spawned for it
+// (`spawning.ts`). What comes back is the words for the row, and what the
+// ship must DO about it.
+//
+// FOUR SHAPES cover the five verbs that need a flight:
+//
+// - a HUNT is a fight, so the course picks the ship and the aim flies it;
+// - a SCAN is a hold: stay near it, and keep the nose on it;
+// - an ESCORT flies alongside, and the fight comes to the pilot;
+// - a RECOVER and a RESCUE are a scoop.
+//
+// The other three verbs need no course of their own. A deliver, a smuggle
+// and an ambush all end at the station, and the station course flies there.
+
+import type * as THREE from 'three';
+import type { NpcShip } from './npc.ts';
+import type { Canister } from './cargo.ts';
+import { liveLegs } from '../missions/queries.ts';
+import type { MissionState, Skeleton } from '../missions/model.ts';
+import { SKELETONS } from '../missions/skeletons/index.ts';
+
+/** What the ship does about this leg. */
+export type MissionHow = 'fight' | 'hold' | 'escort' | 'scoop';
+
+/** The mission's course: its words, what to do, and what to do it to. */
+export interface MissionCourse {
+ /** the row's words, in the job's own terms */
+ readonly what: string;
+ readonly how: MissionHow;
+ /** the ship the leg is about, or null when the leg is about a canister */
+ readonly ship: NpcShip | null;
+ readonly at: THREE.Vector3;
+ /** how fast that target is moving, so a hold and an escort can match it */
+ readonly speed: number;
+}
+
+/**
+ * What this system's live leg asks for, or null when nothing here does.
+ *
+ * The first live leg that has work in this system wins. A commander with two
+ * jobs in one system flies them one at a time.
+ */
+export function missionCourse(
+ st: MissionState,
+ here: number,
+ npcs: readonly NpcShip[],
+ items: readonly Canister[],
+ skeletons: readonly Skeleton[] = SKELETONS,
+): MissionCourse | null {
+ for (const { live, leg } of liveLegs(st, skeletons)) {
+ if (live.target !== here || live.tag === null) continue;
+ const ship = npcs.find((n) => n.state.alive && n.state.missionTag === live.tag) ?? null;
+ const item = items.find((c) => c.missionTag === live.tag) ?? null;
+ const name = ship?.object.name.toUpperCase() ?? '';
+ switch (leg.verb.kind) {
+ case 'hunt':
+ if (ship) {
+ return { what: `HUNT THE ${name}`, how: 'fight', ship, at: ship.object.position, speed: ship.state.speed };
+ }
+ break;
+ case 'scan':
+ if (ship) {
+ return { what: `SCAN THE ${name}`, how: 'hold', ship, at: ship.object.position, speed: ship.state.speed };
+ }
+ break;
+ case 'escort':
+ if (ship) {
+ return { what: `ESCORT THE ${name}`, how: 'escort', ship, at: ship.object.position, speed: ship.state.speed };
+ }
+ break;
+ case 'recover':
+ if (item) {
+ return { what: 'RECOVER THE CARGO', how: 'scoop', ship: null, at: item.object.position, speed: 0 };
+ }
+ break;
+ case 'rescue':
+ if (item) {
+ return { what: 'PICK UP THE SURVIVOR', how: 'scoop', ship: null, at: item.object.position, speed: 0 };
+ }
+ break;
+ default:
+ // deliver, smuggle and ambush all end at the station, and the station
+ // course flies there.
+ break;
+ }
+ }
+ return null;
+}
diff --git a/src/missions/queries.ts b/src/missions/queries.ts
index 09846673..f4fe48bd 100644
--- a/src/missions/queries.ts
+++ b/src/missions/queries.ts
@@ -14,8 +14,12 @@ import { verbJob, verbNeedsShip } from './verbs/registry.ts';
import { SKELETONS, skeletonById } from './skeletons/index.ts';
import { fillSlots, legPay, lineSlots } from './text.ts';
-function liveLegs(
- st: MissionState, from: readonly Skeleton[],
+/**
+ * Every live mission with the leg it is on. The game asks it too, to know
+ * what a commander is here to do (docs/TODO/208 M1).
+ */
+export function liveLegs(
+ st: MissionState, from: readonly Skeleton[] = SKELETONS,
): { live: LiveMission; leg: Leg }[] {
const out: { live: LiveMission; leg: Leg }[] = [];
for (const live of st.live) {
diff --git a/test/course-pilot.test.ts b/test/course-pilot.test.ts
index 754b1b95..91892e6d 100644
--- a/test/course-pilot.test.ts
+++ b/test/course-pilot.test.ts
@@ -43,6 +43,7 @@ const view = (station: THREE.Vector3, over: PartialCONTRACT_RANGE | MAX_FUEL | How far away a contract may send you, in tenths of a light year: exactly as far as a full tank reaches. | | [contracts.ts:27](./contracts.ts#L27) |
| contracts | PASSENGER_BERTH_TONNES | 2 | What one passenger costs the hold, in tonnes. | contracts.passengerBerthTonnes | [contracts.ts:51](./contracts.ts#L51) |
| contracts | SMUGGLE_DELIVERY_NOTORIETY | 0.06 | How loudly a delivered smuggling run is talked about, per tonne landed. | contracts.smuggleDeliveryNotoriety | [contracts.ts:81](./contracts.ts#L81) |
-| course | COURSE_TORUS_CONE | 0.1 | How far off the nose the target may sit, in radians, before the course pilot engages the torus drive. | course.torusCone | [course.ts:31](./course.ts#L31) |
-| course | COURSE_TORUS_DROP | TORUS_MULTIPLIER * PLAYER_FLIGHT.maxSpeed * 2.5 | How far from its target the course pilot drops the torus drive, in world units, before an arrival. | | [course.ts:44](./course.ts#L44) |
-| course | COURSE_ARRIVE_BRAKE | 0.7 | The share of the ship's thrust that an arrival plans to brake with. | course.arriveBrake | [course.ts:56](./course.ts#L56) |
-| course | COURSE_ARRIVE_TOLERANCE | 75 | How near its standoff the ship must be to count as arrived, in world units. | course.arriveTolerance | [course.ts:66](./course.ts#L66) |
-| course | COURSE_DERELICT_STANDOFF | GENERATION_CARGO_SCATTER + 400 | Where the derelict course stops, as a distance from the generation ship's centre, in world units. | | [course.ts:79](./course.ts#L79) |
-| course | COURSE_HERMIT_STANDOFF | 240 | Where the hermit course stops, as a distance from the rock's centre, in world units. | course.hermitStandoff | [course.ts:92](./course.ts#L92) |
-| course | COURSE_SKIM_DISTANCE | 65_000 | The hold distance of the skim course, from the centre of the star, in world units. | course.skimDistance | [course.ts:107](./course.ts#L107) |
-| course | COURSE_PLANET_CLEARANCE | MASS_LOCK_PLANET_ALTITUDE * 1.25 | How high above the planet's surface a course keeps its line, in world units. | | [course.ts:119](./course.ts#L119) |
-| course | COURSE_HERMIT_SPEED | HERMIT_DOCK_SPEED / 2 | The speed at which the hermit course arrives, in world units a second. | | [course.ts:129](./course.ts#L129) |
-| course | SKIP_SPEED | 8 | How much faster the world runs under the fast forward button, as a count of fixed steps in one screen frame (docs/TODO/205 M7). | course.skipSpeed | [course.ts:145](./course.ts#L145) |
-| course | RUN_CLOSE_MARGIN | 50 | Below this lead in top speed, in world units a second, the run row says the ship is only a little faster (docs/TODO/206 M5). | course.runCloseMargin | [course.ts:159](./course.ts#L159) |
-| course | COURSE_RUN_REACH | 50_000 | How far ahead the run course aims, in world units, on the line away from the hostile ships (docs/TODO/206 M5). | course.runReach | [course.ts:169](./course.ts#L169) |
-| course | COURSE_COLLECT_SPEED | 60 | How fast the collect course flies onto a canister, in world units a second (docs/TODO/206 M6). | course.collectSpeed | [course.ts:183](./course.ts#L183) |
-| course | COURSE_DOCK_HANDOVER | 1500 | How far from the station the station course hands the ship to the pilot, in world units (docs/TODO/207 M1). | course.dockHandover | [course.ts:197](./course.ts#L197) |
-| course | COURSE_WATCH_STANDOFF | 1200 | How far from a ship the scan course holds, in world units (docs/TODO/208 M2). | course.watchStandoff | [course.ts:211](./course.ts#L211) |
-| course | COURSE_ESCORT_STANDOFF | 600 | How far from its charge the escort course flies, in world units (docs/TODO/208 M2). | course.escortStandoff | [course.ts:224](./course.ts#L224) |
+| course | COURSE_TORUS_CONE | 0.1 | How far off the nose the target may sit, in radians, before the course pilot engages the torus drive. | course.torusCone | [course.ts:32](./course.ts#L32) |
+| course | COURSE_TORUS_DROP | TORUS_MULTIPLIER * PLAYER_FLIGHT.maxSpeed * 2.5 | How far from its target the course pilot drops the torus drive, in world units, before an arrival. | | [course.ts:45](./course.ts#L45) |
+| course | COURSE_ARRIVE_BRAKE | 0.7 | The share of the ship's thrust that an arrival plans to brake with. | course.arriveBrake | [course.ts:57](./course.ts#L57) |
+| course | COURSE_ARRIVE_TOLERANCE | 75 | How near its standoff the ship must be to count as arrived, in world units. | course.arriveTolerance | [course.ts:67](./course.ts#L67) |
+| course | COURSE_DERELICT_STANDOFF | GENERATION_CARGO_SCATTER + 400 | Where the derelict course stops, as a distance from the generation ship's centre, in world units. | | [course.ts:80](./course.ts#L80) |
+| course | COURSE_HERMIT_STANDOFF | 240 | Where the hermit course stops, as a distance from the rock's centre, in world units. | course.hermitStandoff | [course.ts:93](./course.ts#L93) |
+| course | COURSE_SKIM_DISTANCE | 65_000 | The hold distance of the skim course, from the centre of the star, in world units. | course.skimDistance | [course.ts:108](./course.ts#L108) |
+| course | COURSE_PLANET_CLEARANCE | MASS_LOCK_PLANET_ALTITUDE * 1.25 | How high above the planet's surface a course keeps its line, in world units. | | [course.ts:120](./course.ts#L120) |
+| course | COURSE_HERMIT_SPEED | HERMIT_DOCK_SPEED / 2 | The speed at which the hermit course arrives, in world units a second. | | [course.ts:130](./course.ts#L130) |
+| course | SKIP_SPEED | 8 | How much faster the world runs under the fast forward button, as a count of fixed steps in one screen frame (docs/TODO/205 M7). | course.skipSpeed | [course.ts:146](./course.ts#L146) |
+| course | RUN_CLOSE_MARGIN | 50 | Below this lead in top speed, in world units a second, the run row says the ship is only a little faster (docs/TODO/206 M5). | course.runCloseMargin | [course.ts:160](./course.ts#L160) |
+| course | COURSE_RUN_REACH | 50_000 | How far ahead the run course aims, in world units, on the line away from the hostile ships (docs/TODO/206 M5). | course.runReach | [course.ts:170](./course.ts#L170) |
+| course | COURSE_COLLECT_SPEED | 60 | How fast the collect course flies onto a canister, in world units a second (docs/TODO/206 M6). | course.collectSpeed | [course.ts:184](./course.ts#L184) |
+| course | COURSE_DOCK_HANDOVER | 1500 | How far from the station the station course hands the ship to the pilot, in world units (docs/TODO/207 M1). | course.dockHandover | [course.ts:198](./course.ts#L198) |
+| course | COURSE_WATCH_STANDOFF | 1200 | How far from a ship the scan course holds, in world units (docs/TODO/208 M2). | course.watchStandoff | [course.ts:212](./course.ts#L212) |
+| course | COURSE_ESCORT_STANDOFF | 600 | How far from its charge the escort course flies, in world units (docs/TODO/208 M2). | course.escortStandoff | [course.ts:225](./course.ts#L225) |
+| course | COURSE_POLICE_CLEARANCE | SCAN_WARN_RANGE | How wide of a police ship the smuggling course flies, in world units (docs/TODO/208 M4). | | [course.ts:238](./course.ts#L238) |
| docking | GATE_HALF_WIDTHS | 5 | How far out the approach gate sits, in multiples of the station half-width. | docking.gateHalfWidths | [docking.ts:25](./docking.ts#L25) |
| docking | TURN_IN | Math.PI / 4 | How far round from the slot axis an approach turns in, in radians. | | [docking.ts:49](./docking.ts#L49) |
| docking | RUN_IN_WIDTHS | GATE_HALF_WIDTHS * 0.6 | Where the approach stops the curve and flies straight, in station half-widths. | | [docking.ts:87](./docking.ts#L87) |
diff --git a/src/constants/course.ts b/src/constants/course.ts
index 8e901cfc..e47782e5 100644
--- a/src/constants/course.ts
+++ b/src/constants/course.ts
@@ -7,6 +7,7 @@ import { HERMIT_DOCK_SPEED } from './hermit-market.ts';
import { PLAYER_FLIGHT } from './player-flight.ts';
import { MASS_LOCK_PLANET_ALTITUDE, TORUS_MULTIPLIER } from './torus.ts';
import { GENERATION_CARGO_SCATTER } from './spawn-placement.ts';
+import { SCAN_WARN_RANGE } from './law.ts';
/**
* How far off the nose the target may sit, in radians, before the course
@@ -222,3 +223,16 @@ export const COURSE_WATCH_STANDOFF = 1200;
* @domain course
*/
export const COURSE_ESCORT_STANDOFF = 600;
+
+/**
+ * How wide of a police ship the smuggling course flies, in world units
+ * (docs/TODO/208 M4).
+ *
+ * A policeman reads a hold inside `SCAN_RANGE`, which is 2,600 units. This is
+ * the warning band, `SCAN_WARN_RANGE`, so the course keeps a margin outside
+ * the range that would end the job. It is the same rule from the other side,
+ * so the two cannot drift apart.
+ *
+ * @domain course
+ */
+export const COURSE_POLICE_CLEARANCE = SCAN_WARN_RANGE;
diff --git a/src/game/course-actions.ts b/src/game/course-actions.ts
index 995e5903..c62719ea 100644
--- a/src/game/course-actions.ts
+++ b/src/game/course-actions.ts
@@ -217,7 +217,7 @@ export class CourseActions {
private missionRow(): CourseWorld['mission'] {
const s = this.state();
const m = missionCourse(s.commander.missions, s.commander.systemIndex,
- s.world.npcs, s.world.cargo.items);
+ s.world.npcs, s.world.cargo.items, s.world.station.position);
if (m === null) return null;
const needsScoops = m.how === 'scoop' && !s.commander.equipment.scoops;
return { what: m.what, why: needsScoops ? 'NEEDS FUEL SCOOPS' : null };
diff --git a/src/game/course-pilot.ts b/src/game/course-pilot.ts
index bd747f2a..3f4ac3a8 100644
--- a/src/game/course-pilot.ts
+++ b/src/game/course-pilot.ts
@@ -42,7 +42,7 @@ import {
COURSE_ARRIVE_BRAKE, COURSE_ARRIVE_TOLERANCE, COURSE_DERELICT_STANDOFF,
COURSE_HERMIT_SPEED, COURSE_HERMIT_STANDOFF, COURSE_PLANET_CLEARANCE,
COURSE_COLLECT_SPEED, COURSE_ESCORT_STANDOFF, COURSE_RUN_REACH, COURSE_SKIM_DISTANCE,
- COURSE_TORUS_CONE, COURSE_TORUS_DROP, COURSE_WATCH_STANDOFF,
+ COURSE_POLICE_CLEARANCE, COURSE_TORUS_CONE, COURSE_TORUS_DROP, COURSE_WATCH_STANDOFF,
} from '../constants/course.ts';
import type { MissionHow } from './mission-course.ts';
@@ -68,6 +68,8 @@ export interface CourseView {
readonly tankFull: boolean;
/** where the hostile ships on the scanner are, for the run course */
readonly threats: readonly THREE.Vector3[];
+ /** where the police ships within scanner range are, for the smuggling course */
+ readonly police: readonly THREE.Vector3[];
/** where the cargo adrift within scanner range is, nearest first */
readonly loot: readonly THREE.Vector3[];
/**
@@ -118,6 +120,7 @@ export class CoursePilot {
private readonly fwd = new THREE.Vector3();
private readonly aim = new THREE.Vector3();
private readonly away = new THREE.Vector3();
+ private readonly wideOf = new THREE.Vector3();
/** Forget the bank, for a new course. */
reset(): void { this.mem = freshSteerMemory(); }
@@ -158,6 +161,9 @@ export class CoursePilot {
// A hunt is a fight: `flight-instruments.ts` picks the ship, and the
// computer's aim flies it, as it does for a rock.
if (m.how === 'fight') return IDLE;
+ // A slip is the station course on a line wide of the police
+ // (docs/TODO/208 M4). It hands the ship over as that course does.
+ if (m.how === 'slip') return this.toStation(v, dt, m.at);
const standoff = m.how === 'hold' ? COURSE_WATCH_STANDOFF
: m.how === 'escort' ? COURSE_ESCORT_STANDOFF : 0;
const speed = m.how === 'scoop' ? COURSE_COLLECT_SPEED : m.speed;
@@ -187,7 +193,7 @@ export class CoursePilot {
* the docking computer's range, hand over. The docking computer then flies
* the approach and the slot, and this course asks for nothing more.
*/
- private toStation(v: CourseView, dt: number): CourseStep {
+ private toStation(v: CourseView, dt: number, wide?: THREE.Vector3): CourseStep {
if (v.dcEngaged) return IDLE;
// It ARRIVES at the hand-over, at the speed the slot will take
// (docs/TODO/207 M1). A ship handed over at full speed has less than four
@@ -198,8 +204,11 @@ export class CoursePilot {
if (v.position.distanceTo(v.stationPos) <= v.handOverRange) {
return { demand: null, torus: false, handOver: true, done: false };
}
+ // A smuggling run keeps wide of the police on the way in (M4 of 208).
+ const aim = wide === undefined ? v.stationPos
+ : clearOfPolice(v.position, v.stationPos, v.police, this.wideOf);
return this.arrive(v, {
- target: v.stationPos, standoff: v.handOverRange, speed: SLOT_SPEED_LIMIT,
+ target: aim, standoff: aim === v.stationPos ? v.handOverRange : 0, speed: SLOT_SPEED_LIMIT,
}, dt);
}
@@ -270,12 +279,61 @@ const seg = new THREE.Vector3();
const off = new THREE.Vector3();
/**
- * Where to aim, so that the line to the target clears the planet.
+ * Where to aim, so that the line to the target clears the planet
+ * (docs/TODO/205 M3). The clearance is the planet's own radius plus
+ * `COURSE_PLANET_CLEARANCE`, which is above the height the planet holds the
+ * torus drive down at.
*
- * It finds the nearest point of the line to the planet's centre. That point
- * can be below `COURSE_PLANET_CLEARANCE`. Then it aims beside the planet, on
- * the same side as the line, and half as high again. The line from there
- * clears the planet, and the ship then turns onto the target.
+ * @returns `out`, holding the point to aim at.
+ */
+export function clearOfPlanet(
+ from: THREE.Vector3, to: THREE.Vector3, planet: THREE.Vector3, radius: number,
+ out: THREE.Vector3,
+): THREE.Vector3 {
+ return sidestep(from, to, planet, radius + COURSE_PLANET_CLEARANCE, out);
+}
+
+/**
+ * Where to aim, so that the line to the target keeps clear of the police
+ * (docs/TODO/208 M4).
+ *
+ * A police ship reads a hold inside `SCAN_RANGE`, which is 2,600 units, and
+ * the smuggling course must not be read. It aims wide of the nearest
+ * policeman in the way, at `COURSE_POLICE_CLEARANCE`, which is the warning
+ * band. The line from there is clear, and the ship then turns onto the
+ * target. Where no wide line exists, it takes the widest it can find.
+ */
+export function clearOfPolice(
+ from: THREE.Vector3, to: THREE.Vector3, police: readonly THREE.Vector3[],
+ out: THREE.Vector3,
+): THREE.Vector3 {
+ let worst: { at: THREE.Vector3; miss: number } | null = null;
+ for (const at of police) {
+ const miss = distanceToSegment(from, to, at);
+ if (miss >= COURSE_POLICE_CLEARANCE) continue;
+ if (worst === null || miss < worst.miss) worst = { at, miss };
+ }
+ return worst === null ? out.copy(to)
+ : sidestep(from, to, worst.at, COURSE_POLICE_CLEARANCE, out);
+}
+
+/** How near the line from `from` to `to` passes `at`. */
+function distanceToSegment(
+ from: THREE.Vector3, to: THREE.Vector3, at: THREE.Vector3,
+): number {
+ seg.subVectors(to, from);
+ const len2 = seg.lengthSq();
+ const t = len2 > 0 ? Math.max(0, Math.min(1, off.subVectors(at, from).dot(seg) / len2)) : 0;
+ // The nearest point is the target itself: nothing is between.
+ if (t >= 1) return Infinity;
+ return off.copy(from).addScaledVector(seg, t).sub(at).length();
+}
+
+/**
+ * Where to aim, so that the line keeps `clear` units from one thing in the
+ * way. It aims beside that thing, on the same side as the line, and half as
+ * far again. The line from there is clear, and the ship then turns onto the
+ * target.
*
* A target that is itself the nearest point needs no detour. docs/TODO/205 M3
* found a hermit low over the planet. The line to it always counted as
@@ -283,24 +341,20 @@ const off = new THREE.Vector3();
*
* @returns `out`, holding the point to aim at.
*/
-export function clearOfPlanet(
- from: THREE.Vector3, to: THREE.Vector3, planet: THREE.Vector3, radius: number,
+function sidestep(
+ from: THREE.Vector3, to: THREE.Vector3, at: THREE.Vector3, clear: number,
out: THREE.Vector3,
): THREE.Vector3 {
seg.subVectors(to, from);
const len2 = seg.lengthSq();
- const t = len2 > 0 ? Math.max(0, Math.min(1, off.subVectors(planet, from).dot(seg) / len2)) : 0;
- // The nearest point is the target itself: the planet is not between. A
- // target low over the planet is still reached, because the last of the
- // line runs down to it rather than through the planet.
+ const t = len2 > 0 ? Math.max(0, Math.min(1, off.subVectors(at, from).dot(seg) / len2)) : 0;
if (t >= 1) return out.copy(to);
- off.copy(from).addScaledVector(seg, t).sub(planet);
- const clear = radius + COURSE_PLANET_CLEARANCE;
+ off.copy(from).addScaledVector(seg, t).sub(at);
if (off.length() >= clear) return out.copy(to);
// A line through the centre has no side. Any direction square to it will do.
if (off.lengthSq() < 1e-6) {
off.set(seg.y, -seg.x, 0);
if (off.lengthSq() < 1e-6) off.set(0, seg.z, -seg.y);
}
- return out.copy(planet).addScaledVector(off.normalize(), radius + COURSE_PLANET_CLEARANCE * 1.5);
+ return out.copy(at).addScaledVector(off.normalize(), clear * 1.5);
}
diff --git a/src/game/flight-instruments.ts b/src/game/flight-instruments.ts
index e4574e40..99e55c86 100644
--- a/src/game/flight-instruments.ts
+++ b/src/game/flight-instruments.ts
@@ -158,7 +158,7 @@ export class Instruments {
// as the target, exactly as a rock is.
const mission = s.course !== 'mission' ? null
: missionCourse(this.state.commander.missions, this.state.commander.systemIndex,
- w.npcs, w.cargo.items);
+ w.npcs, w.cargo.items, w.station.position);
if (mission?.how === 'fight' && mission.ship !== null
&& pickedTarget(w.npcs) !== mission.ship) {
pickTarget(w.npcs, mission.ship);
@@ -181,6 +181,10 @@ export class Instruments {
derelictSpeed: derelict?.state.speed ?? 0,
hermitPos: live('hermit')?.object.position ?? null,
tankFull: this.state.commander.fuel >= MAX_FUEL,
+ police: w.npcs
+ .filter((n) => n.state.alive && n.role === 'police'
+ && n.object.position.distanceTo(p.position) <= SCANNER_RANGE * 2)
+ .map((n) => n.object.position),
loot: w.cargo.items
.map((c) => c.object.position)
.filter((at) => at.distanceTo(p.position) <= SCANNER_RANGE)
diff --git a/src/game/mission-course.ts b/src/game/mission-course.ts
index 449ec59e..0ea45892 100644
--- a/src/game/mission-course.ts
+++ b/src/game/mission-course.ts
@@ -15,10 +15,11 @@
// - a HUNT is a fight, so the course picks the ship and the aim flies it;
// - a SCAN is a hold: stay near it, and keep the nose on it;
// - an ESCORT flies alongside, and the fight comes to the pilot;
-// - a RECOVER and a RESCUE are a scoop.
+// - a RECOVER and a RESCUE are a scoop;
+// - a SMUGGLE is a slip: the station, on a line wide of every policeman.
//
-// The other three verbs need no course of their own. A deliver, a smuggle
-// and an ambush all end at the station, and the station course flies there.
+// A deliver and an ambush need no course of their own. Both end at the
+// station, and the station course flies there.
import type * as THREE from 'three';
import type { NpcShip } from './npc.ts';
@@ -28,7 +29,7 @@ import type { MissionState, Skeleton } from '../missions/model.ts';
import { SKELETONS } from '../missions/skeletons/index.ts';
/** What the ship does about this leg. */
-export type MissionHow = 'fight' | 'hold' | 'escort' | 'scoop';
+export type MissionHow = 'fight' | 'hold' | 'escort' | 'scoop' | 'slip';
/** The mission's course: its words, what to do, and what to do it to. */
export interface MissionCourse {
@@ -53,12 +54,18 @@ export function missionCourse(
here: number,
npcs: readonly NpcShip[],
items: readonly Canister[],
+ stationPos: THREE.Vector3,
skeletons: readonly Skeleton[] = SKELETONS,
): MissionCourse | null {
for (const { live, leg } of liveLegs(st, skeletons)) {
- if (live.target !== here || live.tag === null) continue;
- const ship = npcs.find((n) => n.state.alive && n.state.missionTag === live.tag) ?? null;
- const item = items.find((c) => c.missionTag === live.tag) ?? null;
+ if (live.target !== here) continue;
+ // A smuggling run has no tagged thing in the sky: what it asks for is a
+ // way past the police. Every other course below needs its target, and a
+ // leg with no tag has none.
+ const ship = live.tag === null ? null
+ : npcs.find((n) => n.state.alive && n.state.missionTag === live.tag) ?? null;
+ const item = live.tag === null ? null
+ : items.find((c) => c.missionTag === live.tag) ?? null;
const name = ship?.object.name.toUpperCase() ?? '';
switch (leg.verb.kind) {
case 'hunt':
@@ -86,8 +93,12 @@ export function missionCourse(
return { what: 'PICK UP THE SURVIVOR', how: 'scoop', ship: null, at: item.object.position, speed: 0 };
}
break;
+ case 'smuggle':
+ return {
+ what: 'SLIP PAST THE POLICE', how: 'slip', ship: null, at: stationPos, speed: 0,
+ };
default:
- // deliver, smuggle and ambush all end at the station, and the station
+ // A deliver and an ambush both end at the station, and the station
// course flies there.
break;
}
diff --git a/test/course-pilot.test.ts b/test/course-pilot.test.ts
index 91892e6d..552c2612 100644
--- a/test/course-pilot.test.ts
+++ b/test/course-pilot.test.ts
@@ -43,6 +43,7 @@ const view = (station: THREE.Vector3, over: PartialMissions
the route drawn.
- How a job plays. The MISSIONS screen shows the job you hold, + How a job plays. When you jump into the world a job names, the + job is the first button in the list over the view, in its own words: + HUNT THE KRAIT, SCAN THE ANACONDA, ESCORT THE PYTHON, RECOVER THE + CARGO, PICK UP THE SURVIVOR, or SLIP PAST THE POLICE. Choose it and + the ship flies the job. A delivery ends at the station, so + FLY TO THE STATION is the button for it. +
+
+ The MISSIONS screen shows the job you hold,
the briefing, and the order you are on now. The world you must go to
has an amber diamond on both charts. When you jump in, the console
tells you what you are there for and where the target is: how far,
From 6d4ade4df7a27ac8d090bbee6a8a6131ce4bc0c0 Mon Sep 17 00:00:00 2001
From: Chris Greening
+ You will pass other traders on the way in. The console names one when + it comes close. They are carrying cargo, and nothing stops you taking + it: choose TARGETS, pick the ship, and open fire. The law takes + a dim view, so make sure nobody is watching.
MASS_LOCK_STATION | 5000 | How near the station holds the drive down. | | [torus.ts:31](./torus.ts#L31) |
| torus | MASS_LOCK_PLANET_ALTITUDE | 4000 | ...and how near the planet, as an ALTITUDE above the surface. | | [torus.ts:39](./torus.ts#L39) |
| torus | MASS_LOCK_SHIP | 4500 | ...and how near another ship — any live one that is not a rock. | torus.massLockShip | [torus.ts:48](./torus.ts#L48) |
+| torus | CLOSE_PASS_CLEAR | 6750 | How far a ship must open back out before it can be announced a second time (game/close-pass.ts). | torus.closePassClear | [torus.ts:65](./torus.ts#L65) |
| trumbles | TRUMBLE_PURGE_TEMP | 0.55 | The cabin heat that drives them out. | | [trumbles.ts:12](./trumbles.ts#L12) |
| trumbles | BREED_INTERVAL | 20 | Seconds between broods. | trumbles.breedInterval | [trumbles.ts:20](./trumbles.ts#L20) |
| trumbles | BREED_RATE | 1.6 | They multiply by this, plus one, every brood. | | [trumbles.ts:23](./trumbles.ts#L23) |
diff --git a/src/constants/torus.ts b/src/constants/torus.ts
index fb207057..6d657965 100644
--- a/src/constants/torus.ts
+++ b/src/constants/torus.ts
@@ -46,3 +46,20 @@ export const MASS_LOCK_PLANET_ALTITUDE = 4000;
* @rule torus.massLockShip
*/
export const MASS_LOCK_SHIP = 4500;
+
+/**
+ * How far a ship must open back out before it can be announced a second time
+ * (game/close-pass.ts). A ship that comes inside `MASS_LOCK_SHIP` is named
+ * one time. A single line would repeat on every wobble across the radius.
+ *
+ * It is 1.5 times the lock radius. The hysteresis is 2,250 units, which is
+ * over 5 seconds of flight at the ordinary top speed. A pair of ships that
+ * hold station together therefore says nothing more.
+ *
+ * It lives here, beside `MASS_LOCK_SHIP`, because it is measured from that
+ * radius. A move would split one pair of numbers over two files.
+ *
+ * @rule torus.closePassClear
+ * @domain torus
+ */
+export const CLOSE_PASS_CLEAR = 6750;
diff --git a/src/game/close-pass.ts b/src/game/close-pass.ts
new file mode 100644
index 00000000..d3918fa9
--- /dev/null
+++ b/src/game/close-pass.ts
@@ -0,0 +1,69 @@
+// Who came close, and was not announced yet (docs/TODO/209).
+//
+// Chris flew a trip to the station and reported that a neutral trader did not
+// stop the ship. The mass lock rule did fire when the torus drive ran. It did
+// nothing when the drive was already off, and the drive is off for most of a
+// trip. So a trader could pass at 1,000 units and say nothing at all.
+//
+// His own words about the sky, of 2026-09-11: "When anything is in range we
+// should show a list of objects that can be engaged - these can be outright
+// hostiles or neutral ships that we want to pirate." The target list already
+// holds every ship in scanner range. The pilot had no reason to open it.
+//
+// THIS FILE ONLY SPEAKS. It never stops the ship, and it never picks a target.
+// A neutral ship must not interrupt a course, because the pilot chose that
+// course. The line tells the pilot that the chance is there.
+//
+// IT NAMES A TRADER, AND NOTHING ELSE. A hostile ship announces itself: it
+// shoots, it raises the condition light, and the course offers a way out. The
+// law's ships are a risk rather than a chance. A line that invites an attack
+// on a Viper beside the station is bad advice. A trader is the ship
+// Chris named, and a trader carries the cargo.
+//
+// It also says nothing inside the station's own mass lock. The pilot is on
+// the approach there, and traffic is thick.
+//
+// THE FLAG IS ON THE SHIP, as the target pick is (`targets.ts`). A ship has no
+// stable id, and its state is saved with it. The flag clears when the ship
+// opens back out past `CLOSE_PASS_CLEAR`, so a second approach speaks again.
+
+import type * as THREE from 'three';
+import type { NpcShip } from './npc.ts';
+import { isHostileToPlayer } from './hostility.ts';
+import { shipArticle } from './targets.ts';
+import { CLOSE_PASS_CLEAR, MASS_LOCK_SHIP, MASS_LOCK_STATION } from '../constants/torus.ts';
+
+/** Everything the rule reads. */
+export interface CloseView {
+ readonly npcs: readonly NpcShip[];
+ readonly playerPos: THREE.Vector3;
+ readonly legalStatus: number;
+ /** how far the commander is from the station, for the truce */
+ readonly playerToStation: number;
+}
+
+/**
+ * The lines to show for the ships that came close since the last step.
+ *
+ * It sets `announcedClose` on each ship it names. It clears the flag on a ship
+ * that opened back out.
+ */
+export function closePassLines(v: CloseView): string[] {
+ const out: string[] = [];
+ if (v.playerToStation < MASS_LOCK_STATION) return out;
+ for (const npc of v.npcs) {
+ if (npc.role !== 'trader') continue;
+ const range = npc.object.position.distanceTo(v.playerPos);
+ if (range > CLOSE_PASS_CLEAR) { npc.state.announcedClose = false; continue; }
+ if (range > MASS_LOCK_SHIP) continue;
+ if (!npc.state.alive || npc.state.docked) continue;
+ if (npc.state.announcedClose) continue;
+ if (isHostileToPlayer(npc, v.legalStatus, v.playerToStation)) continue;
+ // A ship the pilot already picked, or already shot at, is not an offer.
+ // The pilot made the choice. The line would be an answer to nobody.
+ if (npc.state.targeted || npc.state.provokedByPlayer) continue;
+ npc.state.announcedClose = true;
+ out.push(`${shipArticle(npc)} IS CLOSE — OPEN TARGETS TO ATTACK IT`);
+ }
+ return out;
+}
diff --git a/src/game/flight-instruments.ts b/src/game/flight-instruments.ts
index 776c6c8d..b73be4ff 100644
--- a/src/game/flight-instruments.ts
+++ b/src/game/flight-instruments.ts
@@ -43,7 +43,7 @@ const COURSE_ENDS: PartialPASSENGER_BERTH_TONNES | 2 | What one passenger costs the hold, in tonnes. | contracts.passengerBerthTonnes | [contracts.ts:51](./contracts.ts#L51) |
| contracts | SMUGGLE_DELIVERY_NOTORIETY | 0.06 | How loudly a delivered smuggling run is talked about, per tonne landed. | contracts.smuggleDeliveryNotoriety | [contracts.ts:81](./contracts.ts#L81) |
| course | COURSE_TORUS_CONE | 0.1 | How far off the nose the target may sit, in radians, before the course pilot engages the torus drive. | course.torusCone | [course.ts:32](./course.ts#L32) |
-| course | COURSE_TORUS_DROP | TORUS_MULTIPLIER * PLAYER_FLIGHT.maxSpeed * 2.5 | How far from its target the course pilot drops the torus drive, in world units, before an arrival. | | [course.ts:45](./course.ts#L45) |
-| course | COURSE_ARRIVE_BRAKE | 0.7 | The share of the ship's thrust that an arrival plans to brake with. | course.arriveBrake | [course.ts:57](./course.ts#L57) |
-| course | COURSE_ARRIVE_TOLERANCE | 75 | How near its standoff the ship must be to count as arrived, in world units. | course.arriveTolerance | [course.ts:67](./course.ts#L67) |
-| course | COURSE_DERELICT_STANDOFF | GENERATION_CARGO_SCATTER + 400 | Where the derelict course stops, as a distance from the generation ship's centre, in world units. | | [course.ts:80](./course.ts#L80) |
-| course | COURSE_HERMIT_STANDOFF | 240 | Where the hermit course stops, as a distance from the rock's centre, in world units. | course.hermitStandoff | [course.ts:93](./course.ts#L93) |
-| course | COURSE_SKIM_DISTANCE | 65_000 | The hold distance of the skim course, from the centre of the star, in world units. | course.skimDistance | [course.ts:108](./course.ts#L108) |
-| course | COURSE_PLANET_CLEARANCE | MASS_LOCK_PLANET_ALTITUDE * 1.25 | How high above the planet's surface a course keeps its line, in world units. | | [course.ts:120](./course.ts#L120) |
-| course | COURSE_HERMIT_SPEED | HERMIT_DOCK_SPEED / 2 | The speed at which the hermit course arrives, in world units a second. | | [course.ts:130](./course.ts#L130) |
-| course | SKIP_SPEED | 8 | How much faster the world runs under the fast forward button, as a count of fixed steps in one screen frame (docs/TODO/205 M7). | course.skipSpeed | [course.ts:146](./course.ts#L146) |
-| course | RUN_CLOSE_MARGIN | 50 | Below this lead in top speed, in world units a second, the run row says the ship is only a little faster (docs/TODO/206 M5). | course.runCloseMargin | [course.ts:160](./course.ts#L160) |
-| course | COURSE_RUN_REACH | 50_000 | How far ahead the run course aims, in world units, on the line away from the hostile ships (docs/TODO/206 M5). | course.runReach | [course.ts:170](./course.ts#L170) |
-| course | COURSE_COLLECT_SPEED | 60 | How fast the collect course flies onto a canister, in world units a second (docs/TODO/206 M6). | course.collectSpeed | [course.ts:184](./course.ts#L184) |
-| course | COURSE_DOCK_HANDOVER | 1500 | How far from the station the station course hands the ship to the pilot, in world units (docs/TODO/207 M1). | course.dockHandover | [course.ts:198](./course.ts#L198) |
-| course | COURSE_WATCH_STANDOFF | 1200 | How far from a ship the scan course holds, in world units (docs/TODO/208 M2). | course.watchStandoff | [course.ts:212](./course.ts#L212) |
-| course | COURSE_ESCORT_STANDOFF | 600 | How far from its charge the escort course flies, in world units (docs/TODO/208 M2). | course.escortStandoff | [course.ts:225](./course.ts#L225) |
-| course | COURSE_POLICE_CLEARANCE | SCAN_WARN_RANGE | How wide of a police ship the smuggling course flies, in world units (docs/TODO/208 M4). | | [course.ts:238](./course.ts#L238) |
+| course | COURSE_AIM_DEADZONE | 0.02 | How near the nose a course counts its target as straight ahead, in radians. | | [course.ts:56](./course.ts#L56) |
+| course | COURSE_ROLL_GATE | 0.05 | How near its bank a course pilot must be before it pulls the nose, in radians. | | [course.ts:83](./course.ts#L83) |
+| course | COURSE_TORUS_DROP | TORUS_MULTIPLIER * PLAYER_FLIGHT.maxSpeed * 2.5 | How far from its target the course pilot drops the torus drive, in world units, before an arrival. | | [course.ts:96](./course.ts#L96) |
+| course | COURSE_ARRIVE_BRAKE | 0.7 | The share of the ship's thrust that an arrival plans to brake with. | course.arriveBrake | [course.ts:108](./course.ts#L108) |
+| course | COURSE_ARRIVE_TOLERANCE | 75 | How near its standoff the ship must be to count as arrived, in world units. | course.arriveTolerance | [course.ts:118](./course.ts#L118) |
+| course | COURSE_DERELICT_STANDOFF | GENERATION_CARGO_SCATTER + 400 | Where the derelict course stops, as a distance from the generation ship's centre, in world units. | | [course.ts:131](./course.ts#L131) |
+| course | COURSE_HERMIT_STANDOFF | 240 | Where the hermit course stops, as a distance from the rock's centre, in world units. | course.hermitStandoff | [course.ts:144](./course.ts#L144) |
+| course | COURSE_SKIM_DISTANCE | 65_000 | The hold distance of the skim course, from the centre of the star, in world units. | course.skimDistance | [course.ts:159](./course.ts#L159) |
+| course | COURSE_PLANET_CLEARANCE | MASS_LOCK_PLANET_ALTITUDE * 1.25 | How high above the planet's surface a course keeps its line, in world units. | | [course.ts:171](./course.ts#L171) |
+| course | COURSE_HERMIT_SPEED | HERMIT_DOCK_SPEED / 2 | The speed at which the hermit course arrives, in world units a second. | | [course.ts:181](./course.ts#L181) |
+| course | SKIP_SPEED | 8 | How much faster the world runs under the fast forward button, as a count of fixed steps in one screen frame (docs/TODO/205 M7). | course.skipSpeed | [course.ts:197](./course.ts#L197) |
+| course | RUN_CLOSE_MARGIN | 50 | Below this lead in top speed, in world units a second, the run row says the ship is only a little faster (docs/TODO/206 M5). | course.runCloseMargin | [course.ts:211](./course.ts#L211) |
+| course | COURSE_RUN_REACH | 50_000 | How far ahead the run course aims, in world units, on the line away from the hostile ships (docs/TODO/206 M5). | course.runReach | [course.ts:221](./course.ts#L221) |
+| course | COURSE_COLLECT_SPEED | 60 | How fast the collect course flies onto a canister, in world units a second (docs/TODO/206 M6). | course.collectSpeed | [course.ts:235](./course.ts#L235) |
+| course | COURSE_DOCK_HANDOVER | 1500 | How far from the station the station course hands the ship to the pilot, in world units (docs/TODO/207 M1). | course.dockHandover | [course.ts:249](./course.ts#L249) |
+| course | COURSE_WATCH_STANDOFF | 1200 | How far from a ship the scan course holds, in world units (docs/TODO/208 M2). | course.watchStandoff | [course.ts:263](./course.ts#L263) |
+| course | COURSE_ESCORT_STANDOFF | 600 | How far from its charge the escort course flies, in world units (docs/TODO/208 M2). | course.escortStandoff | [course.ts:276](./course.ts#L276) |
+| course | COURSE_POLICE_CLEARANCE | SCAN_WARN_RANGE | How wide of a police ship the smuggling course flies, in world units (docs/TODO/208 M4). | | [course.ts:289](./course.ts#L289) |
| docking | GATE_HALF_WIDTHS | 5 | How far out the approach gate sits, in multiples of the station half-width. | docking.gateHalfWidths | [docking.ts:25](./docking.ts#L25) |
| docking | TURN_IN | Math.PI / 4 | How far round from the slot axis an approach turns in, in radians. | | [docking.ts:49](./docking.ts#L49) |
| docking | RUN_IN_WIDTHS | GATE_HALF_WIDTHS * 0.6 | Where the approach stops the curve and flies straight, in station half-widths. | | [docking.ts:87](./docking.ts#L87) |
diff --git a/src/constants/course.ts b/src/constants/course.ts
index e47782e5..6c341bf2 100644
--- a/src/constants/course.ts
+++ b/src/constants/course.ts
@@ -31,6 +31,57 @@ import { SCAN_WARN_RANGE } from './law.ts';
*/
export const COURSE_TORUS_CONE = 0.1;
+/**
+ * How near the nose a course counts its target as straight ahead, in radians.
+ * Inside this cone the course pilot asks for no pitch and no roll.
+ *
+ * THE SHIP ROLLED ALL THE WAY TO THE STATION WITHOUT IT (Chris, 2026-09-12:
+ * *"we seem to be constantly rotating when heading towards something"*). The
+ * steering is `bankToTurn` (game/pitch-roll-steer.ts). Its roll ask is a
+ * BEARING: how far round the clock the target sits from the vertical. That
+ * bearing stays large for a target a hair off the nose, and the roll fade has
+ * a floor. So the course rolled for ever to chase the last fraction of a
+ * degree. A measurement of 2026-09-12 counted 41 full turns on a median trip
+ * to the station, with the roll moving in 92% of the samples.
+ *
+ * The combat computer never had the fault. It passes the gun's own hit cone,
+ * which is wide up close, and it holds the sticks still inside it.
+ *
+ * 0.02 radians is 1.1 degrees. At the hand-over range of 1,500 units it is 30
+ * units off the line, which the last of the approach closes. It is a fifth of
+ * `COURSE_TORUS_CONE`, so the drive stays engaged inside it.
+ *
+ * @domain course
+ */
+export const COURSE_AIM_DEADZONE = 0.02;
+
+/**
+ * How near its bank a course pilot must be before it pulls the nose, in
+ * radians. Above this angle the steering asks for no pitch at all.
+ *
+ * IT IS WHAT STOPS THE SHIP ROLLING ALL THE WAY THERE. `bankToTurn` gates the
+ * pitch by the cosine of the roll error, which still leaves a little pitch at
+ * a wide bank. Near the nose that little is enough to hold a cone: the nose
+ * circles the target, and the angle never closes. The comment on `bankToTurn`
+ * (game/pitch-roll-steer.ts) holds the measurement.
+ *
+ * 0.05 radians is 2.9 degrees. A sweep of 2026-09-12 over 6 trips to the
+ * station measured the median count of full turns on the way:
+ *
+ * | gate | full turns |
+ * | --- | --- |
+ * | none | 41 |
+ * | 0.2 | 5.5 |
+ * | 0.1 | 2.9 |
+ * | 0.05 | 1.3 |
+ *
+ * A tighter gate costs a little time in a big turn, because the roll must
+ * finish first. The roll is the faster axis, so the cost is small.
+ *
+ * @domain course
+ */
+export const COURSE_ROLL_GATE = 0.05;
+
/**
* How far from its target the course pilot drops the torus drive, in world
* units, before an arrival.
diff --git a/src/game/course-pilot.ts b/src/game/course-pilot.ts
index 3f4ac3a8..ccab044b 100644
--- a/src/game/course-pilot.ts
+++ b/src/game/course-pilot.ts
@@ -39,7 +39,7 @@ import type { CourseKind } from './courses.ts';
import { PLAYER_FLIGHT } from '../constants/player-flight.ts';
import { SLOT_SPEED_LIMIT } from '../constants/docking.ts';
import {
- COURSE_ARRIVE_BRAKE, COURSE_ARRIVE_TOLERANCE, COURSE_DERELICT_STANDOFF,
+ COURSE_AIM_DEADZONE, COURSE_ROLL_GATE, COURSE_ARRIVE_BRAKE, COURSE_ARRIVE_TOLERANCE, COURSE_DERELICT_STANDOFF,
COURSE_HERMIT_SPEED, COURSE_HERMIT_STANDOFF, COURSE_PLANET_CLEARANCE,
COURSE_COLLECT_SPEED, COURSE_ESCORT_STANDOFF, COURSE_RUN_REACH, COURSE_SKIM_DISTANCE,
COURSE_POLICE_CLEARANCE, COURSE_TORUS_CONE, COURSE_TORUS_DROP, COURSE_WATCH_STANDOFF,
@@ -239,7 +239,12 @@ export class CoursePilot {
v: CourseView, point: THREE.Vector3, throttle: number, dt: number,
): { demand: FlightDemand; torus: boolean } {
this.dir.subVectors(point, v.position);
- const stick = bankToTurn(v.quaternion, this.dir, this.mem);
+ // TWO NUMBERS KEEP THE SHIP FROM ROLLING ALL THE WAY THERE. The deadzone
+ // holds the sticks still inside 1.1 degrees of the nose. The roll gate
+ // holds the pitch still until the bank arrives, which is what stops the
+ // nose from circling the target. See both constants.
+ const stick = bankToTurn(
+ v.quaternion, this.dir, this.mem, COURSE_AIM_DEADZONE, COURSE_ROLL_GATE);
this.fwd.set(0, 0, -1).applyQuaternion(v.quaternion);
return {
demand: {
diff --git a/src/game/pitch-roll-steer.ts b/src/game/pitch-roll-steer.ts
index 7be1ba20..ebfd129e 100644
--- a/src/game/pitch-roll-steer.ts
+++ b/src/game/pitch-roll-steer.ts
@@ -87,6 +87,27 @@ function wrap(a: number): number {
* target. It is the caller's gun cone, which is WIDE up close, because a near
* target subtends a wide angle. Inside it the controller asks for NOTHING.
*
+ * `rollGate` is the angle inside which the BANK counts as finished. Above it
+ * this asks for no pitch at all. Zero keeps the soft gate alone, which is the
+ * `cos` of the roll error, and every combat caller passes zero.
+ *
+ * THE SOFT GATE ALONE CAN CONE (Chris, 2026-09-12: *"we seem to be constantly
+ * rotating when heading towards something"*). A target a hair off the nose
+ * makes the bearing very sensitive to pitch, because the bearing turns by
+ * `pitch / theta`. The ship then finds an equilibrium. It rolls at a steady
+ * rate. The small pitch that the soft gate still allows turns the bearing back
+ * at the same rate. The nose circles the target for ever, and
+ * `theta` never closes. A trace of 2026-09-12 caught it: 0.25 radians a second
+ * of roll held against 0.0087 of pitch, at a fixed 0.035 off the nose.
+ *
+ * A hard gate breaks the equilibrium. With no pitch, the roll alone turns the
+ * bearing, the bank arrives, and the pitch then closes the angle. On a course
+ * to the station it took a median trip from 41 full turns to 1.3.
+ *
+ * The combat computer must NOT take it. Its target manoeuvres, and a pitch
+ * held still while the roll catches up is time off the gun. It also never sees
+ * the fault, because it stops steering inside its own wide gun cone.
+ *
* That is the seasickness fix. A target that already fills the gun still has a
* bearing, and that bearing swings as it drifts a hair off centre. A bank to
* chase the last degree chatters the roll axis for a correction the gun does
@@ -101,6 +122,7 @@ function wrap(a: number): number {
*/
export function bankToTurn(
quat: THREE.Quaternion, dir: THREE.Vector3, mem: SteerMemory, nullBand = 0,
+ rollGate = 0,
): StickCommand {
if (dir.lengthSq() < 1e-12) return { pitch: 0, roll: 0 };
dirNorm.copy(dir).normalize();
@@ -173,7 +195,10 @@ export function bankToTurn(
const pitchSat = localZ > 0
? STEER_PITCH_SATURATION + (STEER_SATURATION - STEER_PITCH_SATURATION) * (1 - localZ)
: STEER_SATURATION;
- const pitch = Math.max(-1, Math.min(1, theta / pitchSat))
+ // `rollGate` HOLDS THE PITCH STILL UNTIL THE BANK ARRIVES. It is 0 for the
+ // combat computer, which keeps the soft `cos` gate alone. See the parameter.
+ const pitch = rollGate > 0 && Math.abs(rollErr) > rollGate ? 0
+ : Math.max(-1, Math.min(1, theta / pitchSat))
* mem.side * Math.max(0, Math.cos(rollErr));
return { pitch, roll };
diff --git a/test/course-pilot.test.ts b/test/course-pilot.test.ts
index 3ba65802..6988f944 100644
--- a/test/course-pilot.test.ts
+++ b/test/course-pilot.test.ts
@@ -151,6 +151,30 @@ console.log('\nwith no docking computer, the course hands the slot to the pilot'
g.state.session.course, null);
}
+// THE SHIP MUST NOT ROLL ALL THE WAY THERE (docs/TODO/210).
+//
+// Chris, 2026-09-12: *"we seem to be constantly rotating when heading towards
+// something"*. The steering held a cone: the nose circled the target at a
+// fixed angle, and the roll never stopped. The measurement is the total roll
+// over a whole trip, in full turns. It was 7.8 turns with a clear sky.
+console.log('\nthe course does not roll the ship all the way there');
+{
+ const g = arrived(20_260_935);
+ g.state.session.course = 'station';
+ const dt = 1 / 60;
+ let rolled = 0;
+ withoutSaving(() => {
+ for (let f = 0, at = 0; f < 240 / dt && !g.state.session.dockTrial; f++) {
+ g.step(dt, at += dt);
+ rolled += Math.abs(g.state.player.rollRate) * dt;
+ }
+ });
+ const turns = rolled / (2 * Math.PI);
+ check('the whole trip to the station costs less than one full turn of roll',
+ turns < 1, `${turns.toFixed(2)} turns`);
+ check('...and the ship still arrives', g.state.session.dockTrial);
+}
+
console.log('\n...and a flight key takes the ship back');
{
const g = arrived(20_260_912);
diff --git a/test/pitch-roll-steer.test.ts b/test/pitch-roll-steer.test.ts
index 2fb51867..8657a4fc 100644
--- a/test/pitch-roll-steer.test.ts
+++ b/test/pitch-roll-steer.test.ts
@@ -224,3 +224,28 @@ const WITHIN_A_GUN_CONE = 0.05; // ~2.9 degrees
check('a target dead abeam asks for no pitch at all',
pitchOnto(level, right) === 0);
}
+
+// THE ROLL GATE: no pitch until the bank arrives (docs/TODO/210).
+//
+// The soft gate is the cosine of the roll error, and it leaves a little pitch
+// at a wide bank. Near the nose that little is enough to hold a cone: the nose
+// circles the target for ever. The course pilot passes a hard gate. Every
+// combat caller passes none, and keeps the soft gate alone.
+console.log('\nthe roll gate holds the pitch until the bank arrives');
+{
+ const level = new THREE.Quaternion();
+ // A target a little off the nose, and off to the side, so the bank is wide.
+ const off = new THREE.Vector3(Math.sin(0.04), 0.0005, -Math.cos(0.04)).normalize();
+ const soft = bankToTurn(level, off, freshSteerMemory());
+ check(`with no gate, a wide bank still asks for pitch (${soft.pitch.toFixed(5)})`,
+ Math.abs(soft.pitch) > 0);
+ const hard = bankToTurn(level, off, freshSteerMemory(), 0, 0.05);
+ check('with the gate, it asks for none', hard.pitch === 0);
+ check('...and it still asks for the same roll', Math.abs(hard.roll - soft.roll) < 1e-12);
+
+ // Once the bank arrives, the gate lets go, and the pitch pulls the nose up.
+ const above = new THREE.Vector3(0, Math.sin(0.04), -Math.cos(0.04));
+ const pulled = bankToTurn(level, above, freshSteerMemory(), 0, 0.05);
+ check(`a target above the nose, with the bank made, pitches (${pulled.pitch.toFixed(4)})`,
+ pulled.pitch > 0);
+}
From d85efd8f7f15b80fbbd6e42bbb01c0035f070de5 Mon Sep 17 00:00:00 2001
From: Chris Greening SLOT_DEPTH | 60 | How far into the -Z face puts a ship in the channel, in world units. * | docking.slotDepth | [docking.ts:133](./docking.ts#L133) |
| docking | ROLL_TOLERANCE | 0.65 | The wings against the slot's long axis, in radians: how badly you may be rolled and still fit through the letterbox. | | [docking.ts:144](./docking.ts#L144) |
| docking | SLOT_SPEED_LIMIT | 120 | How fast a ship may be going when it reaches the slot, in world units a second (docs/TODO/207 M3). | docking.slotSpeedLimit | [docking.ts:165](./docking.ts#L165) |
+| docking | RAILS_LATERAL | 250 | How far off the slot axis the ship may be when the rails take it, in world units (docs/TODO/212). | docking.railsLateral | [docking.ts:187](./docking.ts#L187) |
+| docking | RAILS_RANGE | 900 | How far out the rails take the ship, in world units (docs/TODO/212). | docking.railsRange | [docking.ts:199](./docking.ts#L199) |
+| docking | RAILS_PULL | 2 | How hard the rails pull the ship onto the axis, per second (docs/TODO/212). | docking.railsPull | [docking.ts:211](./docking.ts#L211) |
| docking-computer | DOCK_COMPUTER_RANGE | 3500 | How close to the station the docking computer will take the job, in world units. | | [docking-computer.ts:46](./docking-computer.ts#L46) |
| docking-computer | DC_SLOT_MARGIN | 0.30 | How much of the slot's roll tolerance the TURN may spend, as a fraction. | docking.slotMargin | [docking-computer.ts:97](./docking-computer.ts#L97) |
| docking-computer | DC_TURN_FADE_ANGLE | 0.10 | The off-nose angle, in radians, over which the TURN's claim on the roll axis ramps in. | docking.turnFadeAngle | [docking-computer.ts:151](./docking-computer.ts#L151) |
@@ -213,18 +216,18 @@ search names, meanings and values with `npm run constants:find -- "FUGITIVE | 2 | The top rung: every police ship in the galaxy hunts a Fugitive. | law.fugitive | [law.ts:66](./law.ts#L66) |
| law | STATION_TRUCE | 7000 | How close to the station the commander must be for the truce to hold. | law.stationTruce | [law.ts:94](./law.ts#L94) |
| law | CONTRABAND | [3, 6, 10] | The commodity indices that the Galactic Government defines as illegal: slaves, narcotics and firearms. | | [law.ts:102](./law.ts#L102) |
-| law | OFFENDER_FINE | 250 | The fine for a dock with a record, capped at what you can actually pay. | | [law.ts:108](./law.ts#L108) |
-| law | FUGITIVE_FINE | 750 | | | [law.ts:109](./law.ts#L109) |
-| law | KILLS_PER_RUNG | 5 | Pirate kills that take a legal record down one rung. | law.killsPerRung | [law.ts:140](./law.ts#L140) |
-| law | DEFENCE_RANGE | 9000 | Misbehave within this range of the station slot, and the Vipers launch. | | [law.ts:148](./law.ts#L148) |
-| law | SCAN_RANGE | 2600 | How close a police ship must be to scan your hold. | | [law.ts:151](./law.ts#L151) |
-| law | SCAN_WARN_RANGE | 4400 | A police ship this close is about to be able to read your hold, and the console says so while it stays there. | | [law.ts:171](./law.ts#L171) |
-| law | SCAN_WARN_REPEAT | 2 | Seconds between repeats of that warning, while a patrol stays in the band. | law.scanWarnRepeat | [law.ts:188](./law.ts#L188) |
-| law | BRIBE_SHARE | 0.5 | What a policeman charges to not read your hold: this share of what the contraband aboard is worth at market. | law.bribeShare | [law.ts:217](./law.ts#L217) |
-| law | BRIBE_FLOOR | 500 | ...but never less than this, so a light run is not a free pass. 50 Cr, in tenths of a credit (invariant 8). | law.bribeFloor | [law.ts:237](./law.ts#L237) |
-| law | PATROL_BRIBE_FINES | 4 | What a police ship that already shoots at you charges to break off, as a multiple of the fine for the rung you are on. | law.patrolBribeFines | [law.ts:261](./law.ts#L261) |
-| law | BRIBE_REFUSED | 0.35 | How often an HONEST commander's offer is refused and reported. | law.bribeRefused | [law.ts:293](./law.ts#L293) |
-| law | SCAN_LINE_SECONDS | 4 | How long the scan's own line holds the console, and therefore how long the verdict that explains it waits behind it. | law.scanLineSeconds | [law.ts:308](./law.ts#L308) |
+| law | OFFENDER_FINE | 250 | The fine for a dock with a record, capped at what you can actually pay. | law.offenderFine | [law.ts:110](./law.ts#L110) |
+| law | FUGITIVE_FINE | 750 | ...and the fine for a fugitive, which is three times as much. | law.fugitiveFine | [law.ts:117](./law.ts#L117) |
+| law | KILLS_PER_RUNG | 5 | Pirate kills that take a legal record down one rung. | law.killsPerRung | [law.ts:148](./law.ts#L148) |
+| law | DEFENCE_RANGE | 9000 | Misbehave within this range of the station slot, and the Vipers launch. | | [law.ts:156](./law.ts#L156) |
+| law | SCAN_RANGE | 2600 | How close a police ship must be to scan your hold. | | [law.ts:159](./law.ts#L159) |
+| law | SCAN_WARN_RANGE | 4400 | A police ship this close is about to be able to read your hold, and the console says so while it stays there. | | [law.ts:179](./law.ts#L179) |
+| law | SCAN_WARN_REPEAT | 2 | Seconds between repeats of that warning, while a patrol stays in the band. | law.scanWarnRepeat | [law.ts:196](./law.ts#L196) |
+| law | BRIBE_SHARE | 0.5 | What a policeman charges to not read your hold: this share of what the contraband aboard is worth at market. | law.bribeShare | [law.ts:225](./law.ts#L225) |
+| law | BRIBE_FLOOR | 500 | ...but never less than this, so a light run is not a free pass. 50 Cr, in tenths of a credit (invariant 8). | law.bribeFloor | [law.ts:245](./law.ts#L245) |
+| law | PATROL_BRIBE_FINES | 4 | What a police ship that already shoots at you charges to break off, as a multiple of the fine for the rung you are on. | law.patrolBribeFines | [law.ts:269](./law.ts#L269) |
+| law | BRIBE_REFUSED | 0.35 | How often an HONEST commander's offer is refused and reported. | law.bribeRefused | [law.ts:301](./law.ts#L301) |
+| law | SCAN_LINE_SECONDS | 4 | How long the scan's own line holds the console, and therefore how long the verdict that explains it waits behind it. | law.scanLineSeconds | [law.ts:316](./law.ts#L316) |
| living-galaxy | PRESSURE_DECAY | 0.12 | How fast price pressure decays back toward the 1984 baseline, per day. | | [living-galaxy.ts:9](./living-galaxy.ts#L9) |
| living-galaxy | HEAT_DECAY | 0.06 | How fast talk about the player dies down, per day. | living.heatDecay | [living-galaxy.ts:24](./living-galaxy.ts#L24) |
| living-galaxy | DANGER_DECAY | 0.015 | How fast a system's reputation for piracy fades, per day. | | [living-galaxy.ts:32](./living-galaxy.ts#L32) |
@@ -289,13 +292,13 @@ search names, meanings and values with `npm run constants:find -- "MISSILE_MAX_RANGE | 3200 | The far edge of the seeker's envelope. | | [ordnance.ts:42](./ordnance.ts#L42) |
| ordnance | MISSILE_LAST_STAND_HULL | 0.4 | The hull fraction below which a ship stops saving its missiles for later. | ordnance.missileLastStandHull | [ordnance.ts:53](./ordnance.ts#L53) |
| ordnance | MISSILE_LAST_STAND_GATE | Math.PI / 2 | ...and it launches on a bearing rather than on a firing line. | | [ordnance.ts:58](./ordnance.ts#L58) |
-| ordnance | MISSILE_LAST_STAND_MIN_RANGE | 250 | Desperation widens the envelope INWARD, but not all the way. | | [ordnance.ts:64](./ordnance.ts#L64) |
-| ordnance | MISSILE_RELOAD | 2 | Gap between launches, so a Python does not empty both rails in one frame. | ordnance.missileReload | [ordnance.ts:73](./ordnance.ts#L73) |
-| ordnance | MISSILE_COMMIT_PASSES | 2 | How many passes a ship makes before it accepts that this is not going its way. | ordnance.missileCommitPasses | [ordnance.ts:85](./ordnance.ts#L85) |
-| ordnance | ECM_RANGE | 2800 | A target with an E.C.M. fries an incoming missile inside this. | | [ordnance.ts:90](./ordnance.ts#L90) |
-| ordnance | ECM_RATE | 0.45 | ...at this chance per second. | | [ordnance.ts:92](./ordnance.ts#L92) |
-| ordnance | ECM_ENERGY_COST | ENERGY_BANK_POINTS | A shot of the E.C.M. costs one bank of energy. | | [ordnance.ts:98](./ordnance.ts#L98) |
-| ordnance | ENERGY_BOMB_RANGE | 8000 | The energy bomb reaches this far. | | [ordnance.ts:101](./ordnance.ts#L101) |
+| ordnance | MISSILE_LAST_STAND_MIN_RANGE | 250 | Desperation widens the envelope INWARD, but not all the way. | ordnance.missileLastStandMinRange | [ordnance.ts:66](./ordnance.ts#L66) |
+| ordnance | MISSILE_RELOAD | 2 | Gap between launches, so a Python does not empty both rails in one frame. | ordnance.missileReload | [ordnance.ts:75](./ordnance.ts#L75) |
+| ordnance | MISSILE_COMMIT_PASSES | 2 | How many passes a ship makes before it accepts that this is not going its way. | ordnance.missileCommitPasses | [ordnance.ts:87](./ordnance.ts#L87) |
+| ordnance | ECM_RANGE | 2800 | A target with an E.C.M. fries an incoming missile inside this. | | [ordnance.ts:92](./ordnance.ts#L92) |
+| ordnance | ECM_RATE | 0.45 | ...at this chance per second. | | [ordnance.ts:94](./ordnance.ts#L94) |
+| ordnance | ECM_ENERGY_COST | ENERGY_BANK_POINTS | A shot of the E.C.M. costs one bank of energy. | | [ordnance.ts:100](./ordnance.ts#L100) |
+| ordnance | ENERGY_BOMB_RANGE | 8000 | The energy bomb reaches this far. | | [ordnance.ts:103](./ordnance.ts#L103) |
| pass-aim | PASS_MISS_DISTANCE | 110 | How far to the SIDE of its target a ship aims its attack run. 110 clears the largest pirate hull, plus the commander's radius, twice over. | | [pass-aim.ts:13](./pass-aim.ts#L13) |
| pass-aim | MAX_LEAD_SECONDS | 0.5 | The furthest ahead of a target that a ship will aim, in seconds. | | [pass-aim.ts:21](./pass-aim.ts#L21) |
| pass-aim | MAX_MISS_STRETCH | 3 | The most that geometry may stretch the aim, in multiples of the intended pass. | passaim.maxMissStretch | [pass-aim.ts:31](./pass-aim.ts#L31) |
diff --git a/src/constants/docking.ts b/src/constants/docking.ts
index 24f4439d..6b377ffe 100644
--- a/src/constants/docking.ts
+++ b/src/constants/docking.ts
@@ -163,3 +163,49 @@ export const ROLL_TOLERANCE = 0.65;
* @domain docking
*/
export const SLOT_SPEED_LIMIT = 120;
+
+/**
+ * How far off the slot axis the ship may be when the rails take it, in world
+ * units (docs/TODO/212).
+ *
+ * THE PILOT USED TO GET THE SHIP AT 1,500 UNITS, AND IT WAS NOT LINED UP. A
+ * measurement of 2026-09-12 put the hand-over 407 to 886 units off the axis,
+ * at 224 units a second. The slot channel is 26 units across the half-width.
+ * So the computer still had the whole of the lining up to do, with the pitch
+ * alone, because the pilot owned the roll. An ideal pilot docked 0 times in 4.
+ *
+ * IT IS A SANITY BOUND, NOT THE LINE ITSELF. The rails close the last of the
+ * error themselves, and that is their job. The docking computer reaches its
+ * last leg 610 units out, with 220 units of error. The error is down to 18
+ * units by 280 units out. To wait for the small number would leave the pilot
+ * two seconds of game. So the rails take the ship at the start of the last leg,
+ * and they pull it onto the line while it flies in.
+ *
+ * @rule docking.railsLateral
+ * @domain docking
+ */
+export const RAILS_LATERAL = 250;
+
+/**
+ * How far out the rails take the ship, in world units (docs/TODO/212).
+ *
+ * It is the length of the mini game. At the slot's speed limit of 120 units a
+ * second, 900 units is about seven seconds. The station turns a half circle in
+ * 12 seconds, so the pilot sees the slot come round at least once.
+ *
+ * @rule docking.railsRange
+ * @domain docking
+ */
+export const RAILS_RANGE = 900;
+
+/**
+ * How hard the rails pull the ship onto the axis, per second (docs/TODO/212).
+ *
+ * The worst error the rails take is about 220 units. At 2 a second the first
+ * frame moves the ship 7 units sideways, and the error is gone in about two
+ * seconds. A hard snap would look like a teleport.
+ *
+ * @rule docking.railsPull
+ * @domain docking
+ */
+export const RAILS_PULL = 2;
diff --git a/src/constants/law.ts b/src/constants/law.ts
index e79887fe..5c4fbc48 100644
--- a/src/constants/law.ts
+++ b/src/constants/law.ts
@@ -104,8 +104,16 @@ export const CONTRABAND: readonly number[] = [3, 6, 10];
/**
* The fine for a dock with a record, capped at what you can actually pay. They
* are in tenths of a credit (invariant 8), so these are 25 Cr and 75 Cr.
+ *
+ * @rule law.offenderFine
*/
export const OFFENDER_FINE = 250;
+/**
+ * ...and the fine for a fugitive, which is three times as much. The comment
+ * above covers both.
+ *
+ * @rule law.fugitiveFine
+ */
export const FUGITIVE_FINE = 750;
/**
diff --git a/src/constants/ordnance.ts b/src/constants/ordnance.ts
index bc1c5550..30dd249e 100644
--- a/src/constants/ordnance.ts
+++ b/src/constants/ordnance.ts
@@ -60,6 +60,8 @@ export const MISSILE_LAST_STAND_GATE = Math.PI / 2;
* Desperation widens the envelope INWARD, but not all the way. Inside this, the
* missile arrives before the player can reach the E.C.M. or turn, and a weapon
* you cannot dodge is not a fight.
+ *
+ * @rule ordnance.missileLastStandMinRange
*/
export const MISSILE_LAST_STAND_MIN_RANGE = 250;
/**
diff --git a/src/game/cockpit-buttons.ts b/src/game/cockpit-buttons.ts
index 35431208..653d4b76 100644
--- a/src/game/cockpit-buttons.ts
+++ b/src/game/cockpit-buttons.ts
@@ -63,6 +63,8 @@ export interface ActionSource {
readonly missileInbound: boolean;
/** the pilot flies the last stretch into the slot (docs/TODO/207 M2) */
readonly trial: boolean;
+ /** ...and the rails have it, so the mini game is on (docs/TODO/212) */
+ readonly rails: boolean;
/** the keys that open and close the throttle, for the two held buttons */
readonly accelKey: string | null;
readonly decelKey: string | null;
@@ -80,6 +82,11 @@ export function actionButtonsFor(a: ActionSource): HudButton[] {
// The last stretch into the slot asks for two things and nothing else: the
// roll, and the speed (docs/TODO/207 M2). So the pilot's buttons are those
// two while it runs, and the guns wait.
+ if (a.trial && !a.rails) {
+ // The computer is lining the ship up, and the pilot has nothing to do yet
+ // (docs/TODO/212). A strip that did nothing would be a lie.
+ return [{ code: 'dock-lining-up', label: 'LINING UP', hint: 'STAND BY FOR THE SLOT' }];
+ }
if (a.trial) {
const out: HudButton[] = [{ code: a.rollStripCode, label: 'DRAG TO ROLL', strip: true }];
if (a.accelKey) {
diff --git a/src/game/cockpit-view.ts b/src/game/cockpit-view.ts
index 63ede2dd..fb5c76bc 100644
--- a/src/game/cockpit-view.ts
+++ b/src/game/cockpit-view.ts
@@ -225,6 +225,7 @@ export class CockpitView {
ecmKey: this.state.commander.equipment.ecm ? key('fireEcm') : null,
missileInbound: this.ordnance.missileInbound,
trial: this.state.session.dockTrial,
+ rails: this.state.session.dockRails,
accelKey: keymap().accel[0] ?? null,
decelKey: keymap().decel[0] ?? null,
rollStripCode: ROLL_STRIP_CODE,
@@ -318,6 +319,7 @@ export class CockpitView {
witchspace: this.state.session.witchspace,
assist: this.state.session.ccEngaged,
trial: this.state.session.dockTrial,
+ rails: this.state.session.dockRails,
ecmDetected: this.state.ecmDetectedTimer > 0,
messageText: this.state.session.messageText,
messageTimer: this.state.session.messageTimer,
diff --git a/src/game/dock-rails.ts b/src/game/dock-rails.ts
new file mode 100644
index 00000000..9e310252
--- /dev/null
+++ b/src/game/dock-rails.ts
@@ -0,0 +1,67 @@
+// The rails that hold the ship on the slot axis (docs/TODO/212).
+//
+// Chris flew the pilot's stretch of 207 on a phone and it did not work. His
+// words of 2026-09-12: *"lining up is not actually very accurate until the
+// last few moments. So we aren't actually flying straight and rolling can send
+// you off away from the slot."* He asked for a mini game instead: *"I'm
+// wondering if we actually get lined up by the computer and then hand off to a
+// 'mini' docking game. Something that is completely on rails."*
+//
+// THE FAULT WAS ONE STICK WITH TWO JOBS. The ship has no yaw axis, so a roll
+// is how it aims. The slot wants that same roll for something else: the wings
+// lined up with the letterbox. The docking computer reconciles the two
+// (`docking-sticks.ts`). The pilot's stretch could not, because the pilot held
+// the roll and the computer held only the pitch. A measurement of 2026-09-12
+// put the hand-over 407 to 886 units off the axis, and an ideal pilot docked 0
+// times in 4. With no roll at all, the same pilot docked 2 times in 4.
+//
+// SO THE RAILS HOLD THE LINE, AND THE PILOT HOLDS THE SPIN. This file is the
+// one place in the game that moves the commander's ship other than by flying
+// it. `test/docking.test.ts` scans for that, and it names this file.
+//
+// IT IS NOT A TELEPORT. Both corrections are eased. The nose turns by the
+// SHORTEST rotation onto the axis, which carries no twist about the nose. So
+// the pilot's own roll is untouched, and the roll is what the slot measures.
+
+import * as THREE from 'three';
+import type { PlayerShip } from '../player.ts';
+import type { DockPlan } from './docking.ts';
+import { slotNormal } from '../world/slot.ts';
+import { RAILS_LATERAL, RAILS_PULL, RAILS_RANGE, SLOT_SPEED_LIMIT } from '../constants/docking.ts';
+
+const _out = new THREE.Vector3();
+const _rel = new THREE.Vector3();
+const _fwd = new THREE.Vector3();
+const _turn = new THREE.Quaternion();
+
+/**
+ * Is the ship lined up well enough for the rails to take it?
+ *
+ * Four conditions, and each one matters. The plan must be on its last leg.
+ * The ship must be on the axis, inside `RAILS_LATERAL`. It must be slow enough
+ * for the slot already, so the mini game starts from a speed that can dock.
+ * `RAILS_RANGE` then keeps the game short.
+ */
+export function railsReady(plan: DockPlan, speed: number): boolean {
+ return plan.phase === 'run' && plan.lateral < RAILS_LATERAL
+ && speed <= SLOT_SPEED_LIMIT && plan.along < RAILS_RANGE;
+}
+
+/**
+ * One frame on the rails. It runs AFTER `PlayerShip.update`, so it corrects
+ * the frame the ship just flew.
+ *
+ * @param dt the length of the frame, in seconds
+ */
+export function holdOnRails(player: PlayerShip, station: THREE.Object3D, dt: number): void {
+ const outward = slotNormal(station, _out);
+ const rel = _rel.copy(player.position).sub(station.position);
+ const along = rel.dot(outward);
+ // `rel` becomes the part of the offset that lies ACROSS the axis, once the
+ // part along the axis is taken out of it.
+ rel.addScaledVector(outward, -along);
+ player.position.addScaledVector(rel, -Math.min(1, RAILS_PULL * dt));
+ const fwd = player.getForward(_fwd);
+ _turn.setFromUnitVectors(fwd, outward.multiplyScalar(-1));
+ player.quaternion.premultiply(_turn).normalize();
+}
diff --git a/src/game/docking.ts b/src/game/docking.ts
index 43a01760..f791e659 100644
--- a/src/game/docking.ts
+++ b/src/game/docking.ts
@@ -55,6 +55,11 @@ export interface DockPlan {
arrived: boolean;
/** distance off the slot axis, for HUD and tests */
lateral: number;
+ /**
+ * How far along the slot axis the ship is, in front of the station. It is
+ * negative behind it. The rails read it (docs/TODO/212).
+ */
+ along: number;
/**
* The plane this ship turns in, held across frames. `dock-path.ts` reads and
* writes it. It is saved state, like the phase: a ship restored mid-approach
@@ -91,6 +96,7 @@ export function planDocking(
// perpendicular distance from the axis
const lateral = _rel.addScaledVector(_slotN, -along).length();
out.lateral = lateral;
+ out.along = along;
// The station's local X, and not its Y. `lookAt(heading, up)` puts the ship's
// RIGHT perpendicular to the up-hint. The wings must lie along the slot's
// LONG axis, which is the station's local Y (see the header). The Y put every
@@ -172,6 +178,7 @@ export function planDocking(
/** A fresh plan object to hand to `planDocking` each frame. */
export function makeDockPlan(): DockPlan {
return {
+ along: 0,
heading: new THREE.Vector3(0, 0, -1),
up: new THREE.Vector3(0, 1, 0),
speed: 0,
diff --git a/src/game/flight-instruments.ts b/src/game/flight-instruments.ts
index b73be4ff..49b9515a 100644
--- a/src/game/flight-instruments.ts
+++ b/src/game/flight-instruments.ts
@@ -206,12 +206,12 @@ export class Instruments {
/**
* The station course reaches the hand-over, and the ship changes hands
- * (docs/TODO/207 M1).
+ * (docs/TODO/207 M1, in the shape docs/TODO/212 gave it).
*
* With a docking computer fitted, that computer flies the slot, as it does
- * today. Without one, the pilot flies the last stretch. The computer holds
- * the ship on the slot axis. The pilot matches the station's spin, and the
- * speed.
+ * today. Without one, the pilot's own stretch begins. The computer lines the
+ * ship up first. The rails then take the ship, and the pilot matches the
+ * station's spin and the speed.
*/
private handOver(): void {
const s = this.state.session;
@@ -220,9 +220,10 @@ export class Instruments {
return;
}
s.dockTrial = true;
+ s.dockRails = false;
s.course = null;
this.coursePilot.reset();
- this.host.showMessage('YOU HAVE THE SLOT — MATCH ITS SPIN AND GO IN SLOWLY', 5);
+ this.host.showMessage('THE COMPUTER IS LINING THE SHIP UP — STAND BY', 4);
}
/**
@@ -239,6 +240,7 @@ export class Instruments {
.distanceTo(this.state.world.station.position) > DOCK_COMPUTER_RANGE;
if (!out) return;
s.dockTrial = false;
+ s.dockRails = false;
this.host.showMessage('THE STATION IS BEHIND YOU', 3);
}
diff --git a/src/game/session.ts b/src/game/session.ts
index 354256d2..4e1955eb 100644
--- a/src/game/session.ts
+++ b/src/game/session.ts
@@ -110,6 +110,12 @@ export interface SessionState {
* and the speed. It is saved, because it decides who flies.
*/
dockTrial: boolean;
+ /**
+ * The ship is on the rails, and the pilot flies the mini game
+ * (docs/TODO/212). It is the second half of `dockTrial`. The first half is
+ * the computer's, and it lines the ship up. It is saved, as `dockTrial` is.
+ */
+ dockRails: boolean;
}
/**
@@ -121,6 +127,7 @@ export function endVisit(state: SessionState): void {
state.coursesDone = [];
state.handFlown = false;
state.dockTrial = false;
+ state.dockRails = false;
}
/** Put a message in canonical state; the HUD only paints these fields. */
diff --git a/src/game/state.ts b/src/game/state.ts
index 39e9f45f..e82de7cb 100644
--- a/src/game/state.ts
+++ b/src/game/state.ts
@@ -160,6 +160,7 @@ export function freshSession(): SessionState {
coursesDone: [],
handFlown: false,
dockTrial: false,
+ dockRails: false,
};
}
diff --git a/src/game/world-step.ts b/src/game/world-step.ts
index 423a8ee6..b9563ed8 100644
--- a/src/game/world-step.ts
+++ b/src/game/world-step.ts
@@ -52,7 +52,10 @@ import {
PIRATE_WAVE_RANGE, PIRATE_WAVE_RANGE_SPAN, THARGON_DEPLOY_RANGE,
TRADER_ARRIVAL_RANGE,
} from '../constants/spawn-placement.ts';
-import { planDocking, dockingOutcome, type DockingOutcome } from './docking.ts';
+import {
+ planDocking, dockingOutcome, type DockPlan, type DockingOutcome,
+} from './docking.ts';
+import { holdOnRails, railsReady } from './dock-rails.ts';
import { dockingSticks } from './docking-sticks.ts';
import { NPC_HULL_BOX_MARGIN } from '../constants/docking.ts';
import { BOUNCE_STANDOFF } from '../constants/station.ts';
@@ -322,8 +325,10 @@ export class WorldStep {
// before the ship flies, rather than applied on top of it. Where it hands
// the ship back mid-frame, the pilot's own demand stands, as before.
const dc = session.dcEngaged ? this.dockingComputerStep(dt, pilot, out)
- : session.dockTrial ? this.dockTrialStep(dt, pilot) : null;
+ : session.dockTrial ? this.dockTrialStep(dt, pilot, out) : null;
player.update(dt, dc ?? pilot.demand);
+ // The rails correct the frame the ship just flew (docs/TODO/212).
+ if (session.dockRails) holdOnRails(player, world.station, dt);
// torus drive
if (session.torusEngaged) {
@@ -367,15 +372,32 @@ export class WorldStep {
private dockingComputerStep(
dt: number, pilot: PilotInput, out: StepEvent[],
): FlightDemand | null {
- const { player, session, world } = this.state;
+ const { session } = this.state;
if (pilot.handsOn) {
session.dcEngaged = false;
out.push({ kind: 'dockingMusic', on: false });
out.push(say('MANUAL OVERRIDE', 2));
return null;
}
- const plan = planDocking(player.position, world.station, world.stationDockZ,
- player.maxSpeed, this.state.dockPlan);
+ return this.dockingDemand(dt, pilot, this.dockingPlan());
+ }
+
+ /** The approach the computer is flying this frame. */
+ private dockingPlan(): DockPlan {
+ const s = this.state;
+ return planDocking(s.player.position, s.world.station, s.world.stationDockZ,
+ s.player.maxSpeed, s.dockPlan);
+ }
+
+ /**
+ * What the docking computer asks of the ship this frame: both sticks, and
+ * the plan's own speed.
+ *
+ * The pilot's stretch shares it (docs/TODO/212). The computer lines the ship
+ * up there too, and it hands over only when the ship is ON the axis.
+ */
+ private dockingDemand(dt: number, pilot: PilotInput, plan: DockPlan): FlightDemand {
+ const { player } = this.state;
const sticks = dockingSticks(player.quaternion, plan, player.rollRate);
// Bang-bang on the throttle, with a deadband of one frame's thrust. A
// demand can only ask for full ahead, full astern or coast, because that is
@@ -398,30 +420,38 @@ export class WorldStep {
}
/**
- * One frame of the pilot's own stretch of the approach (docs/TODO/207).
+ * One frame of the pilot's own stretch of the approach (docs/TODO/207, and
+ * docs/TODO/212 for its shape).
*
- * The computer holds the ship on the slot axis, which is the pitch. The
- * pilot owns the roll and the throttle. The slot asks for those two things:
- * the station's spin, and a speed it will take.
+ * IT IS TWO STAGES. The computer lines the ship up first, with both sticks,
+ * exactly as the docking computer does. Then the rails take the ship, and
+ * the pilot plays the mini game: match the slot, and go in slowly.
*
- * It is the docking computer's own plan, with two of its three sticks given
- * back. So the ship follows the same curve to the letterbox, and the last
- * of the manoeuvre is the pilot's.
+ * Chris asked for that on 2026-09-12: *"I'm wondering if we actually get
+ * lined up by the computer and then hand off to a 'mini' docking game.
+ * Something that is completely on rails."* One stick cannot hold a line and
+ * match a spin at the same time. The rails hold the line, so the stick is
+ * free for the spin.
*/
- private dockTrialStep(dt: number, pilot: PilotInput): FlightDemand {
- const { player, world } = this.state;
- const plan = planDocking(player.position, world.station, world.stationDockZ,
- player.maxSpeed, this.state.dockPlan);
- const sticks = dockingSticks(player.quaternion, plan, player.rollRate);
+ private dockTrialStep(dt: number, pilot: PilotInput, out: StepEvent[]): FlightDemand {
+ const { player, session } = this.state;
+ const plan = this.dockingPlan();
+ if (!session.dockRails) {
+ if (!railsReady(plan, player.speed)) return this.dockingDemand(dt, pilot, plan);
+ session.dockRails = true;
+ out.push(say('THE SLOT IS YOURS — MATCH IT, AND GO IN SLOWLY', 4));
+ }
+ // ON THE RAILS. The pitch is nobody's: `holdOnRails` owns the line. The
+ // roll and the throttle are the pilot's, and they are the whole game.
return {
- pitchRate: rampFlightRate(
- player.pitchRate, sticks.pitch * PLAYER_FLIGHT.maxPitch, sticks.pitch !== 0, dt),
+ pitchRate: rampFlightRate(player.pitchRate, 0, false, dt),
rollRate: pilot.demand.rollRate,
throttle: pilot.demand.throttle,
fire: pilot.demand.fire,
};
}
+
/** Everyone else: decisions, despawns, collisions, and who else turns up. */
private stepNpcs(dt: number, out: StepEvent[]): void {
const s = this.state;
@@ -893,7 +923,9 @@ export class WorldStep {
this.host.dock();
return;
}
- // hit the hull, or fluffed the slot
+ // hit the hull, or fluffed the slot. The rails let go, so the computer
+ // lines the ship up again for another go (docs/TODO/212).
+ this.state.session.dockRails = false;
const away = this.tmp2.copy(player.position).sub(station.position).normalize();
player.position.copy(station.position).addScaledVector(away, BOUNCE_STANDOFF);
player.speed = 0;
diff --git a/src/hud/hud-binding.ts b/src/hud/hud-binding.ts
index 58a69880..0f414aee 100644
--- a/src/hud/hud-binding.ts
+++ b/src/hud/hud-binding.ts
@@ -59,6 +59,7 @@ export interface HudSources {
readonly assist: boolean;
/** the pilot flies the last stretch into the slot (docs/TODO/207) */
readonly trial: boolean;
+ readonly rails: boolean;
readonly ecmDetected: boolean;
readonly messageText: string;
readonly messageTimer: number;
@@ -221,6 +222,7 @@ export function buildHudFrame(s: HudSources, scratch: HudScratch): HudFrame {
missionMarker,
assist: s.assist,
trial: s.trial,
+ rails: s.rails,
armed: s.missileArmed,
stationInRange: s.inFlight && !s.witchspace
&& playerPos.distanceTo(world.station.position) < SCANNER_RANGE,
diff --git a/src/hud/hud.ts b/src/hud/hud.ts
index 0663cbf4..5cb9e021 100644
--- a/src/hud/hud.ts
+++ b/src/hud/hud.ts
@@ -156,6 +156,8 @@ export interface HudState {
assist: boolean;
/** the pilot flies the last stretch into the slot (docs/TODO/207) */
trial: boolean;
+ /** ...and the rails have it, so the mini game is on (docs/TODO/212) */
+ rails: boolean;
/** missile armed but not yet locked (yellow pylon) */
armed: boolean;
/** console 'S': the space station is within scanner range */
@@ -288,8 +290,9 @@ export class Hud {
this.altEl.style.width = `${Math.min(100, frame.altitudeFrac * 100)}%`;
this.cabinEl.style.width = `${Math.min(100, frame.cabinTemp * 100)}%`;
this.cabinEl.style.background = frame.cabinTemp > CABIN_GAUGE_WARN ? RED : '';
- this.viewEl.textContent = frame.trial ? '◆ MATCH THE SLOT — GO IN SLOWLY ◆'
- : frame.assist ? '◆ THE COMPUTER IS AIMING ◆' : (VIEW_NAMES[frame.view] ?? '');
+ this.viewEl.textContent = frame.rails ? '◆ MATCH THE SLOT — GO IN SLOWLY ◆'
+ : frame.trial ? '◆ THE COMPUTER IS LINING THE SHIP UP ◆'
+ : frame.assist ? '◆ THE COMPUTER IS AIMING ◆' : (VIEW_NAMES[frame.view] ?? '');
this.crosshairEl.style.display = frame.hasLaser ? '' : 'none';
this.shipIdEl.textContent = frame.shipId;
this.drawEnergy(frame);
diff --git a/test/dock-trial.test.ts b/test/dock-trial.test.ts
index ac33a708..6a1e8ca1 100644
--- a/test/dock-trial.test.ts
+++ b/test/dock-trial.test.ts
@@ -13,7 +13,12 @@ import { keymap } from '../src/engine/keymap.ts';
import { dockingOutcome } from '../src/game/docking.ts';
import { SLOT_SPEED_LIMIT } from '../src/constants/docking.ts';
import { PLAYER_FLIGHT } from '../src/constants/player-flight.ts';
-import { check, eq } from './harness.ts';
+import { Game } from '../src/game/game.ts';
+import { headlessShell } from '../src/engine/shell.ts';
+import { withoutSaving } from '../src/game/storage.ts';
+import { seedWorld } from '../src/game/rng.ts';
+import { SLOT_HALF_ACROSS } from '../src/constants/docking.ts';
+import { check, dismissBriefing, eq } from './harness.ts';
console.log('\nthe last stretch into the slot');
@@ -22,10 +27,19 @@ console.log('\nthe last stretch into the slot');
const trial = actionButtonsFor({
fireKey: 'KeyA', missiles: 3, armed: false, locked: false, armKey: 'KeyT',
launchKey: 'KeyM', ecmKey: 'KeyE', targets: null, missileInbound: false,
- trial: true, accelKey: 'Space', decelKey: 'KeyX', rollStripCode: 'roll',
+ trial: true, rails: true, accelKey: 'Space', decelKey: 'KeyX', rollStripCode: 'roll',
});
eq('the stretch shows a strip and two held buttons, and no guns',
trial.map((b) => b.label).join(), 'DRAG TO ROLL,THRUST,BRAKE');
+ // ...and before the rails take it, the pilot has nothing to do yet
+ // (docs/TODO/212). A strip that did nothing would be a lie.
+ const lining = actionButtonsFor({
+ fireKey: 'KeyA', missiles: 3, armed: false, locked: false, armKey: 'KeyT',
+ launchKey: 'KeyM', ecmKey: 'KeyE', targets: null, missileInbound: false,
+ trial: true, rails: false, accelKey: 'Space', decelKey: 'KeyX', rollStripCode: 'roll',
+ });
+ eq('while the computer lines up, the pilot sees one word and no controls',
+ lining.map((b) => b.label).join(), 'LINING UP');
check('the strip is dragged, not pressed', trial[0]?.strip === true);
check('...and the throttle buttons are held', trial[1]?.hold === true && trial[2]?.hold === true);
}
@@ -87,3 +101,75 @@ console.log('\nthe last stretch into the slot');
check('the limit is well under the ship\'s top speed, so it is a real choice',
SLOT_SPEED_LIMIT < PLAYER_FLIGHT.maxSpeed / 2);
}
+
+// --- THE MINI GAME: THE COMPUTER LINES UP, THE RAILS HOLD THE LINE ----------
+//
+// Chris flew 207's stretch on a phone (2026-09-12): *"The docking does not
+// seem to work at all... lining up is not actually very accurate until the
+// last few moments. So we aren't actually flying straight and rolling can send
+// you off away from the slot."* He asked for the shape this pins: *"we
+// actually get lined up by the computer and then hand off to a 'mini' docking
+// game. Something that is completely on rails."*
+//
+// The whole game, flown headless: the course, the computer's lining up, and
+// then the rails with a pilot's hand on the strip.
+console.log('\nthe docking mini game');
+{
+ /** A commander at the witchpoint with the station course picked. */
+ const arrive = (seed: number): Game => {
+ const g = withoutSaving(() => {
+ seedWorld(seed);
+ const game = new Game(() => headlessShell());
+ dismissBriefing(game);
+ game.launch();
+ game.arriveInSystem();
+ return game;
+ }).value;
+ g.state.world.clearNpcs();
+ g.state.session.course = 'station';
+ return g;
+ };
+
+ /**
+ * Fly to the rails, then hold the strip as a pilot does.
+ *
+ * @param match whether the pilot matches the slot. A pilot who does nothing
+ * is the control: the slot is only there to meet about two turns in five.
+ */
+ const fly = (g: Game, match: boolean): { rails: boolean; docked: boolean; lateral: number } => {
+ const dt = 1 / 60;
+ const q = new THREE.Quaternion();
+ const right = new THREE.Vector3();
+ let rails = false;
+ let lateral = Infinity;
+ withoutSaving(() => {
+ for (let f = 0, at = 0; f < 200 / dt; f++) {
+ const st = g.state.world.station;
+ if (g.state.session.dockRails) {
+ rails = true;
+ const local = g.state.player.position.clone();
+ st.worldToLocal(local);
+ lateral = Math.min(lateral, Math.hypot(local.x, local.y));
+ if (match) {
+ q.copy(st.quaternion).invert().multiply(g.state.player.quaternion);
+ right.set(1, 0, 0).applyQuaternion(q);
+ // The slot fits either way up, so the error wraps at a quarter
+ // turn rather than a half.
+ const err = Math.atan2(right.x, right.y);
+ g.input.rollStick = Math.max(-1, Math.min(1,
+ Math.atan2(Math.sin(2 * err), Math.cos(2 * err)) / 2 * 4));
+ }
+ }
+ g.step(dt, at += dt);
+ if (g.mode !== 'flight') break;
+ }
+ });
+ return { rails, docked: g.mode === 'docked', lateral };
+ };
+
+ const run = fly(arrive(20_260_951), true);
+ check('the rails take the ship', run.rails);
+ check('...and a pilot who matches the slot docks', run.docked);
+ check('...having been held on the line, inside the channel',
+ run.lateral < SLOT_HALF_ACROSS, `${run.lateral.toFixed(0)} units off the axis`);
+}
diff --git a/test/docking.test.ts b/test/docking.test.ts
index ba5f55d6..3f70f876 100644
--- a/test/docking.test.ts
+++ b/test/docking.test.ts
@@ -16,7 +16,7 @@
// slotMiss, hull) is test/world.test.ts's docking section; this file is about
// WHERE the edges are.
-import { readFileSync } from 'node:fs';
+import { readFileSync, readdirSync } from 'node:fs';
import * as THREE from 'three';
import {
dockingOutcome, planDocking, makeDockPlan, type DockingOutcome,
@@ -35,7 +35,7 @@ import { newCommander } from '../src/game/commander.ts';
import { seedWorld } from '../src/game/rng.ts';
import { slotNormal } from '../src/world/slot.ts';
import type { DamageSource } from '../src/game/combat.ts';
-import { check } from './harness.ts';
+import { check, eq } from './harness.ts';
/** The edge between `inside(lo)` and `!inside(hi)`, to a millionth of a unit. */
function bisect(lo: number, hi: number, inside: (x: number) => boolean): number {
@@ -271,6 +271,29 @@ function makeRun() {
!READ_ONLY_QUATERNION.includes('rotateTowards'));
}
+// ...AND ONE FILE IN THE WHOLE GAME MAY MOVE THE SHIP ANOTHER WAY
+// (docs/TODO/212). The rails of the docking mini game hold the ship on the
+// slot axis, and that is not flying. Chris asked for exactly that. The rule
+// above is about STEERING, so it stays. This says where the exception lives,
+// because a reader of the rule above would otherwise believe there is none.
+{
+ const dir = new URL('../src/game/', import.meta.url);
+ const writers = readdirSync(dir)
+ .filter((name) => name.endsWith('.ts'))
+ .filter((name) => {
+ const src = readFileSync(new URL(name, dir), 'utf8')
+ .replace(/^\s*(\/\/|\*|\/\*).*$/gm, '');
+ return [...src.matchAll(/player\.quaternion\s*\.\s*([a-zA-Z]+)/g)]
+ .some((m) => !READ_ONLY_QUATERNION.includes(m[1]));
+ });
+ // The other three PLACE the ship rather than fly it: a new game and a
+ // respawn (game.ts), a restored save (persistence.ts), and the setup of one
+ // training exercise (combat-sim.ts). A fourth name here is a new hand on the
+ // ship, and it must answer for itself.
+ eq('dock-rails.ts is the only file in game/ that turns the ship WHILE it flies',
+ writers.join(', '), 'combat-sim.ts, dock-rails.ts, game.ts, persistence.ts');
+}
+
// --- what a fluffed slot does to you, through the same step ------------------
{
diff --git a/test/fight-buttons.test.ts b/test/fight-buttons.test.ts
index e47697d9..14558537 100644
--- a/test/fight-buttons.test.ts
+++ b/test/fight-buttons.test.ts
@@ -21,7 +21,7 @@ console.log('\nthe pilot\'s buttons');
const base = {
fireKey: 'KeyA', missiles: 3, armed: false, locked: false,
armKey: 'KeyT', launchKey: 'KeyM', ecmKey: null, targets: null, missileInbound: false,
- trial: false, accelKey: 'Space', decelKey: 'KeyX', rollStripCode: 'roll',
+ trial: false, rails: false, accelKey: 'Space', decelKey: 'KeyX', rollStripCode: 'roll',
};
{
const b = actionButtonsFor(base);
From 40ae5468cc8ddce1ef13ca8247bec8bcef72d115 Mon Sep 17 00:00:00 2001
From: Chris Greening RAILS_LATERAL | 250 | How far off the slot axis the ship may be when the rails take it, in world units (docs/TODO/212). | docking.railsLateral | [docking.ts:187](./docking.ts#L187) |
| docking | RAILS_RANGE | 900 | How far out the rails take the ship, in world units (docs/TODO/212). | docking.railsRange | [docking.ts:199](./docking.ts#L199) |
| docking | RAILS_PULL | 2 | How hard the rails pull the ship onto the axis, per second (docs/TODO/212). | docking.railsPull | [docking.ts:211](./docking.ts#L211) |
+| docking | RAILS_STOPPED | 2 | The speed under which the ship counts as stopped, in world units a second (docs/TODO/212). | docking.railsStopped | [docking.ts:225](./docking.ts#L225) |
| docking-computer | DOCK_COMPUTER_RANGE | 3500 | How close to the station the docking computer will take the job, in world units. | | [docking-computer.ts:46](./docking-computer.ts#L46) |
| docking-computer | DC_SLOT_MARGIN | 0.30 | How much of the slot's roll tolerance the TURN may spend, as a fraction. | docking.slotMargin | [docking-computer.ts:97](./docking-computer.ts#L97) |
| docking-computer | DC_TURN_FADE_ANGLE | 0.10 | The off-nose angle, in radians, over which the TURN's claim on the roll axis ramps in. | docking.turnFadeAngle | [docking-computer.ts:151](./docking-computer.ts#L151) |
diff --git a/src/constants/docking.ts b/src/constants/docking.ts
index 6b377ffe..248610d6 100644
--- a/src/constants/docking.ts
+++ b/src/constants/docking.ts
@@ -209,3 +209,17 @@ export const RAILS_RANGE = 900;
* @domain docking
*/
export const RAILS_PULL = 2;
+
+/**
+ * The speed under which the ship counts as stopped, in world units a second
+ * (docs/TODO/212).
+ *
+ * The computer brakes the ship to a halt on the slot axis, and the rails then
+ * take it. The pilot thrusts in from there. A ship thrusts at 220 units a
+ * second per second, so 2 is under a hundredth of a second of thrust. It is a
+ * band rather than a zero, because the brake is a sign and not a gain.
+ *
+ * @rule docking.railsStopped
+ * @domain docking
+ */
+export const RAILS_STOPPED = 2;
diff --git a/src/game/cockpit-buttons.ts b/src/game/cockpit-buttons.ts
index 653d4b76..7ccc280f 100644
--- a/src/game/cockpit-buttons.ts
+++ b/src/game/cockpit-buttons.ts
@@ -90,7 +90,7 @@ export function actionButtonsFor(a: ActionSource): HudButton[] {
if (a.trial) {
const out: HudButton[] = [{ code: a.rollStripCode, label: 'DRAG TO ROLL', strip: true }];
if (a.accelKey) {
- out.push({ code: a.accelKey, label: 'THRUST', hint: 'HOLD TO SPEED UP', hold: true });
+ out.push({ code: a.accelKey, label: 'THRUST', hint: 'HOLD TO GO IN', hold: true });
}
if (a.decelKey) {
out.push({ code: a.decelKey, label: 'BRAKE', hint: 'HOLD TO SLOW DOWN', hold: true });
diff --git a/src/game/dock-rails.ts b/src/game/dock-rails.ts
index 9e310252..dcc1f267 100644
--- a/src/game/dock-rails.ts
+++ b/src/game/dock-rails.ts
@@ -27,7 +27,9 @@ import * as THREE from 'three';
import type { PlayerShip } from '../player.ts';
import type { DockPlan } from './docking.ts';
import { slotNormal } from '../world/slot.ts';
-import { RAILS_LATERAL, RAILS_PULL, RAILS_RANGE, SLOT_SPEED_LIMIT } from '../constants/docking.ts';
+import {
+ RAILS_LATERAL, RAILS_PULL, RAILS_RANGE, RAILS_STOPPED,
+} from '../constants/docking.ts';
const _out = new THREE.Vector3();
const _rel = new THREE.Vector3();
@@ -35,16 +37,28 @@ const _fwd = new THREE.Vector3();
const _turn = new THREE.Quaternion();
/**
- * Is the ship lined up well enough for the rails to take it?
+ * Is the ship at the place where the computer stops it?
*
- * Four conditions, and each one matters. The plan must be on its last leg.
- * The ship must be on the axis, inside `RAILS_LATERAL`. It must be slow enough
- * for the slot already, so the mini game starts from a speed that can dock.
- * `RAILS_RANGE` then keeps the game short.
+ * Three conditions, and each one matters. The plan must be on its last leg.
+ * The ship must be on the axis, inside `RAILS_LATERAL`. `RAILS_RANGE` then
+ * keeps the game short.
*/
-export function railsReady(plan: DockPlan, speed: number): boolean {
+export function railsReached(plan: DockPlan): boolean {
return plan.phase === 'run' && plan.lateral < RAILS_LATERAL
- && speed <= SLOT_SPEED_LIMIT && plan.along < RAILS_RANGE;
+ && plan.along < RAILS_RANGE;
+}
+
+/**
+ * Is the ship stopped, so the rails can take it?
+ *
+ * THE COMPUTER BRINGS THE SHIP TO A HALT FIRST (Chris, 2026-09-12: *"it's a
+ * bit too easy I think. Maybe reducing the speed to nothing so the user has to
+ * thrust forward would be good."*). The pilot then owns the whole run in, from
+ * a standing start. A ship that arrives with the speed already made needs only
+ * a hand on the roll.
+ */
+export function stopped(speed: number): boolean {
+ return speed <= RAILS_STOPPED;
}
/**
diff --git a/src/game/world-step.ts b/src/game/world-step.ts
index b9563ed8..d5350593 100644
--- a/src/game/world-step.ts
+++ b/src/game/world-step.ts
@@ -55,7 +55,7 @@ import {
import {
planDocking, dockingOutcome, type DockPlan, type DockingOutcome,
} from './docking.ts';
-import { holdOnRails, railsReady } from './dock-rails.ts';
+import { holdOnRails, railsReached, stopped } from './dock-rails.ts';
import { dockingSticks } from './docking-sticks.ts';
import { NPC_HULL_BOX_MARGIN } from '../constants/docking.ts';
import { BOUNCE_STANDOFF } from '../constants/station.ts';
@@ -437,9 +437,14 @@ export class WorldStep {
const { player, session } = this.state;
const plan = this.dockingPlan();
if (!session.dockRails) {
- if (!railsReady(plan, player.speed)) return this.dockingDemand(dt, pilot, plan);
+ if (!railsReached(plan)) return this.dockingDemand(dt, pilot, plan);
+ // On the axis, and the computer stops the ship there. The pilot then
+ // flies the whole run in (docs/TODO/212).
+ if (!stopped(player.speed)) {
+ return { ...this.dockingDemand(dt, pilot, plan), throttle: -1 };
+ }
session.dockRails = true;
- out.push(say('THE SLOT IS YOURS — MATCH IT, AND GO IN SLOWLY', 4));
+ out.push(say('THE SLOT IS YOURS — THRUST IN, AND MATCH ITS SPIN', 5));
}
// ON THE RAILS. The pitch is nobody's: `holdOnRails` owns the line. The
// roll and the throttle are the pilot's, and they are the whole game.
diff --git a/src/hud/hud.ts b/src/hud/hud.ts
index 5cb9e021..86e26a7c 100644
--- a/src/hud/hud.ts
+++ b/src/hud/hud.ts
@@ -290,7 +290,7 @@ export class Hud {
this.altEl.style.width = `${Math.min(100, frame.altitudeFrac * 100)}%`;
this.cabinEl.style.width = `${Math.min(100, frame.cabinTemp * 100)}%`;
this.cabinEl.style.background = frame.cabinTemp > CABIN_GAUGE_WARN ? RED : '';
- this.viewEl.textContent = frame.rails ? '◆ MATCH THE SLOT — GO IN SLOWLY ◆'
+ this.viewEl.textContent = frame.rails ? '◆ THRUST IN, AND MATCH THE SLOT ◆'
: frame.trial ? '◆ THE COMPUTER IS LINING THE SHIP UP ◆'
: frame.assist ? '◆ THE COMPUTER IS AIMING ◆' : (VIEW_NAMES[frame.view] ?? '');
this.crosshairEl.style.display = frame.hasLaser ? '' : 'none';
diff --git a/test/dock-trial.test.ts b/test/dock-trial.test.ts
index 6a1e8ca1..de9ea7ef 100644
--- a/test/dock-trial.test.ts
+++ b/test/dock-trial.test.ts
@@ -17,7 +17,7 @@ import { Game } from '../src/game/game.ts';
import { headlessShell } from '../src/engine/shell.ts';
import { withoutSaving } from '../src/game/storage.ts';
import { seedWorld } from '../src/game/rng.ts';
-import { SLOT_HALF_ACROSS } from '../src/constants/docking.ts';
+import { RAILS_STOPPED, SLOT_HALF_ACROSS } from '../src/constants/docking.ts';
import { check, dismissBriefing, eq } from './harness.ts';
console.log('\nthe last stretch into the slot');
@@ -136,20 +136,35 @@ console.log('\nthe docking mini game');
* @param match whether the pilot matches the slot. A pilot who does nothing
* is the control: the slot is only there to meet about two turns in five.
*/
- const fly = (g: Game, match: boolean): { rails: boolean; docked: boolean; lateral: number } => {
+ const fly = (g: Game, match: boolean, hands = true): {
+ rails: boolean; docked: boolean; lateral: number; handedOver: number;
+ } => {
const dt = 1 / 60;
const q = new THREE.Quaternion();
const right = new THREE.Vector3();
+ const thrust = keymap().accel[0] ?? '';
+ const brake = keymap().decel[0] ?? '';
let rails = false;
let lateral = Infinity;
+ let handedOver = -1;
withoutSaving(() => {
for (let f = 0, at = 0; f < 200 / dt; f++) {
const st = g.state.world.station;
if (g.state.session.dockRails) {
+ if (!rails) handedOver = g.state.player.speed;
rails = true;
const local = g.state.player.position.clone();
st.worldToLocal(local);
lateral = Math.min(lateral, Math.hypot(local.x, local.y));
+ // The ship is stopped when the rails take it, so the pilot thrusts
+ // the whole way in, and holds the speed the slot will take.
+ if (!hands) {
+ // the control: no hand on anything
+ } else if (g.state.player.speed < SLOT_SPEED_LIMIT * 0.6) {
+ g.input.press(thrust); g.input.release(brake);
+ } else {
+ g.input.release(thrust); g.input.press(brake);
+ }
if (match) {
q.copy(st.quaternion).invert().multiply(g.state.player.quaternion);
right.set(1, 0, 0).applyQuaternion(q);
@@ -164,12 +179,20 @@ console.log('\nthe docking mini game');
if (g.mode !== 'flight') break;
}
});
- return { rails, docked: g.mode === 'docked', lateral };
+ return { rails, docked: g.mode === 'docked', lateral, handedOver };
};
const run = fly(arrive(20_260_951), true);
check('the rails take the ship', run.rails);
- check('...and a pilot who matches the slot docks', run.docked);
+ check('...with the ship stopped, so the pilot flies the whole run in',
+ run.handedOver <= RAILS_STOPPED, `${run.handedOver.toFixed(1)} units a second`);
+ check('...and a pilot who thrusts in and matches the slot docks', run.docked);
check('...having been held on the line, inside the channel',
run.lateral < SLOT_HALF_ACROSS, `${run.lateral.toFixed(0)} units off the axis`);
+
+ // The control: hands off entirely. The ship stops on the axis and stays
+ // there, so nobody docks by accident (docs/TODO/212). Chris asked for that
+ // on 2026-09-12: the pilot must thrust in.
+ const idle = fly(arrive(20_260_951), false, false);
+ check('a pilot who touches nothing waits there, and never docks', !idle.docked);
}
From 31dafed13cf48d439ba720fb67d3b67b3f011a06 Mon Sep 17 00:00:00 2001
From: Chris Greening PURSUIT_HOLD_CONE | 1.85 | | | [combat-computer.ts:179](./combat-computer.ts#L179) |
| combat-computer | ENGAGED_CONE | 0.6 | The nose-to-target angle, in radians, within which the co-pilot counts itself ENGAGED and will not switch targets. | | [combat-computer.ts:188](./combat-computer.ts#L188) |
| combat-computer | TARGET_DIST_WEIGHT | 800 | How many world units of range weigh as much as one radian of off-nose turn. | copilot.targetDistWeight | [combat-computer.ts:206](./combat-computer.ts#L206) |
+| combat-computer | PURSUIT_LEAD_GAIN | 2.0 | How many SECONDS the co-pilot aims ahead of a target, per radian that its nose still has to swing. | copilot.leadGain | [combat-computer.ts:257](./combat-computer.ts#L257) |
| combat-record | SAMPLE_HZ | 10 | How often the code samples the geometry, in Hz. | | [combat-record.ts:12](./combat-record.ts#L12) |
| combat-record | SIX_CONE | Math.PI / 3 | The rear cone that counts as somebody's six, as a half-angle from directly astern. | | [combat-record.ts:20](./combat-record.ts#L20) |
| combat-record | PASS_CLOSE | 400 | What an attack run is, in ranges. | | [combat-record.ts:42](./combat-record.ts#L42) |
@@ -300,9 +301,9 @@ search names, meanings and values with `npm run constants:find -- "ECM_RATE | 0.45 | ...at this chance per second. | | [ordnance.ts:94](./ordnance.ts#L94) |
| ordnance | ECM_ENERGY_COST | ENERGY_BANK_POINTS | A shot of the E.C.M. costs one bank of energy. | | [ordnance.ts:100](./ordnance.ts#L100) |
| ordnance | ENERGY_BOMB_RANGE | 8000 | The energy bomb reaches this far. | | [ordnance.ts:103](./ordnance.ts#L103) |
-| pass-aim | PASS_MISS_DISTANCE | 110 | How far to the SIDE of its target a ship aims its attack run. 110 clears the largest pirate hull, plus the commander's radius, twice over. | | [pass-aim.ts:13](./pass-aim.ts#L13) |
-| pass-aim | MAX_LEAD_SECONDS | 0.5 | The furthest ahead of a target that a ship will aim, in seconds. | | [pass-aim.ts:21](./pass-aim.ts#L21) |
-| pass-aim | MAX_MISS_STRETCH | 3 | The most that geometry may stretch the aim, in multiples of the intended pass. | passaim.maxMissStretch | [pass-aim.ts:31](./pass-aim.ts#L31) |
+| pass-aim | PASS_MISS_DISTANCE | 110 | How far to the SIDE of its target a ship aims its attack run. 110 clears the largest pirate hull, plus the commander's radius, twice over. | | [pass-aim.ts:18](./pass-aim.ts#L18) |
+| pass-aim | MAX_LEAD_SECONDS | 0.5 | The furthest ahead of a target that a ship will aim, in seconds. | | [pass-aim.ts:32](./pass-aim.ts#L32) |
+| pass-aim | MAX_MISS_STRETCH | 3 | The most that geometry may stretch the aim, in multiples of the intended pass. | passaim.maxMissStretch | [pass-aim.ts:42](./pass-aim.ts#L42) |
| planet | WITCHPOINT_RADII | 16 | How far out of the planet you drop from witch-space, in planet radii. | planet.witchpointRadii | [planet.ts:15](./planet.ts#L15) |
| planet | PLANET_CRASH_ALTITUDE | 80 | The altitude above the surface at which the ship is destroyed. | | [planet.ts:24](./planet.ts#L24) |
| player-flight | PLAYER_FLIGHT | { maxSpeed: 400, accel: 220, maxRoll: 2.5, maxPitch: 1.45, rateRamp: 4.1396, rateDecay: 13.3886, } as const | The player's flight envelope, in one place that a harness can read. | flight.player.maxSpeedflight.player.rateRamp | [player-flight.ts:21](./player-flight.ts#L21) |
diff --git a/src/constants/combat-computer.ts b/src/constants/combat-computer.ts
index ebd105c9..32e48cd4 100644
--- a/src/constants/combat-computer.ts
+++ b/src/constants/combat-computer.ts
@@ -204,3 +204,54 @@ export const ENGAGED_CONE = 0.6;
* @rule copilot.targetDistWeight
*/
export const TARGET_DIST_WEIGHT = 800;
+
+/**
+ * How many SECONDS the co-pilot aims ahead of a target, per radian that its nose
+ * still has to swing. It is a gain, in seconds per radian.
+ *
+ * The CAP on it is `MAX_LEAD_SECONDS` (constants/pass-aim.ts), which the attack
+ * run already owns. That is one rule, not two. Each is the furthest ahead a ship
+ * aims, and the same pitch rate bounds each. A target dead astern asks for about
+ * pi radians of swing. This gain alone turns that into six seconds of lead. That
+ * aim point is nowhere near the fight.
+ *
+ * WHY A LEAD AT ALL, when the laser is hitscan. The gun needs none. The NOSE
+ * does. A swing takes time. The target moves while the swing runs. So an aim at
+ * where the target IS puts the nose where the target WAS. This lead cancels the
+ * swing time. It is lead for the ship, not for the shot.
+ *
+ * WHY IT SHRINKS WITH THE ERROR. A FIXED lead was measured first, and it is
+ * WORSE THAN NO LEAD AT ALL. A fixed lead is only right while the nose lags.
+ * Once the nose catches up, the lead becomes a standing miss. The nose parks
+ * ahead of the target and waits. The aim point then runs on again each frame. A
+ * lead set by the REMAINING swing falls to zero as the nose arrives. So it never
+ * does that.
+ *
+ * The measurement is a probe of this control law and of `player.ts`'s flight
+ * model. It flies 40 scripted targets, at real pirate speeds and at a real
+ * pirate radius. Each number below is the share of frames with the target inside
+ * the gun cone.
+ *
+ * 27.5% is the pursuit before this rule.
+ * 24.2% is a fixed 0.30 second lead.
+ * 21.1% is a fixed 0.30 second lead, with the radial throttle.
+ * 31.2% is the radial throttle alone.
+ * 41.7% is this lead alone.
+ * 46.7% is this lead, with the radial throttle.
+ *
+ * WHY 2.0. It is a plateau with a hole beside it. On a 13-target probe at 2.0 a
+ * fast, close circler scored 86%, and at 3.0 the same target scored 23%, while
+ * every other target held. Sweep this on the exercise wave harness before you
+ * move it. A 13-target sample and a 40-target sample disagreed about the radial
+ * throttle's worth, so a small sample is not enough to retune on.
+ *
+ * THE HOME IS THIS FILE, and the owner check disagrees. It reads the words
+ * "rule" and "bounded" above and reaches for the law domain. This is a feel
+ * setting of the SCRIPTED co-pilot. It sits in the `PURSUIT_*` block that the
+ * header of this file gives to that pilot. Nothing legal reads it, and no brain
+ * flies through it.
+ *
+ * @rule copilot.leadGain
+ * @domain combat-computer
+ */
+export const PURSUIT_LEAD_GAIN = 2.0;
diff --git a/src/constants/pass-aim.ts b/src/constants/pass-aim.ts
index 89a6e8aa..7a0971f7 100644
--- a/src/constants/pass-aim.ts
+++ b/src/constants/pass-aim.ts
@@ -3,6 +3,11 @@
// An attack run has to pass beside where the target WILL BE, on a line it has
// room to get onto. These three numbers say how far beside, and how far ahead.
// `game/pass-aim.ts` turns them into a heading.
+//
+// `MAX_LEAD_SECONDS` has a SECOND reader, and it is not an attack run. The
+// scripted co-pilot caps its own lead with it (`game/scripted-co-pilot.ts`).
+// The two lead for different reasons and take the same ceiling, which the
+// comment on the constant states.
/**
* How far to the SIDE of its target a ship aims its attack run. 110 clears the
@@ -17,6 +22,12 @@ export const PASS_MISS_DISTANCE = 110;
* pitches at 1.45 rad/s, so half a second is already 41 degrees of heading
* change. That is enough lead to matter, and it does not extrapolate a stale
* straight line.
+ *
+ * TWO PILOTS TAKE IT, for two different reasons, and the ceiling is the same
+ * one. The attack run leads to predict a MERGE (`game/pass-aim.ts`). The
+ * scripted co-pilot leads to cancel the time its own NOSE takes to swing
+ * (`PURSUIT_LEAD_GAIN`). Both are "how far ahead a ship may aim", and both are
+ * bounded by that same pitch rate. It stays one rule with one home.
*/
export const MAX_LEAD_SECONDS = 0.5;
diff --git a/src/game/scripted-co-pilot.ts b/src/game/scripted-co-pilot.ts
index 6f2fa155..ddf5cf95 100644
--- a/src/game/scripted-co-pilot.ts
+++ b/src/game/scripted-co-pilot.ts
@@ -3,15 +3,25 @@
// A person gets on the opponent's six and shoots it up. She hauls the throttle
// back to swing the nose round, so that she stays on a target that crosses her.
//
-// That is pure pursuit. Point the nose AT the target: the laser is hitscan, so
-// there is no lead, and you aim where the target is. The line then curves you
-// onto its tail as it turns and runs. A throttle holds a gun-range standoff
-// behind it, and comes off hard when the nose has a long way to swing.
+// That is pursuit. The aim line curves you onto its tail as it turns and runs.
+// A throttle holds a gun-range standoff behind it, and comes off hard when the
+// nose has a long way to swing.
+//
+// It aims a little AHEAD of the target, and the gun is not the reason. The laser
+// is hitscan, so the SHOT needs no lead. The NOSE needs one. A swing takes time.
+// The target moves while the swing runs. So an aim at where the target IS puts
+// the nose where the target WAS. The swing that is LEFT sets the lead. The lead
+// falls to zero as the nose arrives (`PURSUIT_LEAD_GAIN`).
+//
+// The throttle matches the target's RADIAL speed rather than its whole speed. A
+// ship that circles you barely recedes, so the commander stops and turns like a
+// turret. A ship that runs recedes at its full speed, so the commander chases.
//
// It flies `pursuit.ts` rather than the attack run. The pirates fly their own
// pursuit: hold the six, then break into a fast pass when faced (npc.ts
// `pursue`). That is a separate ship and a separate decision. The two share
-// `pursuitSpeed`, so they cannot drift.
+// `pursuitSpeed`, so the standoff rule cannot drift. They hand it a different
+// speed to match, and `pursuitThrottle` below says why.
//
// It DECIDES and reports, like every module here. What comes back is a
// `FlightDemand` — ramped pitch and roll rates, a throttle, a trigger — and one
@@ -33,13 +43,16 @@ import { hitCone } from './gunnery.ts';
import { autopilotEcm } from './ordnance.ts';
import { bankToTurn, freshSteerMemory, type SteerMemory } from './pitch-roll-steer.ts';
import { pursuitSpeed } from './pursuit.ts';
+import { velocityOf } from './flight-maths.ts';
import { rampFlightRate, type FlightDemand } from '../player.ts';
import { LASER_RANGE } from '../constants/player-gun.ts';
import { UNDER_FIRE_SECONDS } from '../constants/attack-run.ts';
import {
THREAT_RANGE, PURSUIT_SPEED_DEADBAND, ENGAGED_CONE, TARGET_DIST_WEIGHT,
+ PURSUIT_LEAD_GAIN,
} from '../constants/combat-computer.ts';
import { PLAYER_FLIGHT } from '../constants/player-flight.ts';
+import { MAX_LEAD_SECONDS } from '../constants/pass-aim.ts';
import type { V3 } from '../ai-training/observation.ts';
export type CoPilotStep =
@@ -63,6 +76,9 @@ export class ScriptedCoPilot {
private readonly lock = new ThreatLockRAILS_LATERAL | 250 | How far off the slot axis the ship may be when the rails take it, in world units (docs/TODO/212). | docking.railsLateral | [docking.ts:187](./docking.ts#L187) |
| docking | RAILS_RANGE | 900 | How far out the rails take the ship, in world units (docs/TODO/212). | docking.railsRange | [docking.ts:199](./docking.ts#L199) |
| docking | RAILS_PULL | 2 | How hard the rails pull the ship onto the axis, per second (docs/TODO/212). | docking.railsPull | [docking.ts:211](./docking.ts#L211) |
-| docking | RAILS_STOPPED | 2 | The speed under which the ship counts as stopped, in world units a second (docs/TODO/212). | docking.railsStopped | [docking.ts:225](./docking.ts#L225) |
+| docking | RAILS_CONE | 0.18 | How far off the slot axis the ship's NOSE may point when the PILOT is given the slot, in radians. | docking.railsCone | [docking.ts:235](./docking.ts#L235) |
+| docking | RAILS_TURN | 3 | How hard the rails turn the nose onto the axis, per second. | docking.railsTurn | [docking.ts:252](./docking.ts#L252) |
+| docking | RAILS_STOPPED | 2 | The speed under which the ship counts as stopped, in world units a second (docs/TODO/212). | docking.railsStopped | [docking.ts:266](./docking.ts#L266) |
| docking-computer | DOCK_COMPUTER_RANGE | 3500 | How close to the station the docking computer will take the job, in world units. | | [docking-computer.ts:46](./docking-computer.ts#L46) |
| docking-computer | DC_SLOT_MARGIN | 0.30 | How much of the slot's roll tolerance the TURN may spend, as a fraction. | docking.slotMargin | [docking-computer.ts:97](./docking-computer.ts#L97) |
| docking-computer | DC_TURN_FADE_ANGLE | 0.10 | The off-nose angle, in radians, over which the TURN's claim on the roll axis ramps in. | docking.turnFadeAngle | [docking-computer.ts:151](./docking-computer.ts#L151) |
diff --git a/src/constants/docking.ts b/src/constants/docking.ts
index 248610d6..9b27ec5f 100644
--- a/src/constants/docking.ts
+++ b/src/constants/docking.ts
@@ -210,6 +210,47 @@ export const RAILS_RANGE = 900;
*/
export const RAILS_PULL = 2;
+/**
+ * How far off the slot axis the ship's NOSE may point when the PILOT is given
+ * the slot, in radians.
+ *
+ * THE HAND-OVER USED TO IGNORE THE NOSE ALTOGETHER (Chris, 2026-09-12: *"we
+ * jump to the on rails version before it's actually lined up"*). `railsReached`
+ * asked for the last leg, `RAILS_LATERAL` and `RAILS_RANGE`. None of those three
+ * says which way the ship points. A trace of the shipped approach put the nose
+ * 69.7 degrees off the axis on the frame the rails took it.
+ *
+ * THE COMPUTER CANNOT CLOSE THAT LAST TURN, and `SessionState.dockHold` states
+ * why. So the rails close it, at `RAILS_TURN`, while the pilot still reads
+ * LINING UP. This is the angle at which the pilot is given the ship.
+ *
+ * 0.18 rad is about 10 degrees. The rails take the measured 70 degrees down to
+ * it in about half a second, so the wait is short. It is a band rather than a
+ * zero, because the rails hold the line from there on. To wait for zero is to
+ * wait for ever.
+ *
+ * @rule docking.railsCone
+ * @domain docking
+ */
+export const RAILS_CONE = 0.18;
+
+/**
+ * How hard the rails turn the nose onto the axis, per second.
+ *
+ * It is `RAILS_PULL` for the rotation, and it exists for the same reason. The
+ * position was always eased. The rotation was not: it went on in full, every
+ * frame, so any error left at the hand-over went in one frame. The module
+ * comment on `game/dock-rails.ts` claimed both were eased, and only one was.
+ *
+ * 3 a second turns the measured 70 degrees of error down to `RAILS_CONE` in
+ * about half a second. It is faster than `RAILS_PULL`, because the pilot waits
+ * on this turn and does not wait on the line.
+ *
+ * @rule docking.railsTurn
+ * @domain docking
+ */
+export const RAILS_TURN = 3;
+
/**
* The speed under which the ship counts as stopped, in world units a second
* (docs/TODO/212).
diff --git a/src/game/dock-rails.ts b/src/game/dock-rails.ts
index dcc1f267..310dfb90 100644
--- a/src/game/dock-rails.ts
+++ b/src/game/dock-rails.ts
@@ -19,22 +19,33 @@
// one place in the game that moves the commander's ship other than by flying
// it. `test/docking.test.ts` scans for that, and it names this file.
//
-// IT IS NOT A TELEPORT. Both corrections are eased. The nose turns by the
-// SHORTEST rotation onto the axis, which carries no twist about the nose. So
-// the pilot's own roll is untouched, and the roll is what the slot measures.
+// IT IS NOT A TELEPORT, AND THE ROTATION USED TO BE ONE. This comment claimed
+// that both corrections were eased. The position was. The rotation went on in
+// full, every frame, so whatever error the hand-over left went in ONE frame.
+// Chris flew it on 2026-09-12: *"we jump to the on rails version before it's
+// actually lined up"*. A trace put that jump at 69.7 degrees.
+//
+// Two things answer it. `railsAligned` holds the hand-over until the nose is
+// near the axis, so the error is small before the rails see it. `RAILS_TURN`
+// then eases what is left, as `RAILS_PULL` always eased the position.
+//
+// The nose turns by the SHORTEST rotation onto the axis, which carries no twist
+// about the nose. An ease along that same arc adds none either. So the pilot's
+// own roll is untouched, and the roll is what the slot measures.
import * as THREE from 'three';
import type { PlayerShip } from '../player.ts';
import type { DockPlan } from './docking.ts';
import { slotNormal } from '../world/slot.ts';
import {
- RAILS_LATERAL, RAILS_PULL, RAILS_RANGE, RAILS_STOPPED,
+ RAILS_CONE, RAILS_LATERAL, RAILS_PULL, RAILS_RANGE, RAILS_STOPPED, RAILS_TURN,
} from '../constants/docking.ts';
const _out = new THREE.Vector3();
const _rel = new THREE.Vector3();
const _fwd = new THREE.Vector3();
const _turn = new THREE.Quaternion();
+const _ease = new THREE.Quaternion();
/**
* Is the ship at the place where the computer stops it?
@@ -48,6 +59,21 @@ export function railsReached(plan: DockPlan): boolean {
&& plan.along < RAILS_RANGE;
}
+/**
+ * Is the nose near enough the slot axis for the rails to take the ship?
+ *
+ * THE THIRD QUESTION THE HAND-OVER NEVER ASKED. `railsReached` above is about
+ * WHERE the ship is. This is about WHICH WAY IT POINTS, and without it the
+ * rails turned the ship up to 69.7 degrees in one frame. See `RAILS_CONE`.
+ *
+ * It measures against the slot axis, which is the same line `holdOnRails`
+ * corrects onto. The gate and the correction cannot come to disagree.
+ */
+export function railsAligned(player: PlayerShip, station: THREE.Object3D): boolean {
+ const inward = slotNormal(station, _out).multiplyScalar(-1);
+ return player.getForward(_fwd).angleTo(inward) < RAILS_CONE;
+}
+
/**
* Is the ship stopped, so the rails can take it?
*
@@ -77,5 +103,9 @@ export function holdOnRails(player: PlayerShip, station: THREE.Object3D, dt: num
player.position.addScaledVector(rel, -Math.min(1, RAILS_PULL * dt));
const fwd = player.getForward(_fwd);
_turn.setFromUnitVectors(fwd, outward.multiplyScalar(-1));
- player.quaternion.premultiply(_turn).normalize();
+ // ...and EASE it, as the position above is eased. A slerp from no rotation
+ // along that same shortest arc keeps the arc's axis, so it still adds no
+ // twist about the nose. The pilot's roll stays the pilot's.
+ _ease.identity().slerp(_turn, Math.min(1, RAILS_TURN * dt));
+ player.quaternion.premultiply(_ease).normalize();
}
diff --git a/src/game/flight-instruments.ts b/src/game/flight-instruments.ts
index 49b9515a..67fc042f 100644
--- a/src/game/flight-instruments.ts
+++ b/src/game/flight-instruments.ts
@@ -221,6 +221,7 @@ export class Instruments {
}
s.dockTrial = true;
s.dockRails = false;
+ s.dockHold = false;
s.course = null;
this.coursePilot.reset();
this.host.showMessage('THE COMPUTER IS LINING THE SHIP UP — STAND BY', 4);
@@ -241,6 +242,7 @@ export class Instruments {
if (!out) return;
s.dockTrial = false;
s.dockRails = false;
+ s.dockHold = false;
this.host.showMessage('THE STATION IS BEHIND YOU', 3);
}
diff --git a/src/game/session.ts b/src/game/session.ts
index 4e1955eb..eb2ec665 100644
--- a/src/game/session.ts
+++ b/src/game/session.ts
@@ -116,6 +116,19 @@ export interface SessionState {
* the computer's, and it lines the ship up. It is saved, as `dockTrial` is.
*/
dockRails: boolean;
+ /**
+ * The rails hold the ship while the COMPUTER finishes the line-up
+ * (docs/TODO/212). It sits between the two halves of `dockTrial`, and it is
+ * saved as they are.
+ *
+ * The computer cannot make this last turn itself. `dockingSticks` pitches
+ * onto the heading, and a pitch cannot fix a sideways error. It got away with
+ * that while the ship flew, because the motion sweeps the pitch plane round.
+ * The ship is stopped by this point, so the sweep stops too, and the nose
+ * stalls up to 69.7 degrees off the axis (Chris, 2026-09-12). So the rails
+ * take the turn, and `dockRails` waits for `railsAligned`.
+ */
+ dockHold: boolean;
}
/**
@@ -128,6 +141,7 @@ export function endVisit(state: SessionState): void {
state.handFlown = false;
state.dockTrial = false;
state.dockRails = false;
+ state.dockHold = false;
}
/** Put a message in canonical state; the HUD only paints these fields. */
diff --git a/src/game/state.ts b/src/game/state.ts
index e82de7cb..f10f758c 100644
--- a/src/game/state.ts
+++ b/src/game/state.ts
@@ -161,6 +161,7 @@ export function freshSession(): SessionState {
handFlown: false,
dockTrial: false,
dockRails: false,
+ dockHold: false,
};
}
diff --git a/src/game/world-step.ts b/src/game/world-step.ts
index d5350593..2fb8255c 100644
--- a/src/game/world-step.ts
+++ b/src/game/world-step.ts
@@ -55,7 +55,7 @@ import {
import {
planDocking, dockingOutcome, type DockPlan, type DockingOutcome,
} from './docking.ts';
-import { holdOnRails, railsReached, stopped } from './dock-rails.ts';
+import { holdOnRails, railsAligned, railsReached, stopped } from './dock-rails.ts';
import { dockingSticks } from './docking-sticks.ts';
import { NPC_HULL_BOX_MARGIN } from '../constants/docking.ts';
import { BOUNCE_STANDOFF } from '../constants/station.ts';
@@ -327,8 +327,10 @@ export class WorldStep {
const dc = session.dcEngaged ? this.dockingComputerStep(dt, pilot, out)
: session.dockTrial ? this.dockTrialStep(dt, pilot, out) : null;
player.update(dt, dc ?? pilot.demand);
- // The rails correct the frame the ship just flew (docs/TODO/212).
- if (session.dockRails) holdOnRails(player, world.station, dt);
+ // The rails correct the frame the ship just flew (docs/TODO/212). They run
+ // for the computer's last turn as well as for the pilot's game, because the
+ // computer cannot make that turn itself. See `SessionState.dockHold`.
+ if (session.dockRails || session.dockHold) holdOnRails(player, world.station, dt);
// torus drive
if (session.torusEngaged) {
@@ -443,7 +445,23 @@ export class WorldStep {
if (!stopped(player.speed)) {
return { ...this.dockingDemand(dt, pilot, plan), throttle: -1 };
}
+ // STOPPED IS NOT LINED UP. The brake used to end the line-up, and the
+ // ship was still up to 69.7 degrees off the axis (Chris, 2026-09-12:
+ // *"we jump to the on rails version before it's actually lined up"*).
+ // The rails take that last turn, eased, while the pilot waits. The
+ // buttons and the message still read LINING UP, because `dockRails` is
+ // what they read.
+ session.dockHold = true;
+ if (!railsAligned(player, this.state.world.station)) {
+ return {
+ pitchRate: rampFlightRate(player.pitchRate, 0, false, dt),
+ rollRate: rampFlightRate(player.rollRate, 0, false, dt),
+ throttle: 0,
+ fire: pilot.demand.fire,
+ };
+ }
session.dockRails = true;
+ session.dockHold = false;
out.push(say('THE SLOT IS YOURS — THRUST IN, AND MATCH ITS SPIN', 5));
}
// ON THE RAILS. The pitch is nobody's: `holdOnRails` owns the line. The
diff --git a/test/dock-trial.test.ts b/test/dock-trial.test.ts
index de9ea7ef..33e760f6 100644
--- a/test/dock-trial.test.ts
+++ b/test/dock-trial.test.ts
@@ -17,7 +17,8 @@ import { Game } from '../src/game/game.ts';
import { headlessShell } from '../src/engine/shell.ts';
import { withoutSaving } from '../src/game/storage.ts';
import { seedWorld } from '../src/game/rng.ts';
-import { RAILS_STOPPED, SLOT_HALF_ACROSS } from '../src/constants/docking.ts';
+import { RAILS_CONE, RAILS_STOPPED, SLOT_HALF_ACROSS } from '../src/constants/docking.ts';
+import { slotNormal } from '../src/world/slot.ts';
import { check, dismissBriefing, eq } from './harness.ts';
console.log('\nthe last stretch into the slot');
@@ -196,3 +197,72 @@ console.log('\nthe docking mini game');
const idle = fly(arrive(20_260_951), false, false);
check('a pilot who touches nothing waits there, and never docks', !idle.docked);
}
+
+// --- THE HAND-OVER WAITS FOR THE NOSE, AND THE TURN IS EASED ---------------
+//
+// Chris, 2026-09-12: *"When docking - we get 'The computer is lining up...' and
+// it starts doing it's job, but we jump to the on rails version before it's
+// actually lined up."*
+//
+// Two faults, one report. `railsReached` asked where the ship was and never
+// which way it pointed, so the rails took it 69.7 degrees off the slot axis.
+// `holdOnRails` then turned it straight inside ONE frame, although the comment
+// on that file claimed both of its corrections were eased. Only the position
+// was.
+//
+// The computer cannot make that last turn itself, and `SessionState.dockHold`
+// says why. So the rails make it, eased, while the pilot still reads LINING UP.
+console.log('\nthe computer finishes the line-up before the pilot gets the slot');
+{
+ const g = withoutSaving(() => {
+ seedWorld(20_260_951);
+ const game = new Game(() => headlessShell());
+ dismissBriefing(game);
+ game.launch();
+ game.arriveInSystem();
+ return game;
+ }).value;
+ g.state.world.clearNpcs();
+ g.state.session.course = 'station';
+
+ const dt = 1 / 60;
+ const out = new THREE.Vector3();
+ const fwd = new THREE.Vector3();
+ const noseOff = (): number => {
+ slotNormal(g.state.world.station, out).multiplyScalar(-1);
+ return g.state.player.getForward(fwd).angleTo(out);
+ };
+
+ let atHandover = -1;
+ let worstJump = 0;
+ let heldFrames = 0;
+ let pilotBeforeHold = false;
+ withoutSaving(() => {
+ let last = noseOff();
+ for (let f = 0, at = 0; f < 120 / dt; f++) {
+ const wasRails = g.state.session.dockRails;
+ g.step(dt, at += dt);
+ const now = noseOff();
+ // the biggest one-frame turn of the nose, over the whole approach
+ if (Math.abs(now - last) > worstJump) worstJump = Math.abs(now - last);
+ last = now;
+ if (g.state.session.dockHold) heldFrames += 1;
+ if (g.state.session.dockRails && !g.state.session.dockHold && heldFrames === 0) {
+ pilotBeforeHold = true;
+ }
+ if (!wasRails && g.state.session.dockRails) { atHandover = now; break; }
+ if (g.mode !== 'flight') break;
+ }
+ });
+
+ check('the rails take the ship only once its nose is near the slot axis',
+ atHandover >= 0 && atHandover < RAILS_CONE,
+ `${(atHandover * 180 / Math.PI).toFixed(1)} degrees off, against a cone of `
+ + `${(RAILS_CONE * 180 / Math.PI).toFixed(1)}`);
+ // The old code turned the ship 69.7 degrees in the hand-over frame.
+ check('...and no single frame turns the nose more than 10 degrees',
+ worstJump < 10 * Math.PI / 180,
+ `worst one-frame turn ${(worstJump * 180 / Math.PI).toFixed(1)} degrees`);
+ check('...having held the ship on the rails first, with the pilot still waiting',
+ heldFrames > 0 && !pilotBeforeHold, `${heldFrames} frames of LINING UP on the rails`);
+}
From 6d5dbdbb22edaa685c5cc60cd918fe4eb46bd86b Mon Sep 17 00:00:00 2001
From: Chris Greening RAILS_LATERAL | 250 | How far off the slot axis the ship may be when the rails take it, in world units (docs/TODO/212). | docking.railsLateral | [docking.ts:187](./docking.ts#L187) |
| docking | RAILS_RANGE | 900 | How far out the rails take the ship, in world units (docs/TODO/212). | docking.railsRange | [docking.ts:199](./docking.ts#L199) |
| docking | RAILS_PULL | 2 | How hard the rails pull the ship onto the axis, per second (docs/TODO/212). | docking.railsPull | [docking.ts:211](./docking.ts#L211) |
-| docking | RAILS_CONE | 0.18 | How far off the slot axis the ship's NOSE may point when the PILOT is given the slot, in radians. | docking.railsCone | [docking.ts:235](./docking.ts#L235) |
-| docking | RAILS_TURN | 3 | How hard the rails turn the nose onto the axis, per second. | docking.railsTurn | [docking.ts:252](./docking.ts#L252) |
-| docking | RAILS_STOPPED | 2 | The speed under which the ship counts as stopped, in world units a second (docs/TODO/212). | docking.railsStopped | [docking.ts:266](./docking.ts#L266) |
+| docking | RAILS_CONE | 0.18 | How far off the slot axis the ship's NOSE may point when the PILOT is given the slot, in radians. | docking.railsCone | [docking.ts:237](./docking.ts#L237) |
+| docking | RAILS_TURN | 3 | How hard the rails turn the nose onto the axis, per second. | docking.railsTurn | [docking.ts:254](./docking.ts#L254) |
+| docking | RAILS_STOPPED | 2 | The speed under which the ship counts as stopped, in world units a second (docs/TODO/212). | docking.railsStopped | [docking.ts:268](./docking.ts#L268) |
| docking-computer | DOCK_COMPUTER_RANGE | 3500 | How close to the station the docking computer will take the job, in world units. | | [docking-computer.ts:46](./docking-computer.ts#L46) |
| docking-computer | DC_SLOT_MARGIN | 0.30 | How much of the slot's roll tolerance the TURN may spend, as a fraction. | docking.slotMargin | [docking-computer.ts:97](./docking-computer.ts#L97) |
| docking-computer | DC_TURN_FADE_ANGLE | 0.10 | The off-nose angle, in radians, over which the TURN's claim on the roll axis ramps in. | docking.turnFadeAngle | [docking-computer.ts:151](./docking-computer.ts#L151) |
diff --git a/src/constants/docking.ts b/src/constants/docking.ts
index 9b27ec5f..cb7c050d 100644
--- a/src/constants/docking.ts
+++ b/src/constants/docking.ts
@@ -220,12 +220,14 @@ export const RAILS_PULL = 2;
* says which way the ship points. A trace of the shipped approach put the nose
* 69.7 degrees off the axis on the frame the rails took it.
*
- * THE COMPUTER CANNOT CLOSE THAT LAST TURN, and `SessionState.dockHold` states
- * why. So the rails close it, at `RAILS_TURN`, while the pilot still reads
- * LINING UP. This is the angle at which the pilot is given the ship.
- *
- * 0.18 rad is about 10 degrees. The rails take the measured 70 degrees down to
- * it in about half a second, so the wait is short. It is a band rather than a
+ * THE COMPUTER FLIES THE SHIP TO THIS ANGLE. It is the pointing half of the
+ * hand-over, and `RAILS_RANGE` is the distance half. The ship is stopped by
+ * then, so `world-step.ts` steers the last turn with `bankToTurn` rather than
+ * `dockingSticks`. That law spends the roll on the letterbox and pitches onto
+ * the heading, and a pitch alone cannot answer a sideways error from a stop.
+ *
+ * 0.18 rad is about 10 degrees. The measured turn from 70 degrees takes about a
+ * second, at the hull's own pitch and roll rates. It is a band rather than a
* zero, because the rails hold the line from there on. To wait for zero is to
* wait for ever.
*
@@ -242,9 +244,9 @@ export const RAILS_CONE = 0.18;
* frame, so any error left at the hand-over went in one frame. The module
* comment on `game/dock-rails.ts` claimed both were eased, and only one was.
*
- * 3 a second turns the measured 70 degrees of error down to `RAILS_CONE` in
- * about half a second. It is faster than `RAILS_PULL`, because the pilot waits
- * on this turn and does not wait on the line.
+ * 3 a second clears what `RAILS_CONE` lets through, which is about 7 degrees
+ * measured, in under a second. The computer flies the big turn, so this only
+ * ever answers the residue, and the station's own drift under it.
*
* @rule docking.railsTurn
* @domain docking
diff --git a/src/game/dock-rails.ts b/src/game/dock-rails.ts
index 310dfb90..cad7ba6d 100644
--- a/src/game/dock-rails.ts
+++ b/src/game/dock-rails.ts
@@ -25,8 +25,9 @@
// Chris flew it on 2026-09-12: *"we jump to the on rails version before it's
// actually lined up"*. A trace put that jump at 69.7 degrees.
//
-// Two things answer it. `railsAligned` holds the hand-over until the nose is
-// near the axis, so the error is small before the rails see it. `RAILS_TURN`
+// Two things answer it. `railsAligned` holds the hand-over until the ship is
+// the right distance out AND pointed at the port. The COMPUTER flies it to
+// both, with its own sticks, so the error the rails see is small. `RAILS_TURN`
// then eases what is left, as `RAILS_PULL` always eased the position.
//
// The nose turns by the SHORTEST rotation onto the axis, which carries no twist
@@ -60,14 +61,15 @@ export function railsReached(plan: DockPlan): boolean {
}
/**
- * Is the nose near enough the slot axis for the rails to take the ship?
+ * Is the nose near enough the slot axis to give the ship to the pilot?
*
- * THE THIRD QUESTION THE HAND-OVER NEVER ASKED. `railsReached` above is about
+ * THE SECOND QUESTION THE HAND-OVER NEVER ASKED. `railsReached` above is about
* WHERE the ship is. This is about WHICH WAY IT POINTS, and without it the
* rails turned the ship up to 69.7 degrees in one frame. See `RAILS_CONE`.
*
- * It measures against the slot axis, which is the same line `holdOnRails`
- * corrects onto. The gate and the correction cannot come to disagree.
+ * Two readers, one line. `world-step.ts` flies the computer's last turn until
+ * this is true. `holdOnRails` below corrects onto the same axis afterward. So
+ * the gate and the correction cannot come to disagree.
*/
export function railsAligned(player: PlayerShip, station: THREE.Object3D): boolean {
const inward = slotNormal(station, _out).multiplyScalar(-1);
diff --git a/src/game/docking.ts b/src/game/docking.ts
index f791e659..ff54927a 100644
--- a/src/game/docking.ts
+++ b/src/game/docking.ts
@@ -36,6 +36,7 @@ import {
} from '../constants/docking.ts';
import { slotNormal } from '../world/slot.ts';
import { dockPath, makeDockPath } from './dock-path.ts';
+import { freshSteerMemory, type SteerMemory } from './pitch-roll-steer.ts';
export type DockPhase =
/** still on the turn — the path decides where the ship goes */
@@ -66,6 +67,19 @@ export interface DockPlan {
* carries on the way round it already took.
*/
swing: THREE.Vector3;
+ /**
+ * Which vertical the LAST turn onto the axis banks through
+ * (`pitch-roll-steer.ts`). It is held across frames for the reason `swing`
+ * above is, and it is saved in the same walk.
+ *
+ * `dockingSticks` does not read it. That law spends the roll on the letterbox
+ * and pitches onto the heading, so it cannot answer a sideways error at all.
+ * It never had to while the ship flew, because the motion sweeps the pitch
+ * plane round. The computer makes its last turn from a STOP, so nothing
+ * sweeps. `bankToTurn` is the law that points a yaw-less ship from there
+ * (world-step.ts, docs/TODO/212).
+ */
+ steer: SteerMemory;
}
const _rel = new THREE.Vector3();
@@ -182,6 +196,7 @@ export function makeDockPlan(): DockPlan {
heading: new THREE.Vector3(0, 0, -1),
up: new THREE.Vector3(0, 1, 0),
speed: 0,
+ steer: freshSteerMemory(),
phase: 'gate',
arrived: false,
lateral: 0,
diff --git a/src/game/flight-instruments.ts b/src/game/flight-instruments.ts
index 67fc042f..49b9515a 100644
--- a/src/game/flight-instruments.ts
+++ b/src/game/flight-instruments.ts
@@ -221,7 +221,6 @@ export class Instruments {
}
s.dockTrial = true;
s.dockRails = false;
- s.dockHold = false;
s.course = null;
this.coursePilot.reset();
this.host.showMessage('THE COMPUTER IS LINING THE SHIP UP — STAND BY', 4);
@@ -242,7 +241,6 @@ export class Instruments {
if (!out) return;
s.dockTrial = false;
s.dockRails = false;
- s.dockHold = false;
this.host.showMessage('THE STATION IS BEHIND YOU', 3);
}
diff --git a/src/game/session.ts b/src/game/session.ts
index eb2ec665..4e1955eb 100644
--- a/src/game/session.ts
+++ b/src/game/session.ts
@@ -116,19 +116,6 @@ export interface SessionState {
* the computer's, and it lines the ship up. It is saved, as `dockTrial` is.
*/
dockRails: boolean;
- /**
- * The rails hold the ship while the COMPUTER finishes the line-up
- * (docs/TODO/212). It sits between the two halves of `dockTrial`, and it is
- * saved as they are.
- *
- * The computer cannot make this last turn itself. `dockingSticks` pitches
- * onto the heading, and a pitch cannot fix a sideways error. It got away with
- * that while the ship flew, because the motion sweeps the pitch plane round.
- * The ship is stopped by this point, so the sweep stops too, and the nose
- * stalls up to 69.7 degrees off the axis (Chris, 2026-09-12). So the rails
- * take the turn, and `dockRails` waits for `railsAligned`.
- */
- dockHold: boolean;
}
/**
@@ -141,7 +128,6 @@ export function endVisit(state: SessionState): void {
state.handFlown = false;
state.dockTrial = false;
state.dockRails = false;
- state.dockHold = false;
}
/** Put a message in canonical state; the HUD only paints these fields. */
diff --git a/src/game/state.ts b/src/game/state.ts
index f10f758c..e82de7cb 100644
--- a/src/game/state.ts
+++ b/src/game/state.ts
@@ -161,7 +161,6 @@ export function freshSession(): SessionState {
handFlown: false,
dockTrial: false,
dockRails: false,
- dockHold: false,
};
}
diff --git a/src/game/world-step.ts b/src/game/world-step.ts
index 2fb8255c..2e7939d3 100644
--- a/src/game/world-step.ts
+++ b/src/game/world-step.ts
@@ -56,6 +56,8 @@ import {
planDocking, dockingOutcome, type DockPlan, type DockingOutcome,
} from './docking.ts';
import { holdOnRails, railsAligned, railsReached, stopped } from './dock-rails.ts';
+import { bankToTurn } from './pitch-roll-steer.ts';
+import { slotNormal } from '../world/slot.ts';
import { dockingSticks } from './docking-sticks.ts';
import { NPC_HULL_BOX_MARGIN } from '../constants/docking.ts';
import { BOUNCE_STANDOFF } from '../constants/station.ts';
@@ -270,6 +272,8 @@ export class WorldStep {
private readonly fire: FireWorld;
private readonly tmp = new THREE.Vector3();
+ /** the slot axis the computer's last turn points down — see `dockTrialStep` */
+ private readonly dockAxis = new THREE.Vector3();
private readonly tmp2 = new THREE.Vector3();
private readonly tmpQ = new THREE.Quaternion();
/** scratch for collisions.ts, so a per-frame call allocates nothing */
@@ -327,10 +331,8 @@ export class WorldStep {
const dc = session.dcEngaged ? this.dockingComputerStep(dt, pilot, out)
: session.dockTrial ? this.dockTrialStep(dt, pilot, out) : null;
player.update(dt, dc ?? pilot.demand);
- // The rails correct the frame the ship just flew (docs/TODO/212). They run
- // for the computer's last turn as well as for the pilot's game, because the
- // computer cannot make that turn itself. See `SessionState.dockHold`.
- if (session.dockRails || session.dockHold) holdOnRails(player, world.station, dt);
+ // The rails correct the frame the ship just flew (docs/TODO/212).
+ if (session.dockRails) holdOnRails(player, world.station, dt);
// torus drive
if (session.torusEngaged) {
@@ -436,7 +438,7 @@ export class WorldStep {
* free for the spin.
*/
private dockTrialStep(dt: number, pilot: PilotInput, out: StepEvent[]): FlightDemand {
- const { player, session } = this.state;
+ const { player, session, world } = this.state;
const plan = this.dockingPlan();
if (!session.dockRails) {
if (!railsReached(plan)) return this.dockingDemand(dt, pilot, plan);
@@ -448,20 +450,29 @@ export class WorldStep {
// STOPPED IS NOT LINED UP. The brake used to end the line-up, and the
// ship was still up to 69.7 degrees off the axis (Chris, 2026-09-12:
// *"we jump to the on rails version before it's actually lined up"*).
- // The rails take that last turn, eased, while the pilot waits. The
- // buttons and the message still read LINING UP, because `dockRails` is
- // what they read.
- session.dockHold = true;
- if (!railsAligned(player, this.state.world.station)) {
+ //
+ // THE COMPUTER FLIES THIS LAST TURN, with both sticks, through the
+ // commander's own envelope. The nose comes round at the rate the hull
+ // turns. The HUD needles read it. Nothing moves the ship other than by
+ // flying it.
+ //
+ // `bankToTurn` is the law, rather than `dockingSticks`. That one spends
+ // the roll on the letterbox, and it pitches onto the heading. A pitch
+ // alone cannot answer a sideways error from a stop. The slot has no claim
+ // on the roll yet, because the pilot does not hold the ship.
+ if (!railsAligned(player, world.station)) {
+ const axis = slotNormal(world.station, this.dockAxis).multiplyScalar(-1);
+ const cmd = bankToTurn(player.quaternion, axis, this.state.dockPlan.steer);
return {
- pitchRate: rampFlightRate(player.pitchRate, 0, false, dt),
- rollRate: rampFlightRate(player.rollRate, 0, false, dt),
+ pitchRate: rampFlightRate(
+ player.pitchRate, cmd.pitch * PLAYER_FLIGHT.maxPitch, cmd.pitch !== 0, dt),
+ rollRate: rampFlightRate(
+ player.rollRate, cmd.roll * PLAYER_FLIGHT.maxRoll, cmd.roll !== 0, dt),
throttle: 0,
fire: pilot.demand.fire,
};
}
session.dockRails = true;
- session.dockHold = false;
out.push(say('THE SLOT IS YOURS — THRUST IN, AND MATCH ITS SPIN', 5));
}
// ON THE RAILS. The pitch is nobody's: `holdOnRails` owns the line. The
diff --git a/test/dock-trial.test.ts b/test/dock-trial.test.ts
index 33e760f6..c8b2d37c 100644
--- a/test/dock-trial.test.ts
+++ b/test/dock-trial.test.ts
@@ -235,8 +235,8 @@ console.log('\nthe computer finishes the line-up before the pilot gets the slot'
let atHandover = -1;
let worstJump = 0;
- let heldFrames = 0;
- let pilotBeforeHold = false;
+ let noseAtStop = -1;
+ let stoppedFrames = 0;
withoutSaving(() => {
let last = noseOff();
for (let f = 0, at = 0; f < 120 / dt; f++) {
@@ -246,23 +246,29 @@ console.log('\nthe computer finishes the line-up before the pilot gets the slot'
// the biggest one-frame turn of the nose, over the whole approach
if (Math.abs(now - last) > worstJump) worstJump = Math.abs(now - last);
last = now;
- if (g.state.session.dockHold) heldFrames += 1;
- if (g.state.session.dockRails && !g.state.session.dockHold && heldFrames === 0) {
- pilotBeforeHold = true;
+ // the computer's last stretch: stopped, and the pilot has nothing yet
+ if (!g.state.session.dockRails && g.state.player.speed <= RAILS_STOPPED) {
+ if (noseAtStop < 0) noseAtStop = now;
+ stoppedFrames += 1;
}
if (!wasRails && g.state.session.dockRails) { atHandover = now; break; }
if (g.mode !== 'flight') break;
}
});
- check('the rails take the ship only once its nose is near the slot axis',
+ check('the pilot is given the slot only once the nose is on the axis',
atHandover >= 0 && atHandover < RAILS_CONE,
- `${(atHandover * 180 / Math.PI).toFixed(1)} degrees off, against a cone of `
- + `${(RAILS_CONE * 180 / Math.PI).toFixed(1)}`);
+ atHandover < 0 ? 'it never handed over at all'
+ : `${(atHandover * 180 / Math.PI).toFixed(1)} degrees off, against a cone of `
+ + `${(RAILS_CONE * 180 / Math.PI).toFixed(1)}`);
// The old code turned the ship 69.7 degrees in the hand-over frame.
- check('...and no single frame turns the nose more than 10 degrees',
- worstJump < 10 * Math.PI / 180,
+ check('...and no single frame turns the nose more than 3 degrees',
+ worstJump < 3 * Math.PI / 180,
`worst one-frame turn ${(worstJump * 180 / Math.PI).toFixed(1)} degrees`);
- check('...having held the ship on the rails first, with the pilot still waiting',
- heldFrames > 0 && !pilotBeforeHold, `${heldFrames} frames of LINING UP on the rails`);
+ // The computer flies this turn. It does not teleport it, and it does not
+ // hand the ship over half way round.
+ check('...and the computer flew it there itself, from a stop',
+ stoppedFrames > 0 && noseAtStop > RAILS_CONE,
+ `${stoppedFrames} frames of LINING UP, from `
+ + `${(noseAtStop * 180 / Math.PI).toFixed(1)} degrees off`);
}
From cb248f6da33e9d593df5a9694eefc0fe94f64b62 Mon Sep 17 00:00:00 2001
From: Chris Greening RAILS_LATERAL | 250 | How far off the slot axis the ship may be when the rails take it, in world units (docs/TODO/212). | docking.railsLateral | [docking.ts:187](./docking.ts#L187) |
| docking | RAILS_RANGE | 900 | How far out the rails take the ship, in world units (docs/TODO/212). | docking.railsRange | [docking.ts:199](./docking.ts#L199) |
| docking | RAILS_PULL | 2 | How hard the rails pull the ship onto the axis, per second (docs/TODO/212). | docking.railsPull | [docking.ts:211](./docking.ts#L211) |
-| docking | RAILS_CONE | 0.18 | How far off the slot axis the ship's NOSE may point when the PILOT is given the slot, in radians. | docking.railsCone | [docking.ts:237](./docking.ts#L237) |
-| docking | RAILS_TURN | 3 | How hard the rails turn the nose onto the axis, per second. | docking.railsTurn | [docking.ts:254](./docking.ts#L254) |
-| docking | RAILS_STOPPED | 2 | The speed under which the ship counts as stopped, in world units a second (docs/TODO/212). | docking.railsStopped | [docking.ts:268](./docking.ts#L268) |
+| docking | RAILS_CONE | 0.03 | How far off the slot axis the ship's NOSE may point when the PILOT is given the slot, in radians. | docking.railsCone | [docking.ts:245](./docking.ts#L245) |
+| docking | RAILS_TURN | 3 | How hard the rails turn the nose onto the axis, per second. | docking.railsTurn | [docking.ts:262](./docking.ts#L262) |
+| docking | RAILS_STOPPED | 2 | The speed under which the ship counts as stopped, in world units a second (docs/TODO/212). | docking.railsStopped | [docking.ts:276](./docking.ts#L276) |
| docking-computer | DOCK_COMPUTER_RANGE | 3500 | How close to the station the docking computer will take the job, in world units. | | [docking-computer.ts:46](./docking-computer.ts#L46) |
| docking-computer | DC_SLOT_MARGIN | 0.30 | How much of the slot's roll tolerance the TURN may spend, as a fraction. | docking.slotMargin | [docking-computer.ts:97](./docking-computer.ts#L97) |
| docking-computer | DC_TURN_FADE_ANGLE | 0.10 | The off-nose angle, in radians, over which the TURN's claim on the roll axis ramps in. | docking.turnFadeAngle | [docking-computer.ts:151](./docking-computer.ts#L151) |
diff --git a/src/constants/docking.ts b/src/constants/docking.ts
index cb7c050d..bb168978 100644
--- a/src/constants/docking.ts
+++ b/src/constants/docking.ts
@@ -226,15 +226,23 @@ export const RAILS_PULL = 2;
* `dockingSticks`. That law spends the roll on the letterbox and pitches onto
* the heading, and a pitch alone cannot answer a sideways error from a stop.
*
- * 0.18 rad is about 10 degrees. The measured turn from 70 degrees takes about a
- * second, at the hull's own pitch and roll rates. It is a band rather than a
- * zero, because the rails hold the line from there on. To wait for zero is to
- * wait for ever.
+ * 0.03 rad is about 1.7 degrees. It was 0.18 rad, about 10 degrees, and Chris
+ * called that too wide on 2026-09-12.
+ *
+ * IT SITS JUST ABOVE THE CRAWL. A trace of the turn with the gate as good as
+ * open measured the whole curve. The nose falls from 68 degrees to 1.8 in one
+ * second. It rings once, and it settles near 1.2 degrees by two seconds. Below
+ * that it creeps: 1.2 degrees to 0.6 takes eight more seconds, because the
+ * steering saturates and the roll fades. So a cone under about 1 degree buys
+ * fractions of a degree for whole seconds of wait.
+ *
+ * It is a band rather than a zero, because the rails hold the line from there
+ * on. To wait for zero is to wait for ever.
*
* @rule docking.railsCone
* @domain docking
*/
-export const RAILS_CONE = 0.18;
+export const RAILS_CONE = 0.03;
/**
* How hard the rails turn the nose onto the axis, per second.
@@ -244,9 +252,9 @@ export const RAILS_CONE = 0.18;
* frame, so any error left at the hand-over went in one frame. The module
* comment on `game/dock-rails.ts` claimed both were eased, and only one was.
*
- * 3 a second clears what `RAILS_CONE` lets through, which is about 7 degrees
- * measured, in under a second. The computer flies the big turn, so this only
- * ever answers the residue, and the station's own drift under it.
+ * 3 a second clears what `RAILS_CONE` lets through, which is under 2 degrees,
+ * in a fraction of a second. The computer flies the big turn, so this only ever
+ * answers the residue, and the station's own drift under it.
*
* @rule docking.railsTurn
* @domain docking
diff --git a/src/game/flight-instruments.ts b/src/game/flight-instruments.ts
index 49b9515a..3d873f1a 100644
--- a/src/game/flight-instruments.ts
+++ b/src/game/flight-instruments.ts
@@ -208,17 +208,18 @@ export class Instruments {
* The station course reaches the hand-over, and the ship changes hands
* (docs/TODO/207 M1, in the shape docs/TODO/212 gave it).
*
- * With a docking computer fitted, that computer flies the slot, as it does
- * today. Without one, the pilot's own stretch begins. The computer lines the
- * ship up first. The rails then take the ship, and the pilot matches the
- * station's spin and the speed.
+ * ONE LINE-UP, WHOEVER FLIES THE SLOT (Chris, 2026-09-12: *"I think we should
+ * merge both paths?"*). The computer flies the ship to the right distance and
+ * turns it to face the port, and it does that for every commander. Only then
+ * does it ask who takes the ship in. A fitted docking computer takes it. A
+ * commander with none gets the rails and the mini game. `world-step.ts`'s
+ * `dockTrialStep` is where that one question is asked.
+ *
+ * It used to branch HERE instead, so a fitted computer flew the whole
+ * approach on its own and never showed a line-up at all.
*/
private handOver(): void {
const s = this.state.session;
- if (this.state.commander.equipment.dockingComputer) {
- this.applyAutopilot(this.autopilot.handOverToDock());
- return;
- }
s.dockTrial = true;
s.dockRails = false;
s.course = null;
diff --git a/src/game/world-step.ts b/src/game/world-step.ts
index 2e7939d3..09fb62fa 100644
--- a/src/game/world-step.ts
+++ b/src/game/world-step.ts
@@ -472,6 +472,22 @@ export class WorldStep {
fire: pilot.demand.fire,
};
}
+ // LINED UP. Now, and only now, the question of who takes it in.
+ //
+ // A fitted docking computer takes it, and this is the one place that
+ // hand-over is decided (docs/TODO/212). `autopilot.ts`'s
+ // `handOverToDock` still engages it from the pilot's own key. That one
+ // resets the plan phase, which is right from cold. It would be wrong from
+ // here, because the run latch is already earned and the ship is on the
+ // axis.
+ if (this.state.commander.equipment.dockingComputer) {
+ session.dockTrial = false;
+ session.dcEngaged = true;
+ out.push(say('DOCKING COMPUTER ENGAGED', 2));
+ out.push({ kind: 'sound', name: 'dockingComputerEngaged' });
+ out.push({ kind: 'dockingMusic', on: true });
+ return this.dockingDemand(dt, pilot, plan);
+ }
session.dockRails = true;
out.push(say('THE SLOT IS YOURS — THRUST IN, AND MATCH ITS SPIN', 5));
}
diff --git a/test/dock-trial.test.ts b/test/dock-trial.test.ts
index c8b2d37c..f549af1e 100644
--- a/test/dock-trial.test.ts
+++ b/test/dock-trial.test.ts
@@ -272,3 +272,55 @@ console.log('\nthe computer finishes the line-up before the pilot gets the slot'
`${stoppedFrames} frames of LINING UP, from `
+ `${(noseAtStop * 180 / Math.PI).toFixed(1)} degrees off`);
}
+
+// --- ONE LINE-UP, WHOEVER TAKES THE SHIP IN --------------------------------
+//
+// Chris, 2026-09-12: *"I think we should merge both paths?"*
+//
+// The course used to ask who flies the slot BEFORE the line-up. A commander
+// with a docking computer fitted was handed straight to it, and never saw a
+// line-up at all. Now every commander gets the same one: the computer flies to
+// the right distance, stops, and turns the nose onto the axis. Only then does
+// it ask who takes the ship in.
+console.log('\na fitted docking computer takes the ship after the same line-up');
+{
+ const g = withoutSaving(() => {
+ seedWorld(20_260_951);
+ const game = new Game(() => headlessShell());
+ dismissBriefing(game);
+ game.launch();
+ game.arriveInSystem();
+ return game;
+ }).value;
+ g.state.commander.equipment.dockingComputer = true;
+ g.state.world.clearNpcs();
+ g.state.session.course = 'station';
+
+ const dt = 1 / 60;
+ const out = new THREE.Vector3();
+ const fwd = new THREE.Vector3();
+ let noseAtEngage = -1;
+ let sawTrial = false;
+ let railsTaken = false;
+ withoutSaving(() => {
+ for (let f = 0, at = 0; f < 300 / dt; f++) {
+ const was = g.state.session.dcEngaged;
+ if (g.state.session.dockTrial && !was) sawTrial = true;
+ g.step(dt, at += dt);
+ if (g.state.session.dockRails) railsTaken = true;
+ if (!was && g.state.session.dcEngaged) {
+ slotNormal(g.state.world.station, out).multiplyScalar(-1);
+ noseAtEngage = g.state.player.getForward(fwd).angleTo(out);
+ }
+ if (g.mode !== 'flight') break;
+ }
+ });
+
+ check('a fitted computer still goes through the line-up first', sawTrial);
+ check('...and takes the ship only once its nose is on the axis',
+ noseAtEngage >= 0 && noseAtEngage < RAILS_CONE,
+ noseAtEngage < 0 ? 'it never took the ship'
+ : `${(noseAtEngage * 180 / Math.PI).toFixed(2)} degrees off`);
+ check('...never through the pilot\'s rails, which it has no use for', !railsTaken);
+ check('...and it flies the ship in from there', g.mode === 'docked');
+}
From 5abc8798602ebce66dd05b534592a89c9595c863 Mon Sep 17 00:00:00 2001
From: Chris Greening SLOT_HALF_ACROSS | 26 | The slot channel, as half-extents ACROSS the slot and ALONG it, in station-local world units. | | [docking.ts:127](./docking.ts#L127) |
| docking | SLOT_HALF_ALONG | 62 | | | [docking.ts:128](./docking.ts#L128) |
| docking | SLOT_DEPTH | 60 | How far into the -Z face puts a ship in the channel, in world units. * | docking.slotDepth | [docking.ts:133](./docking.ts#L133) |
-| docking | ROLL_TOLERANCE | 0.65 | The wings against the slot's long axis, in radians: how badly you may be rolled and still fit through the letterbox. | | [docking.ts:144](./docking.ts#L144) |
-| docking | SLOT_SPEED_LIMIT | 120 | How fast a ship may be going when it reaches the slot, in world units a second (docs/TODO/207 M3). | docking.slotSpeedLimit | [docking.ts:165](./docking.ts#L165) |
-| docking | RAILS_LATERAL | 250 | How far off the slot axis the ship may be when the rails take it, in world units (docs/TODO/212). | docking.railsLateral | [docking.ts:187](./docking.ts#L187) |
-| docking | RAILS_RANGE | 900 | How far out the rails take the ship, in world units (docs/TODO/212). | docking.railsRange | [docking.ts:199](./docking.ts#L199) |
-| docking | RAILS_PULL | 2 | How hard the rails pull the ship onto the axis, per second (docs/TODO/212). | docking.railsPull | [docking.ts:211](./docking.ts#L211) |
-| docking | RAILS_CONE | 0.03 | How far off the slot axis the ship's NOSE may point when the PILOT is given the slot, in radians. | docking.railsCone | [docking.ts:245](./docking.ts#L245) |
-| docking | RAILS_TURN | 3 | How hard the rails turn the nose onto the axis, per second. | docking.railsTurn | [docking.ts:262](./docking.ts#L262) |
-| docking | RAILS_STOPPED | 2 | The speed under which the ship counts as stopped, in world units a second (docs/TODO/212). | docking.railsStopped | [docking.ts:276](./docking.ts#L276) |
+| docking | ROLL_TOLERANCE | 0.24 | The wings against the slot's long axis, in radians, FOR A HAND ON THE STICK. | docking.rollTolerance | [docking.ts:153](./docking.ts#L153) |
+| docking | COMPUTER_ROLL_TOLERANCE | 0.65 | ...and the same angle for a BOUGHT DOCKING COMPUTER, which is wider. | docking.computerRollTolerance | [docking.ts:179](./docking.ts#L179) |
+| docking | SLOT_SPEED_LIMIT | 120 | How fast a ship may be going when it reaches the slot, in world units a second (docs/TODO/207 M3). | docking.slotSpeedLimit | [docking.ts:200](./docking.ts#L200) |
+| docking | RAILS_LATERAL | 250 | How far off the slot axis the ship may be when the rails take it, in world units (docs/TODO/212). | docking.railsLateral | [docking.ts:222](./docking.ts#L222) |
+| docking | RAILS_RANGE | 900 | How far out the rails take the ship, in world units (docs/TODO/212). | docking.railsRange | [docking.ts:234](./docking.ts#L234) |
+| docking | RAILS_PULL | 2 | How hard the rails pull the ship onto the axis, per second (docs/TODO/212). | docking.railsPull | [docking.ts:246](./docking.ts#L246) |
+| docking | RAILS_CONE | 0.03 | How far off the slot axis the ship's NOSE may point when the PILOT is given the slot, in radians. | docking.railsCone | [docking.ts:280](./docking.ts#L280) |
+| docking | RAILS_TURN | 3 | How hard the rails turn the nose onto the axis, per second. | docking.railsTurn | [docking.ts:297](./docking.ts#L297) |
+| docking | RAILS_STOPPED | 2 | The speed under which the ship counts as stopped, in world units a second (docs/TODO/212). | docking.railsStopped | [docking.ts:311](./docking.ts#L311) |
| docking-computer | DOCK_COMPUTER_RANGE | 3500 | How close to the station the docking computer will take the job, in world units. | | [docking-computer.ts:46](./docking-computer.ts#L46) |
| docking-computer | DC_SLOT_MARGIN | 0.30 | How much of the slot's roll tolerance the TURN may spend, as a fraction. | docking.slotMargin | [docking-computer.ts:97](./docking-computer.ts#L97) |
| docking-computer | DC_TURN_FADE_ANGLE | 0.10 | The off-nose angle, in radians, over which the TURN's claim on the roll axis ramps in. | docking.turnFadeAngle | [docking-computer.ts:151](./docking-computer.ts#L151) |
diff --git a/src/constants/docking.ts b/src/constants/docking.ts
index bb168978..bebe1a08 100644
--- a/src/constants/docking.ts
+++ b/src/constants/docking.ts
@@ -133,15 +133,50 @@ export const SLOT_HALF_ALONG = 62;
export const SLOT_DEPTH = 60;
/**
- * The wings against the slot's long axis, in radians: how badly you may be rolled
- * and still fit through the letterbox. It is a quarter turn's tolerance either
- * side, and it is symmetric, so a ship upside down in the slot still fits.
+ * The wings against the slot's long axis, in radians, FOR A HAND ON THE STICK.
+ * It is how badly you may be rolled and still fit through the letterbox. It is
+ * symmetric, so a ship upside down in the slot still fits.
+ *
+ * DOCKING IS SUPPOSED TO BE HARD (Chris, 2026-09-12). It was 0.65, about 37
+ * degrees, which is 41% of every angle the slot can present. A probe of the
+ * mini game flew it 20 times, at a different slot angle each time. A pilot who
+ * matched the spin docked 20 times, first go. A pilot who never touched the
+ * roll ALSO docked 20 times, after one scrape. The skill saved a shield. It was
+ * not what got the ship in.
+ *
+ * What the whole channel is worth, measured at 0.65: over a uniform sample of
+ * approach offsets and rolls, the fraction that docks is 6.68%. That is the
+ * number to measure again if this tolerance or the half-widths above ever move.
+ *
+ * @rule docking.rollTolerance
+ */
+export const ROLL_TOLERANCE = 0.24;
+
+/**
+ * ...and the same angle for a BOUGHT DOCKING COMPUTER, which is wider.
+ *
+ * It is a deliberate cheat (Chris, 2026-09-12: *"we can 'cheat' for the docking
+ * computer and give it more leeway"*). The computer is what a commander buys to
+ * stop docking being hard, so the slot is kinder to it than to a hand.
+ *
+ * 0.65 is what BOTH used to be, so this is the old rule under its own name and
+ * the hand's is the one that moved.
+ *
+ * THE COMPUTER CANNOT MEET THE HAND'S FIGURE. `npm run dock-probe` reports it
+ * arriving up to 13.8 degrees off the slot's long axis. `ROLL_TOLERANCE` at
+ * 0.24 rad is 13.75 degrees, so the worst approach falls outside it. Measured,
+ * the shared constant at that figure cost 2 scrapes over 504 approaches.
+ *
+ * A commander who paid for the computer should not lose a shield to that. So
+ * the licence is wide, and the hand's is not. With the two split, the probe is
+ * back to 504 of 504 with no scrape.
+ *
+ * It has its own rule id, and the two must not be fused. One is a difficulty
+ * setting. The other is the equipment's licence.
*
- * What the whole channel is worth, measured: over a uniform sample of approach
- * offsets and rolls, the fraction that docks is 6.68%. That is the number to
- * measure again if this tolerance or the half-widths above ever move.
+ * @rule docking.computerRollTolerance
*/
-export const ROLL_TOLERANCE = 0.65;
+export const COMPUTER_ROLL_TOLERANCE = 0.65;
/**
* How fast a ship may be going when it reaches the slot, in world units a
diff --git a/src/game/cockpit-view.ts b/src/game/cockpit-view.ts
index fb5c76bc..c37011c3 100644
--- a/src/game/cockpit-view.ts
+++ b/src/game/cockpit-view.ts
@@ -318,6 +318,7 @@ export class CockpitView {
inFlight: this.host.inFlight(),
witchspace: this.state.session.witchspace,
assist: this.state.session.ccEngaged,
+ dockingComputer: this.state.session.dcEngaged,
trial: this.state.session.dockTrial,
rails: this.state.session.dockRails,
ecmDetected: this.state.ecmDetectedTimer > 0,
diff --git a/src/game/docking-sticks.ts b/src/game/docking-sticks.ts
index f7c442e9..d7dd684d 100644
--- a/src/game/docking-sticks.ts
+++ b/src/game/docking-sticks.ts
@@ -19,7 +19,9 @@
import * as THREE from 'three';
-import { LINED_UP_LATERAL, SLOT_HALF_ACROSS, ROLL_TOLERANCE } from '../constants/docking.ts';
+import {
+ LINED_UP_LATERAL, SLOT_HALF_ACROSS, COMPUTER_ROLL_TOLERANCE,
+} from '../constants/docking.ts';
import {
DC_SLOT_MARGIN, DC_TURN_FADE_ANGLE, DC_ROLL_LEAD,
} from '../constants/docking-computer.ts';
@@ -118,7 +120,7 @@ export function dockingSticks(
// last correction and nothing else (docs/TODO/137, `DC_SLOT_MARGIN`).
const onAxis = Math.max(0, Math.min(1,
(LINED_UP_LATERAL - plan.lateral) / (LINED_UP_LATERAL - SLOT_HALF_ACROSS)));
- const budget = Math.PI / 2 + (ROLL_TOLERANCE * DC_SLOT_MARGIN - Math.PI / 2) * onAxis;
+ const budget = Math.PI / 2 + (COMPUTER_ROLL_TOLERANCE * DC_SLOT_MARGIN - Math.PI / 2) * onAxis;
// The attitude the SLOT asks for, but only once the approach COMMITS to the
// letterbox. A slot 1,500 units away on a hull that turns has no opinion
// worth the flight. To track it out there is a roll that never stops. A ship in a
diff --git a/src/game/docking.ts b/src/game/docking.ts
index ff54927a..c642db25 100644
--- a/src/game/docking.ts
+++ b/src/game/docking.ts
@@ -235,8 +235,10 @@ export function inSlotChannel(localX: number, localY: number): boolean {
* the angle away from it. Both magnitudes are absolute: a ship upside down in
* the slot still fits through it.
*/
-export function rollAlignedWithSlot(rightX: number, rightY: number): boolean {
- return slotRollOffset(rightX, rightY) < ROLL_TOLERANCE;
+export function rollAlignedWithSlot(
+ rightX: number, rightY: number, tolerance: number = ROLL_TOLERANCE,
+): boolean {
+ return slotRollOffset(rightX, rightY) < tolerance;
}
/** How far off the slot's long axis the wings are, in radians. */
@@ -273,6 +275,14 @@ export function dockingOutcome(
dockZ: number,
speed: number,
scratch: { v: THREE.Vector3; q: THREE.Quaternion; r: THREE.Vector3 },
+ /**
+ * How far the wings may be off the slot's long axis, in radians. The CALLER
+ * says, because it depends on who holds the stick: `ROLL_TOLERANCE` for a
+ * hand, and the wider `COMPUTER_ROLL_TOLERANCE` for a bought docking
+ * computer. The default is the hard one, so a caller that forgets is strict
+ * rather than lax.
+ */
+ rollTolerance: number = ROLL_TOLERANCE,
): DockingOutcome {
const box = dockZ + HULL_BOX_MARGIN;
const local = scratch.v.copy(pos);
@@ -286,6 +296,6 @@ export function dockingOutcome(
scratch.q.copy(station.quaternion).invert().multiply(quat);
const right = scratch.r.set(1, 0, 0).applyQuaternion(scratch.q);
- if (!rollAlignedWithSlot(right.x, right.y)) return 'slotMiss';
+ if (!rollAlignedWithSlot(right.x, right.y, rollTolerance)) return 'slotMiss';
return speed > SLOT_SPEED_LIMIT ? 'tooFast' : 'docked';
}
diff --git a/src/game/world-step.ts b/src/game/world-step.ts
index 09fb62fa..6f4ba4f0 100644
--- a/src/game/world-step.ts
+++ b/src/game/world-step.ts
@@ -59,7 +59,9 @@ import { holdOnRails, railsAligned, railsReached, stopped } from './dock-rails.t
import { bankToTurn } from './pitch-roll-steer.ts';
import { slotNormal } from '../world/slot.ts';
import { dockingSticks } from './docking-sticks.ts';
-import { NPC_HULL_BOX_MARGIN } from '../constants/docking.ts';
+import {
+ NPC_HULL_BOX_MARGIN, COMPUTER_ROLL_TOLERANCE, ROLL_TOLERANCE,
+} from '../constants/docking.ts';
import { BOUNCE_STANDOFF } from '../constants/station.ts';
import { regenerate, updateCabinTemp, scoopFuel, energyLow } from './systems.ts';
import { SUN_KILL_DIST } from '../constants/sun.ts';
@@ -963,11 +965,15 @@ export class WorldStep {
* ours.
*/
private checkStation(out: StepEvent[]): void {
- const { player, world } = this.state;
+ const { player, world, session } = this.state;
const station = world.station;
+ // WHICH TOLERANCE depends on who holds the stick. A bought docking computer
+ // gets the wider one, and that is the point of buying it
+ // (`COMPUTER_ROLL_TOLERANCE`). A hand gets the hard one.
const outcome = dockingOutcome(
player.position, player.quaternion, station, world.stationDockZ, player.speed,
- { v: this.tmp, q: this.tmpQ, r: this.tmp2 });
+ { v: this.tmp, q: this.tmpQ, r: this.tmp2 },
+ session.dcEngaged ? COMPUTER_ROLL_TOLERANCE : ROLL_TOLERANCE);
if (outcome === 'clear') return;
if (outcome === 'docked') {
this.host.dock();
diff --git a/src/hud/hud-binding.ts b/src/hud/hud-binding.ts
index 0f414aee..cd50a8b5 100644
--- a/src/hud/hud-binding.ts
+++ b/src/hud/hud-binding.ts
@@ -30,6 +30,7 @@ import type { World } from '../game/world.ts';
import type { Missile } from '../game/ordnance.ts';
import type { Canister } from '../game/cargo.ts';
import { MAX_FUEL } from '../constants/commander.ts';
+import { COMPUTER_ROLL_TOLERANCE, ROLL_TOLERANCE } from '../constants/docking.ts';
import {
SCANNER_RANGE, SUNSKIM_COMPASS_RANGE, STATION_COMPASS_RADII,
} from '../constants/console.ts';
@@ -57,6 +58,8 @@ export interface HudSources {
readonly inFlight: boolean;
readonly witchspace: boolean;
readonly assist: boolean;
+ /** a bought docking computer holds the stick, so the slot is kinder to it */
+ readonly dockingComputer: boolean;
/** the pilot flies the last stretch into the slot (docs/TODO/207) */
readonly trial: boolean;
readonly rails: boolean;
@@ -140,7 +143,8 @@ export function buildHudFrame(s: HudSources, scratch: HudScratch): HudFrame {
if (s.inFlight && !s.witchspace) {
({ dockAid, slotMarker } = dockingAid(
world.station, world.stationDockZ, playerPos, s.playerQuat, s.playerForward,
- s.camera, { a: scratch.a, b: scratch.b, q: scratch.q }));
+ s.camera, { a: scratch.a, b: scratch.b, q: scratch.q },
+ s.dockingComputer ? COMPUTER_ROLL_TOLERANCE : ROLL_TOLERANCE));
}
// The tags of the things live missions sent the player for, so the scanner
diff --git a/src/hud/hud-model.ts b/src/hud/hud-model.ts
index c06c60d5..5ee90a28 100644
--- a/src/hud/hud-model.ts
+++ b/src/hud/hud-model.ts
@@ -191,6 +191,8 @@ export function dockingAid(
playerForward: THREE.Vector3,
camera: THREE.Camera,
scratch: { a: THREE.Vector3; b: THREE.Vector3; q: THREE.Quaternion },
+ /** the slot's roll tolerance for whoever holds the stick — see `dockingOutcome` */
+ rollTolerance: number,
): { dockAid: HudState['dockAid']; slotMarker: HudState['slotMarker'] } {
const none = { dockAid: null, slotMarker: null };
const dist = playerPos.distanceTo(station.position);
@@ -215,7 +217,7 @@ export function dockingAid(
// channel and the roll tolerance. So the aid and the dock test could
// disagree, and once the letterbox turned upright they did.
const inSlot = inSlotChannel(local.x, local.y);
- const rollOk = rollAlignedWithSlot(right.x, right.y);
+ const rollOk = rollAlignedWithSlot(right.x, right.y, rollTolerance);
return {
slotMarker,
dockAid: {
diff --git a/test/dock-trial.test.ts b/test/dock-trial.test.ts
index f549af1e..da32b88c 100644
--- a/test/dock-trial.test.ts
+++ b/test/dock-trial.test.ts
@@ -139,6 +139,7 @@ console.log('\nthe docking mini game');
*/
const fly = (g: Game, match: boolean, hands = true): {
rails: boolean; docked: boolean; lateral: number; handedOver: number;
+ scrapes: number;
} => {
const dt = 1 / 60;
const q = new THREE.Quaternion();
@@ -148,6 +149,8 @@ console.log('\nthe docking mini game');
let rails = false;
let lateral = Infinity;
let handedOver = -1;
+ let scrapes = 0;
+ let wasRails = false;
withoutSaving(() => {
for (let f = 0, at = 0; f < 200 / dt; f++) {
const st = g.state.world.station;
@@ -176,11 +179,15 @@ console.log('\nthe docking mini game');
Math.atan2(Math.sin(2 * err), Math.cos(2 * err)) / 2 * 4));
}
}
+ const held = g.state.session.dockRails;
g.step(dt, at += dt);
+ // The rails let go on a scrape, and only on a scrape, while in flight.
+ if (wasRails && !g.state.session.dockRails && g.mode === 'flight') scrapes += 1;
+ wasRails = held;
if (g.mode !== 'flight') break;
}
});
- return { rails, docked: g.mode === 'docked', lateral, handedOver };
+ return { rails, docked: g.mode === 'docked', lateral, handedOver, scrapes };
};
const run = fly(arrive(20_260_951), true);
@@ -191,6 +198,17 @@ console.log('\nthe docking mini game');
check('...having been held on the line, inside the channel',
run.lateral < SLOT_HALF_ACROSS, `${run.lateral.toFixed(0)} units off the axis`);
+ check('...first time, with no scrape at all', run.scrapes === 0);
+
+ // THE SPIN IS THE GAME, and it was not (Chris, 2026-09-12: *"I think we're
+ // still too easy on the docking"*). A probe of 20 approaches found that a
+ // pilot who never touched the roll docked every time, after one scrape. The
+ // slot took a roll 37 degrees out, which is 41% of every angle it presents.
+ // `ROLL_TOLERANCE` is now 0.24, and a bought computer keeps the old 0.65.
+ const lazy = fly(arrive(20_260_951), false);
+ check('a pilot who thrusts in but ignores the spin is bounced off',
+ lazy.scrapes > 0, `${lazy.scrapes} scrape(s)`);
+
// The control: hands off entirely. The ship stops on the axis and stays
// there, so nobody docks by accident (docs/TODO/212). Chris asked for that
// on 2026-09-12: the pilot must thrust in.
diff --git a/test/docking-computer.test.ts b/test/docking-computer.test.ts
index dc069b51..68f31e93 100644
--- a/test/docking-computer.test.ts
+++ b/test/docking-computer.test.ts
@@ -28,7 +28,7 @@ import { PLAYER_FLIGHT } from '../src/constants/player-flight.ts';
import {
DC_TURN_FADE_ANGLE, DC_SLOT_MARGIN, DC_ROLL_LEAD,
} from '../src/constants/docking-computer.ts';
-import { ROLL_TOLERANCE } from '../src/constants/docking.ts';
+import { COMPUTER_ROLL_TOLERANCE } from '../src/constants/docking.ts';
import { STEER_SATURATION } from '../src/constants/combat-computer.ts';
import { rollErrorTo } from '../src/game/pitch-roll-steer.ts';
import { check } from './harness.ts';
@@ -128,7 +128,7 @@ console.log('\nthe docking computer near its own heading');
// the fade is the only thing holding it back and not some other clamp.
check(`...and outside it spends the budget DC_SLOT_MARGIN allows (${
outside.toFixed(4)})`,
- near(Math.abs(outside), ROLL_TOLERANCE * DC_SLOT_MARGIN / STEER_SATURATION, 1e-6));
+ near(Math.abs(outside), COMPUTER_ROLL_TOLERANCE * DC_SLOT_MARGIN / STEER_SATURATION, 1e-6));
}
// The GATE phase hands the whole axis to the turn: a slot on a spinning hull
@@ -145,10 +145,10 @@ console.log('\nthe docking computer near its own heading');
const q = new THREE.Quaternion().setFromRotationMatrix(
new THREE.Matrix4().lookAt(new THREE.Vector3(), plan.heading, plan.up));
q.multiply(new THREE.Quaternion()
- .setFromAxisAngle(new THREE.Vector3(0, 0, 1), ROLL_TOLERANCE * 2));
+ .setFromAxisAngle(new THREE.Vector3(0, 0, 1), COMPUTER_ROLL_TOLERANCE * 2));
const s = dockingSticks(q, plan, AT_REST);
check(`...so with the nose on the gate heading it holds the wings still (${
- s.roll.toFixed(6)}), rolled ${(ROLL_TOLERANCE * 2).toFixed(2)} off the slot`,
+ s.roll.toFixed(6)}), rolled ${(COMPUTER_ROLL_TOLERANCE * 2).toFixed(2)} off the slot`,
Math.abs(s.roll) < 1e-6);
}
}
diff --git a/test/hud-model.test.ts b/test/hud-model.test.ts
index 0614fdf9..2bd62fcc 100644
--- a/test/hud-model.test.ts
+++ b/test/hud-model.test.ts
@@ -172,7 +172,7 @@ console.log('\ndocking port marker');
const aidAt = (x: number, off: number) => {
const p = pose(x, off);
return dockingAid(
- station, DOCK_Z, p.pos, p.quat, p.forward, camera, scratch).dockAid;
+ station, DOCK_Z, p.pos, p.quat, p.forward, camera, scratch, ROLL_TOLERANCE).dockAid;
};
const straight = aidAt(0, 0);
diff --git a/test/world.test.ts b/test/world.test.ts
index 6ee9424c..a9c80367 100644
--- a/test/world.test.ts
+++ b/test/world.test.ts
@@ -6,7 +6,7 @@
import * as THREE from 'three';
import { dockingOutcome } from '../src/game/docking.ts';
-import { ROLL_TOLERANCE } from '../src/constants/docking.ts';
+import { COMPUTER_ROLL_TOLERANCE, ROLL_TOLERANCE } from '../src/constants/docking.ts';
import { freshTimers, stepEncounters } from '../src/game/encounters.ts';
import { planPopulation, policeFor } from '../src/game/population.ts';
import { World } from '../src/game/world.ts';
@@ -227,6 +227,21 @@ console.log('\ndocking');
at(0, 0, -(DOCK_Z - 20), rolledFrom(-(ROLL_TOLERANCE - 0.05))) === 'docked'
&& at(0, 0, -(DOCK_Z - 20), rolledFrom(-(ROLL_TOLERANCE + 0.05))) === 'slotMiss');
}
+ {
+ // A BOUGHT DOCKING COMPUTER GETS A WIDER SLOT, and that is the point of
+ // buying it (Chris, 2026-09-12: *"docking is supposed to be hard. But we
+ // can 'cheat' for the docking computer and give it more leeway"*). The
+ // caller says which tolerance to use, so the two cannot be confused.
+ const roll = (ROLL_TOLERANCE + COMPUTER_ROLL_TOLERANCE) / 2;
+ const byHand = dockingOutcome(new THREE.Vector3(0, 0, -(DOCK_Z - 20)),
+ rolledFrom(roll), station, DOCK_Z, 0, scratch, ROLL_TOLERANCE);
+ const byComputer = dockingOutcome(new THREE.Vector3(0, 0, -(DOCK_Z - 20)),
+ rolledFrom(roll), station, DOCK_Z, 0, scratch, COMPUTER_ROLL_TOLERANCE);
+ check('a roll between the two tolerances is a miss for a hand', byHand === 'slotMiss');
+ check('...and a dock for the bought computer', byComputer === 'docked');
+ check('...so the computer is the wider of the two',
+ COMPUTER_ROLL_TOLERANCE > ROLL_TOLERANCE);
+ }
}
// --- who is hunting whom, across a reload -----------------------------------
diff --git a/train/dock-probe.ts b/train/dock-probe.ts
index ea9dfc7b..4f7833a9 100644
--- a/train/dock-probe.ts
+++ b/train/dock-probe.ts
@@ -111,7 +111,7 @@ import { WorldStep, type StepHost } from '../src/game/world-step.ts';
import { Ordnance } from '../src/game/ordnance.ts';
import { seedWorld } from '../src/game/rng.ts';
import { slotRollOffset } from '../src/game/docking.ts';
-import { ROLL_TOLERANCE } from '../src/constants/docking.ts';
+import { COMPUTER_ROLL_TOLERANCE } from '../src/constants/docking.ts';
/** Hands off the stick: the autopilot is the only pilot in these runs. */
const COAST = { rollRate: 0, pitchRate: 0, throttle: 0, fire: false };
@@ -178,7 +178,7 @@ interface Run {
/**
* ...and how far off the slot's LONG AXIS the wings still were, in degrees —
* the other half of going through a letterbox, and the half the whole roll
- * axis exists for (`rollAlignedWithSlot`). `ROLL_TOLERANCE` is what fits;
+ * axis exists for (`rollAlignedWithSlot`). `COMPUTER_ROLL_TOLERANCE` is what fits;
* this is what the autopilot actually arrives with.
*/
entryRoll: number;
@@ -381,7 +381,7 @@ console.log(`roll reversals: median ${median(rolls)} · worst ${worst(rolls)}`
console.log(`still pointing this far off the slot axis going in: median ${
median(entries).toFixed(1)}° · worst ${worst(entries).toFixed(1)}°`);
console.log(`...and this far off its long axis, against ${
- (ROLL_TOLERANCE * 180 / Math.PI).toFixed(0)}° of tolerance: median ${
+ (COMPUTER_ROLL_TOLERANCE * 180 / Math.PI).toFixed(0)}° of tolerance: median ${
median(rolls2).toFixed(1)}° · worst ${worst(rolls2).toFixed(1)}°`);
console.log(`the plan's own heading jumps: median ${median(jumps).toFixed(1)}°`
+ ` · worst ${worst(jumps).toFixed(1)}° in one frame`
From 152459bb26264df6e6d86d3191793eea7c11beae Mon Sep 17 00:00:00 2001
From: Chris Greening PURSUIT_SLASH_CONE | 1.3 | A pursuit PIRATE switches flight models on where it sits in the commander's arc. | | [combat-computer.ts:178](./combat-computer.ts#L178) |
| combat-computer | PURSUIT_HOLD_CONE | 1.85 | | | [combat-computer.ts:179](./combat-computer.ts#L179) |
| combat-computer | ENGAGED_CONE | 0.6 | The nose-to-target angle, in radians, within which the co-pilot counts itself ENGAGED and will not switch targets. | | [combat-computer.ts:188](./combat-computer.ts#L188) |
-| combat-computer | TARGET_DIST_WEIGHT | 800 | How many world units of range weigh as much as one radian of off-nose turn. | copilot.targetDistWeight | [combat-computer.ts:206](./combat-computer.ts#L206) |
-| combat-computer | PURSUIT_LEAD_GAIN | 2.0 | How many SECONDS the co-pilot aims ahead of a target, per radian that its nose still has to swing. | copilot.leadGain | [combat-computer.ts:257](./combat-computer.ts#L257) |
+| combat-computer | COMBAT_ROLL_GATE | 0.27 | How near its bank the co-pilot must be before it pulls the nose, in radians. | copilot.rollGate | [combat-computer.ts:240](./combat-computer.ts#L240) |
+| combat-computer | TARGET_DIST_WEIGHT | 800 | How many world units of range weigh as much as one radian of off-nose turn. | copilot.targetDistWeight | [combat-computer.ts:258](./combat-computer.ts#L258) |
+| combat-computer | PURSUIT_LEAD_GAIN | 2.0 | How many SECONDS the co-pilot aims ahead of a target, per radian that its nose still has to swing. | copilot.leadGain | [combat-computer.ts:309](./combat-computer.ts#L309) |
| combat-record | SAMPLE_HZ | 10 | How often the code samples the geometry, in Hz. | | [combat-record.ts:12](./combat-record.ts#L12) |
| combat-record | SIX_CONE | Math.PI / 3 | The rear cone that counts as somebody's six, as a half-angle from directly astern. | | [combat-record.ts:20](./combat-record.ts#L20) |
| combat-record | PASS_CLOSE | 400 | What an attack run is, in ranges. | | [combat-record.ts:42](./combat-record.ts#L42) |
diff --git a/src/constants/combat-computer.ts b/src/constants/combat-computer.ts
index 32e48cd4..d61cc98d 100644
--- a/src/constants/combat-computer.ts
+++ b/src/constants/combat-computer.ts
@@ -187,6 +187,58 @@ export const PURSUIT_HOLD_CONE = 1.85;
*/
export const ENGAGED_CONE = 0.6;
+/**
+ * How near its bank the co-pilot must be before it pulls the nose, in radians.
+ * Above it, `bankToTurn` asks for NO pitch at all.
+ *
+ * IT WAS ZERO, AND THE COMMENT SAID IT HAD TO BE. `pitch-roll-steer.ts` held
+ * that the combat computer must not take the hard gate. A pitch held still
+ * while the roll catches up is time off the gun, it said, and the wide gun cone
+ * hid the fault anyway. A review of 2026-09-12 measured both claims false
+ * (`docs/COMBAT-COMPUTER-REVIEW.md`).
+ *
+ * WHAT THE SOFT GATE COSTS. Pitch scaled by the cosine of the bank error never
+ * reaches zero. That pitch moves the target's bearing while the roll chases the
+ * same bearing, so the nose can circle the target for ever. A vertical orbit
+ * needs no roll, and it tracked 100%. The horizontal and tilted orbits need
+ * both axes, and they fell to 17%.
+ *
+ * THREE MEASURES CHOSE THE VALUE, and the review's own probe is two of them. It
+ * flies the shipped controller and the real flight model against prescribed
+ * paths. Time on the gun means inside `hitCone` and `LASER_RANGE`, after the
+ * first ten seconds. The third is 72 exercises against real pirate brains, with
+ * real shots. Its figure is the share of engaged frames with a live hostile
+ * inside the gun cone.
+ *
+ * | gate | 21 paths, 60s | 54 orbits, 90s | 72 fights |
+ * | 0 | 64.9%, 7.5% | 53.9%, 3.8% | 39.5% |
+ * | 0.15 | 98.5%, 87.6% | 98.0%, 80.2% | 55.4% |
+ * | 0.20 | 98.5%, 87.8% | 97.9%, 79.2% | 58.9% |
+ * | 0.27 | 98.3%, 88.5% | 97.6%, 77.2% | 57.5% |
+ * | 0.30 | 98.7%, 90.8% | 97.7%, 78.2% | 57.8% |
+ * | 0.40 | 98.4%, 84.4% | 97.1%, 72.4% | 60.2% |
+ *
+ * The second figure in each pair is the worst case, not the mean.
+ *
+ * WHY 0.27. It is the middle of a plateau rather than a peak. The prescribed
+ * paths hold about 98% from 0.08 to 0.3, and they fall away by 0.4. The fights
+ * rise to about 58% by 0.2 and stay there. Any value from 0.2 to 0.3 measures
+ * the same, so the exact figure inside that band is not load-bearing. It is
+ * about 15 degrees, against the course pilot's `COURSE_ROLL_GATE` of 0.05. A
+ * course has one fixed heading to reach. This one chases a heading that moves.
+ *
+ * WHAT IT COSTS. Acquisition is slower, because the nose waits for the bank.
+ * The worst first lock over the 21 paths went from 3.28 to 3.87 seconds. A gate
+ * of 0.08 pushed the same figure to 8.02 seconds, which is why the band is not
+ * tighter.
+ *
+ * It is a feel setting, and no brain flies through it. The prescribed paths
+ * carry no shots, no damage, and no opponent that fights back.
+ *
+ * @rule copilot.rollGate
+ */
+export const COMBAT_ROLL_GATE = 0.27;
+
/**
* How many world units of range weigh as much as one radian of off-nose turn.
* The co-pilot ranks targets by how easy they are to lock
diff --git a/src/game/pitch-roll-steer.ts b/src/game/pitch-roll-steer.ts
index ebfd129e..19cd6620 100644
--- a/src/game/pitch-roll-steer.ts
+++ b/src/game/pitch-roll-steer.ts
@@ -89,7 +89,9 @@ function wrap(a: number): number {
*
* `rollGate` is the angle inside which the BANK counts as finished. Above it
* this asks for no pitch at all. Zero keeps the soft gate alone, which is the
- * `cos` of the roll error, and every combat caller passes zero.
+ * `cos` of the roll error. The course pilot passes `COURSE_ROLL_GATE`, and the
+ * combat co-pilot passes `COMBAT_ROLL_GATE`. The docking computer does not come
+ * through here at all.
*
* THE SOFT GATE ALONE CAN CONE (Chris, 2026-09-12: *"we seem to be constantly
* rotating when heading towards something"*). A target a hair off the nose
@@ -104,9 +106,12 @@ function wrap(a: number): number {
* bearing, the bank arrives, and the pitch then closes the angle. On a course
* to the station it took a median trip from 41 full turns to 1.3.
*
- * The combat computer must NOT take it. Its target manoeuvres, and a pitch
- * held still while the roll catches up is time off the gun. It also never sees
- * the fault, because it stops steering inside its own wide gun cone.
+ * THE COMBAT COMPUTER TAKES IT TOO, and this comment said the opposite until
+ * 2026-09-12. It held that a target which manoeuvres cannot afford a pitch held
+ * still, and that the wide gun cone hid the fault anyway. A review measured
+ * both claims false. The co-pilot circled a horizontal orbit at 17% time on the
+ * gun, and the gate took the same grid to 98%. `COMBAT_ROLL_GATE` is its value,
+ * and the whole measurement is beside it.
*
* That is the seasickness fix. A target that already fills the gun still has a
* bearing, and that bearing swings as it drifts a hair off centre. A bank to
diff --git a/src/game/scripted-co-pilot.ts b/src/game/scripted-co-pilot.ts
index ddf5cf95..7f0d94d2 100644
--- a/src/game/scripted-co-pilot.ts
+++ b/src/game/scripted-co-pilot.ts
@@ -49,7 +49,7 @@ import { LASER_RANGE } from '../constants/player-gun.ts';
import { UNDER_FIRE_SECONDS } from '../constants/attack-run.ts';
import {
THREAT_RANGE, PURSUIT_SPEED_DEADBAND, ENGAGED_CONE, TARGET_DIST_WEIGHT,
- PURSUIT_LEAD_GAIN,
+ PURSUIT_LEAD_GAIN, COMBAT_ROLL_GATE,
} from '../constants/combat-computer.ts';
import { PLAYER_FLIGHT } from '../constants/player-flight.ts';
import { MAX_LEAD_SECONDS } from '../constants/pass-aim.ts';
@@ -179,10 +179,15 @@ export class ScriptedCoPilot {
// which is the same rule: how far ahead a ship may aim.
this.aim.copy(targetPos).addScaledVector(
this.threatVel, Math.min(MAX_LEAD_SECONDS, PURSUIT_LEAD_GAIN * facing));
+ // BANK FIRST, THEN PULL. `COMBAT_ROLL_GATE` holds the pitch still until the
+ // roll arrives. Without it the pitch moves the target's bearing. The roll
+ // then chases that same bearing, and the nose circles the target instead of
+ // closing on it. See the constant for what that cost, measured.
+ //
// It ramps through the commander's own envelope (PLAYER_FLIGHT), so the
// co-pilot flies your ship as your hands would.
const cmd = bankToTurn(player.quaternion,
- this.aim.sub(player.position), this.steerMem, cone);
+ this.aim.sub(player.position), this.steerMem, cone, COMBAT_ROLL_GATE);
this.pitchRate = rampFlightRate(
this.pitchRate, cmd.pitch * PLAYER_FLIGHT.maxPitch, cmd.pitch !== 0, dt);
this.rollRate = rampFlightRate(
diff --git a/test/co-pilot-tracking.test.ts b/test/co-pilot-tracking.test.ts
new file mode 100644
index 00000000..f554ba82
--- /dev/null
+++ b/test/co-pilot-tracking.test.ts
@@ -0,0 +1,162 @@
+// TRACKING: does the co-pilot's nose stay on a target that moves?
+//
+// Split from `scripted-co-pilot.test.ts` on 2026-09-12, when that file passed
+// the 400-line ceiling. It is a different question from the one next door.
+// That file pins the co-pilot's CONTRACT: what it engages, when it asks for the
+// trigger, when it answers a warhead, and when it hands back. This one flies
+// the controller against prescribed target paths and measures the GEOMETRY.
+//
+// The review of 2026-09-12 is the reason it exists in this shape
+// (`docs/COMBAT-COMPUTER-REVIEW.md`). Its own probe is the model for the four
+// measures, and `npm run combat-aim` is the third check, in real fights.
+
+import * as THREE from 'three';
+import { ScriptedCoPilot } from '../src/game/scripted-co-pilot.ts';
+import { hitCone } from '../src/game/gunnery.ts';
+import { LASER_RANGE } from '../src/constants/player-gun.ts';
+import { freshState } from '../src/game/state.ts';
+import { newCommander } from '../src/game/commander.ts';
+import { seedWorld } from '../src/game/rng.ts';
+import { check } from './harness.ts';
+
+// --- TRACKING: DOES THE NOSE STAY ON A TARGET THAT MOVES? ------------------
+//
+// This replaces two weaker tests, on the review of 2026-09-12
+// (`docs/COMBAT-COMPUTER-REVIEW.md`). One accepted 20% of frames inside the gun
+// cone and passed at 29%. The other measured a fixed 0.1 rad rather than the
+// real cone, ran 20 seconds, and flew a target sideways while its nose pointed
+// somewhere else. The co-pilot reads that nose for the target's velocity, so
+// the fixture asked for a lead it then called wrong.
+//
+// The rules this fixture keeps:
+//
+// - Position, orientation and speed all come from ONE path. A fixture that
+// disagrees with itself measures nothing.
+// - The gun cone is `hitCone`, the gun's own. A fixed angle is not the test
+// the trigger applies.
+// - Four numbers, not one. A high average hides a target lost for 40 seconds,
+// so the longest gap is its own limit. Acquisition and roll travel are the
+// two costs a tracking gain can be paid for with.
+// - Horizontal, vertical and tilted paths. A vertical orbit needs pitch
+// alone and always tracked well. The coupled axes are where it broke.
+console.log('\nthe co-pilot tracks a target that moves');
+{
+ const DT = 1 / 60;
+ const UP = new THREE.Vector3(0, 1, 0);
+
+ interface Track {
+ onGun: number; firstLock: number | null; longestGap: number; rollTurns: number;
+ }
+ /**
+ * Fly `path` for `seconds` and report the four measures.
+ *
+ * @param path writes the target's position and velocity at time t.
+ */
+ const track = (path: (t: number, pos: THREE.Vector3, vel: THREE.Vector3) => void,
+ seconds: number): Track => {
+ seedWorld(4245);
+ const st = freshState(newCommander());
+ st.world.build(st.systems[st.commander.systemIndex]);
+ st.world.clearNpcs();
+ st.player.position.set(0, 0, 0);
+ st.player.quaternion.identity();
+ st.player.speed = 200;
+ const pirate = st.world.spawn('pirate', new THREE.Vector3(0, 0, -1000), 1);
+ const cp = new ScriptedCoPilot();
+ const pos = new THREE.Vector3();
+ const vel = new THREE.Vector3();
+ const look = new THREE.Matrix4();
+ const ahead = new THREE.Vector3();
+ const nose = new THREE.Vector3();
+ const to = new THREE.Vector3();
+ let onGun = 0; let late = 0; let rollTravel = 0;
+ let firstLock: number | null = null;
+ let gap = 0; let longestGap = 0;
+ for (let i = 0; i < seconds / DT; i++) {
+ const t = i * DT;
+ path(t, pos, vel);
+ pirate.object.position.copy(pos);
+ pirate.state.speed = vel.length();
+ // ONE path: the nose points along the velocity the same path gives, which
+ // is what `velocityOf` reads back out of it.
+ if (pirate.state.speed > 1e-6) {
+ look.lookAt(pos, ahead.copy(pos).add(vel), UP);
+ pirate.object.quaternion.setFromRotationMatrix(look);
+ }
+ const step = cp.step(DT, st.player, st.world.npcs, st.commander.legalStatus,
+ false, null, Infinity, pirate);
+ if (step.kind !== 'fly') throw new Error('should be flying a live threat');
+ st.player.update(DT, step.demand);
+ rollTravel += Math.abs(step.demand.rollRate) * DT;
+
+ nose.set(0, 0, -1).applyQuaternion(st.player.quaternion);
+ to.copy(pirate.object.position).sub(st.player.position);
+ const lock = nose.angleTo(to) < hitCone(pirate.radius, to.length())
+ && to.length() <= LASER_RANGE;
+ if (lock && firstLock === null) firstLock = t;
+ // the gap, and the window, both skip the first ten seconds of the swing on
+ if (t >= 10) {
+ late += 1;
+ if (lock) { onGun += 1; gap = 0; } else { gap += DT; longestGap = Math.max(longestGap, gap); }
+ }
+ }
+ return {
+ onGun: onGun / late,
+ firstLock,
+ longestGap,
+ rollTurns: rollTravel / (2 * Math.PI) / (seconds / 60),
+ };
+ };
+
+ /** An orbit of a fixed centre, in one of the three planes. */
+ const orbit = (radius: number, speed: number, plane: 'horizontal' | 'vertical' | 'tilted') =>
+ (t: number, pos: THREE.Vector3, vel: THREE.Vector3): void => {
+ const a = (speed / radius) * t;
+ const x = Math.cos(a) * radius; const y = Math.sin(a) * radius;
+ const vx = -Math.sin(a) * speed; const vy = Math.cos(a) * speed;
+ if (plane === 'horizontal') { pos.set(x, 0, y); vel.set(vx, 0, vy); return; }
+ if (plane === 'vertical') { pos.set(0, x, y); vel.set(0, vx, vy); return; }
+ pos.set(x, y / Math.SQRT2, y / Math.SQRT2);
+ vel.set(vx, vy / Math.SQRT2, vy / Math.SQRT2);
+ };
+
+ // The limits are per case, and each one has room under the measured figure.
+ // The UNGATED controller is in the last column, so no limit here is vacuous:
+ // it fails every coupled case. See `COMBAT_ROLL_GATE`.
+ const CASES: {
+ name: string; seconds: number; minOnGun: number; maxLock: number;
+ maxGap: number; maxTurns: number;
+ path: (t: number, p: THREE.Vector3, v: THREE.Vector3) => void;
+ }[] = [
+ { name: 'a horizontal orbit, 400 units at 300', seconds: 60, minOnGun: 0.80,
+ maxLock: 5, maxGap: 3, maxTurns: 6, path: orbit(400, 300, 'horizontal') },
+ { name: 'a vertical orbit, 400 units at 300', seconds: 60, minOnGun: 0.95,
+ maxLock: 5, maxGap: 1, maxTurns: 1, path: orbit(400, 300, 'vertical') },
+ { name: 'a tilted orbit, 800 units at 300', seconds: 60, minOnGun: 0.90,
+ maxLock: 5, maxGap: 2, maxTurns: 3, path: orbit(800, 300, 'tilted') },
+ { name: 'a target crossing the front at 150', seconds: 60, minOnGun: 0.60,
+ maxLock: 5, maxGap: 12, maxTurns: 3,
+ path: (t, p, v) => { p.set(-900 + 150 * t, 0, -700); v.set(150, 0, 0); } },
+ { name: 'a target weaving away', seconds: 60, minOnGun: 0.90,
+ maxLock: 5, maxGap: 2, maxTurns: 3,
+ path: (t, p, v) => {
+ p.set(350 * Math.sin(0.8 * t), 0, -1200 - 180 * t);
+ v.set(280 * Math.cos(0.8 * t), 0, -180);
+ } },
+ { name: 'a target running straight away', seconds: 60, minOnGun: 0.99,
+ maxLock: 1, maxGap: 0.5, maxTurns: 0.5,
+ path: (t, p, v) => { p.set(0, 0, -1800 - 280 * t); v.set(0, 0, -280); } },
+ ];
+
+ for (const c of CASES) {
+ const r = track(c.path, c.seconds);
+ const said = `${(r.onGun * 100).toFixed(0)}% on the gun, `
+ + `lock ${r.firstLock === null ? 'never' : r.firstLock.toFixed(2) + 's'}, `
+ + `worst gap ${r.longestGap.toFixed(2)}s, ${r.rollTurns.toFixed(1)} turns a minute`;
+ check(`it tracks ${c.name} (${said})`,
+ r.onGun >= c.minOnGun
+ && r.firstLock !== null && r.firstLock <= c.maxLock
+ && r.longestGap <= c.maxGap
+ && r.rollTurns <= c.maxTurns, said);
+ }
+}
diff --git a/test/run.ts b/test/run.ts
index 0355a7ca..55b0b1a4 100644
--- a/test/run.ts
+++ b/test/run.ts
@@ -128,6 +128,7 @@ import './separation.test.ts';
import './tactics.test.ts';
import './tactic-choice.test.ts';
import './scripted-co-pilot.test.ts';
+import './co-pilot-tracking.test.ts';
import './pitch-roll-steer.test.ts';
import './pursuit.test.ts';
import './human-shape.test.ts';
diff --git a/test/scripted-co-pilot.test.ts b/test/scripted-co-pilot.test.ts
index 06de8600..12e85ecd 100644
--- a/test/scripted-co-pilot.test.ts
+++ b/test/scripted-co-pilot.test.ts
@@ -133,48 +133,6 @@ console.log('\nscripted combat computer');
held === ahead && held !== abeam);
}
- // --- it PURSUES: get on a crossing target's six and hold it -----------------
- // The failure this replaced: the co-pilot flew the pirates' attack run, whose
- // pass phase steers nowhere on purpose, so a target crossing close up was
- // lost — "it lines up, shoots, then doesn't follow" (Chris, flying it). A
- // pursuit dogfighter keeps the nose on it. This flies a target straight
- // across the front, through the very `PlayerShip.update` the Game applies, and
- // asserts the co-pilot ends up pointing near it and closed to gun range —
- // which the attack run could not do.
- {
- const flier = new ScriptedCoPilot();
- while (state.world.npcs.length) state.world.npcs.pop();
- state.player.position.set(0, 0, 0);
- state.player.quaternion.identity();
- state.world.spawn('pirate', new THREE.Vector3(-900, 0, -700), 1);
- const target = state.world.npcs[state.world.npcs.length - 1];
- target.state.speed = 150;
- const nose = new THREE.Vector3();
- const to = new THREE.Vector3();
- let onTargetLate = 0;
- let lateFrames = 0;
- const SECONDS = 20;
- for (let i = 0; i < 60 * SECONDS; i++) {
- // straight across the front, from left to right, a few hundred units ahead
- target.object.position.set(-900 + (i / 60) * 150, 0, -700);
- const s = flier.step(1 / 60, state.player, state.world.npcs, legal, false, null);
- if (s.kind !== 'fly') throw new Error('should be flying a live threat');
- state.player.update(1 / 60, s.demand);
- // measure only the second half, after it has had time to swing round
- if (i > 60 * (SECONDS / 2)) {
- lateFrames += 1;
- nose.set(0, 0, -1).applyQuaternion(state.player.quaternion);
- to.copy(target.object.position).sub(state.player.position);
- if (nose.angleTo(to) < 0.1) onTargetLate += 1; // within ~6 degrees
- }
- }
- const held = onTargetLate / lateFrames;
- check(`it holds a crossing target near the nose (${(held * 100).toFixed(0)}% of the late window)`,
- held > 0.6);
- const finalDist = target.object.position.distanceTo(state.player.position);
- check(`...and closes to gun range, not off in the distance (${finalDist.toFixed(0)} units)`,
- finalDist < LASER_RANGE);
- }
}
// --- IT STOPS AT THE STANDOFF, AND IT STOPS DEAD (docs/TODO/211) ------------
@@ -315,66 +273,3 @@ console.log('\nthe co-pilot leads the nose and matches the radial speed');
check('...but one that crosses at 300 keeps the ship turning with it',
turnFor(300) > 0);
}
-
-// --- IT HOLDS A SHIP THAT CIRCLES, WHICH THE UNLED PURSUIT COULD NOT --------
-//
-// The regression that pins the pair above. A pirate orbits the commander at 400
-// units and 300 units a second, which is a bearing rate of 0.75 radians a
-// second. The nose can turn at 1.45, so the rate was never the limit. The unled
-// pursuit still held it on the gun 51% of the time, because it aimed where the
-// target WAS and it chased a ship that was not running.
-console.log('\nthe co-pilot holds a ship that circles it');
-{
- const ORBIT = 400;
- const SPEED = 300;
- const RATE = SPEED / ORBIT;
-
- /** Fly the orbit for `seconds`, and report the share of the late window on the gun. */
- const orbitRun = (seconds: number): number => {
- seedWorld(4245);
- const state = freshState(newCommander());
- state.world.build(state.systems[state.commander.systemIndex]);
- state.world.clearNpcs();
- state.player.position.set(0, 0, 0);
- state.player.quaternion.identity();
- state.player.speed = 200;
- const legal = state.commander.legalStatus;
- const pirate = state.world.spawn('pirate', new THREE.Vector3(ORBIT, 0, 0), 1);
- pirate.state.speed = SPEED;
- const cp = new ScriptedCoPilot();
- const look = new THREE.Matrix4();
- const up = new THREE.Vector3(0, 1, 0);
- const ahead = new THREE.Vector3();
- const nose = new THREE.Vector3();
- const to = new THREE.Vector3();
- let held = 0;
- let late = 0;
- for (let i = 0; i < 60 * seconds; i++) {
- const t = i / 60;
- pirate.object.position.set(Math.cos(RATE * t) * ORBIT, 0, Math.sin(RATE * t) * ORBIT);
- // its nose along the orbit, which is the velocity `velocityOf` returns
- ahead.set(-Math.sin(RATE * t), 0, Math.cos(RATE * t)).multiplyScalar(SPEED);
- look.lookAt(pirate.object.position, ahead.add(pirate.object.position), up);
- pirate.object.quaternion.setFromRotationMatrix(look);
- const s = cp.step(1 / 60, state.player, state.world.npcs, legal,
- false, null, Infinity, pirate);
- if (s.kind !== 'fly') throw new Error('should be flying a live threat');
- state.player.update(1 / 60, s.demand);
- if (i > 60 * 10) {
- late += 1;
- nose.set(0, 0, -1).applyQuaternion(state.player.quaternion);
- to.copy(pirate.object.position).sub(state.player.position);
- if (nose.angleTo(to) < hitCone(pirate.radius, to.length())) held += 1;
- }
- }
- return held / late;
- };
-
- // TWO SAMPLE SIZES, because one share of one window is not a measurement. The
- // unled pursuit scores about 5% of either window, so the floor is not tight.
- const short = orbitRun(30);
- const long = orbitRun(60);
- check(`it keeps a circling ship inside the gun cone over 30s (${(short * 100).toFixed(0)}%)`,
- short > 0.20);
- check(`...and over 60s (${(long * 100).toFixed(0)}%)`, long > 0.20);
-}
diff --git a/train/combat-aim.ts b/train/combat-aim.ts
new file mode 100644
index 00000000..830c9a57
--- /dev/null
+++ b/train/combat-aim.ts
@@ -0,0 +1,91 @@
+// Does the co-pilot's aim hold up in a REAL fight? — the third measure.
+//
+// npm run combat-aim
+//
+// The review of 2026-09-12 (`docs/COMBAT-COMPUTER-REVIEW.md`) measured the
+// co-pilot against PRESCRIBED target paths. That probe is exact and repeatable,
+// and it carries no shots, no damage, and no opponent that fights back. Its own
+// closing words ask for actual NPC fights before release. This is that check.
+//
+// It runs the combat exercise (`game/combat-sim.ts`), which is the live game
+// with a different step behind it: real pirate brains, real guns, the real
+// seeded stream. The commander is fitted with a combat computer, because that
+// is what pulls the trigger, so the aim becomes shots.
+//
+// WHAT IT MEASURES, per fight:
+//
+// - the share of engaged frames with a live hostile inside `hitCone` and
+// `LASER_RANGE`, which is the same question the prescribed probe asks;
+// - how long the fight took, and how many hostiles died.
+//
+// TIME TO CLEAR IS NOISY, and the share on the gun is not. A sweep of
+// `COMBAT_ROLL_GATE` over 72 fights moved the share from 39.5% to about 58%,
+// and moved the seconds around with no trend. Read the share.
+//
+// It is not a balance test. Nothing here says whether a fight is fun.
+import * as THREE from 'three';
+import { Game } from '../src/game/game.ts';
+import { headlessShell } from '../src/engine/shell.ts';
+import { withoutSaving } from '../src/game/storage.ts';
+import { seedWorld } from '../src/game/rng.ts';
+import { dismissBriefing } from '../test/harness.ts';
+import { hitCone } from '../src/game/gunnery.ts';
+import { LASER_RANGE } from '../src/constants/player-gun.ts';
+import type { ExerciseSpec, ScenarioId } from '../src/game/combat-sim-scenarios.ts';
+
+const DT = 1 / 60;
+const nose = new THREE.Vector3();
+const to = new THREE.Vector3();
+
+const fight = (scenario: ScenarioId, tier: number, seed: number) => {
+ const g = withoutSaving(() => {
+ seedWorld(seed);
+ const game = new Game(() => headlessShell());
+ dismissBriefing(game);
+ return game;
+ }).value;
+ const spec: ExerciseSpec = { mode: 'scenario', scenario, tier, seed };
+ if (!g.startExercise(spec, { equipment: { combatComputer: true } })) return null;
+ const s = g.state;
+ const start = s.world.npcs.filter((n) => n.state.alive).length;
+ let frames = 0; let onGun = 0; let engaged = 0;
+ withoutSaving(() => {
+ let t = 0;
+ for (let i = 0; i < 200 / DT; i++) {
+ g.step(DT, t); t += DT; frames++;
+ s.world.scene.updateMatrixWorld(true);
+ if (g.coursePanel() !== null) break; // the exercise ended
+ const live = s.world.npcs.filter((n) => n.state.alive);
+ if (live.length === 0) continue;
+ engaged++;
+ s.player.getForward(nose);
+ // the easiest live hostile: the one nearest the nose, which is what the
+ // gun would take
+ let best = Infinity; let bestCone = 0; let bestDist = 0;
+ for (const n of live) {
+ to.copy(n.object.position).sub(s.player.position);
+ const a = nose.angleTo(to);
+ if (a < best) { best = a; bestCone = hitCone(n.radius, to.length()); bestDist = to.length(); }
+ }
+ if (best < bestCone && bestDist <= LASER_RANGE) onGun++;
+ }
+ });
+ const left = s.world.npcs.filter((n) => n.state.alive).length;
+ return { seconds: +(frames * DT).toFixed(1), killed: start - left, left,
+ onGun: engaged ? +(100 * onGun / engaged).toFixed(1) : 0 };
+};
+
+let secs = 0; let killed = 0; let left = 0; let onGunSum = 0; let n = 0;
+for (const scenario of ['single-pirate', 'pirate-pair', 'pirate-gang'] as ScenarioId[]) {
+ for (const tier of [1, 3, 5]) {
+ for (const seed of [4242, 777, 20260912, 31337, 99, 555, 12345, 6006]) {
+ const r = fight(scenario, tier, seed);
+ if (!r) continue;
+ n++; secs += r.seconds; killed += r.killed; left += r.left; onGunSum += r.onGun;
+ }
+ }
+}
+// `left` is how many hostiles outlived the fight. Zero across the grid means
+// the co-pilot cleared every one of them.
+console.log(JSON.stringify({ fights: n, meanSeconds: +(secs / n).toFixed(1),
+ killed, left, meanOnGun: +(onGunSum / n).toFixed(1) }));
From c54bde51e00b1adb0138374566661cf0bf622920 Mon Sep 17 00:00:00 2001
From: Chris Greening PURSUIT_SLASH_CONE | 1.3 | A pursuit PIRATE switches flight models on where it sits in the commander's arc. | | [combat-computer.ts:178](./combat-computer.ts#L178) |
| combat-computer | PURSUIT_HOLD_CONE | 1.85 | | | [combat-computer.ts:179](./combat-computer.ts#L179) |
| combat-computer | ENGAGED_CONE | 0.6 | The nose-to-target angle, in radians, within which the co-pilot counts itself ENGAGED and will not switch targets. | | [combat-computer.ts:188](./combat-computer.ts#L188) |
-| combat-computer | COMBAT_ROLL_GATE | 0.27 | How near its bank the co-pilot must be before it pulls the nose, in radians. | copilot.rollGate | [combat-computer.ts:240](./combat-computer.ts#L240) |
-| combat-computer | TARGET_DIST_WEIGHT | 800 | How many world units of range weigh as much as one radian of off-nose turn. | copilot.targetDistWeight | [combat-computer.ts:258](./combat-computer.ts#L258) |
-| combat-computer | PURSUIT_LEAD_GAIN | 2.0 | How many SECONDS the co-pilot aims ahead of a target, per radian that its nose still has to swing. | copilot.leadGain | [combat-computer.ts:309](./combat-computer.ts#L309) |
+| combat-computer | ENGAGED_PATIENCE | 3 | How long the co-pilot stays committed to a target it cannot shoot, in seconds. | copilot.engagedPatience | [combat-computer.ts:221](./combat-computer.ts#L221) |
+| combat-computer | COMBAT_ROLL_GATE | 0.27 | How near its bank the co-pilot must be before it pulls the nose, in radians. | copilot.rollGate | [combat-computer.ts:273](./combat-computer.ts#L273) |
+| combat-computer | TARGET_DIST_WEIGHT | 800 | How many world units of range weigh as much as one radian of off-nose turn. | copilot.targetDistWeight | [combat-computer.ts:291](./combat-computer.ts#L291) |
+| combat-computer | PURSUIT_LEAD_GAIN | 2.0 | How many SECONDS the co-pilot aims ahead of a target, per radian that its nose still has to swing. | copilot.leadGain | [combat-computer.ts:358](./combat-computer.ts#L358) |
| combat-record | SAMPLE_HZ | 10 | How often the code samples the geometry, in Hz. | | [combat-record.ts:12](./combat-record.ts#L12) |
| combat-record | SIX_CONE | Math.PI / 3 | The rear cone that counts as somebody's six, as a half-angle from directly astern. | | [combat-record.ts:20](./combat-record.ts#L20) |
| combat-record | PASS_CLOSE | 400 | What an attack run is, in ranges. | | [combat-record.ts:42](./combat-record.ts#L42) |
diff --git a/src/constants/combat-computer.ts b/src/constants/combat-computer.ts
index d61cc98d..47cc2b77 100644
--- a/src/constants/combat-computer.ts
+++ b/src/constants/combat-computer.ts
@@ -187,6 +187,39 @@ export const PURSUIT_HOLD_CONE = 1.85;
*/
export const ENGAGED_CONE = 0.6;
+/**
+ * How long the co-pilot stays committed to a target it cannot shoot, in
+ * seconds. Past it, `ENGAGED_CONE` no longer vetoes a switch.
+ *
+ * ENGAGED USED TO MEAN ROUGHLY ON THE NOSE, AND NOTHING MORE. The review of
+ * 2026-09-12 held a target 3,000 units away and 23 degrees off, with a second
+ * hostile 500 units dead ahead. Ten seconds later the lock still refused the
+ * easy shot, and a fresh controller took it. A cone says where a ship is. It
+ * does not say the attack is going anywhere.
+ *
+ * So the veto now asks for PROGRESS as well. The target must fall inside the
+ * gun cone within this many seconds. The distance rule still governs the rest, and
+ * `THREAT_MIN_HOLD` and `THREAT_SWITCH_MARGIN` are untouched. A switch still
+ * needs a target much nearer, held for long enough.
+ *
+ * THE VALUE IS NOT PINNED BY MEASUREMENT, and that is worth saying plainly. A
+ * sweep from 1 second to 999 moved nothing. It moved neither the review's 21
+ * prescribed paths, which carry one target and cannot exercise a switch at all,
+ * nor 72 real fights through `npm run combat-aim`. The rule is dormant in
+ * everything this repository can measure. It fires in the case the review
+ * built, where a near hostile is ignored for a far one. That case is a fight a
+ * player can meet.
+ *
+ * So 3 seconds is reasoned rather than fitted. It is about two swings of the
+ * nose at `MAX_LEAD_SECONDS`. It is long enough that a target crossing the
+ * sight is not dropped between passes, and short enough that a chase going
+ * nowhere ends. Measure it on a harness that produces the geometry before you
+ * move it.
+ *
+ * @rule copilot.engagedPatience
+ */
+export const ENGAGED_PATIENCE = 3;
+
/**
* How near its bank the co-pilot must be before it pulls the nose, in radians.
* Above it, `bankToTurn` asks for NO pitch at all.
@@ -297,6 +330,22 @@ export const TARGET_DIST_WEIGHT = 800;
* move it. A 13-target sample and a 40-target sample disagreed about the radial
* throttle's worth, so a small sample is not enough to retune on.
*
+ * RE-MEASURED AT `COMBAT_ROLL_GATE`, on the review of 2026-09-12. That review
+ * asked for the lead to be revisited once the bank was fixed. A lead cost the
+ * straight-crossing case 74.3%, against 98.7% with none. At the gate this
+ * repository settled on, that trade is gone. The same case scores 89% WITH the
+ * lead. The sweep over the review's two grids and 72 real fights:
+ *
+ * | gain | 21 paths, 60s | 54 orbits, 90s | 72 fights |
+ * | 0 | 93.4%, 32.7% | 93.3%, 50.2% | 33.8% |
+ * | 1 | 97.8%, 81.7% | 96.9%, 70.3% | 60.0% |
+ * | 2 | 98.3%, 88.5% | 97.6%, 77.2% | 57.5% |
+ * | 3 | 98.9%, 86.1% | 93.4%, 0.0% | 59.7% |
+ *
+ * The second figure of each pair is the worst case. 2.0 holds the best worst
+ * case on both grids. At 3.0 one orbit is lost outright, which is the hole the
+ * paragraph above names, found again on a different grid.
+ *
* THE HOME IS THIS FILE, and the owner check disagrees. It reads the words
* "rule" and "bounded" above and reaches for the law domain. This is a feel
* setting of the SCRIPTED co-pilot. It sits in the `PURSUIT_*` block that the
diff --git a/src/game/scripted-co-pilot.ts b/src/game/scripted-co-pilot.ts
index 37768cd6..55d18565 100644
--- a/src/game/scripted-co-pilot.ts
+++ b/src/game/scripted-co-pilot.ts
@@ -48,8 +48,8 @@ import { rampFlightRate, type FlightDemand } from '../player.ts';
import { LASER_RANGE } from '../constants/player-gun.ts';
import { UNDER_FIRE_SECONDS } from '../constants/attack-run.ts';
import {
- THREAT_RANGE, PURSUIT_SPEED_DEADBAND, ENGAGED_CONE, TARGET_DIST_WEIGHT,
- PURSUIT_LEAD_GAIN, COMBAT_ROLL_GATE,
+ THREAT_RANGE, PURSUIT_SPEED_DEADBAND, ENGAGED_CONE, ENGAGED_PATIENCE,
+ TARGET_DIST_WEIGHT, PURSUIT_LEAD_GAIN, COMBAT_ROLL_GATE,
} from '../constants/combat-computer.ts';
import { PLAYER_FLIGHT } from '../constants/player-flight.ts';
import { MAX_LEAD_SECONDS } from '../constants/pass-aim.ts';
@@ -99,6 +99,13 @@ export class ScriptedCoPilot {
* evasive behaviour needs no new wiring.
*/
private underFire = 0;
+ /**
+ * Seconds since the held target was last inside the gun cone, and the ship
+ * the count belongs to. Together they are "is this attack going anywhere".
+ * `ENGAGED_PATIENCE` is what reads them.
+ */
+ private sinceGunOn = 0;
+ private counting: NpcShip | null = null;
noteHit(): void {
this.underFire = UNDER_FIRE_SECONDS;
@@ -116,6 +123,8 @@ export class ScriptedCoPilot {
this.lock.clear();
this.steerMem.side = freshSteerMemory().side;
this.underFire = 0;
+ this.sinceGunOn = 0;
+ this.counting = null;
}
step(
@@ -159,7 +168,12 @@ export class ScriptedCoPilot {
// easier (Chris). The ranking hands over a better target only when the
// co-pilot is NOT engaged, which means the current one ran wide or ran
// behind.
- (npc) => offNose(npc) < ENGAGED_CONE,
+ //
+ // ...AND THAT THE KILL IS GOING SOMEWHERE. A cone alone held a target
+ // 3,000 units off at 23 degrees while a second hostile sat 500 units dead
+ // ahead (the review of 2026-09-12). `ENGAGED_PATIENCE` is how long a
+ // target may go unshot and still block the switch.
+ (npc) => offNose(npc) < ENGAGED_CONE && this.sinceGunOn < ENGAGED_PATIENCE,
);
if (!threat) {
this.reset();
@@ -226,6 +240,12 @@ export class ScriptedCoPilot {
// step. Copy it again before you use it as a distance below this line.
const recede = Math.max(0, this.threatVel.dot(this.toThreat.normalize()));
+ // IS THIS ATTACK GOING ANYWHERE? The count is per target, so a switch
+ // gives the new one a full `ENGAGED_PATIENCE` before it can be dropped.
+ const onGun = dist <= LASER_RANGE && facing < cone;
+ if (threat !== this.counting) { this.counting = threat; this.sinceGunOn = 0; }
+ this.sinceGunOn = onGun ? 0 : this.sinceGunOn + dt;
+
return {
kind: 'fly',
demand: {
@@ -241,7 +261,7 @@ export class ScriptedCoPilot {
// the trigger only when the shot would count: the player gun's own cone
// and range (gunnery.ts). The laser's heat and cooldown pace it from
// there, which is what makes this a marksman rather than a sprayer
- fire: dist <= LASER_RANGE && facing < cone,
+ fire: onGun,
},
// a warhead is always answered. Whether one is on its way is the world's
// fact, and the gate is the same one every E.C.M. press goes through
diff --git a/test/co-pilot-engagement.test.ts b/test/co-pilot-engagement.test.ts
new file mode 100644
index 00000000..a02a3a9b
--- /dev/null
+++ b/test/co-pilot-engagement.test.ts
@@ -0,0 +1,154 @@
+// THE ENGAGEMENT: who the co-pilot holds, and what happens at a handover.
+//
+// Split from `scripted-co-pilot.test.ts` on 2026-09-12, when that file passed
+// the 400-line ceiling. Three files now divide the co-pilot's tests by the
+// question each one asks:
+//
+// - `scripted-co-pilot.test.ts` — what it ASKS FOR: the trigger, the E.C.M.,
+// the standoff, the lead and the throttle;
+// - `co-pilot-tracking.test.ts` — where the nose GOES, over time;
+// - this file — WHO it holds, and what survives when the ship changes hands.
+//
+// Both subjects here come from the review of 2026-09-12
+// (`docs/COMBAT-COMPUTER-REVIEW.md`), and neither is visible in a demand read
+// one frame at a time.
+
+import * as THREE from 'three';
+import { ScriptedCoPilot } from '../src/game/scripted-co-pilot.ts';
+import { freshState } from '../src/game/state.ts';
+import { newCommander } from '../src/game/commander.ts';
+import { seedWorld } from '../src/game/rng.ts';
+import { check, eq } from './harness.ts';
+
+// --- ONE HANDOVER CONTRACT ------------------------------------------------
+//
+// The review of 2026-09-12 found three ways a resumed co-pilot flew differently
+// from a fresh one at the same geometry. The contract that answers all three:
+//
+// 1. THE RATES ARE THE SHIP'S. The co-pilot keeps no copy of its last ask. It
+// ramps from `player.pitchRate` and `player.rollRate`, which
+// `PlayerShip.update` writes from the demand it flew. So a manual override
+// and a restored save both leave the ramp reading the truth.
+// 2. THE ENGAGEMENT MEMORY GOES ON `reset`. The lock, the bank side and the
+// under-fire timer are all the engagement's, and none of them is worth
+// inheriting.
+// 3. NOTHING HERE NEEDS SAVING. What survives a save is the ship's own rates,
+// which `persistence.ts` already carries.
+console.log('\nthe co-pilot hands the ship over on one contract');
+{
+ seedWorld(4246);
+ const state = freshState(newCommander());
+ state.world.build(state.systems[state.commander.systemIndex]);
+ state.world.clearNpcs();
+ state.player.position.set(0, 0, 0);
+ state.player.quaternion.identity();
+ state.player.speed = 200;
+ const legal = state.commander.legalStatus;
+ // off to one side, so there is a real bank to make
+ const pirate = state.world.spawn('pirate', new THREE.Vector3(900, 120, -1400), 1);
+ pirate.state.speed = 0;
+
+ /** One step of a co-pilot at the fixture's geometry. */
+ const ask = (cp: ScriptedCoPilot) => {
+ const s = cp.step(1 / 60, state.player, state.world.npcs, legal, false, null);
+ if (s.kind !== 'fly') throw new Error('should be flying a live threat');
+ return s.demand;
+ };
+
+ // A controller that flew for a while, then had the ship taken off it.
+ const used = new ScriptedCoPilot();
+ for (let i = 0; i < 90; i++) ask(used);
+ const handedBack = used.step(1 / 60, state.player, state.world.npcs, legal, true, null);
+ check('touching the controls hands the ship back', handedBack.kind === 'disengage');
+
+ // The pilot flies it somewhere, and the ship stops turning.
+ state.player.pitchRate = 0;
+ state.player.rollRate = 0;
+ const resumed = ask(used);
+ const fresh = ask(new ScriptedCoPilot());
+ eq('a resumed co-pilot asks for the same roll a fresh one does',
+ resumed.rollRate, fresh.rollRate);
+ eq('...and the same pitch', resumed.pitchRate, fresh.pitchRate);
+
+ // ...AND A RESET ONE TOO, which `steerMem.side` used to break.
+ //
+ // The geometry has to be one where the memory actually decides. A target
+ // BELOW commits the bank to the bottom. A target LEVEL to one side then has
+ // the same roll either way round, so the flip margin holds whichever side is
+ // committed, and a stale one shows.
+ const wrongWay = new ScriptedCoPilot();
+ pirate.object.position.set(0, -900, -1400);
+ for (let i = 0; i < 60; i++) ask(wrongWay);
+ pirate.object.position.set(900, 0, -1400);
+ state.player.pitchRate = 0;
+ state.player.rollRate = 0;
+ check('a co-pilot that kept flying banks the way it committed to',
+ ask(wrongWay).rollRate * ask(new ScriptedCoPilot()).rollRate < 0,
+ 'the fixture is only a test of reset() while these disagree');
+ state.player.pitchRate = 0;
+ state.player.rollRate = 0;
+ wrongWay.reset();
+ eq('...and a reset one banks the way a fresh one does',
+ ask(wrongWay).rollRate, ask(new ScriptedCoPilot()).rollRate);
+
+ // THE RAMP READS THE SHIP. A ship already rolling continues from that rate,
+ // rather than from a zero the co-pilot kept to itself.
+ state.player.rollRate = -1.2;
+ const carried = ask(new ScriptedCoPilot()).rollRate;
+ state.player.rollRate = 0;
+ const fromRest = ask(new ScriptedCoPilot()).rollRate;
+ check('a ship already rolling carries that rate into the ramp',
+ carried < fromRest - 0.5, `${carried.toFixed(3)} against ${fromRest.toFixed(3)}`);
+}
+
+// --- THE COMMITMENT HAS TO BE GOING SOMEWHERE ------------------------------
+//
+// The review of 2026-09-12 built this geometry. A held target 3,000 units away
+// and 23 degrees off the nose, and a second hostile 500 units dead ahead. The
+// cone alone called the far one an attack in progress and vetoed the switch, so
+// ten seconds later the easy shot was still refused. `ENGAGED_PATIENCE` is the
+// answer, and `npm run combat-aim` cannot see it: over 72 real fights a sweep of
+// that constant from 1 second to 999 moved nothing at all.
+console.log('\nthe co-pilot lets go of a kill that is going nowhere');
+{
+ seedWorld(4247);
+ const state = freshState(newCommander());
+ state.world.build(state.systems[state.commander.systemIndex]);
+ state.world.clearNpcs();
+ state.player.position.set(0, 0, 0);
+ state.player.quaternion.identity();
+ state.player.speed = 0;
+ const legal = state.commander.legalStatus;
+
+ // 23 degrees off the nose, far out. Inside `ENGAGED_CONE`, and no shot.
+ const far = state.world.spawn('pirate',
+ new THREE.Vector3(Math.sin(0.4) * 3000, 0, -Math.cos(0.4) * 3000), 1);
+ far.state.speed = 0;
+ const cp = new ScriptedCoPilot();
+ // The geometry is held still, so only the SELECTION rule can move. The ship
+ // flies nothing, because the question is which ship the co-pilot holds.
+ for (let i = 0; i < 120; i++) {
+ cp.step(1 / 60, state.player, state.world.npcs, legal, false, null);
+ }
+ const near = state.world.spawn('pirate', new THREE.Vector3(0, 0, -500), 2);
+ near.state.speed = 0;
+
+ // Long enough to pass `ENGAGED_PATIENCE` with no shot on the far one.
+ let fires = false;
+ for (let i = 0; i < 60 * 6; i++) {
+ const s = cp.step(1 / 60, state.player, state.world.npcs, legal, false, null);
+ if (s.kind === 'fly' && s.demand.fire) fires = true;
+ }
+ check('a target it cannot shoot stops blocking the easy one', fires);
+
+ // THE CONTROL: a commitment that IS going somewhere still holds. The near
+ // ship is on the gun, so the far one must not steal it back.
+ const steady = new ScriptedCoPilot();
+ let held = 0;
+ for (let i = 0; i < 60 * 6; i++) {
+ const s = steady.step(1 / 60, state.player, state.world.npcs, legal, false, null);
+ if (s.kind === 'fly' && s.demand.fire) held += 1;
+ }
+ check('...and one that is landing shots keeps the lock', held > 60 * 3,
+ `${(held / 60).toFixed(1)}s of trigger over 6s`);
+}
diff --git a/test/run.ts b/test/run.ts
index 55b0b1a4..0d455dee 100644
--- a/test/run.ts
+++ b/test/run.ts
@@ -129,6 +129,7 @@ import './tactics.test.ts';
import './tactic-choice.test.ts';
import './scripted-co-pilot.test.ts';
import './co-pilot-tracking.test.ts';
+import './co-pilot-engagement.test.ts';
import './pitch-roll-steer.test.ts';
import './pursuit.test.ts';
import './human-shape.test.ts';
diff --git a/test/scripted-co-pilot.test.ts b/test/scripted-co-pilot.test.ts
index 53392cbf..1b7b5f9d 100644
--- a/test/scripted-co-pilot.test.ts
+++ b/test/scripted-co-pilot.test.ts
@@ -289,84 +289,3 @@ console.log('\nthe co-pilot leads the nose and matches the radial speed');
check('...but one that crosses at 300 keeps the ship turning with it',
turnFor(300) > 0);
}
-
-// --- ONE HANDOVER CONTRACT ------------------------------------------------
-//
-// The review of 2026-09-12 found three ways a resumed co-pilot flew differently
-// from a fresh one at the same geometry. The contract that answers all three:
-//
-// 1. THE RATES ARE THE SHIP'S. The co-pilot keeps no copy of its last ask. It
-// ramps from `player.pitchRate` and `player.rollRate`, which
-// `PlayerShip.update` writes from the demand it flew. So a manual override
-// and a restored save both leave the ramp reading the truth.
-// 2. THE ENGAGEMENT MEMORY GOES ON `reset`. The lock, the bank side and the
-// under-fire timer are all the engagement's, and none of them is worth
-// inheriting.
-// 3. NOTHING HERE NEEDS SAVING. What survives a save is the ship's own rates,
-// which `persistence.ts` already carries.
-console.log('\nthe co-pilot hands the ship over on one contract');
-{
- seedWorld(4246);
- const state = freshState(newCommander());
- state.world.build(state.systems[state.commander.systemIndex]);
- state.world.clearNpcs();
- state.player.position.set(0, 0, 0);
- state.player.quaternion.identity();
- state.player.speed = 200;
- const legal = state.commander.legalStatus;
- // off to one side, so there is a real bank to make
- const pirate = state.world.spawn('pirate', new THREE.Vector3(900, 120, -1400), 1);
- pirate.state.speed = 0;
-
- /** One step of a co-pilot at the fixture's geometry. */
- const ask = (cp: ScriptedCoPilot) => {
- const s = cp.step(1 / 60, state.player, state.world.npcs, legal, false, null);
- if (s.kind !== 'fly') throw new Error('should be flying a live threat');
- return s.demand;
- };
-
- // A controller that flew for a while, then had the ship taken off it.
- const used = new ScriptedCoPilot();
- for (let i = 0; i < 90; i++) ask(used);
- const handedBack = used.step(1 / 60, state.player, state.world.npcs, legal, true, null);
- check('touching the controls hands the ship back', handedBack.kind === 'disengage');
-
- // The pilot flies it somewhere, and the ship stops turning.
- state.player.pitchRate = 0;
- state.player.rollRate = 0;
- const resumed = ask(used);
- const fresh = ask(new ScriptedCoPilot());
- eq('a resumed co-pilot asks for the same roll a fresh one does',
- resumed.rollRate, fresh.rollRate);
- eq('...and the same pitch', resumed.pitchRate, fresh.pitchRate);
-
- // ...AND A RESET ONE TOO, which `steerMem.side` used to break.
- //
- // The geometry has to be one where the memory actually decides. A target
- // BELOW commits the bank to the bottom. A target LEVEL to one side then has
- // the same roll either way round, so the flip margin holds whichever side is
- // committed, and a stale one shows.
- const wrongWay = new ScriptedCoPilot();
- pirate.object.position.set(0, -900, -1400);
- for (let i = 0; i < 60; i++) ask(wrongWay);
- pirate.object.position.set(900, 0, -1400);
- state.player.pitchRate = 0;
- state.player.rollRate = 0;
- check('a co-pilot that kept flying banks the way it committed to',
- ask(wrongWay).rollRate * ask(new ScriptedCoPilot()).rollRate < 0,
- 'the fixture is only a test of reset() while these disagree');
- state.player.pitchRate = 0;
- state.player.rollRate = 0;
- wrongWay.reset();
- eq('...and a reset one banks the way a fresh one does',
- ask(wrongWay).rollRate, ask(new ScriptedCoPilot()).rollRate);
-
- // THE RAMP READS THE SHIP. A ship already rolling continues from that rate,
- // rather than from a zero the co-pilot kept to itself.
- state.player.rollRate = -1.2;
- const carried = ask(new ScriptedCoPilot()).rollRate;
- state.player.rollRate = 0;
- const fromRest = ask(new ScriptedCoPilot()).rollRate;
- check('a ship already rolling carries that rate into the ramp',
- carried < fromRest - 0.5, `${carried.toFixed(3)} against ${fromRest.toFixed(3)}`);
-}
From 1198911d6f7f3a93432d8bb9fc9ed3886ad98c9b Mon Sep 17 00:00:00 2001
From: Chris Greening COURSE_WATCH_STANDOFF | 1200 | How far from a ship the scan course holds, in world units (docs/TODO/208 M2). | course.watchStandoff | [course.ts:263](./course.ts#L263) |
| course | COURSE_ESCORT_STANDOFF | 600 | How far from its charge the escort course flies, in world units (docs/TODO/208 M2). | course.escortStandoff | [course.ts:276](./course.ts#L276) |
| course | COURSE_POLICE_CLEARANCE | SCAN_WARN_RANGE | How wide of a police ship the smuggling course flies, in world units (docs/TODO/208 M4). | | [course.ts:289](./course.ts#L289) |
+| course | COURSE_OBSTACLE_CLEARANCE | PLAYER_FLIGHT.maxSpeed / PLAYER_FLIGHT.maxPitch | How far clear of a solid thing's HULL a course flies, in world units. | | [course.ts:315](./course.ts#L315) |
| docking | GATE_HALF_WIDTHS | 5 | How far out the approach gate sits, in multiples of the station half-width. | docking.gateHalfWidths | [docking.ts:25](./docking.ts#L25) |
| docking | TURN_IN | Math.PI / 4 | How far round from the slot axis an approach turns in, in radians. | | [docking.ts:49](./docking.ts#L49) |
| docking | RUN_IN_WIDTHS | GATE_HALF_WIDTHS * 0.6 | Where the approach stops the curve and flies straight, in station half-widths. | | [docking.ts:87](./docking.ts#L87) |
diff --git a/src/constants/course.ts b/src/constants/course.ts
index 6c341bf2..1f12f56a 100644
--- a/src/constants/course.ts
+++ b/src/constants/course.ts
@@ -287,3 +287,29 @@ export const COURSE_ESCORT_STANDOFF = 600;
* @domain course
*/
export const COURSE_POLICE_CLEARANCE = SCAN_WARN_RANGE;
+
+/**
+ * How far clear of a solid thing's HULL a course flies, in world units.
+ *
+ * Chris, 2026-09-12: *"I visited a rock hermit and then when I left clicked fly
+ * to the station - the computer crashed me straight into the rock hermit - we
+ * need to have some avoidance of obstacles"*. The line went round the planet
+ * and round a policeman, and through everything else. Measured, it killed the
+ * commander outright at 399 units a second.
+ *
+ * IT IS THE SHIP'S OWN TURNING CIRCLE, not a number chosen by eye. At top
+ * speed the tightest circle the commander can fly has a radius of speed over
+ * turn rate. A quarter circle moves a ship one radius sideways. So a line that
+ * passes that far from a hull leaves exactly the room a quarter turn needs.
+ * `sidestep` then aims half as far again, which is the margin.
+ *
+ * It moves with the envelope rather than against it. A faster or less agile
+ * hull needs a wider berth, and this says so without being retuned.
+ *
+ * FROM THE HULL, as the docking standoff is (docs/TODO/211). A hermit is 120
+ * units across the radius, and the derelict is 340. A clearance from the CENTRE
+ * would be a different rule for each of them.
+ *
+ * @domain course
+ */
+export const COURSE_OBSTACLE_CLEARANCE = PLAYER_FLIGHT.maxSpeed / PLAYER_FLIGHT.maxPitch;
diff --git a/src/game/course-clearance.ts b/src/game/course-clearance.ts
new file mode 100644
index 00000000..89e047cb
--- /dev/null
+++ b/src/game/course-clearance.ts
@@ -0,0 +1,149 @@
+// WHERE TO AIM, so that the line a course flies is clear of what is on it.
+//
+// Split from `course-pilot.ts` on 2026-09-12, when that file passed the
+// 400-line ceiling. It is a different subject from the one next door. That file
+// flies a course: which leg, what speed, which switches to ask for. This one is
+// pure geometry over a line and a thing beside it. It knows nothing about a
+// course, a demand or a ship.
+//
+// THREE THINGS ARE IN THE WAY, and each has its own clearance:
+//
+// - the PLANET, at `COURSE_PLANET_CLEARANCE` above its surface;
+// - a SOLID in the sky — a rock hermit, an asteroid, the derelict — at
+// `COURSE_OBSTACLE_CLEARANCE` from its hull;
+// - a POLICEMAN, on a smuggling run alone. That one is about being SEEN
+// rather than about hitting anything, which is why its clearance comes
+// from the scan's own warning band.
+//
+// All three end in `sidestep`, which is the one rule. Aim beside the thing, on
+// the side the line already passes, and half as far again.
+
+import * as THREE from 'three';
+import {
+ COURSE_OBSTACLE_CLEARANCE, COURSE_PLANET_CLEARANCE, COURSE_POLICE_CLEARANCE,
+} from '../constants/course.ts';
+
+/** Something solid a course must not fly through — see `clearOfObstacles`. */
+export interface Obstacle {
+ at: THREE.Vector3;
+ /** the hull's own radius, because the clearance is measured from the hull */
+ radius: number;
+}
+
+const seg = new THREE.Vector3();
+const off = new THREE.Vector3();
+
+/**
+ * Where to aim, so that the line to the target clears the planet
+ * (docs/TODO/205 M3). The clearance is the planet's own radius plus
+ * `COURSE_PLANET_CLEARANCE`, which is above the height the planet holds the
+ * torus drive down at.
+ *
+ * @returns `out`, holding the point to aim at.
+ */
+export function clearOfPlanet(
+ from: THREE.Vector3, to: THREE.Vector3, planet: THREE.Vector3, radius: number,
+ out: THREE.Vector3,
+): THREE.Vector3 {
+ return sidestep(from, to, planet, radius + COURSE_PLANET_CLEARANCE, out);
+}
+
+/**
+ * Where to aim, so that the line to the target keeps clear of the police
+ * (docs/TODO/208 M4).
+ *
+ * A police ship reads a hold inside `SCAN_RANGE`, which is 2,600 units, and
+ * the smuggling course must not be read. It aims wide of the nearest
+ * policeman in the way, at `COURSE_POLICE_CLEARANCE`, which is the warning
+ * band. The line from there is clear, and the ship then turns onto the
+ * target. Where no wide line exists, it takes the widest it can find.
+ */
+export function clearOfPolice(
+ from: THREE.Vector3, to: THREE.Vector3, police: readonly THREE.Vector3[],
+ out: THREE.Vector3,
+): THREE.Vector3 {
+ let worst: { at: THREE.Vector3; miss: number } | null = null;
+ for (const at of police) {
+ const miss = distanceToSegment(from, to, at);
+ if (miss >= COURSE_POLICE_CLEARANCE) continue;
+ if (worst === null || miss < worst.miss) worst = { at, miss };
+ }
+ return worst === null ? out.copy(to)
+ : sidestep(from, to, worst.at, COURSE_POLICE_CLEARANCE, out);
+}
+
+/**
+ * Where to aim, so that the line keeps clear of everything solid in the way.
+ *
+ * Chris flew into a rock hermit on the station course (2026-09-12). The line
+ * went round the planet and round a policeman, and through everything else.
+ *
+ * It takes the WORST one, by how near the line passes its hull, and aims wide
+ * of that. One detour a frame is enough, because the ship re-plans on the next
+ * one. The line from a detour point is a new line, with its own worst thing in
+ * the way. Where nothing is in the way it returns the target unchanged.
+ *
+ * A thing at the END of the line is never in the way of it. That is
+ * `distanceToSegment`'s rule, and it is what lets the hermit course fly to a
+ * hermit.
+ *
+ * @returns `out`, holding the point to aim at.
+ */
+export function clearOfObstacles(
+ from: THREE.Vector3, to: THREE.Vector3, obstacles: readonly Obstacle[],
+ out: THREE.Vector3,
+): THREE.Vector3 {
+ let worst: { o: Obstacle; over: number } | null = null;
+ for (const o of obstacles) {
+ const clear = o.radius + COURSE_OBSTACLE_CLEARANCE;
+ // How far INSIDE its clearance the line passes, so the worst is the one
+ // with least room rather than the one nearest the ship.
+ const over = clear - distanceToSegment(from, to, o.at);
+ if (over <= 0) continue;
+ if (worst === null || over > worst.over) worst = { o, over };
+ }
+ return worst === null ? out.copy(to)
+ : sidestep(from, to, worst.o.at, worst.o.radius + COURSE_OBSTACLE_CLEARANCE, out);
+}
+
+/** How near the line from `from` to `to` passes `at`. */
+function distanceToSegment(
+ from: THREE.Vector3, to: THREE.Vector3, at: THREE.Vector3,
+): number {
+ seg.subVectors(to, from);
+ const len2 = seg.lengthSq();
+ const t = len2 > 0 ? Math.max(0, Math.min(1, off.subVectors(at, from).dot(seg) / len2)) : 0;
+ // The nearest point is the target itself: nothing is between.
+ if (t >= 1) return Infinity;
+ return off.copy(from).addScaledVector(seg, t).sub(at).length();
+}
+
+/**
+ * Where to aim, so that the line keeps `clear` units from one thing in the
+ * way. It aims beside that thing, on the same side as the line, and half as
+ * far again. The line from there is clear, and the ship then turns onto the
+ * target.
+ *
+ * A target that is itself the nearest point needs no detour. docs/TODO/205 M3
+ * found a hermit low over the planet. The line to it always counted as
+ * blocked, and the ship circled the detour point for ever.
+ *
+ * @returns `out`, holding the point to aim at.
+ */
+function sidestep(
+ from: THREE.Vector3, to: THREE.Vector3, at: THREE.Vector3, clear: number,
+ out: THREE.Vector3,
+): THREE.Vector3 {
+ seg.subVectors(to, from);
+ const len2 = seg.lengthSq();
+ const t = len2 > 0 ? Math.max(0, Math.min(1, off.subVectors(at, from).dot(seg) / len2)) : 0;
+ if (t >= 1) return out.copy(to);
+ off.copy(from).addScaledVector(seg, t).sub(at);
+ if (off.length() >= clear) return out.copy(to);
+ // A line through the centre has no side. Any direction square to it will do.
+ if (off.lengthSq() < 1e-6) {
+ off.set(seg.y, -seg.x, 0);
+ if (off.lengthSq() < 1e-6) off.set(0, seg.z, -seg.y);
+ }
+ return out.copy(at).addScaledVector(off.normalize(), clear * 1.5);
+}
diff --git a/src/game/course-pilot.ts b/src/game/course-pilot.ts
index ccab044b..eb8abe47 100644
--- a/src/game/course-pilot.ts
+++ b/src/game/course-pilot.ts
@@ -27,8 +27,19 @@
// holds until the tank is full;
// - the jump course flies straight while the countdown runs.
//
-// EVERY LINE GOES ROUND THE PLANET. A line that dips below the clearance
-// altitude aims at a point beside the planet instead, until the line clears.
+// EVERY LINE GOES ROUND WHAT IT WOULD HIT. Three things are in the way, and
+// each has its own clearance:
+//
+// - the PLANET, at `COURSE_PLANET_CLEARANCE` above its surface;
+// - a SOLID in the sky — a rock hermit, an asteroid, the derelict — at
+// `COURSE_OBSTACLE_CLEARANCE` from its hull;
+// - a POLICEMAN, but only on a smuggling run, which is a different rule
+// about being seen rather than about hitting anything.
+//
+// A line that dips below the clearance altitude aims at a point beside the
+// planet instead, until the line clears. A solid is the same trick at a smaller
+// scale. It came from a flight, where the station course took the commander
+// straight into the hermit she just left.
// The arrival at the witchpoint already sits on the station's side of the
// planet. A launch, the star and a rock far out have no such promise.
@@ -40,10 +51,13 @@ import { PLAYER_FLIGHT } from '../constants/player-flight.ts';
import { SLOT_SPEED_LIMIT } from '../constants/docking.ts';
import {
COURSE_AIM_DEADZONE, COURSE_ROLL_GATE, COURSE_ARRIVE_BRAKE, COURSE_ARRIVE_TOLERANCE, COURSE_DERELICT_STANDOFF,
- COURSE_HERMIT_SPEED, COURSE_HERMIT_STANDOFF, COURSE_PLANET_CLEARANCE,
+ COURSE_HERMIT_SPEED, COURSE_HERMIT_STANDOFF,
COURSE_COLLECT_SPEED, COURSE_ESCORT_STANDOFF, COURSE_RUN_REACH, COURSE_SKIM_DISTANCE,
- COURSE_POLICE_CLEARANCE, COURSE_TORUS_CONE, COURSE_TORUS_DROP, COURSE_WATCH_STANDOFF,
+ COURSE_TORUS_CONE, COURSE_TORUS_DROP, COURSE_WATCH_STANDOFF,
} from '../constants/course.ts';
+import {
+ clearOfObstacles, clearOfPlanet, clearOfPolice, type Obstacle,
+} from './course-clearance.ts';
import type { MissionHow } from './mission-course.ts';
/** What the course pilot reads for one frame. A flat view, so a test needs no world. */
@@ -68,6 +82,15 @@ export interface CourseView {
readonly tankFull: boolean;
/** where the hostile ships on the scanner are, for the run course */
readonly threats: readonly THREE.Vector3[];
+ /**
+ * The solid things within scanner range that a line must not pass through:
+ * a rock hermit, an asteroid and the derelict. Every course reads it.
+ *
+ * NOT every ship. A pirate is small and it moves, and a line bent round one
+ * would be a line bent round a fight. These are the things that sit still and
+ * are big enough to kill you (Chris, 2026-09-12).
+ */
+ readonly obstacles: readonly Obstacle[];
/** where the police ships within scanner range are, for the smuggling course */
readonly police: readonly THREE.Vector3[];
/** where the cargo adrift within scanner range is, nearest first */
@@ -121,6 +144,8 @@ export class CoursePilot {
private readonly aim = new THREE.Vector3();
private readonly away = new THREE.Vector3();
private readonly wideOf = new THREE.Vector3();
+ /** the aim once it is clear of the planet AND of anything solid */
+ private readonly clearAim = new THREE.Vector3();
/** Forget the bank, for a new course. */
reset(): void { this.mem = freshSteerMemory(); }
@@ -229,7 +254,11 @@ export class CoursePilot {
a.speed + Math.sqrt(2 * COURSE_ARRIVE_BRAKE * PLAYER_FLIGHT.accel * Math.max(0, left)));
const band = PLAYER_FLIGHT.accel * dt;
const throttle = v.speed < wanted - band ? 1 : v.speed > wanted + band ? -1 : 0;
- const aim = clearOfPlanet(v.position, a.target, v.planetPos, v.planetRadius, this.aim);
+ // ROUND THE PLANET FIRST, then round anything solid on what is left. The
+ // planet is the bigger detour, so a line bent round it is the line a rock
+ // can then be in the way of.
+ const wide = clearOfPlanet(v.position, a.target, v.planetPos, v.planetRadius, this.aim);
+ const aim = clearOfObstacles(v.position, wide, v.obstacles, this.clearAim);
const p = this.pointAt(v, aim, throttle, dt);
return { ...p, torus: p.torus && left > COURSE_TORUS_DROP, handOver: false, done: false };
}
@@ -279,87 +308,3 @@ function straight(v: CourseView, dt: number): FlightDemand {
function hold(v: CourseView, dt: number): FlightDemand {
return { ...straight(v, dt), throttle: v.speed > PLAYER_FLIGHT.accel * dt ? -1 : 0 };
}
-
-const seg = new THREE.Vector3();
-const off = new THREE.Vector3();
-
-/**
- * Where to aim, so that the line to the target clears the planet
- * (docs/TODO/205 M3). The clearance is the planet's own radius plus
- * `COURSE_PLANET_CLEARANCE`, which is above the height the planet holds the
- * torus drive down at.
- *
- * @returns `out`, holding the point to aim at.
- */
-export function clearOfPlanet(
- from: THREE.Vector3, to: THREE.Vector3, planet: THREE.Vector3, radius: number,
- out: THREE.Vector3,
-): THREE.Vector3 {
- return sidestep(from, to, planet, radius + COURSE_PLANET_CLEARANCE, out);
-}
-
-/**
- * Where to aim, so that the line to the target keeps clear of the police
- * (docs/TODO/208 M4).
- *
- * A police ship reads a hold inside `SCAN_RANGE`, which is 2,600 units, and
- * the smuggling course must not be read. It aims wide of the nearest
- * policeman in the way, at `COURSE_POLICE_CLEARANCE`, which is the warning
- * band. The line from there is clear, and the ship then turns onto the
- * target. Where no wide line exists, it takes the widest it can find.
- */
-export function clearOfPolice(
- from: THREE.Vector3, to: THREE.Vector3, police: readonly THREE.Vector3[],
- out: THREE.Vector3,
-): THREE.Vector3 {
- let worst: { at: THREE.Vector3; miss: number } | null = null;
- for (const at of police) {
- const miss = distanceToSegment(from, to, at);
- if (miss >= COURSE_POLICE_CLEARANCE) continue;
- if (worst === null || miss < worst.miss) worst = { at, miss };
- }
- return worst === null ? out.copy(to)
- : sidestep(from, to, worst.at, COURSE_POLICE_CLEARANCE, out);
-}
-
-/** How near the line from `from` to `to` passes `at`. */
-function distanceToSegment(
- from: THREE.Vector3, to: THREE.Vector3, at: THREE.Vector3,
-): number {
- seg.subVectors(to, from);
- const len2 = seg.lengthSq();
- const t = len2 > 0 ? Math.max(0, Math.min(1, off.subVectors(at, from).dot(seg) / len2)) : 0;
- // The nearest point is the target itself: nothing is between.
- if (t >= 1) return Infinity;
- return off.copy(from).addScaledVector(seg, t).sub(at).length();
-}
-
-/**
- * Where to aim, so that the line keeps `clear` units from one thing in the
- * way. It aims beside that thing, on the same side as the line, and half as
- * far again. The line from there is clear, and the ship then turns onto the
- * target.
- *
- * A target that is itself the nearest point needs no detour. docs/TODO/205 M3
- * found a hermit low over the planet. The line to it always counted as
- * blocked, and the ship circled the detour point for ever.
- *
- * @returns `out`, holding the point to aim at.
- */
-function sidestep(
- from: THREE.Vector3, to: THREE.Vector3, at: THREE.Vector3, clear: number,
- out: THREE.Vector3,
-): THREE.Vector3 {
- seg.subVectors(to, from);
- const len2 = seg.lengthSq();
- const t = len2 > 0 ? Math.max(0, Math.min(1, off.subVectors(at, from).dot(seg) / len2)) : 0;
- if (t >= 1) return out.copy(to);
- off.copy(from).addScaledVector(seg, t).sub(at);
- if (off.length() >= clear) return out.copy(to);
- // A line through the centre has no side. Any direction square to it will do.
- if (off.lengthSq() < 1e-6) {
- off.set(seg.y, -seg.x, 0);
- if (off.lengthSq() < 1e-6) off.set(0, seg.z, -seg.y);
- }
- return out.copy(at).addScaledVector(off.normalize(), clear * 1.5);
-}
diff --git a/src/game/flight-course.ts b/src/game/flight-course.ts
index 65ed6eeb..5b216ade 100644
--- a/src/game/flight-course.ts
+++ b/src/game/flight-course.ts
@@ -22,6 +22,12 @@
// holding a second copy of the rule.
import { CoursePilot } from './course-pilot.ts';
+
+/**
+ * The roles a course steers round. See `CourseView.obstacles` for why these
+ * three and no ship.
+ */
+const OBSTACLE_ROLES = new Set(['hermit', 'asteroid', 'generation']);
import type { CourseKind } from './courses.ts';
import { MAX_FUEL } from '../constants/commander.ts';
import { hostilesNear, hostilesOnScanner } from './hostility.ts';
@@ -132,6 +138,15 @@ export class FlightCourse {
.sort((a, b) => a.distanceTo(p.position) - b.distanceTo(p.position)),
threats: hostilesOnScanner(w.npcs, p.position, this.state.commander.legalStatus,
p.position.distanceTo(w.station.position)).map((n) => n.object.position),
+ // THE SOLID THINGS, so a line does not go through one. Three roles sit
+ // still and are big enough to kill the commander. A rock hermit is 120
+ // units across the radius, the derelict is 340, and a rock is 54. Every
+ // other ship moves, and a course that bent round those would bend round
+ // a fight.
+ obstacles: w.npcs
+ .filter((n) => n.state.alive && OBSTACLE_ROLES.has(n.role)
+ && n.object.position.distanceTo(p.position) <= SCANNER_RANGE)
+ .map((n) => ({ at: n.object.position, radius: n.radius })),
dcEngaged: s.dcEngaged,
mission: mission === null ? null
: { at: mission.at, speed: mission.speed, how: mission.how },
diff --git a/test/constants.test.ts b/test/constants.test.ts
index 813e4b0f..879acbed 100644
--- a/test/constants.test.ts
+++ b/test/constants.test.ts
@@ -407,9 +407,11 @@ const OUTSIDE: readonly Group[] = [
+ ' finishes its work (docs/TODO/205 M3), beside the switch that says it.'
+ ' What a course button says while it flies, and what a jump row says'
+ ' when the ship cannot jump, beside the rule that raises them (M5).'
- + ' KINDS is the list of courses, read off the codes that name them',
+ + ' KINDS is the list of courses, read off the codes that name them.'
+ + ' OBSTACLE_ROLES is which NPC roles a course steers round, and a role'
+ + ' is a word rather than a number',
files: {
- 'game/flight-course.ts': ['COURSE_ENDS'],
+ 'game/flight-course.ts': ['COURSE_ENDS', 'OBSTACLE_ROLES'],
'game/courses.ts': ['COURSE_NAMES', 'JUMP_WHY'],
'game/course-actions.ts': ['KINDS'],
// what each role is called on the target list (docs/TODO/206 M1)
diff --git a/test/course-clearance.test.ts b/test/course-clearance.test.ts
new file mode 100644
index 00000000..fb6f40d6
--- /dev/null
+++ b/test/course-clearance.test.ts
@@ -0,0 +1,98 @@
+// WHERE A COURSE AIMS, so that its line is clear of what is on it.
+//
+// Split from `course-pilot.test.ts` on 2026-09-12, when that file passed the
+// 400-line ceiling, and it follows the split of the code it tests. That file
+// asks what a course DOES for one frame. This one asks where the line goes.
+//
+// `src/game/course-clearance.ts` is the subject. The second block is the flight
+// it came from, so the pure geometry above it is held to a real approach.
+
+import * as THREE from 'three';
+import { Game } from '../src/game/game.ts';
+import { headlessShell } from '../src/engine/shell.ts';
+import { withoutSaving } from '../src/game/storage.ts';
+import { seedWorld } from '../src/game/rng.ts';
+import { clearOfObstacles } from '../src/game/course-clearance.ts';
+import { COURSE_OBSTACLE_CLEARANCE } from '../src/constants/course.ts';
+import { check, dismissBriefing, eq } from './harness.ts';
+
+// --- A COURSE GOES ROUND WHAT IT WOULD HIT ---------------------------------
+//
+// Chris, 2026-09-12: *"I visited a rock hermit and then when I left clicked fly
+// to the station - the computer crashed me straight into the rock hermit - we
+// need to have some avoidance of obstacles"*.
+//
+// The line already went round the planet and, on a smuggling run, round a
+// policeman. It went through everything else.
+console.log('\na course goes round what it would hit');
+{
+ const out = new THREE.Vector3();
+ const from = new THREE.Vector3();
+ const to = new THREE.Vector3(0, 0, -60_000);
+ const rock = (at: THREE.Vector3, radius = 120) => ({ at, radius });
+
+ eq('with nothing in the way, the aim is the target',
+ clearOfObstacles(from, to, [], out).equals(to), true);
+ check('a hermit ON the line moves the aim off it',
+ clearOfObstacles(from, to, [rock(new THREE.Vector3(0, 0, -600))], out)
+ .distanceTo(to) > 1, `${out.x.toFixed(0)},${out.y.toFixed(0)},${out.z.toFixed(0)}`);
+ check('...far enough that the new line clears its hull',
+ clearOfObstacles(from, to, [rock(new THREE.Vector3(0, 0, -600))], out)
+ .distanceTo(new THREE.Vector3(0, 0, -600)) > 120 + COURSE_OBSTACLE_CLEARANCE,
+ `${out.distanceTo(new THREE.Vector3(0, 0, -600)).toFixed(0)} units from its centre`);
+ check('a hermit well off the line is left alone',
+ clearOfObstacles(from, to, [rock(new THREE.Vector3(9000, 0, -600))], out).equals(to));
+ // A thing AT the end of the line is the target, not an obstacle. That is what
+ // lets the hermit course fly to a hermit.
+ check('a hermit at the far end of the line is not in the way of it',
+ clearOfObstacles(from, to, [rock(to.clone())], out).equals(to));
+ // The worst is the one with least room, not the one nearest the ship.
+ const near = rock(new THREE.Vector3(300, 0, -600));
+ const tight = rock(new THREE.Vector3(20, 0, -4000));
+ check('the tightest squeeze is the one it steers round, not the nearest',
+ clearOfObstacles(from, to, [near, tight], out).distanceTo(tight.at)
+ < clearOfObstacles(from, to, [near, tight], out).distanceTo(near.at));
+}
+
+// ...and the flight it came from: the station course past the hermit the
+// commander just left. Without the detour this kills her outright at 399 units
+// a second.
+{
+ const g = withoutSaving(() => {
+ seedWorld(20_260_933);
+ const game = new Game(() => headlessShell());
+ dismissBriefing(game);
+ game.launch();
+ return game;
+ }).value;
+ withoutSaving(() => { for (let f = 0, at = 0; f < 400; f++) g.step(1 / 60, at += 1 / 60); });
+ g.state.world.clearNpcs();
+
+ // Out in the belt, with the station on the far side of the hermit, and the
+ // nose already down the line — a ship that has just left a hermit.
+ const away = new THREE.Vector3(1, 0.2, 0.3).normalize();
+ g.state.player.position.copy(g.state.world.station.position).addScaledVector(away, 60_000);
+ const toStation = g.state.world.station.position.clone()
+ .sub(g.state.player.position).normalize();
+ const at = g.state.player.position.clone().addScaledVector(toStation, 600);
+ const hermit = g.state.world.spawn('hermit', at, 3);
+ hermit.state.speed = 0;
+ g.state.player.quaternion.setFromRotationMatrix(new THREE.Matrix4()
+ .lookAt(g.state.player.position, g.state.world.station.position, new THREE.Vector3(0, 1, 0)));
+ g.state.session.course = 'station';
+
+ let closest = Infinity;
+ withoutSaving(() => {
+ for (let f = 0; f < 60 * 40; f++) {
+ g.step(1 / 60, 20 + f / 60);
+ hermit.object.position.copy(at); // hold it still, so only the RULES move
+ closest = Math.min(closest, g.state.player.position.distanceTo(at) - hermit.radius);
+ if (g.mode !== 'flight') break;
+ }
+ });
+ check('the station course flies past the hermit rather than into it',
+ g.mode === 'flight', `ended as ${g.mode}`);
+ check('...clear of its hull by most of a turning circle',
+ closest > COURSE_OBSTACLE_CLEARANCE * 0.8,
+ `${closest.toFixed(0)} units, against a clearance of ${COURSE_OBSTACLE_CLEARANCE.toFixed(0)}`);
+}
diff --git a/test/course-pilot.test.ts b/test/course-pilot.test.ts
index 6988f944..5fbb87fe 100644
--- a/test/course-pilot.test.ts
+++ b/test/course-pilot.test.ts
@@ -11,7 +11,8 @@ import { Game } from '../src/game/game.ts';
import { headlessShell } from '../src/engine/shell.ts';
import { withoutSaving } from '../src/game/storage.ts';
import { seedWorld } from '../src/game/rng.ts';
-import { CoursePilot, clearOfPlanet, type CourseView } from '../src/game/course-pilot.ts';
+import { CoursePilot, type CourseView } from '../src/game/course-pilot.ts';
+import { clearOfPlanet } from '../src/game/course-clearance.ts';
import { COURSE_DERELICT_STANDOFF, COURSE_PLANET_CLEARANCE } from '../src/constants/course.ts';
import { HERMIT_DOCK_SPEED } from '../src/constants/hermit-market.ts';
import { CABIN_TEMP_FATAL } from '../src/constants/sun.ts';
@@ -44,6 +45,7 @@ const view = (station: THREE.Vector3, over: PartialRUN_CLOSE_MARGIN | 50 | Below this lead in top speed, in world units a second, the run row says the ship is only a little faster (docs/TODO/206 M5). | course.runCloseMargin | [course.ts:211](./course.ts#L211) |
| course | COURSE_RUN_REACH | 50_000 | How far ahead the run course aims, in world units, on the line away from the hostile ships (docs/TODO/206 M5). | course.runReach | [course.ts:221](./course.ts#L221) |
| course | COURSE_COLLECT_SPEED | 60 | How fast the collect course flies onto a canister, in world units a second (docs/TODO/206 M6). | course.collectSpeed | [course.ts:235](./course.ts#L235) |
-| course | COURSE_DOCK_HANDOVER | 1500 | How far from the station the station course hands the ship to the pilot, in world units (docs/TODO/207 M1). | course.dockHandover | [course.ts:249](./course.ts#L249) |
-| course | COURSE_WATCH_STANDOFF | 1200 | How far from a ship the scan course holds, in world units (docs/TODO/208 M2). | course.watchStandoff | [course.ts:263](./course.ts#L263) |
-| course | COURSE_ESCORT_STANDOFF | 600 | How far from its charge the escort course flies, in world units (docs/TODO/208 M2). | course.escortStandoff | [course.ts:276](./course.ts#L276) |
-| course | COURSE_POLICE_CLEARANCE | SCAN_WARN_RANGE | How wide of a police ship the smuggling course flies, in world units (docs/TODO/208 M4). | | [course.ts:289](./course.ts#L289) |
-| course | COURSE_OBSTACLE_CLEARANCE | PLAYER_FLIGHT.maxSpeed / PLAYER_FLIGHT.maxPitch | How far clear of a solid thing's HULL a course flies, in world units. | | [course.ts:315](./course.ts#L315) |
+| course | COURSE_COLLECT_LEAD | 5 | The furthest ahead of a drifting canister the collect course will aim, in seconds. | course.collectLead | [course.ts:270](./course.ts#L270) |
+| course | COURSE_DOCK_HANDOVER | 1500 | How far from the station the station course hands the ship to the pilot, in world units (docs/TODO/207 M1). | course.dockHandover | [course.ts:284](./course.ts#L284) |
+| course | COURSE_WATCH_STANDOFF | 1200 | How far from a ship the scan course holds, in world units (docs/TODO/208 M2). | course.watchStandoff | [course.ts:298](./course.ts#L298) |
+| course | COURSE_ESCORT_STANDOFF | 600 | How far from its charge the escort course flies, in world units (docs/TODO/208 M2). | course.escortStandoff | [course.ts:311](./course.ts#L311) |
+| course | COURSE_POLICE_CLEARANCE | SCAN_WARN_RANGE | How wide of a police ship the smuggling course flies, in world units (docs/TODO/208 M4). | | [course.ts:324](./course.ts#L324) |
+| course | COURSE_OBSTACLE_CLEARANCE | PLAYER_FLIGHT.maxSpeed / PLAYER_FLIGHT.maxPitch | How far clear of a solid thing's HULL a course flies, in world units. | | [course.ts:350](./course.ts#L350) |
| docking | GATE_HALF_WIDTHS | 5 | How far out the approach gate sits, in multiples of the station half-width. | docking.gateHalfWidths | [docking.ts:25](./docking.ts#L25) |
| docking | TURN_IN | Math.PI / 4 | How far round from the slot axis an approach turns in, in radians. | | [docking.ts:49](./docking.ts#L49) |
| docking | RUN_IN_WIDTHS | GATE_HALF_WIDTHS * 0.6 | Where the approach stops the curve and flies straight, in station half-widths. | | [docking.ts:87](./docking.ts#L87) |
diff --git a/src/constants/course.ts b/src/constants/course.ts
index 1f12f56a..8d038be8 100644
--- a/src/constants/course.ts
+++ b/src/constants/course.ts
@@ -234,6 +234,41 @@ export const COURSE_RUN_REACH = 50_000;
*/
export const COURSE_COLLECT_SPEED = 60;
+/**
+ * The furthest ahead of a drifting canister the collect course will aim, in
+ * seconds.
+ *
+ * Chris, 2026-09-12: *"Collecting cargo often seems to be difficult - we miss
+ * it quite a lot - especially when it is moving."* The course aimed at where
+ * the canister WAS. A canister drifts at up to 45 units a second, and the scoop
+ * reaches 45. So an aim at its old place arrives a whole scoop behind it.
+ *
+ * The lead itself is the time to cover the gap at `COURSE_COLLECT_SPEED`, which
+ * is an intercept rather than a guess. THE CAP IS WHAT IS FITTED. `arrive`
+ * flies faster than the collect speed while it is far out, so the raw time
+ * over-leads at range. An uncapped aim then chases a point the canister never
+ * reaches.
+ *
+ * Measured over 80 runs a row, five canisters a run, at four drifts and four
+ * scatter directions. The figure is how many of 240 were aboard inside two
+ * minutes, across the three moving drifts:
+ *
+ * | cap | collected | mean time |
+ * | 0 | 191/240 | 75.0s |
+ * | 2 | 210/240 | 58.6s |
+ * | 3 | 216/240 | 60.9s |
+ * | 4 | 217/240 | 55.1s |
+ * | 5 | 231/240 | 50.8s |
+ * | 6 | 219/240 | 59.4s |
+ *
+ * Cargo at rest is untouched at every cap, because a still canister has no
+ * velocity to lead on. It was 80 of 80 in 21.6 seconds throughout.
+ *
+ * @rule course.collectLead
+ * @domain course
+ */
+export const COURSE_COLLECT_LEAD = 5;
+
/**
* How far from the station the station course hands the ship to the pilot,
* in world units (docs/TODO/207 M1).
diff --git a/src/game/course-pilot.ts b/src/game/course-pilot.ts
index eb8abe47..75e643cf 100644
--- a/src/game/course-pilot.ts
+++ b/src/game/course-pilot.ts
@@ -16,7 +16,9 @@
//
// THE STEER MEMORY IS NOT SAVED. It holds which vertical a bank takes, and a
// restore that starts it fresh costs one bank at most. The scripted co-pilot
-// makes the same bargain.
+// makes the same bargain. The canister the collect course holds is the same
+// bargain again. A restore picks the nearest, and that is the pick it makes
+// from cold anyway.
//
// THREE SHAPES OF COURSE fly today:
//
@@ -51,7 +53,7 @@ import { PLAYER_FLIGHT } from '../constants/player-flight.ts';
import { SLOT_SPEED_LIMIT } from '../constants/docking.ts';
import {
COURSE_AIM_DEADZONE, COURSE_ROLL_GATE, COURSE_ARRIVE_BRAKE, COURSE_ARRIVE_TOLERANCE, COURSE_DERELICT_STANDOFF,
- COURSE_HERMIT_SPEED, COURSE_HERMIT_STANDOFF,
+ COURSE_COLLECT_LEAD, COURSE_HERMIT_SPEED, COURSE_HERMIT_STANDOFF,
COURSE_COLLECT_SPEED, COURSE_ESCORT_STANDOFF, COURSE_RUN_REACH, COURSE_SKIM_DISTANCE,
COURSE_TORUS_CONE, COURSE_TORUS_DROP, COURSE_WATCH_STANDOFF,
} from '../constants/course.ts';
@@ -94,7 +96,7 @@ export interface CourseView {
/** where the police ships within scanner range are, for the smuggling course */
readonly police: readonly THREE.Vector3[];
/** where the cargo adrift within scanner range is, nearest first */
- readonly loot: readonly THREE.Vector3[];
+ readonly loot: readonly Adrift[];
/**
* What the mission asks for here (docs/TODO/208 M1): where its target is,
* how fast it moves, and what the ship does about it. Null when no live leg
@@ -110,6 +112,13 @@ export interface CourseView {
readonly handOverRange: number;
}
+/** A canister the collect course can fly at, and where it is going. */
+export interface Adrift {
+ at: THREE.Vector3;
+ /** its drift, in world units a second — the collect course leads on it */
+ velocity: THREE.Vector3;
+}
+
/** What the course pilot asks for this frame. */
export interface CourseStep {
/** what it wants flown, or null where something else flies the ship */
@@ -139,6 +148,23 @@ interface Arrival {
export class CoursePilot {
private mem: SteerMemory = freshSteerMemory();
+ /**
+ * The canister the collect course is flying at, held until it is taken or it
+ * leaves the scanner.
+ *
+ * IT USED TO TAKE THE NEAREST, EVERY FRAME. Chris, 2026-09-12: *"Collecting
+ * cargo often seems to be difficult - we miss it quite a lot - especially
+ * when it is moving."* The ship closed on one canister, a second became
+ * nearer as it moved, and the course turned away from the first. Measured
+ * over five canisters, that was 7 missed passes with the cargo at rest and 16
+ * with it adrift. One canister alone was never missed at all, at any drift,
+ * which is what named the fault.
+ *
+ * The identity is the canister's own position vector, which `flight-course.ts`
+ * passes by reference. A canister that is scooped or drifts out of range
+ * leaves the list, and the next pick is the nearest again.
+ */
+ private held: THREE.Vector3 | null = null;
private readonly dir = new THREE.Vector3();
private readonly fwd = new THREE.Vector3();
private readonly aim = new THREE.Vector3();
@@ -146,9 +172,14 @@ export class CoursePilot {
private readonly wideOf = new THREE.Vector3();
/** the aim once it is clear of the planet AND of anything solid */
private readonly clearAim = new THREE.Vector3();
+ /** where a drifting canister will be when the ship gets there */
+ private readonly lead = new THREE.Vector3();
/** Forget the bank, for a new course. */
- reset(): void { this.mem = freshSteerMemory(); }
+ reset(): void {
+ this.mem = freshSteerMemory();
+ this.held = null;
+ }
step(v: CourseView, dt: number): CourseStep {
switch (v.course) {
@@ -171,11 +202,22 @@ export class CoursePilot {
}
case 'run': return v.threats.length === 0 ? ended() : this.run(v, dt);
case 'collect': {
- const next = v.loot[0];
- if (next === undefined) return ended();
- // Fly onto it. The scoop takes it aboard inside `SCOOP_RANGE`, and
- // the next one is then the nearest (docs/TODO/206 M6).
- return { ...this.arrive(v, { target: next, standoff: 0, speed: COURSE_COLLECT_SPEED }, dt), done: false };
+ const next = this.holdLoot(v.loot);
+ if (next === null) return ended();
+ // FLY ONTO WHERE IT WILL BE. A canister drifts at up to 45 units a
+ // second, and the scoop reaches 45. An aim at where it IS therefore
+ // arrives a whole scoop behind it (Chris, 2026-09-12). The lead is the
+ // time to cover the gap at the speed the course flies. That is the same
+ // shape the co-pilot leads a ship with.
+ const gap = v.position.distanceTo(next.at);
+ this.lead.copy(next.at).addScaledVector(
+ next.velocity, Math.min(COURSE_COLLECT_LEAD, gap / COURSE_COLLECT_SPEED));
+ // The scoop takes it aboard inside `SCOOP_RANGE`, and the next one is
+ // then the nearest (docs/TODO/206 M6).
+ return {
+ ...this.arrive(v, { target: this.lead, standoff: 0, speed: COURSE_COLLECT_SPEED }, dt),
+ done: false,
+ };
}
// A rock is fought, not flown to: `flight-instruments.ts` picks the next
// one as the target, and the computer's aim lines the ship up on it.
@@ -263,6 +305,20 @@ export class CoursePilot {
return { ...p, torus: p.torus && left > COURSE_TORUS_DROP, handOver: false, done: false };
}
+ /**
+ * Which canister to fly at: the one already held, while it is still there.
+ *
+ * A pilot who is nearly on a canister does not turn away because another
+ * drifted closer. It is the same rule the combat co-pilot keeps for a target
+ * it is lined up on, and the same fault it was fixed for.
+ */
+ private holdLoot(loot: readonly Adrift[]): Adrift | null {
+ const still = loot.find((c) => c.at === this.held);
+ if (still !== undefined) return still;
+ this.held = loot[0]?.at ?? null;
+ return loot[0] ?? null;
+ }
+
/** Bank and pull the nose onto a point, with this throttle. */
private pointAt(
v: CourseView, point: THREE.Vector3, throttle: number, dt: number,
diff --git a/src/game/flight-course.ts b/src/game/flight-course.ts
index 5b216ade..58452ffb 100644
--- a/src/game/flight-course.ts
+++ b/src/game/flight-course.ts
@@ -133,9 +133,9 @@ export class FlightCourse {
&& n.object.position.distanceTo(p.position) <= SCANNER_RANGE * 2)
.map((n) => n.object.position),
loot: w.cargo.items
- .map((c) => c.object.position)
- .filter((at) => at.distanceTo(p.position) <= SCANNER_RANGE)
- .sort((a, b) => a.distanceTo(p.position) - b.distanceTo(p.position)),
+ .filter((c) => c.object.position.distanceTo(p.position) <= SCANNER_RANGE)
+ .map((c) => ({ at: c.object.position, velocity: c.velocity }))
+ .sort((a, b) => a.at.distanceTo(p.position) - b.at.distanceTo(p.position)),
threats: hostilesOnScanner(w.npcs, p.position, this.state.commander.legalStatus,
p.position.distanceTo(w.station.position)).map((n) => n.object.position),
// THE SOLID THINGS, so a line does not go through one. Three roles sit
diff --git a/test/course-pilot.test.ts b/test/course-pilot.test.ts
index 5fbb87fe..ed353bce 100644
--- a/test/course-pilot.test.ts
+++ b/test/course-pilot.test.ts
@@ -329,3 +329,60 @@ console.log('\nnothing appears inside the planet');
check('...and a ship placed well clear of it appears where it was placed (the control)',
aboveGround(w, high).distanceTo(high) < 1e-6);
}
+
+// --- THE COLLECT COURSE HOLDS ONE CANISTER, AND LEADS IT -------------------
+//
+// Chris, 2026-09-12: *"Collecting cargo often seems to be difficult - we miss
+// it quite a lot - especially when it is moving."*
+//
+// Two faults, and a probe named them by flying ONE canister against five. One
+// alone was never missed, at any drift. Five were missed seven times with the
+// cargo at rest. So the first fault was the PICK: the course took the nearest
+// every frame, and turned away from a canister it was nearly on when another
+// drifted closer. The second was the AIM, which was where the canister had
+// been.
+//
+// Both are asserted on the RULE rather than through a two-minute flight. The
+// flight is what measured them, and its figures are beside
+// `COURSE_COLLECT_LEAD`: over 80 runs a drift, the led course took 231 of 240
+// canisters against 191 unled.
+console.log('\nthe collect course holds one canister, and leads it');
+{
+ const DT = 1 / 60;
+ const far = new THREE.Vector3(0, 0, -50_000);
+ const still = (at: THREE.Vector3) => ({ at, velocity: new THREE.Vector3() });
+ const ahead = new THREE.Vector3(0, 0, -600);
+ const aside = new THREE.Vector3(1400, 0, -600);
+
+ // THE PICK. A canister dead ahead asks for no turn. One off to the side asks
+ // for a big one. So the demand says which of the two the course is flying at.
+ const turn = (s: { demand: { pitchRate: number; rollRate: number } | null }): number =>
+ Math.abs(s.demand?.pitchRate ?? 0) + Math.abs(s.demand?.rollRate ?? 0);
+
+ const fresh = new CoursePilot();
+ const onAhead = turn(fresh.step(view(far, { course: 'collect', loot: [still(ahead)] }), DT));
+ const onAside = turn(new CoursePilot()
+ .step(view(far, { course: 'collect', loot: [still(aside)] }), DT));
+ check('the fixture can tell the two apart', onAside > onAhead + 0.01,
+ `${onAhead.toFixed(3)} ahead against ${onAside.toFixed(3)} aside`);
+
+ const held = new CoursePilot();
+ held.step(view(far, { course: 'collect', loot: [still(aside)] }), DT);
+ // ...and now a nearer one turns up, first in the list.
+ const kept = turn(held.step(
+ view(far, { course: 'collect', loot: [still(ahead), still(aside)] }), DT));
+ check('a nearer canister does not steal one the course is already on',
+ kept > onAhead + 0.01, `${kept.toFixed(3)}, against ${onAhead.toFixed(3)} for the near one`);
+
+ // ...until it is gone, and then the nearest is the next.
+ const moved = turn(held.step(view(far, { course: 'collect', loot: [still(ahead)] }), DT));
+ check('...and when it is aboard, the course takes the next', moved <= onAhead + 0.01,
+ `${moved.toFixed(3)}`);
+
+ // THE AIM. A canister dead ahead and drifting sideways is not where it was.
+ const drifting = { at: ahead.clone(), velocity: new THREE.Vector3(200, 0, 0) };
+ const led = turn(new CoursePilot()
+ .step(view(far, { course: 'collect', loot: [drifting] }), DT));
+ check('a drifting canister is aimed ahead of, not at', led > onAhead + 0.01,
+ `${led.toFixed(3)}, against ${onAhead.toFixed(3)} for the same place at rest`);
+}
From dc2973df966d16f134f07ad8ba68de4a72883f75 Mon Sep 17 00:00:00 2001
From: Chris Greening COURSE_RUN_REACH | 50_000 | How far ahead the run course aims, in world units, on the line away from the hostile ships (docs/TODO/206 M5). | course.runReach | [course.ts:221](./course.ts#L221) |
| course | COURSE_COLLECT_SPEED | 60 | How fast the collect course flies onto a canister, in world units a second (docs/TODO/206 M6). | course.collectSpeed | [course.ts:235](./course.ts#L235) |
| course | COURSE_COLLECT_LEAD | 5 | The furthest ahead of a drifting canister the collect course will aim, in seconds. | course.collectLead | [course.ts:270](./course.ts#L270) |
-| course | COURSE_DOCK_HANDOVER | 1500 | How far from the station the station course hands the ship to the pilot, in world units (docs/TODO/207 M1). | course.dockHandover | [course.ts:284](./course.ts#L284) |
-| course | COURSE_WATCH_STANDOFF | 1200 | How far from a ship the scan course holds, in world units (docs/TODO/208 M2). | course.watchStandoff | [course.ts:298](./course.ts#L298) |
-| course | COURSE_ESCORT_STANDOFF | 600 | How far from its charge the escort course flies, in world units (docs/TODO/208 M2). | course.escortStandoff | [course.ts:311](./course.ts#L311) |
-| course | COURSE_POLICE_CLEARANCE | SCAN_WARN_RANGE | How wide of a police ship the smuggling course flies, in world units (docs/TODO/208 M4). | | [course.ts:324](./course.ts#L324) |
-| course | COURSE_OBSTACLE_CLEARANCE | PLAYER_FLIGHT.maxSpeed / PLAYER_FLIGHT.maxPitch | How far clear of a solid thing's HULL a course flies, in world units. | | [course.ts:350](./course.ts#L350) |
+| course | COURSE_COLLECT_CAP | COURSE_COLLECT_SPEED * 2 | The fastest the collect course flies on its way to a canister, in world units a second. | course.collectCap | [course.ts:307](./course.ts#L307) |
+| course | COURSE_DOCK_HANDOVER | 1500 | How far from the station the station course hands the ship to the pilot, in world units (docs/TODO/207 M1). | course.dockHandover | [course.ts:321](./course.ts#L321) |
+| course | COURSE_WATCH_STANDOFF | 1200 | How far from a ship the scan course holds, in world units (docs/TODO/208 M2). | course.watchStandoff | [course.ts:335](./course.ts#L335) |
+| course | COURSE_ESCORT_STANDOFF | 600 | How far from its charge the escort course flies, in world units (docs/TODO/208 M2). | course.escortStandoff | [course.ts:348](./course.ts#L348) |
+| course | COURSE_POLICE_CLEARANCE | SCAN_WARN_RANGE | How wide of a police ship the smuggling course flies, in world units (docs/TODO/208 M4). | | [course.ts:361](./course.ts#L361) |
+| course | COURSE_OBSTACLE_CLEARANCE | PLAYER_FLIGHT.maxSpeed / PLAYER_FLIGHT.maxPitch | How far clear of a solid thing's HULL a course flies, in world units. | | [course.ts:387](./course.ts#L387) |
| docking | GATE_HALF_WIDTHS | 5 | How far out the approach gate sits, in multiples of the station half-width. | docking.gateHalfWidths | [docking.ts:25](./docking.ts#L25) |
| docking | TURN_IN | Math.PI / 4 | How far round from the slot axis an approach turns in, in radians. | | [docking.ts:49](./docking.ts#L49) |
| docking | RUN_IN_WIDTHS | GATE_HALF_WIDTHS * 0.6 | Where the approach stops the curve and flies straight, in station half-widths. | | [docking.ts:87](./docking.ts#L87) |
diff --git a/src/constants/course.ts b/src/constants/course.ts
index 8d038be8..c7b7c397 100644
--- a/src/constants/course.ts
+++ b/src/constants/course.ts
@@ -269,6 +269,43 @@ export const COURSE_COLLECT_SPEED = 60;
*/
export const COURSE_COLLECT_LEAD = 5;
+/**
+ * The fastest the collect course flies on its way to a canister, in world
+ * units a second.
+ *
+ * Chris, 2026-09-12: *"collecting cargo seems to be broken sometimes. I seem to
+ * run at maximum speed, then slow down and then miss it and then run at maximum
+ * speed."* That is the trace exactly. `arrive` picks a speed from the braking
+ * curve, which is right for a big target on a straight line. It reached 400
+ * with a canister 250 units off.
+ *
+ * A SHIP THAT FAST CANNOT CORRECT. Its tightest turn has a radius of 276 units,
+ * and the scoop reaches 45. So a lateral error the steering leaves at that
+ * point cannot be closed at all. The ship sails past, and comes round again.
+ * Held on one canister it loops for ever, which is what "broken" looked like.
+ *
+ * It is twice `COURSE_COLLECT_SPEED`, the speed the course settles at. That is
+ * a turn radius of 83 units, which is under twice the scoop's reach.
+ *
+ * Measured over 64 runs, five canisters a run, at four drifts and four scatter
+ * directions. The figure is how many runs left cargo behind after two minutes:
+ *
+ * | cap | left behind | mean time |
+ * | 400 | 4 of 64 | 43.5s |
+ * | 180 | 4 of 64 | 35.9s |
+ * | 150 | 0 of 64 | 32.0s |
+ * | 120 | 0 of 64 | 29.8s |
+ * | 90 | 0 of 64 | 32.6s |
+ * | 60 | 36 of 64 | |
+ *
+ * At 60 it is the collect speed itself, and the course can no longer catch a
+ * canister that drifts at 45.
+ *
+ * @rule course.collectCap
+ * @domain course
+ */
+export const COURSE_COLLECT_CAP = COURSE_COLLECT_SPEED * 2;
+
/**
* How far from the station the station course hands the ship to the pilot,
* in world units (docs/TODO/207 M1).
diff --git a/src/game/course-pilot.ts b/src/game/course-pilot.ts
index 75e643cf..22c888c4 100644
--- a/src/game/course-pilot.ts
+++ b/src/game/course-pilot.ts
@@ -53,7 +53,7 @@ import { PLAYER_FLIGHT } from '../constants/player-flight.ts';
import { SLOT_SPEED_LIMIT } from '../constants/docking.ts';
import {
COURSE_AIM_DEADZONE, COURSE_ROLL_GATE, COURSE_ARRIVE_BRAKE, COURSE_ARRIVE_TOLERANCE, COURSE_DERELICT_STANDOFF,
- COURSE_COLLECT_LEAD, COURSE_HERMIT_SPEED, COURSE_HERMIT_STANDOFF,
+ COURSE_COLLECT_CAP, COURSE_COLLECT_LEAD, COURSE_HERMIT_SPEED, COURSE_HERMIT_STANDOFF,
COURSE_COLLECT_SPEED, COURSE_ESCORT_STANDOFF, COURSE_RUN_REACH, COURSE_SKIM_DISTANCE,
COURSE_TORUS_CONE, COURSE_TORUS_DROP, COURSE_WATCH_STANDOFF,
} from '../constants/course.ts';
@@ -141,6 +141,8 @@ const IDLE: CourseStep = { demand: null, torus: false, handOver: false, done: fa
* about 19 u/s for ever, and never counted as arrived (docs/TODO/205 M3).
*/
interface Arrival {
+ /** the fastest it may fly on the way, or undefined for the ship's own top speed */
+ cap?: number;
readonly target: THREE.Vector3;
readonly standoff: number;
readonly speed: number;
@@ -215,7 +217,10 @@ export class CoursePilot {
// The scoop takes it aboard inside `SCOOP_RANGE`, and the next one is
// then the nearest (docs/TODO/206 M6).
return {
- ...this.arrive(v, { target: this.lead, standoff: 0, speed: COURSE_COLLECT_SPEED }, dt),
+ ...this.arrive(v, {
+ target: this.lead, standoff: 0,
+ speed: COURSE_COLLECT_SPEED, cap: COURSE_COLLECT_CAP,
+ }, dt),
done: false,
};
}
@@ -292,7 +297,7 @@ export class CoursePilot {
if (Math.abs(left) <= COURSE_ARRIVE_TOLERANCE && v.speed <= a.speed + PLAYER_FLIGHT.accel * dt) {
return { demand: hold(v, dt), torus: false, handOver: false, done: true };
}
- const wanted = Math.min(PLAYER_FLIGHT.maxSpeed,
+ const wanted = Math.min(a.cap ?? PLAYER_FLIGHT.maxSpeed,
a.speed + Math.sqrt(2 * COURSE_ARRIVE_BRAKE * PLAYER_FLIGHT.accel * Math.max(0, left)));
const band = PLAYER_FLIGHT.accel * dt;
const throttle = v.speed < wanted - band ? 1 : v.speed > wanted + band ? -1 : 0;
diff --git a/test/course-arrivals.test.ts b/test/course-arrivals.test.ts
new file mode 100644
index 00000000..eb186da2
--- /dev/null
+++ b/test/course-arrivals.test.ts
@@ -0,0 +1,162 @@
+// THE COURSES THAT FLY TO A THING AND STOP THERE.
+//
+// Split from `course-pilot.test.ts` on 2026-09-12, when that file passed the
+// 400-line ceiling. The seam is `CoursePilot.arrive`, one function with four
+// callers: the derelict, the hermit, the star and the loose cargo. Each flies
+// to a standoff from something and settles at a speed.
+//
+// What stays next door is the STATION course, and the rules every line obeys:
+// the hand-over to the pilot, the planet detour, the roll and the flight key.
+
+import * as THREE from 'three';
+import { CoursePilot } from '../src/game/course-pilot.ts';
+import { HERMIT_DOCK_SPEED } from '../src/constants/hermit-market.ts';
+import { COURSE_COLLECT_CAP, COURSE_DERELICT_STANDOFF } from '../src/constants/course.ts';
+import { CABIN_TEMP_FATAL } from '../src/constants/sun.ts';
+import { MAX_FUEL } from '../src/constants/commander.ts';
+import { PLAYER_FLIGHT } from '../src/constants/player-flight.ts';
+import { SCOOP_RANGE } from '../src/constants/scoop.ts';
+import { derelictReport } from '../src/game/derelict.ts';
+import { generateGalaxy } from '../src/galaxy/galaxy.ts';
+import { arrived, arrivedWith, fly, view } from './course-fixtures.ts';
+import { check, eq } from './harness.ts';
+
+console.log('\nthe derelict course');
+{
+ const g = arrivedWith('generation');
+ g.state.session.course = 'derelict';
+ fly(g, 300, () => g.state.session.course === null);
+ const gen = g.state.world.npcs.find((n) => n.role === 'generation')!;
+ const dist = g.state.player.position.distanceTo(gen.object.position);
+ check('the ship stops at the standoff from the generation ship',
+ Math.abs(dist - COURSE_DERELICT_STANDOFF) < 200, `${Math.round(dist)} units`);
+ check('...at the derelict\'s own drift', Math.abs(g.state.player.speed - gen.state.speed) < 10,
+ `${g.state.player.speed.toFixed(1)} u/s against ${gen.state.speed.toFixed(1)}`);
+ check('...and the course is done for the visit', g.state.session.coursesDone.includes('derelict'));
+ check('...and the scan says what is there, in the world\'s own words',
+ g.state.session.messageText.length > 20, `said: ${g.state.session.messageText}`);
+}
+
+console.log('\nwhat a derelict\'s scan reports');
+{
+ // The words come off the world's seed, so a derelict tells the same story
+ // on every visit, and two worlds tell different ones (docs/TODO/208 M5).
+ const systems = generateGalaxy(1);
+ const twice = [derelictReport(systems[7]), derelictReport(systems[7])];
+ eq('the same world reports the same thing twice', twice[0], twice[1]);
+ const said = new Set(systems.map((sys) => derelictReport(sys)));
+ check('...and the galaxy tells more than one story', said.size > 3, `${said.size} of them`);
+ check('...each of them a sentence', [...said].every((line) => line.endsWith('.')));
+}
+
+console.log('\nthe hermit course');
+{
+ const g = arrivedWith('hermit');
+ g.state.session.course = 'hermit';
+ fly(g, 400, () => g.state.session.hermitTrading);
+ check('the hermit opens his trade at the end of the course', g.state.session.hermitTrading);
+ check('...with the ship slow enough for the hermit\'s rule',
+ g.state.player.speed < HERMIT_DOCK_SPEED, `${g.state.player.speed.toFixed(1)} u/s`);
+ check('...and the ship never struck the rock', g.state.sys.foreShield > 0 && g.mode !== 'dead');
+}
+
+console.log('\nthe star course');
+{
+ const g = arrived(20_260_913);
+ g.state.commander.equipment.scoops = true;
+ g.state.commander.fuel = 5;
+ g.state.session.course = 'skim';
+ let peak = 0;
+ fly(g, 400, () => {
+ peak = Math.max(peak, g.state.sys.cabinTemp);
+ return g.state.session.course === null;
+ });
+ eq('the star course fills the tank', g.state.commander.fuel, MAX_FUEL);
+ check('...the ship lives, and the cabin stays short of fatal',
+ g.mode !== 'dead' && peak < CABIN_TEMP_FATAL * 0.6, `peak ${peak.toFixed(3)}`);
+ check('...and the course is done for the visit', g.state.session.coursesDone.includes('skim'));
+}
+
+// --- THE COLLECT COURSE HOLDS ONE CANISTER, AND LEADS IT -------------------
+//
+// Chris, 2026-09-12: *"Collecting cargo often seems to be difficult - we miss
+// it quite a lot - especially when it is moving."*
+//
+// Two faults, and a probe named them by flying ONE canister against five. One
+// alone was never missed, at any drift. Five were missed seven times with the
+// cargo at rest. So the first fault was the PICK: the course took the nearest
+// every frame, and turned away from a canister it was nearly on when another
+// drifted closer. The second was the AIM, which was where the canister had
+// been.
+//
+// Both are asserted on the RULE rather than through a two-minute flight. The
+// flight is what measured them, and its figures are beside
+// `COURSE_COLLECT_LEAD`: over 80 runs a drift, the led course took 231 of 240
+// canisters against 191 unled.
+console.log('\nthe collect course holds one canister, and leads it');
+{
+ const DT = 1 / 60;
+ const far = new THREE.Vector3(0, 0, -50_000);
+ const still = (at: THREE.Vector3) => ({ at, velocity: new THREE.Vector3() });
+ const ahead = new THREE.Vector3(0, 0, -600);
+ const aside = new THREE.Vector3(1400, 0, -600);
+
+ // THE PICK. A canister dead ahead asks for no turn. One off to the side asks
+ // for a big one. So the demand says which of the two the course is flying at.
+ const turn = (s: { demand: { pitchRate: number; rollRate: number } | null }): number =>
+ Math.abs(s.demand?.pitchRate ?? 0) + Math.abs(s.demand?.rollRate ?? 0);
+
+ const fresh = new CoursePilot();
+ const onAhead = turn(fresh.step(view(far, { course: 'collect', loot: [still(ahead)] }), DT));
+ const onAside = turn(new CoursePilot()
+ .step(view(far, { course: 'collect', loot: [still(aside)] }), DT));
+ check('the fixture can tell the two apart', onAside > onAhead + 0.01,
+ `${onAhead.toFixed(3)} ahead against ${onAside.toFixed(3)} aside`);
+
+ const held = new CoursePilot();
+ held.step(view(far, { course: 'collect', loot: [still(aside)] }), DT);
+ // ...and now a nearer one turns up, first in the list.
+ const kept = turn(held.step(
+ view(far, { course: 'collect', loot: [still(ahead), still(aside)] }), DT));
+ check('a nearer canister does not steal one the course is already on',
+ kept > onAhead + 0.01, `${kept.toFixed(3)}, against ${onAhead.toFixed(3)} for the near one`);
+
+ // ...until it is gone, and then the nearest is the next.
+ const moved = turn(held.step(view(far, { course: 'collect', loot: [still(ahead)] }), DT));
+ check('...and when it is aboard, the course takes the next', moved <= onAhead + 0.01,
+ `${moved.toFixed(3)}`);
+
+ // THE AIM. A canister dead ahead and drifting sideways is not where it was.
+ const drifting = { at: ahead.clone(), velocity: new THREE.Vector3(200, 0, 0) };
+ const led = turn(new CoursePilot()
+ .step(view(far, { course: 'collect', loot: [drifting] }), DT));
+ check('a drifting canister is aimed ahead of, not at', led > onAhead + 0.01,
+ `${led.toFixed(3)}, against ${onAhead.toFixed(3)} for the same place at rest`);
+}
+
+// ...AND IT DOES NOT FLY AT IT TOO FAST TO CORRECT.
+//
+// Chris, 2026-09-12: *"collecting cargo seems to be broken sometimes. I seem to
+// run at maximum speed, then slow down and then miss it and then run at maximum
+// speed."* A trace showed exactly that: 400 units a second with a canister 250
+// units off, a sail past, and a loop. `arrive` picks its speed from a braking
+// curve, which is right for a big target on a straight line. At 400 the ship's
+// tightest turn has a radius of 276 units, and the scoop reaches 45.
+//
+// Holding one canister (above) made the loop permanent rather than causing it.
+// The old course switched away, which looked like progress.
+{
+ const adrift = { at: new THREE.Vector3(0, 0, -4000), velocity: new THREE.Vector3() };
+ const collect = new CoursePilot()
+ .step(view(new THREE.Vector3(0, 0, -50_000), { course: 'collect', loot: [adrift] }), 1 / 60);
+ check('a canister 4,000 units off still opens the throttle',
+ collect.demand?.throttle === 1);
+ check('...and the cap is under half what the ship can fly',
+ COURSE_COLLECT_CAP < PLAYER_FLIGHT.maxSpeed / 2,
+ `${COURSE_COLLECT_CAP} against ${PLAYER_FLIGHT.maxSpeed}`);
+ check('...and its turning circle is inside twice the scoop',
+ COURSE_COLLECT_CAP / PLAYER_FLIGHT.maxPitch < SCOOP_RANGE * 2,
+ `${(COURSE_COLLECT_CAP / PLAYER_FLIGHT.maxPitch).toFixed(0)} units against ${SCOOP_RANGE}`);
+ check('...while still fast enough to catch a canister at its top drift',
+ COURSE_COLLECT_CAP > 45, `${COURSE_COLLECT_CAP}`);
+}
diff --git a/test/course-fixtures.ts b/test/course-fixtures.ts
new file mode 100644
index 00000000..c3a62bac
--- /dev/null
+++ b/test/course-fixtures.ts
@@ -0,0 +1,90 @@
+// The fixtures the course tests share: a flat view, two arrivals and a flight.
+//
+// A fixture module rather than a block in one file, for the reason
+// `audio-fixtures.ts` is one. `course-pilot.test.ts` and
+// `course-arrivals.test.ts` split on 2026-09-12, and both need the same four. A
+// second copy would be two fixtures that drift, and a fixture that disagrees
+// with its twin measures nothing.
+
+import * as THREE from 'three';
+import { Game } from '../src/game/game.ts';
+import { headlessShell } from '../src/engine/shell.ts';
+import { withoutSaving } from '../src/game/storage.ts';
+import { seedWorld } from '../src/game/rng.ts';
+import type { CourseView } from '../src/game/course-pilot.ts';
+import { DOCK_COMPUTER_RANGE } from '../src/constants/docking-computer.ts';
+import { dismissBriefing } from './harness.ts';
+
+export /** A ship at the origin, nose down −Z, and a station somewhere. */
+const view = (station: THREE.Vector3, over: PartialEXTEND_RANGE_MAX | 850 | | | [attack-run.ts:28](./attack-run.ts#L28) |
| attack-run | EXTEND_RANGE | (EXTEND_RANGE_MIN + EXTEND_RANGE_MAX) / 2 | Default turn-back range for a caller that rolled none — mid-band. | | [attack-run.ts:31](./attack-run.ts#L31) |
| attack-run | UNDER_FIRE_SECONDS | 1.2 | How long a ship keeps to evasive flight after the last hit. | | [attack-run.ts:37](./attack-run.ts#L37) |
-| attack-run | CLOSING_THROTTLE_MIN | 0.45 | The slowest that an attacking ship throttles back to in order to turn. | | [attack-run.ts:45](./attack-run.ts#L45) |
-| attack-run | MIN_CRUISE_FRACTION | 0.43 | A hostile cannot throttle below this fraction of its top speed. | | [attack-run.ts:52](./attack-run.ts#L52) |
+| attack-run | TRADER_CALM_SECONDS | 20 | How long a trader keeps to its run after the last hit it took: twenty seconds. | trader.calmSeconds | [attack-run.ts:53](./attack-run.ts#L53) |
+| attack-run | CLOSING_THROTTLE_MIN | 0.45 | The slowest that an attacking ship throttles back to in order to turn. | | [attack-run.ts:61](./attack-run.ts#L61) |
+| attack-run | MIN_CRUISE_FRACTION | 0.43 | A hostile cannot throttle below this fraction of its top speed. | | [attack-run.ts:68](./attack-run.ts#L68) |
| audio | AUDIBLE_RANGE | SCANNER_RANGE | How far a bang carries, in world units. | audio.audibleRange | [audio.ts:35](./audio.ts#L35) |
| audio | STEREO_WIDTH | 0.7 | How far across the stereo field a sound may sit: 0 is mono, 1 is one ear only. | audio.stereoWidth | [audio.ts:58](./audio.ts#L58) |
| blame | PROVOKES | { laser: true, missile: true, bomb: true, ram: false, } | Which of the commander's damage sources provoke the ship they hit. | | [blame.ts:22](./blame.ts#L22) |
@@ -117,28 +118,25 @@ search names, meanings and values with `npm run constants:find -- "CONTRACT_RANGE | MAX_FUEL | How far away a contract may send you, in tenths of a light year: exactly as far as a full tank reaches. | | [contracts.ts:27](./contracts.ts#L27) |
| contracts | PASSENGER_BERTH_TONNES | 2 | What one passenger costs the hold, in tonnes. | contracts.passengerBerthTonnes | [contracts.ts:51](./contracts.ts#L51) |
| contracts | SMUGGLE_DELIVERY_NOTORIETY | 0.06 | How loudly a delivered smuggling run is talked about, per tonne landed. | contracts.smuggleDeliveryNotoriety | [contracts.ts:81](./contracts.ts#L81) |
-| course | COURSE_TORUS_CONE | 0.1 | How far off the nose the target may sit, in radians, before the course pilot engages the torus drive. | course.torusCone | [course.ts:32](./course.ts#L32) |
-| course | COURSE_AIM_DEADZONE | 0.02 | How near the nose a course counts its target as straight ahead, in radians. | | [course.ts:56](./course.ts#L56) |
-| course | COURSE_ROLL_GATE | 0.05 | How near its bank a course pilot must be before it pulls the nose, in radians. | | [course.ts:83](./course.ts#L83) |
-| course | COURSE_TORUS_DROP | TORUS_MULTIPLIER * PLAYER_FLIGHT.maxSpeed * 2.5 | How far from its target the course pilot drops the torus drive, in world units, before an arrival. | | [course.ts:96](./course.ts#L96) |
-| course | COURSE_ARRIVE_BRAKE | 0.7 | The share of the ship's thrust that an arrival plans to brake with. | course.arriveBrake | [course.ts:108](./course.ts#L108) |
-| course | COURSE_ARRIVE_TOLERANCE | 75 | How near its standoff the ship must be to count as arrived, in world units. | course.arriveTolerance | [course.ts:118](./course.ts#L118) |
-| course | COURSE_DERELICT_STANDOFF | GENERATION_CARGO_SCATTER + 400 | Where the derelict course stops, as a distance from the generation ship's centre, in world units. | | [course.ts:131](./course.ts#L131) |
-| course | COURSE_HERMIT_STANDOFF | 240 | Where the hermit course stops, as a distance from the rock's centre, in world units. | course.hermitStandoff | [course.ts:144](./course.ts#L144) |
-| course | COURSE_SKIM_DISTANCE | 65_000 | The hold distance of the skim course, from the centre of the star, in world units. | course.skimDistance | [course.ts:159](./course.ts#L159) |
-| course | COURSE_PLANET_CLEARANCE | MASS_LOCK_PLANET_ALTITUDE * 1.25 | How high above the planet's surface a course keeps its line, in world units. | | [course.ts:171](./course.ts#L171) |
-| course | COURSE_HERMIT_SPEED | HERMIT_DOCK_SPEED / 2 | The speed at which the hermit course arrives, in world units a second. | | [course.ts:181](./course.ts#L181) |
-| course | SKIP_SPEED | 8 | How much faster the world runs under the fast forward button, as a count of fixed steps in one screen frame (docs/TODO/205 M7). | course.skipSpeed | [course.ts:197](./course.ts#L197) |
-| course | RUN_CLOSE_MARGIN | 50 | Below this lead in top speed, in world units a second, the run row says the ship is only a little faster (docs/TODO/206 M5). | course.runCloseMargin | [course.ts:211](./course.ts#L211) |
-| course | COURSE_RUN_REACH | 50_000 | How far ahead the run course aims, in world units, on the line away from the hostile ships (docs/TODO/206 M5). | course.runReach | [course.ts:221](./course.ts#L221) |
-| course | COURSE_COLLECT_SPEED | 60 | How fast the collect course flies onto a canister, in world units a second (docs/TODO/206 M6). | course.collectSpeed | [course.ts:235](./course.ts#L235) |
-| course | COURSE_COLLECT_LEAD | 5 | The furthest ahead of a drifting canister the collect course will aim, in seconds. | course.collectLead | [course.ts:270](./course.ts#L270) |
-| course | COURSE_COLLECT_CAP | COURSE_COLLECT_SPEED * 2 | The fastest the collect course flies on its way to a canister, in world units a second. | course.collectCap | [course.ts:307](./course.ts#L307) |
-| course | COURSE_DOCK_HANDOVER | 1500 | How far from the station the station course hands the ship to the pilot, in world units (docs/TODO/207 M1). | course.dockHandover | [course.ts:321](./course.ts#L321) |
-| course | COURSE_WATCH_STANDOFF | 1200 | How far from a ship the scan course holds, in world units (docs/TODO/208 M2). | course.watchStandoff | [course.ts:335](./course.ts#L335) |
-| course | COURSE_ESCORT_STANDOFF | 600 | How far from its charge the escort course flies, in world units (docs/TODO/208 M2). | course.escortStandoff | [course.ts:348](./course.ts#L348) |
-| course | COURSE_POLICE_CLEARANCE | SCAN_WARN_RANGE | How wide of a police ship the smuggling course flies, in world units (docs/TODO/208 M4). | | [course.ts:361](./course.ts#L361) |
-| course | COURSE_OBSTACLE_CLEARANCE | PLAYER_FLIGHT.maxSpeed / PLAYER_FLIGHT.maxPitch | How far clear of a solid thing's HULL a course flies, in world units. | | [course.ts:387](./course.ts#L387) |
+| course | COURSE_TORUS_CONE | 0.1 | How far off the nose the target may sit, in radians, before the course pilot engages the torus drive. | course.torusCone | [course.ts:31](./course.ts#L31) |
+| course | COURSE_AIM_DEADZONE | 0.02 | How near the nose a course counts its target as straight ahead, in radians. | | [course.ts:55](./course.ts#L55) |
+| course | COURSE_ROLL_GATE | 0.05 | How near its bank a course pilot must be before it pulls the nose, in radians. | | [course.ts:82](./course.ts#L82) |
+| course | COURSE_TORUS_DROP | TORUS_MULTIPLIER * PLAYER_FLIGHT.maxSpeed * 2.5 | How far from its target the course pilot drops the torus drive, in world units, before an arrival. | | [course.ts:95](./course.ts#L95) |
+| course | COURSE_ARRIVE_BRAKE | 0.7 | The share of the ship's thrust that an arrival plans to brake with. | course.arriveBrake | [course.ts:107](./course.ts#L107) |
+| course | COURSE_ARRIVE_TOLERANCE | 75 | How near its standoff the ship must be to count as arrived, in world units. | course.arriveTolerance | [course.ts:117](./course.ts#L117) |
+| course | COURSE_DERELICT_STANDOFF | GENERATION_CARGO_SCATTER + 400 | Where the derelict course stops, as a distance from the generation ship's centre, in world units. | | [course.ts:130](./course.ts#L130) |
+| course | COURSE_HERMIT_STANDOFF | 240 | Where the hermit course stops, as a distance from the rock's centre, in world units. | course.hermitStandoff | [course.ts:143](./course.ts#L143) |
+| course | COURSE_SKIM_DISTANCE | 65_000 | The hold distance of the skim course, from the centre of the star, in world units. | course.skimDistance | [course.ts:158](./course.ts#L158) |
+| course | COURSE_PLANET_CLEARANCE | MASS_LOCK_PLANET_ALTITUDE * 1.25 | How high above the planet's surface a course keeps its line, in world units. | | [course.ts:170](./course.ts#L170) |
+| course | COURSE_HERMIT_SPEED | HERMIT_DOCK_SPEED / 2 | The speed at which the hermit course arrives, in world units a second. | | [course.ts:180](./course.ts#L180) |
+| course | SKIP_SPEED | 8 | How much faster the world runs under the fast forward button, as a count of fixed steps in one screen frame (docs/TODO/205 M7). | course.skipSpeed | [course.ts:196](./course.ts#L196) |
+| course | RUN_CLOSE_MARGIN | 50 | Below this lead in top speed, in world units a second, the run row says the ship is only a little faster (docs/TODO/206 M5). | course.runCloseMargin | [course.ts:210](./course.ts#L210) |
+| course | COURSE_RUN_REACH | 50_000 | How far ahead the run course aims, in world units, on the line away from the hostile ships (docs/TODO/206 M5). | course.runReach | [course.ts:220](./course.ts#L220) |
+| course | COURSE_COLLECT_SPEED | 60 | How fast the collect course flies onto a canister, in world units a second (docs/TODO/206 M6). | course.collectSpeed | [course.ts:234](./course.ts#L234) |
+| course | COURSE_COLLECT_LEAD | 5 | The furthest ahead of a drifting canister the collect course will aim, in seconds. | course.collectLead | [course.ts:269](./course.ts#L269) |
+| course | COURSE_COLLECT_CAP | COURSE_COLLECT_SPEED * 2 | The fastest the collect course flies on its way to a canister, in world units a second. | course.collectCap | [course.ts:306](./course.ts#L306) |
+| course | COURSE_DOCK_HANDOVER | 1500 | How far from the station the station course hands the ship to the pilot, in world units (docs/TODO/207 M1). | course.dockHandover | [course.ts:320](./course.ts#L320) |
+| course | COURSE_OBSTACLE_CLEARANCE | PLAYER_FLIGHT.maxSpeed / PLAYER_FLIGHT.maxPitch | How far clear of a solid thing's HULL a course flies, in world units. | | [course.ts:346](./course.ts#L346) |
| docking | GATE_HALF_WIDTHS | 5 | How far out the approach gate sits, in multiples of the station half-width. | docking.gateHalfWidths | [docking.ts:25](./docking.ts#L25) |
| docking | TURN_IN | Math.PI / 4 | How far round from the slot axis an approach turns in, in radians. | | [docking.ts:49](./docking.ts#L49) |
| docking | RUN_IN_WIDTHS | GATE_HALF_WIDTHS * 0.6 | Where the approach stops the curve and flies straight, in station half-widths. | | [docking.ts:87](./docking.ts#L87) |
@@ -249,6 +247,10 @@ search names, meanings and values with `npm run constants:find -- "SALE_NOTORIETY_REVENUE | 40_000 | What a sale does to your reputation. | | [market.ts:30](./market.ts#L30) |
| market | SALE_NOTORIETY_CONTRABAND | 0.04 | Extra talk per tonne of CONTRABAND sold, on top of the takings. | | [market.ts:45](./market.ts#L45) |
| market | SALE_NOTORIETY_MAX | 0.5 | The most heat that one sale can raise, out of the 0..1 bar that `LivingGalaxy` keeps. | market.saleNotorietyMax | [market.ts:61](./market.ts#L61) |
+| mission-course | COURSE_WATCH_STANDOFF | 1200 | How far from a ship the scan course holds, in world units (docs/TODO/208 M2). | course.watchStandoff | [mission-course.ts:23](./mission-course.ts#L23) |
+| mission-course | COURSE_ESCORT_STANDOFF | 600 | How far from its charge the escort course flies, in world units (docs/TODO/208 M2). | course.escortStandoff | [mission-course.ts:36](./mission-course.ts#L36) |
+| mission-course | COURSE_ESCORT_CLOSING | 60 | How much faster than its charge the escort course may close, in world units a second, once it is inside three standoffs of it: sixty. | course.escortClosing | [mission-course.ts:51](./mission-course.ts#L51) |
+| mission-course | COURSE_POLICE_CLEARANCE | SCAN_WARN_RANGE | How wide of a police ship the smuggling course flies, in world units (docs/TODO/208 M4). | | [mission-course.ts:64](./mission-course.ts#L64) |
| missions | MISSION_KILL_THRESHOLD | 16 | Kills before the Navy considers you worth a word: 16, as the original demanded. | missions.killThreshold | [missions.ts:14](./missions.ts#L14) |
| missions | MISSION_HUNT_RANGE | { min: 30, max: 80 } as const | The Constrictor hides this far from where you are briefed, in tenths of a light year. | | [missions.ts:22](./missions.ts#L22) |
| missions | MISSION_COURIER_RANGE | { min: 50, max: 90 } as const | The courier run is longer: the plans matter more than your convenience. | | [missions.ts:25](./missions.ts#L25) |
diff --git a/src/constants/attack-run.ts b/src/constants/attack-run.ts
index e67fbec2..62f9fc97 100644
--- a/src/constants/attack-run.ts
+++ b/src/constants/attack-run.ts
@@ -36,6 +36,22 @@ export const EXTEND_RANGE = (EXTEND_RANGE_MIN + EXTEND_RANGE_MAX) / 2;
*/
export const UNDER_FIRE_SECONDS = 1.2;
+/**
+ * How long a trader keeps to its run after the last hit it took: twenty
+ * seconds. After that, with no live attacker, it goes back to work.
+ *
+ * A trader that took a hit ran for the rest of its life until docs/TODO/213
+ * M1. An escort's charge that a pirate grazed then flew from the station
+ * for ever. So did one that the commander's own course bumped. The escort
+ * could not end. The clock is the same shape as `UNDER_FIRE_SECONDS`: a decay from
+ * the last hit, not a latch. It is much longer, because a trader that turns
+ * back into a fight it just fled is a trader that dies. Twenty seconds is a
+ * pirate wave's approach, so a wave that is still there keeps it running.
+ *
+ * @rule trader.calmSeconds
+ */
+export const TRADER_CALM_SECONDS = 20;
+
/**
* The slowest that an attacking ship throttles back to in order to turn. There
* are two literals on purpose. This one sits just above `MIN_CRUISE_FRACTION`, so
diff --git a/src/constants/course.ts b/src/constants/course.ts
index c7b7c397..60ccc500 100644
--- a/src/constants/course.ts
+++ b/src/constants/course.ts
@@ -7,7 +7,6 @@ import { HERMIT_DOCK_SPEED } from './hermit-market.ts';
import { PLAYER_FLIGHT } from './player-flight.ts';
import { MASS_LOCK_PLANET_ALTITUDE, TORUS_MULTIPLIER } from './torus.ts';
import { GENERATION_CARGO_SCATTER } from './spawn-placement.ts';
-import { SCAN_WARN_RANGE } from './law.ts';
/**
* How far off the nose the target may sit, in radians, before the course
@@ -320,46 +319,6 @@ export const COURSE_COLLECT_CAP = COURSE_COLLECT_SPEED * 2;
*/
export const COURSE_DOCK_HANDOVER = 1500;
-/**
- * How far from a ship the scan course holds, in world units
- * (docs/TODO/208 M2).
- *
- * A scan counts seconds while the ship is inside `SCANNER_RANGE`, which is
- * 6,000, and within `WATCH_CONE` of the nose. So the hold sits well inside
- * the range, and near enough that the ship fills a useful part of the cone.
- * It is far enough out that a trader's own wandering does not shake it off.
- *
- * @rule course.watchStandoff
- * @domain course
- */
-export const COURSE_WATCH_STANDOFF = 1200;
-
-/**
- * How far from its charge the escort course flies, in world units
- * (docs/TODO/208 M2).
- *
- * The escort is safe when no hostile ship is within 3,500 units of the
- * charge. A pilot who flies this close is inside that ring, and the fight
- * comes to the pilot rather than to the charge.
- *
- * @rule course.escortStandoff
- * @domain course
- */
-export const COURSE_ESCORT_STANDOFF = 600;
-
-/**
- * How wide of a police ship the smuggling course flies, in world units
- * (docs/TODO/208 M4).
- *
- * A policeman reads a hold inside `SCAN_RANGE`, which is 2,600 units. This is
- * the warning band, `SCAN_WARN_RANGE`, so the course keeps a margin outside
- * the range that would end the job. It is the same rule from the other side,
- * so the two cannot drift apart.
- *
- * @domain course
- */
-export const COURSE_POLICE_CLEARANCE = SCAN_WARN_RANGE;
-
/**
* How far clear of a solid thing's HULL a course flies, in world units.
*
diff --git a/src/constants/mission-course.ts b/src/constants/mission-course.ts
new file mode 100644
index 00000000..0c9e4f5e
--- /dev/null
+++ b/src/constants/mission-course.ts
@@ -0,0 +1,64 @@
+// How a mission's course flies the ship (docs/TODO/208).
+//
+// A live job is the first row of the course list. `game/course-pilot.ts`
+// flies it in one of five shapes: a fight, a hold, an escort, a scoop and a
+// slip past the police. These are the numbers the hold, the escort and the
+// slip spend. They left `course.ts` when docs/TODO/213 M1 pushed that file
+// over the size ceiling, and a mission's course is a subject of its own.
+
+import { SCAN_WARN_RANGE } from './law.ts';
+
+/**
+ * How far from a ship the scan course holds, in world units
+ * (docs/TODO/208 M2).
+ *
+ * A scan counts seconds while the ship is inside `SCANNER_RANGE`, which is
+ * 6,000, and within `WATCH_CONE` of the nose. So the hold sits well inside
+ * the range, and near enough that the ship fills a useful part of the cone.
+ * It is far enough out that a trader's own wandering does not shake it off.
+ *
+ * @rule course.watchStandoff
+ * @domain mission-course
+ */
+export const COURSE_WATCH_STANDOFF = 1200;
+
+/**
+ * How far from its charge the escort course flies, in world units
+ * (docs/TODO/208 M2).
+ *
+ * The escort is safe when no hostile ship is within 3,500 units of the
+ * charge. A pilot who flies this close is inside that ring, and the fight
+ * comes to the pilot rather than to the charge.
+ *
+ * @rule course.escortStandoff
+ * @domain mission-course
+ */
+export const COURSE_ESCORT_STANDOFF = 600;
+
+/**
+ * How much faster than its charge the escort course may close, in world
+ * units a second, once it is inside three standoffs of it: sixty.
+ *
+ * The approach used to brake from full speed to the charge's own at the
+ * standoff, and it overshot into the hull (docs/TODO/213 M1). A ram is a hit
+ * from the commander, and a trader that is hit runs. So the escort's own
+ * course set its charge to flight in one run of eight. Sixty over the charge
+ * closes 1,800 units in half a minute, and it stops inside the tolerance.
+ *
+ * @rule course.escortClosing
+ * @domain mission-course
+ */
+export const COURSE_ESCORT_CLOSING = 60;
+
+/**
+ * How wide of a police ship the smuggling course flies, in world units
+ * (docs/TODO/208 M4).
+ *
+ * A policeman reads a hold inside `SCAN_RANGE`, which is 2,600 units. This is
+ * the warning band, `SCAN_WARN_RANGE`, so the course keeps a margin outside
+ * the range that would end the job. It is the same rule from the other side,
+ * so the two cannot drift apart.
+ *
+ * @domain mission-course
+ */
+export const COURSE_POLICE_CLEARANCE = SCAN_WARN_RANGE;
diff --git a/src/game/course-clearance.ts b/src/game/course-clearance.ts
index 89e047cb..e0995047 100644
--- a/src/game/course-clearance.ts
+++ b/src/game/course-clearance.ts
@@ -19,9 +19,8 @@
// the side the line already passes, and half as far again.
import * as THREE from 'three';
-import {
- COURSE_OBSTACLE_CLEARANCE, COURSE_PLANET_CLEARANCE, COURSE_POLICE_CLEARANCE,
-} from '../constants/course.ts';
+import { COURSE_OBSTACLE_CLEARANCE, COURSE_PLANET_CLEARANCE } from '../constants/course.ts';
+import { COURSE_POLICE_CLEARANCE } from '../constants/mission-course.ts';
/** Something solid a course must not fly through — see `clearOfObstacles`. */
export interface Obstacle {
diff --git a/src/game/course-pilot.ts b/src/game/course-pilot.ts
index 22c888c4..ca16a673 100644
--- a/src/game/course-pilot.ts
+++ b/src/game/course-pilot.ts
@@ -54,9 +54,12 @@ import { SLOT_SPEED_LIMIT } from '../constants/docking.ts';
import {
COURSE_AIM_DEADZONE, COURSE_ROLL_GATE, COURSE_ARRIVE_BRAKE, COURSE_ARRIVE_TOLERANCE, COURSE_DERELICT_STANDOFF,
COURSE_COLLECT_CAP, COURSE_COLLECT_LEAD, COURSE_HERMIT_SPEED, COURSE_HERMIT_STANDOFF,
- COURSE_COLLECT_SPEED, COURSE_ESCORT_STANDOFF, COURSE_RUN_REACH, COURSE_SKIM_DISTANCE,
- COURSE_TORUS_CONE, COURSE_TORUS_DROP, COURSE_WATCH_STANDOFF,
+ COURSE_COLLECT_SPEED, COURSE_PLANET_CLEARANCE, COURSE_RUN_REACH, COURSE_SKIM_DISTANCE,
+ COURSE_TORUS_CONE, COURSE_TORUS_DROP,
} from '../constants/course.ts';
+import {
+ COURSE_ESCORT_CLOSING, COURSE_ESCORT_STANDOFF, COURSE_WATCH_STANDOFF,
+} from '../constants/mission-course.ts';
import {
clearOfObstacles, clearOfPlanet, clearOfPolice, type Obstacle,
} from './course-clearance.ts';
@@ -129,6 +132,8 @@ export interface CourseStep {
readonly handOver: boolean;
/** the course is finished, and it leaves the ship */
readonly done: boolean;
+ /** why it ended before its work was done, for the console; absent for an ordinary end */
+ readonly why?: string;
}
const IDLE: CourseStep = { demand: null, torus: false, handOver: false, done: false };
@@ -236,10 +241,24 @@ export class CoursePilot {
// A slip is the station course on a line wide of the police
// (docs/TODO/208 M4). It hands the ship over as that course does.
if (m.how === 'slip') return this.toStation(v, dt, m.at);
+ // A TARGET BELOW THE CLEARANCE IS REFUSED (docs/TODO/213 M1). A ship
+ // does not crash, and a charge that ran flew through the planet with
+ // the course 600 units behind it. The commander crashed at 80 units
+ // with full shields. The line round the planet cannot help when the
+ // target itself is inside it.
+ if (m.at.distanceTo(v.planetPos) - v.planetRadius < COURSE_PLANET_CLEARANCE) {
+ return { ...ended(), why: 'THE TARGET IS TOO NEAR THE PLANET — COURSE OFF' };
+ }
const standoff = m.how === 'hold' ? COURSE_WATCH_STANDOFF
: m.how === 'escort' ? COURSE_ESCORT_STANDOFF : 0;
const speed = m.how === 'scoop' ? COURSE_COLLECT_SPEED : m.speed;
- return { ...this.arrive(v, { target: m.at, standoff, speed }, dt), done: false };
+ // An escort closes gently. Inside three standoffs its speed is the
+ // charge's plus `COURSE_ESCORT_CLOSING`, so the approach cannot ram
+ // the charge and set it to flight (docs/TODO/213 M1).
+ const near = m.how === 'escort'
+ && v.position.distanceTo(m.at) <= COURSE_ESCORT_STANDOFF * 3;
+ const cap = near ? m.speed + COURSE_ESCORT_CLOSING : undefined;
+ return { ...this.arrive(v, { target: m.at, standoff, speed, cap }, dt), done: false };
}
default: return IDLE;
}
diff --git a/src/game/flight-course.ts b/src/game/flight-course.ts
index 58452ffb..c4165485 100644
--- a/src/game/flight-course.ts
+++ b/src/game/flight-course.ts
@@ -157,7 +157,7 @@ export class FlightCourse {
if (step.torus !== s.torusEngaged && (!step.torus || !this.host.massLocked())) {
this.host.toggleTorus();
}
- if (step.done) this.endCourse(s.course);
+ if (step.done) this.endCourse(s.course, step.why);
return step.demand;
}
@@ -275,15 +275,16 @@ export class FlightCourse {
* A course finished its work. It leaves the ship, and it leaves the list
* for the rest of the visit. The console says what the ship did.
*/
- private endCourse(kind: CourseKind): void {
+ private endCourse(kind: CourseKind, why?: string): void {
const s = this.state.session;
s.course = null;
- if (!s.coursesDone.includes(kind)) s.coursesDone.push(kind);
+ // A course that ended for a reason is not done: the list offers it again.
+ if (why === undefined && !s.coursesDone.includes(kind)) s.coursesDone.push(kind);
this.coursePilot.reset();
// The derelict's own words, read off the world's seed (docs/TODO/208 M5).
- const said = kind === 'derelict'
+ const said = why ?? (kind === 'derelict'
? derelictReport(this.state.systems[this.state.commander.systemIndex] as StarSystem)
- : COURSE_ENDS[kind];
+ : COURSE_ENDS[kind]);
if (said) this.host.showMessage(said, 6);
}
}
diff --git a/src/game/npc-state.ts b/src/game/npc-state.ts
index a0d4e0d2..c0b5a73a 100644
--- a/src/game/npc-state.ts
+++ b/src/game/npc-state.ts
@@ -153,6 +153,12 @@ export interface NpcState {
flownBy: 'brain' | 'scripted' | 'pursuit' | 'fleeing' | 'none';
/** seconds of evasive flying left after the last hit taken — see break-off.ts */
underFire: number;
+ /**
+ * Seconds since the last hit this ship took. A trader that fled reads it
+ * against `TRADER_CALM_SECONDS` to go back to work (docs/TODO/213 M1). It
+ * is saved with the rest, and an old save reads it as 0.
+ */
+ calm: number;
/**
* How far out THIS run goes before turning back, rolled from the band in
* break-off.ts every time the ship starts extending. State for `hasEcm`'s
@@ -275,7 +281,7 @@ export function freshNpcState(maxEnergy: number): NpcState {
tumbleAxis: randomDirection(new THREE.Vector3()),
energy: maxEnergy, regenCarry: 0,
alive: true, provoked: false, provokedByPlayer: false, missiles: 0,
- missionTag: null, targeted: false, announcedClose: false, observed: 0, missionReported: false, fleeing: false, attackPhase: 'closing', underFire: 0, flownBy: 'none',
+ missionTag: null, targeted: false, announcedClose: false, observed: 0, missionReported: false, fleeing: false, attackPhase: 'closing', underFire: 0, calm: 0, flownBy: 'none',
extendRange: EXTEND_RANGE_MAX, passSide: 1, passesMade: 0,
tactic: 'run', tacticClock: 0, dryFor: 0,
tradeTimer: 0,
diff --git a/src/game/npc-trader.ts b/src/game/npc-trader.ts
index 38dedce8..c947858e 100644
--- a/src/game/npc-trader.ts
+++ b/src/game/npc-trader.ts
@@ -31,6 +31,7 @@ import * as THREE from 'three';
import { defenceBrain } from './brains.ts';
import { defenceBrainNameFor } from './brain-names.ts';
import { TURN_AND_FIGHT_RANGE } from '../constants/player-interest.ts';
+import { TRADER_CALM_SECONDS } from '../constants/attack-run.ts';
import { approach, velocityOf } from './flight-maths.ts';
import { attack } from './npc-attack-run.ts';
import { brainFly } from './npc-brain-pilot.ts';
@@ -54,6 +55,14 @@ class Trader implements NpcBehaviour {
const distPlayer = tmpDir.copy(player.position)
.sub(ship.object.position).length();
+ // THE RUN ENDS. A trader that took no hit for the calm, with nobody left
+ // hunting it, goes back to its working life (docs/TODO/213 M1). It used
+ // to run for the rest of its life, so an escort's charge that was grazed
+ // once never reached the station.
+ if (ship.state.fleeing && ship.state.calm >= TRADER_CALM_SECONDS
+ && ship.nearestAttacker(dt) === null) {
+ ship.state.fleeing = false;
+ }
if (ship.state.fleeing) {
// Armed traders turn and fight. WHICH pilot is brain-names.ts's answer.
// The shipped answer is the hand-written three-phase attack run, pointed
diff --git a/src/game/npc.ts b/src/game/npc.ts
index d4846370..0b8b75af 100644
--- a/src/game/npc.ts
+++ b/src/game/npc.ts
@@ -789,6 +789,7 @@ export class NpcShip {
// funnels through, because damage-dealt.ts routes lasers, ordnance and rams
// here. So the attack run answers all of them, and not gunfire alone.
this.state.underFire = UNDER_FIRE_SECONDS;
+ this.state.calm = 0;
if (byPlayer) this.state.provokedByPlayer = true;
if (from && this.role === 'trader') {
this.state.fleeFrom.copy(from);
@@ -819,6 +820,7 @@ export class NpcShip {
tickClocks(dt: number): void {
this.regenerate(dt);
this.state.underFire = Math.max(0, this.state.underFire - dt);
+ this.state.calm += dt;
this.state.missileReload = Math.max(0, this.state.missileReload - dt);
}
diff --git a/src/missions/verbs/escort.ts b/src/missions/verbs/escort.ts
index 6911aa62..9b93c3c7 100644
--- a/src/missions/verbs/escort.ts
+++ b/src/missions/verbs/escort.ts
@@ -3,7 +3,9 @@
// The game decides when that holds and sends `escortSafe` (world-step.ts,
// under the rules docs/TODO/190 states). This leg only reads the verdict. A
// ship lost on the way, to anyone, is `targetDestroyed`. One that jumps out
-// is `targetEscaped`.
+// is `targetEscaped`, whether it left on its own or ran from a hit. The
+// charge that ran was ignored until docs/TODO/213 M1, and the commonest
+// way an escort is lost was silent.
import type { VerbModule } from './verb.ts';
@@ -12,6 +14,6 @@ export const escort: VerbModule = (ctx, input) => {
if (!('tag' in input) || input.tag !== ctx.live.tag) return null;
if (input.kind === 'escortSafe') return { trigger: 'success' };
if (input.kind === 'escortLost' || input.kind === 'destroyed') return { trigger: 'targetDestroyed' };
- if (input.kind === 'escaped') return { trigger: 'targetEscaped' };
+ if (input.kind === 'escaped' || input.kind === 'fled') return { trigger: 'targetEscaped' };
return null;
};
diff --git a/src/missions/verbs/scan.ts b/src/missions/verbs/scan.ts
index 981ef136..98284cab 100644
--- a/src/missions/verbs/scan.ts
+++ b/src/missions/verbs/scan.ts
@@ -2,7 +2,9 @@
//
// The game counts the seconds (world-step.ts) and sends `scanned` once they
// are up. A target destroyed before then is `targetDestroyed`, and a patron
-// who wanted it watched, not killed, says what that costs.
+// who wanted it watched, not killed, says what that costs. A subject wrecked
+// with credit to nobody is destroyed all the same. One that ran from a hit
+// escaped, as much as one that jumped out (docs/TODO/213 M1).
import type { VerbModule } from './verb.ts';
@@ -10,7 +12,7 @@ export const scan: VerbModule = (ctx, input) => {
if (ctx.leg.verb.kind !== 'scan') return null;
if (!('tag' in input) || input.tag !== ctx.live.tag) return null;
if (input.kind === 'scanned') return { trigger: 'success' };
- if (input.kind === 'destroyed') return { trigger: 'targetDestroyed' };
- if (input.kind === 'escaped') return { trigger: 'targetEscaped' };
+ if (input.kind === 'destroyed' || input.kind === 'escortLost') return { trigger: 'targetDestroyed' };
+ if (input.kind === 'escaped' || input.kind === 'fled') return { trigger: 'targetEscaped' };
return null;
};
diff --git a/test/course-pilot.test.ts b/test/course-pilot.test.ts
index 3701c2c0..ba66d01a 100644
--- a/test/course-pilot.test.ts
+++ b/test/course-pilot.test.ts
@@ -194,6 +194,22 @@ console.log('\nthe station course goes round a planet in its way');
eq('...and it still docks', g.mode, 'docked');
}
+console.log('\na mission target below the clearance is refused');
+{
+ // docs/TODO/213 M1: a charge that ran flew through the planet, and the
+ // escort course followed it. The commander crashed with full shields.
+ const v = view(new THREE.Vector3(0, 0, -50_000));
+ const low = v.planetPos.clone().add(new THREE.Vector3(0, -(v.planetRadius + 100), 0));
+ const refused = new CoursePilot().step(
+ view(v.stationPos, { course: 'mission', mission: { at: low, speed: 100, how: 'escort' } }), 1 / 60);
+ check('a target 100 units above the planet ends the course', refused.done);
+ check('...with a reason for the console', typeof refused.why === 'string' && refused.why.length > 0);
+ const clear = v.planetPos.clone().add(new THREE.Vector3(0, -(v.planetRadius + COURSE_PLANET_CLEARANCE * 2), 0));
+ const flown = new CoursePilot().step(
+ view(v.stationPos, { course: 'mission', mission: { at: clear, speed: 100, how: 'escort' } }), 1 / 60);
+ check('...and one well above it is flown (the control)', !flown.done && flown.demand !== null);
+}
+
console.log('\nnothing appears inside the planet');
{
// docs/TODO/205 M3 found a hermit 1,545 units inside the planet, and a
diff --git a/test/mission-courses.test.ts b/test/mission-courses.test.ts
index 3c2919e9..547bb25a 100644
--- a/test/mission-courses.test.ts
+++ b/test/mission-courses.test.ts
@@ -18,7 +18,8 @@ import { runMissions } from '../src/game/mission-bridge.ts';
import { missionCourse } from '../src/game/mission-course.ts';
import { clearOfPolice } from '../src/game/course-clearance.ts';
import { SCAN_RANGE } from '../src/constants/law.ts';
-import { COURSE_POLICE_CLEARANCE } from '../src/constants/course.ts';
+import { COURSE_ESCORT_STANDOFF, COURSE_POLICE_CLEARANCE } from '../src/constants/mission-course.ts';
+import { TRADER_CALM_SECONDS } from '../src/constants/attack-run.ts';
import { COURSE_KEYS } from '../src/game/bindings.ts';
import { keymap } from '../src/engine/keymap.ts';
import { check, dismissBriefing, eq } from './harness.ts';
@@ -124,6 +125,32 @@ const words = (g: Game): string | null => missionCourse(
g.coursePanel()?.rows?.some((r) => r.kind === 'station') === true);
}
+console.log('\nthe escort course does not ram its charge, and a grazed charge goes back to work');
+{
+ // docs/TODO/213 M1. The approach braked from full speed to the charge's
+ // own at the standoff and overshot into the hull. A ram is a hit from the
+ // commander, and a trader that is hit ran for the rest of its life.
+ const g = onTheJob('side-escort', 20_260_947);
+ const charge = g.state.world.npcs.find((n) => n.state.missionTag !== null);
+ if (!charge) throw new Error('the escort spawned no charge');
+ const shields = g.state.sys.foreShield;
+ let nearest = Infinity;
+ fly(g, 90, () => {
+ nearest = Math.min(nearest, g.state.player.position.distanceTo(charge.object.position));
+ return false;
+ });
+ check('ninety seconds beside the charge cost no shield', g.state.sys.foreShield === shields,
+ `${g.state.sys.foreShield} of ${shields}`);
+ check('...and the ship never came inside a quarter of the standoff',
+ nearest > COURSE_ESCORT_STANDOFF / 4, `${Math.round(nearest)} units at the nearest`);
+ check('...and the charge is not on the run', !charge.state.fleeing);
+ charge.takeLaserHit(1, g.state.player.position.clone(), false);
+ check('a graze sets the charge to flight', charge.state.fleeing);
+ fly(g, TRADER_CALM_SECONDS + 5, () => !charge.state.fleeing);
+ check('...and it goes back to work once the calm has passed', !charge.state.fleeing);
+ eq('...on its way to the station', charge.state.traderPhase, 'arriving');
+}
+
console.log('\na hunted ship that runs has fled, not escaped');
{
// Before docs/TODO/208 M3 the world sent `escaped` whichever way a tagged
diff --git a/test/mission-verbs.test.ts b/test/mission-verbs.test.ts
index 42601288..56fb1c88 100644
--- a/test/mission-verbs.test.ts
+++ b/test/mission-verbs.test.ts
@@ -172,6 +172,10 @@ console.log('\nescort and scan, through the machine');
stepMissions(st, { kind: 'escortLost', tag }, moved(ctx, target)).state.done[SIDE_ESCORT.id], 'fail');
eq('...and so does one that jumps out',
stepMissions(st, { kind: 'escaped', tag }, moved(ctx, target)).state.done[SIDE_ESCORT.id], 'fail');
+ // A charge that a pirate hit runs, and jumps out while it runs. The world
+ // sends `fled` for that, and the verb ignored it until docs/TODO/213 M1.
+ eq('...and so does one that ran from a hit',
+ stepMissions(st, { kind: 'fled', tag }, moved(ctx, target)).state.done[SIDE_ESCORT.id], 'fail');
const sctx = boardFor(SIDE_SCAN);
const sst = accept(SIDE_SCAN, sctx);
@@ -183,6 +187,10 @@ console.log('\nescort and scan, through the machine');
eq('the scan pays', paid(scanned.effects), SIDE_JOB_PAY.scan);
const killed = stepMissions(sst, { kind: 'destroyed', tag: stag }, sctx);
eq('a subject destroyed fails it, and the patron minds', killed.state.standing[`world-${sctx.commander.systemIndex}`], -3);
+ const wrecked = stepMissions(sst, { kind: 'escortLost', tag: stag }, sctx);
+ eq('...and one wrecked by nobody is destroyed all the same', wrecked.state.standing[`world-${sctx.commander.systemIndex}`], -3);
+ eq('a subject that ran from a hit has escaped',
+ stepMissions(sst, { kind: 'fled', tag: stag }, sctx).state.done[SIDE_SCAN.id], 'fail');
const hctx = boardFor(SIDE_HUNT);
const hst = accept(SIDE_HUNT, hctx);
diff --git a/test/npc-trader.test.ts b/test/npc-trader.test.ts
index 979e6299..1150a0f6 100644
--- a/test/npc-trader.test.ts
+++ b/test/npc-trader.test.ts
@@ -24,6 +24,7 @@ import type { BehaviourShip } from '../src/game/npc-behaviour.ts';
import { freshNpcState } from '../src/game/npc-state.ts';
import { seedWorld } from '../src/game/rng.ts';
import { SHIPPED_BRAINS } from '../src/game/brain-names.ts';
+import { TRADER_CALM_SECONDS } from '../src/constants/attack-run.ts';
import { check, eq } from './harness.ts';
// --- it flies a BehaviourShip, and it calls the working life ----------------
@@ -135,4 +136,25 @@ console.log('a trader, flown off an object literal');
eq('...and it reports no flight model', it.state.flownBy, 'none');
eq('...and it advanced too', it.advanced(), DT);
}
+
+ // 3. THE RUN ENDS (docs/TODO/213 M1). A trader that fled ran for the rest
+ // of its life, so an escort's charge that was grazed once never reached
+ // the station. The calm is seconds since the last hit, with nobody left
+ // hunting it.
+ {
+ const it = ship(true);
+ it.state.calm = TRADER_CALM_SECONDS;
+ traderBehaviour().fly(it, DT, commander, view);
+ check('a trader calm for long enough goes back to work', !it.state.fleeing);
+ check('...and picks a waypoint at once', it.state.waypoint.lengthSq() > 0);
+ const still = ship(true);
+ still.state.calm = TRADER_CALM_SECONDS - 1;
+ traderBehaviour().fly(still, DT, commander, view);
+ check('one that was hit more recently still runs', still.state.fleeing);
+ const hunted = ship(true);
+ hunted.state.calm = TRADER_CALM_SECONDS * 2;
+ hunted.nearestAttacker = () => ({} as never);
+ traderBehaviour().fly(hunted, DT, commander, view);
+ check('...and so does one that is still hunted, however calm', hunted.state.fleeing);
+ }
}
From 1af5cbef2c653b267c563d3d8730b1212de14e88 Mon Sep 17 00:00:00 2001
From: Chris Greening COURSE_ESCORT_STANDOFF | 600 | How far from its charge the escort course flies, in world units (docs/TODO/208 M2). | course.escortStandoff | [mission-course.ts:36](./mission-course.ts#L36) |
| mission-course | COURSE_ESCORT_CLOSING | 60 | How much faster than its charge the escort course may close, in world units a second, once it is inside three standoffs of it: sixty. | course.escortClosing | [mission-course.ts:51](./mission-course.ts#L51) |
| mission-course | COURSE_POLICE_CLEARANCE | SCAN_WARN_RANGE | How wide of a police ship the smuggling course flies, in world units (docs/TODO/208 M4). | | [mission-course.ts:64](./mission-course.ts#L64) |
-| missions | MISSION_KILL_THRESHOLD | 16 | Kills before the Navy considers you worth a word: 16, as the original demanded. | missions.killThreshold | [missions.ts:14](./missions.ts#L14) |
-| missions | MISSION_HUNT_RANGE | { min: 30, max: 80 } as const | The Constrictor hides this far from where you are briefed, in tenths of a light year. | | [missions.ts:22](./missions.ts#L22) |
-| missions | MISSION_COURIER_RANGE | { min: 50, max: 90 } as const | The courier run is longer: the plans matter more than your convenience. | | [missions.ts:25](./missions.ts#L25) |
-| missions | CONSTRICTOR_BOUNTY | 25_000 | What a kill of the Constrictor pays — 2,500 Cr, in tenths of a credit. | | [missions.ts:28](./missions.ts#L28) |
-| missions | COURIER_PAYMENT | 15_000 | ...and what a delivery of the plans pays: 1,500 Cr. | | [missions.ts:31](./missions.ts#L31) |
-| missions | MISSION_LIVE_CAP | 3 | How many missions a commander can hold open at one time: three. | missions.liveCap | [missions.ts:47](./missions.ts#L47) |
-| missions | MISSION_REOFFER_DAYS | 7 | Days before a finished side job is offered again: a week. | missions.reofferDays | [missions.ts:63](./missions.ts#L63) |
-| missions | LEAD_RUMOUR_JUMPS | 5 | Inside this many jumps of a lead's world, the station talks about it. | missions.leadRumourJumps | [missions.ts:76](./missions.ts#L76) |
-| missions | LEAD_NAG_DOCKS | 4 | Docks with no mission progress before a patron with a lead writes a second time. | missions.leadNagDocks | [missions.ts:88](./missions.ts#L88) |
-| missions | SIDE_JOB_PAY | { hunt: 8_000, deliver: 3_000, recover: 4_000, rescue: 5_000, ambush: 6_000, smuggle: 7_000, escort: 6_000, scan: 2_500, } as const | What a side job pays, per verb, in tenths of a credit. | missions.sideJobPay | [missions.ts:102](./missions.ts#L102) |
-| missions | RESCUE_SALVAGE_PAY | 1_500 | The lower fee a rescue pays when the pod is lost and the data still arrives, in tenths of a credit. | missions.rescueSalvagePay | [missions.ts:118](./missions.ts#L118) |
-| missions | SIDE_JOB_RANGE | { min: 20, max: 70 } as const | How far a side job sends the commander, in tenths of a light year: two to seven. | missions.sideJobRange | [missions.ts:126](./missions.ts#L126) |
-| missions | SIDE_JOB_DAYS | 14 | Days a side job allows before its deadline passes: two weeks. | missions.sideJobDays | [missions.ts:133](./missions.ts#L133) |
-| missions | DEADLINE_WARNING_DAYS | 3 | How many days before a deadline the console starts to say how many are left. | missions.deadlineWarningDays | [missions.ts:144](./missions.ts#L144) |
-| missions | SCAN_SECONDS | 20 | Seconds a scan target must stay under the scanner lock: twenty, which is a pass and a turn at a trader's speed. | missions.scanSeconds | [missions.ts:152](./missions.ts#L152) |
-| missions | WATCH_CONE | 0.35 | How far off the centre of the view a scan's subject may sit and still count as watched, in radians: 0.35, which is twenty degrees. | missions.watchCone | [missions.ts:166](./missions.ts#L166) |
-| missions | SMUGGLE_TONNES | 3 | Tonnes of the patron's goods on a smuggle job: three, which fits a Cobra's hold beside its own stock. | missions.smuggleTonnes | [missions.ts:178](./missions.ts#L178) |
-| missions | ESCORT_ENEMY_ROLES | ['pirate', 'hunter', 'thargoid', 'thargon'] | The roles that count as an enemy near an escorted ship: the ones that prey. | | [missions.ts:187](./missions.ts#L187) |
-| missions | TOUR_ARCS | 5 | How many arcs the tour holds: five, Chris's number (docs/TODO/190, item 192). | missions.tourArcs | [missions.ts:197](./missions.ts#L197) |
-| missions | TOUR_STEP_JUMPS | { min: 4, max: 6 } as const | How far each arc's start world is from the one before it, in JUMPS on the full-tank graph: four to six. | missions.tourStepJumps | [missions.ts:210](./missions.ts#L210) |
-| missions | ARC_HANDOVER_JUMPS | { min: 2, max: 4 } as const | How far an arc's final leg lies from the next arc's start world, in JUMPS: two to four. | missions.arcHandoverJumps | [missions.ts:219](./missions.ts#L219) |
-| missions | ARC_PAY | { hunt: 12_000, deliver: 5_000, recover: 6_000, rescue: 8_000, ambush: 9_000, smuggle: 10_000, escort: 9_000, scan: 4_000, } as const | What an arc leg pays, per verb, in tenths of a credit. | missions.arcPay | [missions.ts:232](./missions.ts#L232) |
-| missions | ARC_LEG_DAYS | 30 | Days an arc leg allows before its deadline passes: a month, twice a side job's fortnight, because an arc leg may be four jumps out. | missions.arcLegDays | [missions.ts:245](./missions.ts#L245) |
+| missions | MISSION_KILL_THRESHOLD | 16 | Kills before the Navy considers you worth a word: 16, as the original demanded. | missions.killThreshold | [missions.ts:15](./missions.ts#L15) |
+| missions | MISSION_HUNT_RANGE | { min: 30, max: 80 } as const | The Constrictor hides this far from where you are briefed, in tenths of a light year. | | [missions.ts:23](./missions.ts#L23) |
+| missions | MISSION_COURIER_RANGE | { min: 50, max: 90 } as const | The courier run is longer: the plans matter more than your convenience. | | [missions.ts:26](./missions.ts#L26) |
+| missions | CONSTRICTOR_BOUNTY | 25_000 | What a kill of the Constrictor pays — 2,500 Cr, in tenths of a credit. | | [missions.ts:29](./missions.ts#L29) |
+| missions | COURIER_PAYMENT | 15_000 | ...and what a delivery of the plans pays: 1,500 Cr. | | [missions.ts:32](./missions.ts#L32) |
+| missions | MISSION_LIVE_CAP | 3 | How many missions a commander can hold open at one time: three. | missions.liveCap | [missions.ts:48](./missions.ts#L48) |
+| missions | MISSION_REOFFER_DAYS | 7 | Days before a finished side job is offered again: a week. | missions.reofferDays | [missions.ts:64](./missions.ts#L64) |
+| missions | LEAD_RUMOUR_JUMPS | 5 | Inside this many jumps of a lead's world, the station talks about it. | missions.leadRumourJumps | [missions.ts:77](./missions.ts#L77) |
+| missions | LEAD_NAG_DOCKS | 4 | Docks with no mission progress before a patron with a lead writes a second time. | missions.leadNagDocks | [missions.ts:89](./missions.ts#L89) |
+| missions | SIDE_JOB_PAY | { hunt: 8_000, deliver: 3_000, recover: 4_000, rescue: 5_000, ambush: 6_000, smuggle: 7_000, escort: 6_000, scan: 2_500, } as const | What a side job pays, per verb, in tenths of a credit. | missions.sideJobPay | [missions.ts:103](./missions.ts#L103) |
+| missions | RESCUE_SALVAGE_PAY | 1_500 | The lower fee a rescue pays when the pod is lost and the data still arrives, in tenths of a credit. | missions.rescueSalvagePay | [missions.ts:119](./missions.ts#L119) |
+| missions | SIDE_JOB_RANGE | { min: 20, max: 70 } as const | How far a side job sends the commander, in tenths of a light year: two to seven. | missions.sideJobRange | [missions.ts:127](./missions.ts#L127) |
+| missions | SIDE_JOB_DAYS | 14 | Days a side job allows before its deadline passes: two weeks. | missions.sideJobDays | [missions.ts:134](./missions.ts#L134) |
+| missions | DEADLINE_WARNING_DAYS | 3 | How many days before a deadline the console starts to say how many are left. | missions.deadlineWarningDays | [missions.ts:145](./missions.ts#L145) |
+| missions | SCAN_SECONDS | 20 | Seconds a scan target must stay under the scanner lock: twenty, which is a pass and a turn at a trader's speed. | missions.scanSeconds | [missions.ts:153](./missions.ts#L153) |
+| missions | WATCH_CONE | 0.35 | How far off the centre of the view a scan's subject may sit and still count as watched, in radians: 0.35, which is twenty degrees. | missions.watchCone | [missions.ts:167](./missions.ts#L167) |
+| missions | SMUGGLE_TONNES | 3 | Tonnes of the patron's goods on a smuggle job: three, which fits a Cobra's hold beside its own stock. | missions.smuggleTonnes | [missions.ts:179](./missions.ts#L179) |
+| missions | ESCORT_ENEMY_ROLES | ['pirate', 'hunter', 'thargoid', 'thargon'] | The roles that count as an enemy near an escorted ship: the ones that prey. | | [missions.ts:188](./missions.ts#L188) |
+| missions | TOUR_ARCS | 5 | How many arcs the tour holds: five, Chris's number (docs/TODO/190, item 192). | missions.tourArcs | [missions.ts:198](./missions.ts#L198) |
+| missions | TOUR_STEP_JUMPS | { min: 4, max: 6 } as const | How far each arc's start world is from the one before it, in JUMPS on the full-tank graph: four to six. | missions.tourStepJumps | [missions.ts:211](./missions.ts#L211) |
+| missions | ARC_HANDOVER_JUMPS | { min: 2, max: 4 } as const | How far an arc's final leg lies from the next arc's start world, in JUMPS: two to four. | missions.arcHandoverJumps | [missions.ts:220](./missions.ts#L220) |
+| missions | ARC_PAY | { hunt: 12_000, deliver: 5_000, recover: 6_000, rescue: 8_000, ambush: 9_000, smuggle: 10_000, escort: 9_000, scan: 4_000, } as const | What an arc leg pays, per verb, in tenths of a credit. | missions.arcPay | [missions.ts:233](./missions.ts#L233) |
+| missions | ARC_LEG_DAYS | 30 | Days an arc leg allows before its deadline passes: a month, twice a side job's fortnight, because an arc leg may be four jumps out. | missions.arcLegDays | [missions.ts:246](./missions.ts#L246) |
| npc-gun | NPC_LASER_RANGE | LASER_RANGE | How far an NPC can shoot: the player's reach. | | [npc-gun.ts:17](./npc-gun.ts#L17) |
| npc-gun | NPC_COOLDOWN_LO | 0.9 | Time between an NPC's shots. | | [npc-gun.ts:31](./npc-gun.ts#L31) |
| npc-gun | NPC_COOLDOWN_SPREAD | 0.8 | | | [npc-gun.ts:32](./npc-gun.ts#L32) |
diff --git a/src/constants/missions.ts b/src/constants/missions.ts
index c9bdad7f..02f86f5d 100644
--- a/src/constants/missions.ts
+++ b/src/constants/missions.ts
@@ -1,9 +1,10 @@
// The Navy mission, as numbers: what earns the briefing, how far away each leg
// is laid, and what the Navy pays.
//
-// The five-stage machine that spends these is game/missions.ts. Money is in
-// tenths of a credit (invariant 8), and distances are in tenths of a light year,
-// as everywhere else.
+// The skeletons under src/missions/skeletons/ spend these, and the machine
+// in src/missions/machine.ts runs them (docs/TODO/190). Money is in tenths
+// of a credit (invariant 8), and distances are in tenths of a light year, as
+// everywhere else.
/**
* Kills before the Navy considers you worth a word: 16, as the original demanded.
diff --git a/src/game/screens/log.ts b/src/game/screens/log.ts
index 3cdcd7a6..0a27d2b5 100644
--- a/src/game/screens/log.ts
+++ b/src/game/screens/log.ts
@@ -9,7 +9,7 @@ import { renderLog } from '../../ui/screens-log.ts';
import type { CommanderData } from '../commander.ts';
import type { StarSystem } from '../../galaxy/galaxy.ts';
import type { Input } from '../../engine/input.ts';
-import type { Patron } from '../../missions/model.ts';
+import type { Patron } from '../../missions/words.ts';
import { NAVY_PATRON, patronFor } from '../../missions/patrons.ts';
import { storyPages, type StoryPage } from '../../missions/story.ts';
import { routeMapSvg } from '../../missions/route-map.ts';
diff --git a/src/game/screens/missions.ts b/src/game/screens/missions.ts
index 60c975d9..60b4561e 100644
--- a/src/game/screens/missions.ts
+++ b/src/game/screens/missions.ts
@@ -20,7 +20,8 @@ import { renderMissions, type HeldRow, type OfferRow } from '../../ui/screens.ts
import type { Screen, ScreenOutcome } from '../../ui/screen-host.ts';
import type { CommanderData } from '../commander.ts';
import { standingOrders, type MissionOrder } from '../orders.ts';
-import type { Dossier, Skeleton } from '../../missions/model.ts';
+import type { Skeleton } from '../../missions/model.ts';
+import type { Dossier } from '../../missions/words.ts';
import { dossierFor } from '../../missions/dossiers.ts';
import { leadLine } from '../../missions/hints.ts';
import { patronFor } from '../../missions/patrons.ts';
diff --git a/src/missions/dossiers.ts b/src/missions/dossiers.ts
index 94e47670..efb94614 100644
--- a/src/missions/dossiers.ts
+++ b/src/missions/dossiers.ts
@@ -9,7 +9,8 @@
import type { StarSystem } from '../galaxy/galaxy.ts';
import { DOSSIER_FILES } from './dossiers/index.ts';
-import type { CommanderFacts, Dossier, DossierWord } from './model.ts';
+import type { CommanderFacts, DossierWord } from './model.ts';
+import type { Dossier } from './words.ts';
import { patronFor } from './patrons.ts';
import { skeletonById } from './skeletons/index.ts';
import { fillSlots } from './text.ts';
diff --git a/src/missions/dossiers/index.ts b/src/missions/dossiers/index.ts
index 6a4518a6..529fd45f 100644
--- a/src/missions/dossiers/index.ts
+++ b/src/missions/dossiers/index.ts
@@ -4,7 +4,7 @@
// own to fall out of step with this directory. `--check` fails when a file
// here is missing from this list.
-import type { DossierFile } from '../model.ts';
+import type { DossierFile } from '../words.ts';
import d_arc_edle from './arc-edle.json' with { type: 'json' };
import d_arc_lave from './arc-lave.json' with { type: 'json' };
import d_arc_rabedira from './arc-rabedira.json' with { type: 'json' };
diff --git a/src/missions/hints.ts b/src/missions/hints.ts
index dffcee05..6f8e29a2 100644
--- a/src/missions/hints.ts
+++ b/src/missions/hints.ts
@@ -25,7 +25,8 @@ import { LEAD_NAG_DOCKS, LEAD_RUMOUR_JUMPS } from '../constants/missions.ts';
import type { StarSystem } from '../galaxy/galaxy.ts';
import { routeEstimate } from '../galaxy/route.ts';
import { dossierFor, hintSlots } from './dossiers.ts';
-import type { CommanderFacts, Dossier, DossierWord, Lead, MissionState } from './model.ts';
+import type { CommanderFacts, DossierWord, Lead, MissionState } from './model.ts';
+import type { Dossier } from './words.ts';
import { fillSlots } from './text.ts';
type Dossiers = (id: string) => Dossier | null;
diff --git a/src/missions/lint.ts b/src/missions/lint.ts
index 862e65f5..7e648c66 100644
--- a/src/missions/lint.ts
+++ b/src/missions/lint.ts
@@ -16,6 +16,9 @@
//
// docs/TODO/213 M2 adds one. A world patron's skeleton names the galaxy it
// is offered in, because a seed slot is an index that every galaxy has.
+//
+// docs/TODO/213 M5 adds one more. Every trigger a verb can emit has a
+// branch on the leg, or the leg lists it under `ignores`.
import { ARC_HANDOVER_JUMPS } from '../constants/missions.ts';
import type { StarSystem } from '../galaxy/galaxy.ts';
@@ -23,7 +26,8 @@ import { distanceTenths } from '../galaxy/navigation.ts';
import { routeTable } from '../galaxy/route.ts';
import type { Leg, Skeleton } from './model.ts';
import { specForDesign } from '../game/ship-specs.ts';
-import { verbJob, verbModule, verbNeedsShip } from './verbs/registry.ts';
+import { verbJob, verbModule, verbNeedsShip, verbTriggers } from './verbs/registry.ts';
+import { sameTrigger, triggerLabel } from './triggers.ts';
import { pickByJumps } from './placement.ts';
export function lintSkeleton(
@@ -45,6 +49,11 @@ export function lintSkeleton(
if (!specForDesign(role, leg.verb.ship)) out.push(`${at}: no ${role} row for ${leg.verb.ship}`);
}
if (!leg.next.some((b) => b.on === 'failed')) out.push(`${at}: no failed branch`);
+ for (const t of verbModule(leg.verb.kind) ? verbTriggers(leg.verb) : []) {
+ const answered = leg.next.some((b) => sameTrigger(b.on, t))
+ || (leg.ignores ?? []).some((i) => sameTrigger(i, t));
+ if (!answered) out.push(`${at}: no branch for ${triggerLabel(t)}, and it is not ignored`);
+ }
for (const b of leg.next) {
if (b.to !== 'complete' && b.to !== 'fail' && !ids.has(b.to)) {
out.push(`${at}: branch to unknown leg ${b.to}`);
diff --git a/src/missions/model.ts b/src/missions/model.ts
index bbbf574f..737aa9a0 100644
--- a/src/missions/model.ts
+++ b/src/missions/model.ts
@@ -14,7 +14,8 @@
//
// Nothing here runs. The types are the contract between four places. Those
// are the skeletons under `skeletons/`, the verb modules, the machine, and the
-// game code that sends inputs and applies effects.
+// game code that sends inputs and applies effects. The shape of the words a
+// model writes, a patron and a dossier, is `words.ts` (docs/TODO/213 M5).
import type { BlueprintOverride } from '../game/blueprint-set.ts';
import type { ShipDesignId } from '../game/ship-identity.ts';
@@ -22,7 +23,7 @@ import type { ShipDesignId } from '../game/ship-identity.ts';
/** A mission ship names a catalogue design (`ship-identity.ts`). */
export type ShipId = ShipDesignId;
-/** An item a recovery leg wants. The item table arrives with docs/TODO/190 M4. */
+/** An item a recovery leg wants, as a free name. No table of items exists. */
export type ItemId = string;
/**
@@ -169,6 +170,13 @@ export interface Leg {
spawn?: TaggedShip[];
/** true raises the mis-jump chance, as the 1984 courier run did */
carryingPlans?: boolean;
+ /**
+ * Triggers the verb can emit that this leg answers with nothing, on
+ * purpose. The lint refuses a leg that drops a trigger without saying so
+ * (docs/TODO/213 M5). A side hunt ignores `targetFled`, because a pirate
+ * cannot leave a system today, and 214 revisits that.
+ */
+ ignores?: Trigger[];
/** the first branch whose trigger matches is the one taken */
next: Branch[];
}
@@ -212,57 +220,6 @@ export interface Skeleton {
cap?: number;
}
-export interface Patron {
- id: string;
- world: number | 'navy';
- name: string;
- role: string;
- species: string;
- voice: string;
- /** image path; '' uses the world's portrait */
- portrait: string;
-}
-
-/**
- * A mission's generated words. Each field may carry the slots its comment
- * names and no other, and `tools/dossier-faults.ts` holds that (docs/TODO/191).
- */
-export interface Dossier {
- skeleton: string;
- /** the prompt hash it was written from, which covers the skeleton's shape */
- hash: string;
- /** a name for the mission, with no slot */
- title: string;
- /** pages the patron speaks, with {PATRON} {HERE} */
- briefing: string[];
- /** by leg: the console's word on the leg, with {TARGET} {PAY} */
- legs: RecordSALE_NOTORIETY_REVENUE | 40_000 | What a sale does to your reputation. | | [market.ts:30](./market.ts#L30) |
| market | SALE_NOTORIETY_CONTRABAND | 0.04 | Extra talk per tonne of CONTRABAND sold, on top of the takings. | | [market.ts:45](./market.ts#L45) |
| market | SALE_NOTORIETY_MAX | 0.5 | The most heat that one sale can raise, out of the 0..1 bar that `LivingGalaxy` keeps. | market.saleNotorietyMax | [market.ts:61](./market.ts#L61) |
-| mission-course | COURSE_WATCH_STANDOFF | 1200 | How far from a ship the scan course holds, in world units (docs/TODO/208 M2). | course.watchStandoff | [mission-course.ts:23](./mission-course.ts#L23) |
-| mission-course | COURSE_ESCORT_STANDOFF | 600 | How far from its charge the escort course flies, in world units (docs/TODO/208 M2). | course.escortStandoff | [mission-course.ts:36](./mission-course.ts#L36) |
-| mission-course | COURSE_ESCORT_CLOSING | 60 | How much faster than its charge the escort course may close, in world units a second, once it is inside three standoffs of it: sixty. | course.escortClosing | [mission-course.ts:51](./mission-course.ts#L51) |
-| mission-course | COURSE_POLICE_CLEARANCE | SCAN_WARN_RANGE | How wide of a police ship the smuggling course flies, in world units (docs/TODO/208 M4). | | [mission-course.ts:64](./mission-course.ts#L64) |
+| mission-course | COURSE_WATCH_STANDOFF | 1200 | How far from a ship the scan course holds, in world units (docs/TODO/208 M2). | course.watchStandoff | [mission-course.ts:24](./mission-course.ts#L24) |
+| mission-course | COURSE_ESCORT_STANDOFF | 600 | How far from its charge the escort course flies, in world units (docs/TODO/208 M2). | course.escortStandoff | [mission-course.ts:37](./mission-course.ts#L37) |
+| mission-course | COURSE_ESCORT_CLOSING | 60 | How much faster than its charge the escort course may close, in world units a second, once it is inside three standoffs of it: sixty. | course.escortClosing | [mission-course.ts:52](./mission-course.ts#L52) |
+| mission-course | ESCORT_LEASH | SCANNER_RANGE * 2 / 3 | How far the commander may fall behind her charge before it holds for her, in world units (docs/TODO/214 M3). | course.escortLeash | [mission-course.ts:68](./mission-course.ts#L68) |
+| mission-course | COURSE_POLICE_CLEARANCE | SCAN_WARN_RANGE | How wide of a police ship the smuggling course flies, in world units (docs/TODO/208 M4). | | [mission-course.ts:81](./mission-course.ts#L81) |
| missions | MISSION_KILL_THRESHOLD | 16 | Kills before the Navy considers you worth a word: 16, as the original demanded. | missions.killThreshold | [missions.ts:15](./missions.ts#L15) |
| missions | MISSION_HUNT_RANGE | { min: 30, max: 80 } as const | The Constrictor hides this far from where you are briefed, in tenths of a light year. | | [missions.ts:23](./missions.ts#L23) |
| missions | MISSION_COURIER_RANGE | { min: 50, max: 90 } as const | The courier run is longer: the plans matter more than your convenience. | | [missions.ts:26](./missions.ts#L26) |
diff --git a/src/constants/mission-course.ts b/src/constants/mission-course.ts
index 0c9e4f5e..142b1500 100644
--- a/src/constants/mission-course.ts
+++ b/src/constants/mission-course.ts
@@ -7,6 +7,7 @@
// over the size ceiling, and a mission's course is a subject of its own.
import { SCAN_WARN_RANGE } from './law.ts';
+import { SCANNER_RANGE } from './console.ts';
/**
* How far from a ship the scan course holds, in world units
@@ -50,6 +51,22 @@ export const COURSE_ESCORT_STANDOFF = 600;
*/
export const COURSE_ESCORT_CLOSING = 60;
+/**
+ * How far the commander may fall behind her charge before it holds for
+ * her, in world units (docs/TODO/214 M3). It is two thirds of the scanner,
+ * which is four thousand.
+ *
+ * The charge flew to the station on its own, and the escort was a job the
+ * commander watched. It moves while she is inside the leash and holds where
+ * it is when she is not. The leash is well outside the escort standoff of
+ * 600, so the course never trips it. It derives from the scanner so that a
+ * charge that holds is always still on her scanner.
+ *
+ * @rule course.escortLeash
+ * @domain mission-course
+ */
+export const ESCORT_LEASH = SCANNER_RANGE * 2 / 3;
+
/**
* How wide of a police ship the smuggling course flies, in world units
* (docs/TODO/208 M4).
diff --git a/src/game/npc-state.ts b/src/game/npc-state.ts
index c0b5a73a..6a78315f 100644
--- a/src/game/npc-state.ts
+++ b/src/game/npc-state.ts
@@ -130,6 +130,14 @@ export interface NpcState {
observed: number;
/** the escort or the scan verdict was sent once; it is never sent again */
missionReported: boolean;
+ /**
+ * A mission's charge waits, because the commander is beyond the leash
+ * (docs/TODO/214 M3). The world step decides it each frame, and the
+ * working life reads it as a speed of zero.
+ */
+ holding: boolean;
+ /** the console said the charge is holding, once */
+ holdSaid: boolean;
fleeing: boolean;
/** where this ship is in its attack run — see break-off.ts */
attackPhase: AttackPhase;
@@ -281,7 +289,7 @@ export function freshNpcState(maxEnergy: number): NpcState {
tumbleAxis: randomDirection(new THREE.Vector3()),
energy: maxEnergy, regenCarry: 0,
alive: true, provoked: false, provokedByPlayer: false, missiles: 0,
- missionTag: null, targeted: false, announcedClose: false, observed: 0, missionReported: false, fleeing: false, attackPhase: 'closing', underFire: 0, calm: 0, flownBy: 'none',
+ missionTag: null, targeted: false, announcedClose: false, observed: 0, missionReported: false, holding: false, holdSaid: false, fleeing: false, attackPhase: 'closing', underFire: 0, calm: 0, flownBy: 'none',
extendRange: EXTEND_RANGE_MAX, passSide: 1, passesMade: 0,
tactic: 'run', tacticClock: 0, dryFor: 0,
tradeTimer: 0,
diff --git a/src/game/trader-flight.ts b/src/game/trader-flight.ts
index 406666c1..a32b8edd 100644
--- a/src/game/trader-flight.ts
+++ b/src/game/trader-flight.ts
@@ -51,6 +51,8 @@ export interface TraderState {
waypointTimer: number;
/** Seconds of business left at the station. */
tradeTimer: number;
+ /** a mission's charge waits for the commander, so its speed is zero (docs/TODO/214 M3) */
+ holding: boolean;
/** Decided at spawn: does this one have business at the station? */
docksHere: boolean;
/** On final approach into the slot — the station must not shove it away. */
@@ -108,7 +110,7 @@ export function stepTrader(ship: TraderShip, dt: number, world: TraderWorld): vo
switch (state.traderPhase) {
case 'arriving': {
steerToward(ship, home, dt);
- state.speed = approach(state.speed, ship.maxSpeed * 0.85, 90 * dt);
+ state.speed = approach(state.speed, state.holding ? 0 : ship.maxSpeed * 0.85, 90 * dt);
if (ship.object.position.distanceTo(home) < TRADER_ARRIVED) {
state.traderPhase = 'trading';
}
@@ -131,7 +133,7 @@ export function stepTrader(ship: TraderShip, dt: number, world: TraderWorld): vo
.add(randomDirection(new THREE.Vector3()).multiplyScalar(600 + random() * 1200));
}
steerToward(ship, state.waypoint, dt);
- state.speed = approach(state.speed, ship.maxSpeed * 0.35, 60 * dt);
+ state.speed = approach(state.speed, state.holding ? 0 : ship.maxSpeed * 0.35, 60 * dt);
if (state.tradeTimer <= 0) {
// about half put in at the station; the rest jump out from here
if (state.docksHere) {
diff --git a/src/game/world-step.ts b/src/game/world-step.ts
index 4f2f6270..fec8971a 100644
--- a/src/game/world-step.ts
+++ b/src/game/world-step.ts
@@ -85,6 +85,7 @@ import { applyMissions, runMissions, type MissionOutcome } from './mission-bridg
import { scanSecondsFor } from '../missions/queries.ts';
import { DOCK_COMPUTER_RANGE } from '../constants/docking-computer.ts';
import { ESCORT_ENEMY_ROLES, WATCH_CONE } from '../constants/missions.ts';
+import { ESCORT_LEASH } from '../constants/mission-course.ts';
import { SCANNER_RANGE } from '../constants/console.ts';
import { random, randomInt, randomDirection } from './rng.ts';
import type { GameState } from './state.ts';
@@ -650,9 +651,13 @@ export class WorldStep {
* The two verdicts only the world can give (docs/TODO/190 M4).
*
* An ESCORT is safe when its ship is alive, inside `DOCK_COMPUTER_RANGE` of
- * the station, and no enemy is inside that same radius of it. All three at
- * once, and it is sent ONCE. `missionReported` latches, so a fight after
- * the fee cannot undo it. The ship goes on to dock as any trader does.
+ * the station. The commander must be inside that radius of it, and no
+ * enemy may be. All four at once, and it is sent ONCE.
+ * `missionReported` latches, so a fight after the fee cannot undo it. The
+ * ship goes on to dock as any trader does. The commander's own place was
+ * not measured until docs/TODO/214 M3, and the escort was a job she
+ * watched. The charge also HOLDS for her beyond `ESCORT_LEASH`, and the
+ * console says so once.
*
* A SCAN counts the seconds a tagged ship spends under the scanner lock,
* and sends `scanned` when the leg's seconds are up, once.
@@ -693,7 +698,14 @@ export class WorldStep {
continue;
}
const at = npc.object.position;
+ const away = player.position.distanceTo(at);
+ npc.state.holding = away > ESCORT_LEASH;
+ if (npc.state.holding && !npc.state.holdSaid) {
+ npc.state.holdSaid = true;
+ out.push(say(`THE ${npc.object.name.toUpperCase()} IS HOLDING FOR YOU. STAY WITH IT.`, 4));
+ }
if (at.distanceTo(world.station.position) > DOCK_COMPUTER_RANGE) continue;
+ if (away > DOCK_COMPUTER_RANGE) continue;
const threatened = world.npcs.some((other) => other !== npc && other.state.alive
&& ESCORT_ENEMY_ROLES.includes(other.role) && other.object.position.distanceTo(at) <= DOCK_COMPUTER_RANGE);
if (threatened) continue;
diff --git a/src/missions/skeletons/arcs/edle.ts b/src/missions/skeletons/arcs/edle.ts
index 09a41a39..50936852 100644
--- a/src/missions/skeletons/arcs/edle.ts
+++ b/src/missions/skeletons/arcs/edle.ts
@@ -15,7 +15,7 @@ import { ARC_LEG_DAYS, ARC_PAY, SIDE_JOB_RANGE } from '../../../constants/missio
import { SOURCE_DESIGN } from '../../../game/ship-specs.ts';
import { shipDesignIdOf } from '../../../game/ship-identity.ts';
import type { Skeleton } from '../../model.ts';
-import { PAIR, wingmanOf } from '../lane.ts';
+import { LANE_PIRATES, PAIR, wingmanOf } from '../lane.ts';
const AWAY = { kind: 'band', ...SIDE_JOB_RANGE } as const;
@@ -37,7 +37,7 @@ export const ARC_EDLE: Skeleton = {
],
},
{
- id: 'guard', verb: { kind: 'escort', ship: shipDesignIdOf(SOURCE_DESIGN.transporter) }, place: AWAY,
+ id: 'guard', verb: { kind: 'escort', ship: shipDesignIdOf(SOURCE_DESIGN.transporter) }, place: AWAY, spawn: [...LANE_PIRATES],
line: 'COLONEL: SEE THE TRANSPORTER INTO STATION RANGE AT {TARGET}', deadlineDays: ARC_LEG_DAYS,
next: [
{ on: 'success', to: 'purge', settle: { pay: ARC_PAY.escort, say: 'TRANSPORTER SAFE — {PAY}. NOW THE ASP, NEAR {TARGET}.' } },
diff --git a/src/missions/skeletons/arcs/rabedira.ts b/src/missions/skeletons/arcs/rabedira.ts
index fe662636..26d3b59f 100644
--- a/src/missions/skeletons/arcs/rabedira.ts
+++ b/src/missions/skeletons/arcs/rabedira.ts
@@ -13,7 +13,7 @@ import { ARC_HANDOVER_JUMPS, ARC_LEG_DAYS, ARC_PAY, SIDE_JOB_RANGE } from '../..
import { SOURCE_DESIGN } from '../../../game/ship-specs.ts';
import { shipDesignIdOf } from '../../../game/ship-identity.ts';
import type { Skeleton } from '../../model.ts';
-import { PAIR } from '../lane.ts';
+import { LANE_PIRATES, PAIR } from '../lane.ts';
const TOWARD = { kind: 'handover', toward: 'arc-vetitice', ...ARC_HANDOVER_JUMPS } as const;
@@ -35,7 +35,7 @@ export const ARC_RABEDIRA: Skeleton = {
],
},
{
- id: 'convoy', verb: { kind: 'escort', ship: shipDesignIdOf(SOURCE_DESIGN.boa) }, place: TOWARD,
+ id: 'convoy', verb: { kind: 'escort', ship: shipDesignIdOf(SOURCE_DESIGN.boa) }, place: TOWARD, spawn: [...LANE_PIRATES],
line: 'ENVOY: SEE THE BOA INTO STATION RANGE AT {TARGET}', deadlineDays: ARC_LEG_DAYS,
next: [
{ on: 'success', to: 'complete', settle: { pay: ARC_PAY.escort, say: 'THE ENVOY IS SAFE — {PAY} FROM RABEDIRA' } },
diff --git a/src/missions/skeletons/side.ts b/src/missions/skeletons/side.ts
index d116a026..ccfc273f 100644
--- a/src/missions/skeletons/side.ts
+++ b/src/missions/skeletons/side.ts
@@ -159,7 +159,7 @@ export const SIDE_ESCORT: Skeleton = {
pitch: 'A PYTHON IS LEAVING FOR A NEIGHBOUR AND WANTS A GUN BESIDE HER. SEE HER INTO STATION RANGE.',
offer: {},
legs: [{
- id: 'cover', verb: { kind: 'escort', ship: shipDesignIdOf(SOURCE_DESIGN.python) }, place: AWAY,
+ id: 'cover', verb: { kind: 'escort', ship: shipDesignIdOf(SOURCE_DESIGN.python) }, place: AWAY, spawn: [...PAIR],
line: 'ESCORT: SEE THE PYTHON INTO STATION RANGE AT {TARGET}', deadlineDays: SIDE_JOB_DAYS,
next: [
{ on: 'success', to: 'complete', settle: { pay: SIDE_JOB_PAY.escort, say: 'THE PYTHON IS SAFE IN STATION RANGE. THE STATION PAYS {PAY}.' } },
diff --git a/test/fixtures.ts b/test/fixtures.ts
index 64d3489f..e04f9a73 100644
--- a/test/fixtures.ts
+++ b/test/fixtures.ts
@@ -17,7 +17,9 @@ import { makeRng } from '../src/game/rng.ts';
import { FIXED_DT } from '../src/constants/world-clock.ts';
import { CONSTRICTOR_SPEC } from '../src/game/ship-specs.ts';
import { emptyMissionState } from '../src/missions/state.ts';
-import type { MissionState } from '../src/missions/model.ts';
+import type { CommanderFacts, MissionEffect, MissionState, Skeleton } from '../src/missions/model.ts';
+import { stepMissions, type MissionContext } from '../src/missions/machine.ts';
+import { canAccept } from '../src/missions/offers.ts';
/**
* Galaxy 1: the canonical universe, and the most-shared fixture in the suite.
@@ -93,3 +95,33 @@ export function constrictorAt(
}
return st;
}
+
+// --- a commander on a side job, for the verb and the company tests ----------
+//
+// They lived in test/mission-verbs.test.ts until docs/TODO/214 M3 pushed it
+// over the size ceiling, and test/mission-company.test.ts needs the same
+// four. A second copy would be two fixtures that drift.
+
+/** The facts a mission rule reads, at Lave with scoops fitted, unless `over` says otherwise. */
+export const facts = (over: PartialEXTEND_RANGE | (EXTEND_RANGE_MIN + EXTEND_RANGE_MAX) / 2 | Default turn-back range for a caller that rolled none — mid-band. | | [attack-run.ts:31](./attack-run.ts#L31) |
| attack-run | UNDER_FIRE_SECONDS | 1.2 | How long a ship keeps to evasive flight after the last hit. | | [attack-run.ts:37](./attack-run.ts#L37) |
| attack-run | TRADER_CALM_SECONDS | 20 | How long a trader keeps to its run after the last hit it took: twenty seconds. | trader.calmSeconds | [attack-run.ts:53](./attack-run.ts#L53) |
-| attack-run | CLOSING_THROTTLE_MIN | 0.45 | The slowest that an attacking ship throttles back to in order to turn. | | [attack-run.ts:61](./attack-run.ts#L61) |
-| attack-run | MIN_CRUISE_FRACTION | 0.43 | A hostile cannot throttle below this fraction of its top speed. | | [attack-run.ts:68](./attack-run.ts#L68) |
+| attack-run | HUNT_FLEE_FRACTION | 0.25 | The fraction of its energy under which a hunt's target runs for the edge of the system: a quarter (docs/TODO/214 M4). | hunt.fleeFraction | [attack-run.ts:70](./attack-run.ts#L70) |
+| attack-run | CLOSING_THROTTLE_MIN | 0.45 | The slowest that an attacking ship throttles back to in order to turn. | | [attack-run.ts:78](./attack-run.ts#L78) |
+| attack-run | MIN_CRUISE_FRACTION | 0.43 | A hostile cannot throttle below this fraction of its top speed. | | [attack-run.ts:85](./attack-run.ts#L85) |
| audio | AUDIBLE_RANGE | SCANNER_RANGE | How far a bang carries, in world units. | audio.audibleRange | [audio.ts:35](./audio.ts#L35) |
| audio | STEREO_WIDTH | 0.7 | How far across the stereo field a sound may sit: 0 is mono, 1 is one ear only. | audio.stereoWidth | [audio.ts:58](./audio.ts#L58) |
| blame | PROVOKES | { laser: true, missile: true, bomb: true, ram: false, } | Which of the commander's damage sources provoke the ship they hit. | | [blame.ts:22](./blame.ts#L22) |
@@ -179,9 +180,9 @@ search names, meanings and values with `npm run constants:find -- "OPENING_CONE_DEG | 8 | The cone that a visible opening is scattered through, as a half-angle in degrees. | exercise.openingConeDeg | [exercise.ts:60](./exercise.ts#L60) |
| exercise | AMBUSH_CONE_DEG | 30 | An ambush spreads wide behind you: 16 to 43 degrees off your tail. | exercise.ambushCone | [exercise.ts:71](./exercise.ts#L71) |
| exercise | IN_VIEW_DEG | 20 | How far off the nose still counts as "the pilot can see it". | exercise.inViewDeg | [exercise.ts:82](./exercise.ts#L82) |
-| exercise | ENTRY_THROTTLE | 0.25 | Where the exercise starts you, as a fraction of the ship's top speed. | | [exercise.ts:85](./exercise.ts#L85) |
-| exercise | SCENARIO_TIMEOUT | 120 | Seconds a scenario exercise may run before it times out. * | exercise.scenarioTimeout | [exercise.ts:90](./exercise.ts#L90) |
-| exercise | NO_AMBIENT_TRAFFIC | 1e9 | How far out the encounter timers are pushed while an exercise runs. | | [exercise.ts:100](./exercise.ts#L100) |
+| exercise | ENTRY_THROTTLE | 0.25 | Where the exercise starts you, as a fraction of the ship's top speed. | exercise.entryThrottle | [exercise.ts:89](./exercise.ts#L89) |
+| exercise | SCENARIO_TIMEOUT | 120 | Seconds a scenario exercise may run before it times out. * | exercise.scenarioTimeout | [exercise.ts:94](./exercise.ts#L94) |
+| exercise | NO_AMBIENT_TRAFFIC | 1e9 | How far out the encounter timers are pushed while an exercise runs. | | [exercise.ts:104](./exercise.ts#L104) |
| extend-arc | EXTEND_ARC_ANGLE | (60 * Math.PI) / 180 | The angle that the run-out holds off the OUTWARD radial, at its tightest. | | [extend-arc.ts:13](./extend-arc.ts#L13) |
| extend-arc | CLEAR_RANGE | 340 | How far out the ship gets before it starts to curve at all. | | [extend-arc.ts:22](./extend-arc.ts#L22) |
| hermit-market | HERMIT_ORE | new Set(['Minerals', 'Gold', 'Platinum', 'Gem-Stones']) | What a hermit sits on: whatever they dug up. | | [hermit-market.ts:16](./hermit-market.ts#L16) |
@@ -194,9 +195,9 @@ search names, meanings and values with `npm run constants:find -- "HERMIT_HAIL_RANGE | 900 | How near a rock hermit the commander must be to hear its hail. | hermit.hail | [hermit-market.ts:82](./hermit-market.ts#L82) |
| hermit-market | HERMIT_DOCK_RANGE | 320 | How near the commander must be to actually trade with a hermit. | hermit.dockRange | [hermit-market.ts:93](./hermit-market.ts#L93) |
| hermit-market | HERMIT_DOCK_SPEED | 40 | How slow the commander must be flying to trade with a hermit. | hermit.dockSpeed | [hermit-market.ts:105](./hermit-market.ts#L105) |
-| hull-breach | EQUIPMENT_DAMAGE_CHANCE | 0.25 | The chance that a hit which reaches the hull wrecks cargo or a fitting. | | [hull-breach.ts:19](./hull-breach.ts#L19) |
-| hull-breach | CARGO_LOSS_CHANCE | 0.7 | Cargo is lost this often when there is any aboard. | breach.cargoLossChance | [hull-breach.ts:35](./hull-breach.ts#L35) |
-| hull-breach | BREAKABLE | [ ['ecm', 'E.C.M. SYSTEM'], ['scoops', 'FUEL SCOOPS'], ['rearLaser', 'REAR LASER'], ['leftLaser', 'LEFT LASER'], ['rightLaser', 'RIGHT LASER'], ['dockingComputer', 'DOCKING COMPUTER'], ['combatComputer', 'COMBAT COMPUTER'], ] as const | The fittings that a hull breach can knock out, in the order they are offered. | | [hull-breach.ts:46](./hull-breach.ts#L46) |
+| hull-breach | EQUIPMENT_DAMAGE_CHANCE | 0.25 | The chance that a hit which reaches the hull wrecks cargo or a fitting. | hullbreach.equipmentDamageChance | [hull-breach.ts:21](./hull-breach.ts#L21) |
+| hull-breach | CARGO_LOSS_CHANCE | 0.7 | Cargo is lost this often when there is any aboard. | breach.cargoLossChance | [hull-breach.ts:37](./hull-breach.ts#L37) |
+| hull-breach | BREAKABLE | [ ['ecm', 'E.C.M. SYSTEM'], ['scoops', 'FUEL SCOOPS'], ['rearLaser', 'REAR LASER'], ['leftLaser', 'LEFT LASER'], ['rightLaser', 'RIGHT LASER'], ['dockingComputer', 'DOCKING COMPUTER'], ['combatComputer', 'COMBAT COMPUTER'], ] as const | The fittings that a hull breach can knock out, in the order they are offered. | | [hull-breach.ts:48](./hull-breach.ts#L48) |
| hull-motion | TURN | { pitch: 1.4, roll: 2.4 } as const | A hull's `turnRate` is one number. | | [hull-motion.ts:23](./hull-motion.ts#L23) |
| hull-motion | ACCEL_FRACTION | 0.46 | How hard a hull accelerates, as a fraction of its top speed. | | [hull-motion.ts:37](./hull-motion.ts#L37) |
| hunt-ranges | PIRATE_HUNT_RANGE | 6000 | How far a pirate will look for a trader to rob. | hunt.pirate | [hunt-ranges.ts:14](./hunt-ranges.ts#L14) |
@@ -279,13 +280,13 @@ search names, meanings and values with `npm run constants:find -- "NPC_COOLDOWN_LO | 0.9 | Time between an NPC's shots. | | [npc-gun.ts:31](./npc-gun.ts#L31) |
| npc-gun | NPC_COOLDOWN_SPREAD | 0.8 | | | [npc-gun.ts:32](./npc-gun.ts#L32) |
| npc-gun | NPC_MEAN_COOLDOWN | NPC_COOLDOWN_LO + NPC_COOLDOWN_SPREAD / 2 | What a shot costs a gun that never waits to be aimed: the LO plus half the spread, because `npcTriggerPull` draws uniformly across it. | | [npc-gun.ts:44](./npc-gun.ts#L44) |
-| npc-gun | NPC_FIRE_GATE | 0.25 | How near the nose a target must be before an NPC pulls the trigger. | | [npc-gun.ts:61](./npc-gun.ts#L61) |
-| npc-gun | THARGOID_FIRE_RATE | 0.7 | Thargoids reload faster than anything else in the galaxy. | npc.thargoidFireRate | [npc-gun.ts:73](./npc-gun.ts#L73) |
-| npc-gun | NPC_HIT_BASE | 0.9 | Hit chance falls off with range, clamped at both ends. | | [npc-gun.ts:76](./npc-gun.ts#L76) |
-| npc-gun | NPC_HIT_FALLOFF | NPC_LASER_RANGE | The slope of the falloff. | | [npc-gun.ts:84](./npc-gun.ts#L84) |
-| npc-gun | NPC_HIT_CAP | 0.85 | | | [npc-gun.ts:85](./npc-gun.ts#L85) |
-| npc-gun | NPC_HIT_FLOOR | 0.15 | The far end of that curve. | npc.hitFloor | [npc-gun.ts:95](./npc-gun.ts#L95) |
-| npc-gun | NPC_VS_NPC_HIT | 0.5 | Whether one ship's shot at another connects: a coin flip. | | [npc-gun.ts:102](./npc-gun.ts#L102) |
+| npc-gun | NPC_FIRE_GATE | 0.25 | How near the nose a target must be before an NPC pulls the trigger. | npcgun.fireGate | [npc-gun.ts:63](./npc-gun.ts#L63) |
+| npc-gun | THARGOID_FIRE_RATE | 0.7 | Thargoids reload faster than anything else in the galaxy. | npc.thargoidFireRate | [npc-gun.ts:75](./npc-gun.ts#L75) |
+| npc-gun | NPC_HIT_BASE | 0.9 | Hit chance falls off with range, clamped at both ends. | | [npc-gun.ts:78](./npc-gun.ts#L78) |
+| npc-gun | NPC_HIT_FALLOFF | NPC_LASER_RANGE | The slope of the falloff. | | [npc-gun.ts:86](./npc-gun.ts#L86) |
+| npc-gun | NPC_HIT_CAP | 0.85 | | | [npc-gun.ts:87](./npc-gun.ts#L87) |
+| npc-gun | NPC_HIT_FLOOR | 0.15 | The far end of that curve. | npc.hitFloor | [npc-gun.ts:97](./npc-gun.ts#L97) |
+| npc-gun | NPC_VS_NPC_HIT | 0.5 | Whether one ship's shot at another connects: a coin flip. | | [npc-gun.ts:104](./npc-gun.ts#L104) |
| opposition-ring | OPPOSITION_RANGE | 3200 | The default ring radius, in units. | | [opposition-ring.ts:19](./opposition-ring.ts#L19) |
| opposition-ring | OPPOSITION_RANGE_MAX | 20_000 | A ceiling on the ring radius. | | [opposition-ring.ts:26](./opposition-ring.ts#L26) |
| opposition-ring | OPPOSITION_CONE | 0.5 | Half-angle of the cone, in radians, when a facing is known and the caller says no more. | | [opposition-ring.ts:35](./opposition-ring.ts#L35) |
@@ -412,10 +413,10 @@ search names, meanings and values with `npm run constants:find -- "PASS_CLEARANCE | 1.6 | How much wider than contact a pass has to be AIMED to actually clear, as a multiple of the two hulls' radii. | | [tactic-choice.ts:21](./tactic-choice.ts#L21) |
| tactic-choice | RAM_MIN_SPEED | PLAYER_FLIGHT.maxSpeed * 0.7 | The slowest hull that may be offered a ram, as a fraction of the commander's top speed. | | [tactic-choice.ts:30](./tactic-choice.ts#L30) |
| tactic-choice | TACTIC_HURT_HEALTH | 0.6 | How hurt a ship has to be before a hit makes it rethink. | | [tactic-choice.ts:37](./tactic-choice.ts#L37) |
-| tactic-choice | TACTIC_LAST_STAND_HEALTH | 0.25 | ...and how hurt before a ram is on the table, and nothing else new is. | | [tactic-choice.ts:40](./tactic-choice.ts#L40) |
-| tactic-choice | TACTIC_WEIGHTS | { spawn: { run: 50, slash: 25, knife: 25, ram: 0 }, sleeper: { run: 40, slash: 30, knife: 30, ram: 0 }, hurt: { run: 20, slash: 40, knife: 40, ram: 0 }, lastStand: { run: 15, slash: 40, knife: 0, ram: 45 }, } as const | How likely each tactic is, per reason. | | [tactic-choice.ts:54](./tactic-choice.ts#L54) |
-| tactic-choice | TACTIC_MIN_DWELL | 5 | The least time a ship keeps a tactic before it may take another. | tactic.minDwell | [tactic-choice.ts:72](./tactic-choice.ts#L72) |
-| tactic-choice | TACTIC_SLEEPER_SECONDS | 12 | How long a ship goes without a shot away before it concludes that whatever it does is not working. | | [tactic-choice.ts:80](./tactic-choice.ts#L80) |
+| tactic-choice | TACTIC_LAST_STAND_HEALTH | 0.25 | ...and how hurt before a ram is on the table, and nothing else new is. | tactic.lastStandHealth | [tactic-choice.ts:44](./tactic-choice.ts#L44) |
+| tactic-choice | TACTIC_WEIGHTS | { spawn: { run: 50, slash: 25, knife: 25, ram: 0 }, sleeper: { run: 40, slash: 30, knife: 30, ram: 0 }, hurt: { run: 20, slash: 40, knife: 40, ram: 0 }, lastStand: { run: 15, slash: 40, knife: 0, ram: 45 }, } as const | How likely each tactic is, per reason. | | [tactic-choice.ts:58](./tactic-choice.ts#L58) |
+| tactic-choice | TACTIC_MIN_DWELL | 5 | The least time a ship keeps a tactic before it may take another. | tactic.minDwell | [tactic-choice.ts:76](./tactic-choice.ts#L76) |
+| tactic-choice | TACTIC_SLEEPER_SECONDS | 12 | How long a ship goes without a shot away before it concludes that whatever it does is not working. | | [tactic-choice.ts:84](./tactic-choice.ts#L84) |
| tactics | TACTIC_IDS | ['slash', 'run', 'knife', 'ram'] | Every tactic, least to most committed — the order a readout should list. | | [tactics.ts:23](./tactics.ts#L23) |
| tactics | TACTICS | { run: { id: 'run', missDistance: PASS_MISS_DISTANCE, arcAngle: EXTEND_ARC_ANGLE, throttleFloor: CLOSING_THROTTLE_MIN, aimsToHit: false, }, slash: { id: 'slash', missDistance: 175, arcAngle: (45 * Math.PI) / 180, throttleFloor: 0.72, aimsToHit: false, }, knife: { id: 'knife', missDistance: 100, arcAngle: (70 * Math.PI) / 180, throttleFloor: CLOSING_THROTTLE_MIN, aimsToHit: false, }, ram: { id: 'ram', missDistance: 0, arcAngle: EXTEND_ARC_ANGLE, throttleFloor: 1, aimsToHit: true, }, } | The four tactics, as the three numbers each one overrides. | | [tactics.ts:52](./tactics.ts#L52) |
| tech-level | TECH_MIN | 1 | The lowest tech level any system shows. | tech.levelMin | [tech-level.ts:16](./tech-level.ts#L16) |
@@ -458,9 +459,9 @@ search names, meanings and values with `npm run constants:find -- "THARGOID_AMBUSH_RANGE | 3500 | How far out they wait. | | [witchspace.ts:48](./witchspace.ts#L48) |
| witchspace | THARGOID_AMBUSH_RANGE_SPAN | 2500 | ...and the width of that band, so they do not all arrive at one distance. | witchspace.thargoidAmbushRangeSpan | [witchspace.ts:59](./witchspace.ts#L59) |
| world-clock | FIXED_DT | 1 / 60 | The world advances in slices of exactly this. 60Hz. | | [world-clock.ts:16](./world-clock.ts#L16) |
-| world-clock | MAX_FRAME_TIME | 0.25 | The longest real interval the loop will simulate before it drops the backlog. | | [world-clock.ts:23](./world-clock.ts#L23) |
-| world-clock | MAX_STEPS_PER_FRAME | 5 | ...and the most steps one frame may run, so a stall cannot spiral. | clock.maxStepsPerFrame | [world-clock.ts:35](./world-clock.ts#L35) |
-| world-clock | CARRY_LIMIT | 3 | Unread taps of one key that the input carries across busy frames. | clock.carryLimit | [world-clock.ts:45](./world-clock.ts#L45) |
+| world-clock | MAX_FRAME_TIME | 0.25 | The longest real interval the loop will simulate before it drops the backlog. | clock.maxFrameTime | [world-clock.ts:25](./world-clock.ts#L25) |
+| world-clock | MAX_STEPS_PER_FRAME | 5 | ...and the most steps one frame may run, so a stall cannot spiral. | clock.maxStepsPerFrame | [world-clock.ts:37](./world-clock.ts#L37) |
+| world-clock | CARRY_LIMIT | 3 | Unread taps of one key that the input carries across busy frames. | clock.carryLimit | [world-clock.ts:47](./world-clock.ts#L47) |
| wreck | ESCAPE_CHANCE | { trader: 0.45, other: 0.2 } as const | How often the pilot punches out before the hull goes. | | [wreck.ts:22](./wreck.ts#L22) |
| wreck | WRECK_BURST_GRACE | 1.0 | Seconds the commander's beam registers nothing on a bystander, counted from the moment her own shot destroys a ship (GitHub #35). | wreck.wreckBurstGrace | [wreck.ts:67](./wreck.ts#L67) |
| wreck | POD_LAUNCH_GRACE | 1.5 | Seconds a fresh capsule cannot be shot, counted from the moment it launches (GitHub #28). | wreck.podLaunchGrace | [wreck.ts:91](./wreck.ts#L91) |
diff --git a/src/constants/attack-run.ts b/src/constants/attack-run.ts
index 62f9fc97..f81bff4b 100644
--- a/src/constants/attack-run.ts
+++ b/src/constants/attack-run.ts
@@ -52,6 +52,23 @@ export const UNDER_FIRE_SECONDS = 1.2;
*/
export const TRADER_CALM_SECONDS = 20;
+/**
+ * The fraction of its energy under which a hunt's target runs for the edge
+ * of the system: a quarter (docs/TODO/214 M4). A ship that ran at half would
+ * leave most fights. A ship that ran at a tenth would die in the turn. So
+ * the commander sees a fight, and then a chase, and the chase is short.
+ *
+ * It is the target's rule alone. A trader runs on the first hit, and it
+ * comes back to work once calm (`TRADER_CALM_SECONDS`). A wingman fights to
+ * the end. The world step stamps `canFlee` on the target of a hunt that
+ * `canEscape`, and `NpcShip.takeDamage` reads the fraction. The Constrictor
+ * cannot escape, so it never runs.
+ *
+ * @domain attack-run
+ * @rule hunt.fleeFraction
+ */
+export const HUNT_FLEE_FRACTION = 0.25;
+
/**
* The slowest that an attacking ship throttles back to in order to turn. There
* are two literals on purpose. This one sits just above `MIN_CRUISE_FRACTION`, so
diff --git a/src/constants/exercise.ts b/src/constants/exercise.ts
index 029c3807..9d0005d2 100644
--- a/src/constants/exercise.ts
+++ b/src/constants/exercise.ts
@@ -81,7 +81,11 @@ export const AMBUSH_CONE_DEG = 30;
*/
export const IN_VIEW_DEG = 20;
-/** Where the exercise starts you, as a fraction of the ship's top speed. */
+/**
+ * Where the exercise starts you, as a fraction of the ship's top speed.
+ *
+ * @rule exercise.entryThrottle
+ */
export const ENTRY_THROTTLE = 0.25;
/** Seconds a scenario exercise may run before it times out. *
diff --git a/src/constants/hull-breach.ts b/src/constants/hull-breach.ts
index 80eaa5c0..62a7f46b 100644
--- a/src/constants/hull-breach.ts
+++ b/src/constants/hull-breach.ts
@@ -15,6 +15,8 @@
* one time per penetrating hit. It never rolls per point, and the damage never
* scales it. Pool size therefore does not change how often equipment breaks.
* `test/systems.test.ts` counts the rolls.
+ *
+ * @rule hullbreach.equipmentDamageChance
*/
export const EQUIPMENT_DAMAGE_CHANCE = 0.25;
diff --git a/src/constants/npc-gun.ts b/src/constants/npc-gun.ts
index f0e860c7..3b1088d4 100644
--- a/src/constants/npc-gun.ts
+++ b/src/constants/npc-gun.ts
@@ -57,6 +57,8 @@ export const NPC_MEAN_COOLDOWN = NPC_COOLDOWN_LO + NPC_COOLDOWN_SPREAD / 2;
* DESIGN, at 102 and 142 degrees. The legs that want the nose ON her are
* `closing`, at 64 degrees, and `on your six`, at 37. So a pooled figure is not
* evidence about this gate, and M3 decided against a wider one.
+ *
+ * @rule npcgun.fireGate
*/
export const NPC_FIRE_GATE = 0.25;
diff --git a/src/constants/tactic-choice.ts b/src/constants/tactic-choice.ts
index 56c5feb7..6c2abb23 100644
--- a/src/constants/tactic-choice.ts
+++ b/src/constants/tactic-choice.ts
@@ -36,7 +36,11 @@ export const RAM_MIN_SPEED = PLAYER_FLIGHT.maxSpeed * 0.7;
*/
export const TACTIC_HURT_HEALTH = 0.6;
-/** ...and how hurt before a ram is on the table, and nothing else new is. */
+/**
+ * ...and how hurt before a ram is on the table, and nothing else new is.
+ *
+ * @rule tactic.lastStandHealth
+ */
export const TACTIC_LAST_STAND_HEALTH = 0.25;
/**
diff --git a/src/constants/world-clock.ts b/src/constants/world-clock.ts
index 481c5182..f13a3bdb 100644
--- a/src/constants/world-clock.ts
+++ b/src/constants/world-clock.ts
@@ -19,6 +19,8 @@ export const FIXED_DT = 1 / 60;
* The longest real interval the loop will simulate before it drops the backlog. A
* backgrounded tab, a breakpoint or a slow first paint can hand the loop seconds.
* To simulate them lands the ship somewhere it was never flown to.
+ *
+ * @rule clock.maxFrameTime
*/
export const MAX_FRAME_TIME = 0.25;
diff --git a/src/game/course-pilot.ts b/src/game/course-pilot.ts
index ca16a673..c95cd477 100644
--- a/src/game/course-pilot.ts
+++ b/src/game/course-pilot.ts
@@ -105,7 +105,11 @@ export interface CourseView {
* how fast it moves, and what the ship does about it. Null when no live leg
* has work in this system.
*/
- readonly mission: { readonly at: THREE.Vector3; readonly speed: number; readonly how: MissionHow } | null;
+ readonly mission: {
+ readonly at: THREE.Vector3; readonly speed: number; readonly how: MissionHow;
+ /** the target is on the run, so a fight becomes a chase (docs/TODO/214 M4) */
+ readonly fleeing: boolean;
+ } | null;
/** the docking computer already has the ship */
readonly dcEngaged: boolean;
/**
@@ -236,8 +240,13 @@ export class CoursePilot {
const m = v.mission;
if (m === null) return ended();
// A hunt is a fight: `flight-instruments.ts` picks the ship, and the
- // computer's aim flies it, as it does for a rock.
- if (m.how === 'fight') return IDLE;
+ // computer's aim flies it, as it does for a rock. A target on the run
+ // is chased at full speed, until the computer takes the stick again
+ // when the target is near (docs/TODO/214 M4).
+ if (m.how === 'fight') {
+ if (!m.fleeing) return IDLE;
+ return { ...this.arrive(v, { target: m.at, standoff: 0, speed: PLAYER_FLIGHT.maxSpeed }, dt), done: false };
+ }
// A slip is the station course on a line wide of the police
// (docs/TODO/208 M4). It hands the ship over as that course does.
if (m.how === 'slip') return this.toStation(v, dt, m.at);
diff --git a/src/game/flight-course.ts b/src/game/flight-course.ts
index c4165485..1b078c0b 100644
--- a/src/game/flight-course.ts
+++ b/src/game/flight-course.ts
@@ -149,7 +149,7 @@ export class FlightCourse {
.map((n) => ({ at: n.object.position, radius: n.radius })),
dcEngaged: s.dcEngaged,
mission: mission === null ? null
- : { at: mission.at, speed: mission.speed, how: mission.how },
+ : { at: mission.at, speed: mission.speed, how: mission.how, fleeing: mission.fleeing },
handOverRange: this.state.commander.equipment.dockingComputer
? DOCK_COMPUTER_RANGE : COURSE_DOCK_HANDOVER,
}, dt);
diff --git a/src/game/mission-course.ts b/src/game/mission-course.ts
index 0ea45892..de1c7e67 100644
--- a/src/game/mission-course.ts
+++ b/src/game/mission-course.ts
@@ -41,6 +41,8 @@ export interface MissionCourse {
readonly at: THREE.Vector3;
/** how fast that target is moving, so a hold and an escort can match it */
readonly speed: number;
+ /** the target is on the run, so the course chases it (docs/TODO/214 M4) */
+ readonly fleeing: boolean;
}
/**
@@ -70,32 +72,37 @@ export function missionCourse(
switch (leg.verb.kind) {
case 'hunt':
if (ship) {
- return { what: `HUNT THE ${name}`, how: 'fight', ship, at: ship.object.position, speed: ship.state.speed };
+ // A target that runs is chased, and the row says so (docs/TODO/214 M4).
+ const fleeing = ship.state.fleeing;
+ return {
+ what: `${fleeing ? 'CHASE' : 'HUNT'} THE ${name}`, how: 'fight', ship,
+ at: ship.object.position, speed: ship.state.speed, fleeing,
+ };
}
break;
case 'scan':
if (ship) {
- return { what: `SCAN THE ${name}`, how: 'hold', ship, at: ship.object.position, speed: ship.state.speed };
+ return { what: `SCAN THE ${name}`, how: 'hold', ship, at: ship.object.position, speed: ship.state.speed, fleeing: false };
}
break;
case 'escort':
if (ship) {
- return { what: `ESCORT THE ${name}`, how: 'escort', ship, at: ship.object.position, speed: ship.state.speed };
+ return { what: `ESCORT THE ${name}`, how: 'escort', ship, at: ship.object.position, speed: ship.state.speed, fleeing: false };
}
break;
case 'recover':
if (item) {
- return { what: 'RECOVER THE CARGO', how: 'scoop', ship: null, at: item.object.position, speed: 0 };
+ return { what: 'RECOVER THE CARGO', how: 'scoop', ship: null, at: item.object.position, speed: 0, fleeing: false };
}
break;
case 'rescue':
if (item) {
- return { what: 'PICK UP THE SURVIVOR', how: 'scoop', ship: null, at: item.object.position, speed: 0 };
+ return { what: 'PICK UP THE SURVIVOR', how: 'scoop', ship: null, at: item.object.position, speed: 0, fleeing: false };
}
break;
case 'smuggle':
return {
- what: 'SLIP PAST THE POLICE', how: 'slip', ship: null, at: stationPos, speed: 0,
+ what: 'SLIP PAST THE POLICE', how: 'slip', ship: null, at: stationPos, speed: 0, fleeing: false,
};
default:
// A deliver and an ambush both end at the station, and the station
diff --git a/src/game/npc-fighter.ts b/src/game/npc-fighter.ts
index 44c91c4a..820afc03 100644
--- a/src/game/npc-fighter.ts
+++ b/src/game/npc-fighter.ts
@@ -7,6 +7,10 @@
// 2. is there another ship worth attacking?
// 3. otherwise, amble.
//
+// A SHIP ON THE RUN ANSWERS NONE OF THEM (docs/TODO/214 M4). A hunt's target
+// that is nearly dead runs for the edge and jumps out, and `runOut` below
+// flies that before the three questions are asked.
+//
// THE ORDER IS LOAD-BEARING. A ship that can reach the commander does that
// before it looks at an NPC target. So a pirate mid-duel with a trader breaks
// off for her, rather than the other way about. `game/npc.ts` ran these three in
@@ -35,6 +39,7 @@ import { STATION_TRUCE } from '../constants/law.ts';
import { AMBLE_ARRIVED, AMBLE_NEAR, AMBLE_SPAN } from '../constants/amble.ts';
import { PLAYER_INTEREST_RANGE } from '../constants/player-interest.ts';
import { HUNT_HOLD_RANGE } from '../constants/hunt-ranges.ts';
+import { TRADER_JUMP_OUT } from '../constants/spawn-placement.ts';
import { approach, velocityOf } from './flight-maths.ts';
import { random, randomDirection } from './rng.ts';
import { attack } from './npc-attack-run.ts';
@@ -52,6 +57,7 @@ class Fighter implements NpcBehaviour {
ship: BehaviourShip, dt: number, player: PlayerRef, view: WorldView,
): FireEvent | null {
const { station, fleet, playerLegal, brains } = view;
+ if (ship.state.fleeing) return runOut(ship, dt);
const toPlayer = tmpDir.copy(player.position).sub(ship.object.position);
const distPlayer = toPlayer.length();
@@ -107,5 +113,23 @@ class Fighter implements NpcBehaviour {
}
}
+/**
+ * The run for the edge of the system (docs/TODO/214 M4). `NpcShip.takeDamage`
+ * set the waypoint `DEEP_TRADER_RUN` away from the shot. The ship flies at
+ * it at full speed, and it jumps out `TRADER_JUMP_OUT` short of it, as a
+ * departing trader does. The world step then sends `fled` for it. Nothing
+ * ends the run. A trader's calm brings a trader back, and this ship is gone.
+ */
+function runOut(ship: BehaviourShip, dt: number): null {
+ ship.state.flownBy = 'fleeing';
+ ship.steerToward(ship.state.waypoint, dt);
+ ship.state.speed = approach(ship.state.speed, ship.maxSpeed, 150 * dt);
+ ship.advance(dt);
+ if (ship.object.position.distanceTo(ship.state.waypoint) < TRADER_JUMP_OUT) {
+ ship.state.wantsDespawn = true;
+ }
+ return null;
+}
+
/** The behaviour every fighting role flies. */
export const fighterBehaviour = (): NpcBehaviour => new Fighter();
diff --git a/src/game/npc-state.ts b/src/game/npc-state.ts
index 6a78315f..5bd449bb 100644
--- a/src/game/npc-state.ts
+++ b/src/game/npc-state.ts
@@ -138,6 +138,15 @@ export interface NpcState {
holding: boolean;
/** the console said the charge is holding, once */
holdSaid: boolean;
+ /**
+ * This ship may run for the edge of the system when it is nearly dead
+ * (docs/TODO/214 M4). The world step stamps it each frame on the target
+ * of a hunt that `canEscape`, and on nothing else. `takeDamage` reads it
+ * against `HUNT_FLEE_FRACTION`.
+ */
+ canFlee: boolean;
+ /** the console said the target is on the run, once */
+ runSaid: boolean;
fleeing: boolean;
/** where this ship is in its attack run — see break-off.ts */
attackPhase: AttackPhase;
@@ -289,7 +298,7 @@ export function freshNpcState(maxEnergy: number): NpcState {
tumbleAxis: randomDirection(new THREE.Vector3()),
energy: maxEnergy, regenCarry: 0,
alive: true, provoked: false, provokedByPlayer: false, missiles: 0,
- missionTag: null, targeted: false, announcedClose: false, observed: 0, missionReported: false, holding: false, holdSaid: false, fleeing: false, attackPhase: 'closing', underFire: 0, calm: 0, flownBy: 'none',
+ missionTag: null, targeted: false, announcedClose: false, observed: 0, missionReported: false, holding: false, holdSaid: false, canFlee: false, runSaid: false, fleeing: false, attackPhase: 'closing', underFire: 0, calm: 0, flownBy: 'none',
extendRange: EXTEND_RANGE_MAX, passSide: 1, passesMade: 0,
tactic: 'run', tacticClock: 0, dryFor: 0,
tradeTimer: 0,
diff --git a/src/game/npc.ts b/src/game/npc.ts
index 0b8b75af..0ece8ff5 100644
--- a/src/game/npc.ts
+++ b/src/game/npc.ts
@@ -112,7 +112,8 @@ import type { NpcEnergyPoints } from './damage-units.ts';
import { random, randomDirection, randomQuaternion } from './rng.ts';
import { PursuitPilot } from './npc-pursuit.ts';
import type { PilotShip } from './npc-pilot.ts';
-import { MIN_CRUISE_FRACTION, UNDER_FIRE_SECONDS } from '../constants/attack-run.ts';
+import { HUNT_FLEE_FRACTION, MIN_CRUISE_FRACTION, UNDER_FIRE_SECONDS } from '../constants/attack-run.ts';
+import { DEEP_TRADER_RUN } from '../constants/spawn-placement.ts';
import type { NpcBehaviour } from './npc-behaviour.ts';
import { derelictIdle, hermitIdle, rockIdle } from './npc-idle.ts';
import { fighterBehaviour } from './npc-fighter.ts';
@@ -791,11 +792,17 @@ export class NpcShip {
this.state.underFire = UNDER_FIRE_SECONDS;
this.state.calm = 0;
if (byPlayer) this.state.provokedByPlayer = true;
+ this.state.energy = energyAfterDamage(this.state.energy, points);
if (from && this.role === 'trader') {
this.state.fleeFrom.copy(from);
this.state.fleeing = true;
}
- this.state.energy = energyAfterDamage(this.state.energy, points);
+ // A HUNT'S TARGET RUNS WHEN IT IS NEARLY DEAD (docs/TODO/214 M4). It runs
+ // once, and it never turns back. `npc-fighter.ts` flies the run.
+ if (from && this.state.canFlee && !this.state.fleeing
+ && this.state.energy < this.maxEnergy * HUNT_FLEE_FRACTION) {
+ this.runOut(from);
+ }
if (isDestroyed(this.state.energy) && this.state.alive) {
this.state.alive = false;
return true;
@@ -803,6 +810,21 @@ export class NpcShip {
return false;
}
+ /**
+ * Set the run for the edge: away from the shot, `DEEP_TRADER_RUN` out, as
+ * a departing trader flies (docs/TODO/214 M4). `npc-fighter.ts` flies at
+ * the waypoint and jumps out `TRADER_JUMP_OUT` short of it. A shot from the
+ * ship's own place has no direction, so the ship then runs the way it
+ * points. It allocates, and it runs once in a ship's life.
+ */
+ private runOut(from: THREE.Vector3): void {
+ this.state.fleeFrom.copy(from);
+ this.state.fleeing = true;
+ const away = new THREE.Vector3().subVectors(this.object.position, from);
+ if (away.lengthSq() < 1e-6) away.set(0, 0, -1).applyQuaternion(this.object.quaternion);
+ this.state.waypoint.copy(this.object.position).addScaledVector(away.normalize(), DEEP_TRADER_RUN);
+ }
+
/**
* EVERYTHING THAT RUNS ON ELAPSED TIME, whatever the ship is doing.
*
diff --git a/src/game/world-step.ts b/src/game/world-step.ts
index fec8971a..b390da06 100644
--- a/src/game/world-step.ts
+++ b/src/game/world-step.ts
@@ -82,7 +82,7 @@ import type { NpcShip, FireEvent, WorldView } from './npc.ts';
import { nearestNpc } from './hostility.ts';
import type { SoundEvent, SoundName } from './sounds.ts';
import { applyMissions, runMissions, type MissionOutcome } from './mission-bridge.ts';
-import { scanSecondsFor } from '../missions/queries.ts';
+import { huntCanFlee, scanSecondsFor } from '../missions/queries.ts';
import { DOCK_COMPUTER_RANGE } from '../constants/docking-computer.ts';
import { ESCORT_ENEMY_ROLES, WATCH_CONE } from '../constants/missions.ts';
import { ESCORT_LEASH } from '../constants/mission-course.ts';
@@ -678,6 +678,17 @@ export class WorldStep {
for (const npc of world.npcs) {
const tag = npc.state.missionTag;
if (tag === null || npc.state.missionReported || !npc.state.alive) continue;
+ if (npc.role !== 'trader') {
+ // A hunt's target may run for the edge when it is nearly dead
+ // (docs/TODO/214 M4). The stamp is here, each frame, so a restored
+ // ship carries it again at once. The console says the run once.
+ npc.state.canFlee = huntCanFlee(commander.missions, tag);
+ if (npc.state.fleeing && !npc.state.runSaid) {
+ npc.state.runSaid = true;
+ out.push(say(`THE ${npc.object.name.toUpperCase()} IS RUNNING FOR IT. CHASE IT.`, 4));
+ }
+ continue;
+ }
if (npc.role === 'trader') {
const wanted = scanSecondsFor(commander.missions, tag);
if (wanted !== null) {
diff --git a/src/missions/queries.ts b/src/missions/queries.ts
index 06dbf3ea..202cdea4 100644
--- a/src/missions/queries.ts
+++ b/src/missions/queries.ts
@@ -102,6 +102,20 @@ export function scanSecondsFor(
return null;
}
+/**
+ * Whether the ship with `tag` is the target of a hunt it may run from
+ * (docs/TODO/214 M4). The world step stamps `canFlee` on it each frame, so
+ * a restored ship carries it again on its first frame.
+ */
+export function huntCanFlee(
+ st: MissionState, tag: string, from: readonly Skeleton[] = SKELETONS,
+): boolean {
+ for (const { live, leg } of liveLegs(st, from)) {
+ if (live.tag === tag && leg.verb.kind === 'hunt') return leg.verb.canEscape;
+ }
+ return false;
+}
+
/** The standing order for one live mission, in the game's voice. */
export function orderLine(
live: LiveMission, systems: readonly StarSystem[], from: readonly Skeleton[] = SKELETONS,
diff --git a/src/missions/skeletons/side.ts b/src/missions/skeletons/side.ts
index ccfc273f..02e263f7 100644
--- a/src/missions/skeletons/side.ts
+++ b/src/missions/skeletons/side.ts
@@ -33,13 +33,12 @@ export const SIDE_HUNT: Skeleton = {
id: 'hunt', verb: { kind: 'hunt', ship: shipDesignIdOf(SOURCE_DESIGN.krait), canEscape: true },
place: AWAY, line: 'BOUNTY: DESTROY THE KRAIT — LAST SEEN AT {TARGET}', deadlineDays: SIDE_JOB_DAYS,
spawn: [...wingmanOf(shipDesignIdOf(SOURCE_DESIGN.krait), 'side-hunt')],
- // A pirate cannot leave a system today, so a Krait that runs is a Krait
- // that comes back on the next arrival. A branch would change the dossier
- // hash, and 214 M4 makes the chase real (docs/TODO/213 M5).
- ignores: ['targetFled'],
+ // A Krait that is nearly dead runs for the edge, and the chase is the
+ // commander's (docs/TODO/214 M4). One that gets away pays nothing.
next: [
{ on: 'targetDestroyed', to: 'complete', settle: { pay: SIDE_JOB_PAY.hunt, say: 'THE KRAIT IS DESTROYED. THE STATION PAYS {PAY}.' } },
{ on: 'targetEscaped', to: 'fail' },
+ { on: 'targetFled', to: 'fail', settle: { pay: 0, say: 'THE KRAIT RAN FOR THE EDGE AND JUMPED. THE STATION PAYS NOTHING.' } },
FAIL,
],
}],
diff --git a/test/course-pilot.test.ts b/test/course-pilot.test.ts
index ba66d01a..19ab2487 100644
--- a/test/course-pilot.test.ts
+++ b/test/course-pilot.test.ts
@@ -201,15 +201,28 @@ console.log('\na mission target below the clearance is refused');
const v = view(new THREE.Vector3(0, 0, -50_000));
const low = v.planetPos.clone().add(new THREE.Vector3(0, -(v.planetRadius + 100), 0));
const refused = new CoursePilot().step(
- view(v.stationPos, { course: 'mission', mission: { at: low, speed: 100, how: 'escort' } }), 1 / 60);
+ view(v.stationPos, { course: 'mission', mission: { at: low, speed: 100, how: 'escort', fleeing: false } }), 1 / 60);
check('a target 100 units above the planet ends the course', refused.done);
check('...with a reason for the console', typeof refused.why === 'string' && refused.why.length > 0);
const clear = v.planetPos.clone().add(new THREE.Vector3(0, -(v.planetRadius + COURSE_PLANET_CLEARANCE * 2), 0));
const flown = new CoursePilot().step(
- view(v.stationPos, { course: 'mission', mission: { at: clear, speed: 100, how: 'escort' } }), 1 / 60);
+ view(v.stationPos, { course: 'mission', mission: { at: clear, speed: 100, how: 'escort', fleeing: false } }), 1 / 60);
check('...and one well above it is flown (the control)', !flown.done && flown.demand !== null);
}
+console.log('\na hunt is a fight until the target runs, and then it is a chase (docs/TODO/214 M4)');
+{
+ const v = view(new THREE.Vector3(0, 0, -50_000));
+ const ahead = new THREE.Vector3(0, 0, -6_000);
+ const fight = new CoursePilot().step(
+ view(v.stationPos, { course: 'mission', mission: { at: ahead, speed: 290, how: 'fight', fleeing: false } }), 1 / 60);
+ check('a target that stands and fights is left to the computer\'s aim', fight.demand === null && !fight.done);
+ const chase = new CoursePilot().step(
+ view(v.stationPos, { course: 'mission', mission: { at: ahead, speed: 290, how: 'fight', fleeing: true } }), 1 / 60);
+ check('a target on the run is chased at full throttle', chase.demand?.throttle === 1 && !chase.done);
+ check('...with the nose held on it', chase.demand !== null && chase.demand.pitchRate === 0 && chase.demand.rollRate === 0);
+}
+
console.log('\nnothing appears inside the planet');
{
// docs/TODO/205 M3 found a hermit 1,545 units inside the planet, and a
diff --git a/test/hunt-chase.test.ts b/test/hunt-chase.test.ts
new file mode 100644
index 00000000..72c47e2c
--- /dev/null
+++ b/test/hunt-chase.test.ts
@@ -0,0 +1,80 @@
+// A hunt's target that is nearly dead runs for the edge, and the chase is the
+// commander's (docs/TODO/214 M4).
+//
+// Two claims about the ship. `NpcShip.takeDamage` sets the run on a ship the
+// world step marked, and on no other pirate, while a trader still runs on the
+// first hit. And the fighter behaviour flies the run: straight at the
+// waypoint, at full speed, and off the edge `TRADER_JUMP_OUT` short of it.
+//
+// The mission's side of it is `test/mission-courses.test.ts`, through a real
+// world step: the stamp, the row's words, the line on the console, and the
+// leg that fails when the ship jumps out.
+
+import * as THREE from 'three';
+import { NpcShip } from '../src/game/npc.ts';
+import { seedWorld } from '../src/game/rng.ts';
+import { SHIPPED_BRAINS } from '../src/game/brain-names.ts';
+import { HUNT_FLEE_FRACTION } from '../src/constants/attack-run.ts';
+import { DEEP_TRADER_RUN, TRADER_JUMP_OUT } from '../src/constants/spawn-placement.ts';
+import { check, eq } from './harness.ts';
+
+const origin = new THREE.Vector3();
+const station = new THREE.Object3D();
+const player = { position: origin, quaternion: new THREE.Quaternion(), speed: 0 } as never;
+const view = {
+ station, dockZ: 160, fleet: [], playerLegal: 0, brains: SHIPPED_BRAINS,
+ missileInbound: false, playerToStation: Infinity,
+} as never;
+
+/** A pirate 2,000 units down the nose, with this much of its energy left. */
+function pirate(fraction: number, canFlee: boolean): NpcShip {
+ seedWorld(7);
+ const npc = new NpcShip('pirate', new THREE.Vector3(0, 0, -2000), 3);
+ npc.state.energy = Math.floor(npc.maxEnergy * fraction);
+ npc.state.canFlee = canFlee;
+ return npc;
+}
+
+console.log('\na hunt\'s target runs when it is nearly dead, and no other pirate does');
+{
+ const marked = pirate(HUNT_FLEE_FRACTION, true);
+ marked.takeLaserHit(1, origin.clone(), true);
+ check('a marked pirate hit under the fraction runs', marked.state.fleeing && marked.state.alive);
+ const run = marked.state.waypoint.clone().sub(marked.object.position);
+ check(`...straight away from the shot, ${DEEP_TRADER_RUN} out`,
+ run.z < 0 && Math.abs(run.length() - DEEP_TRADER_RUN) < 1, `${run.length().toFixed(0)} along z ${run.z.toFixed(0)}`);
+
+ const whole = pirate(1, true);
+ whole.takeLaserHit(1, origin.clone(), true);
+ check('...and one hit at full energy stands and fights', !whole.state.fleeing);
+
+ const unmarked = pirate(HUNT_FLEE_FRACTION, false);
+ unmarked.takeLaserHit(1, origin.clone(), true);
+ check('a pirate the world step never marked fights to the end', !unmarked.state.fleeing);
+
+ seedWorld(7);
+ const trader = new NpcShip('trader', new THREE.Vector3(0, 0, -2000), 3);
+ trader.takeLaserHit(1, origin.clone(), true);
+ check('a trader still runs on the first hit, as before', trader.state.fleeing);
+ check('...and a trader\'s run sets no waypoint', trader.state.waypoint.lengthSq() === 0);
+}
+
+console.log('\nthe fighter flies the run, and jumps out short of the waypoint');
+{
+ const npc = pirate(HUNT_FLEE_FRACTION, true);
+ npc.takeLaserHit(1, origin.clone(), true);
+ const start = npc.object.position.clone();
+ for (let f = 0; f < 5 * 60; f++) npc.update(1 / 60, player, view);
+ const moved = npc.object.position.clone().sub(start);
+ eq('a ship on the run reports the flight it flew', npc.state.flownBy, 'fleeing');
+ check('...and five seconds on it is well down the run, away from the shot',
+ moved.z < -500 && moved.length() > 500, `${moved.length().toFixed(0)} units, z ${moved.z.toFixed(0)}`);
+ check('...at its top speed', Math.abs(npc.state.speed - npc.maxSpeed) < 1, `${npc.state.speed.toFixed(0)} of ${npc.maxSpeed}`);
+ check('...and it has not left yet', !npc.state.wantsDespawn);
+
+ // The waypoint brought within reach: one more second, and the ship asks to go.
+ npc.state.waypoint.copy(npc.object.position).add(new THREE.Vector3(0, 0, -(TRADER_JUMP_OUT + 100)));
+ for (let f = 0; f < 60 && !npc.state.wantsDespawn; f++) npc.update(1 / 60, player, view);
+ check(`inside ${TRADER_JUMP_OUT} of the waypoint it jumps out`, npc.state.wantsDespawn);
+ check('...alive, so the leg reads a ship that fled and not a wreck', npc.state.alive && npc.state.fleeing);
+}
diff --git a/test/mission-courses.test.ts b/test/mission-courses.test.ts
index bc397450..39147e73 100644
--- a/test/mission-courses.test.ts
+++ b/test/mission-courses.test.ts
@@ -19,7 +19,8 @@ import { missionCourse } from '../src/game/mission-course.ts';
import { clearOfPolice } from '../src/game/course-clearance.ts';
import { SCAN_RANGE } from '../src/constants/law.ts';
import { COURSE_ESCORT_STANDOFF, COURSE_POLICE_CLEARANCE, ESCORT_LEASH } from '../src/constants/mission-course.ts';
-import { TRADER_CALM_SECONDS } from '../src/constants/attack-run.ts';
+import { HUNT_FLEE_FRACTION, TRADER_CALM_SECONDS } from '../src/constants/attack-run.ts';
+import { TRADER_JUMP_OUT } from '../src/constants/spawn-placement.ts';
import { COURSE_KEYS } from '../src/game/bindings.ts';
import { keymap } from '../src/engine/keymap.ts';
import { check, dismissBriefing, eq } from './harness.ts';
@@ -195,7 +196,8 @@ console.log('\na hunted ship that runs has fled, not escaped');
{
// Before docs/TODO/208 M3 the world sent `escaped` whichever way a tagged
// ship left, and a side hunt fails on that. A ship that ran from the
- // commander has fled, and three arcs have a branch for it.
+ // commander has fled. Three arcs have a branch for it, and the side hunt
+ // has one since docs/TODO/214 M4: it fails, and pays nothing.
const flown = (fleeing: boolean): string | undefined => {
const g = onTheJob('side-hunt', 20_260_945);
// The target, and not the wingman that flies with it since 214 M1.
@@ -208,7 +210,7 @@ console.log('\na hunted ship that runs has fled, not escaped');
return g.state.commander.missions.done['side-hunt'];
};
eq('a tagged ship that jumps out escapes, and the side hunt fails', flown(false), 'fail');
- eq('...and one that runs has fled, which a side hunt does not answer', flown(true), undefined);
+ eq('...and one that runs has fled, which fails the side hunt too', flown(true), 'fail');
}
console.log('\na smuggling run keeps wide of the police');
@@ -242,3 +244,52 @@ console.log('\na smuggling run keeps wide of the police');
check('...and the ship goes round a policeman in the way', nearest > SCAN_RANGE,
`${Math.round(nearest)} units at the nearest`);
}
+
+console.log('\nthe hunt is a chase: a Krait that is nearly dead runs, and the station pays nothing (docs/TODO/214 M4)');
+{
+ const g = onTheJob('side-hunt', 20_260_951);
+ const live = g.state.commander.missions.live[0];
+ // The target alone: the wingman of 214 M1 fights to the end, and the run
+ // is the subject.
+ for (const n of g.state.world.npcs) if (n.state.missionTag !== live.tag) n.state.alive = false;
+ const krait = g.state.world.npcs.find((n) => n.state.missionTag === live.tag);
+ if (!krait) throw new Error('the hunt spawned no target');
+ const row = () => missionCourse(g.state.commander.missions, g.state.commander.systemIndex,
+ g.state.world.npcs, g.state.world.cargo.items, g.state.world.station.position);
+ withoutSaving(() => g.step(1 / 60, 100));
+ check('the world step marks the hunt\'s target as one that may run', krait.state.canFlee);
+ eq('...and the course row says HUNT', row()?.what, 'HUNT THE KRAIT');
+
+ // Shot down to the fraction from where the commander stands.
+ krait.state.energy = Math.floor(krait.maxEnergy * HUNT_FLEE_FRACTION);
+ krait.takeLaserHit(1, g.state.player.position.clone(), true);
+ check('a hit that leaves it under the fraction sets it to flight', krait.state.fleeing);
+ eq('...and the course row says CHASE', row()?.what, 'CHASE THE KRAIT');
+ withoutSaving(() => g.step(1 / 60, 101));
+ check('...and the console says so, once', krait.state.runSaid);
+
+ // The edge brought within reach, so the run ends inside the test.
+ const away = krait.state.waypoint.clone().sub(krait.object.position).normalize();
+ krait.state.waypoint.copy(krait.object.position).addScaledVector(away, TRADER_JUMP_OUT + 600);
+ const took = fly(g, 30, () => !g.state.world.npcs.includes(krait));
+ check('the Krait jumps out at the edge', !g.state.world.npcs.includes(krait), `after ${took.toFixed(1)}s`);
+ eq('...and the leg fails', g.state.commander.missions.done['side-hunt'], 'fail');
+ eq('...with nothing paid', g.state.commander.credits, 1000);
+}
+
+console.log('\n...and a commander who chases can still make the kill');
+{
+ const g = onTheJob('side-hunt', 20_260_953);
+ const live = g.state.commander.missions.live[0];
+ for (const n of g.state.world.npcs) if (n.state.missionTag !== live.tag) n.state.alive = false;
+ const krait = g.state.world.npcs.find((n) => n.state.missionTag === live.tag);
+ if (!krait) throw new Error('the hunt spawned no target');
+ withoutSaving(() => g.step(1 / 60, 100));
+ krait.state.energy = Math.floor(krait.maxEnergy * HUNT_FLEE_FRACTION);
+ krait.takeLaserHit(1, g.state.player.position.clone(), true);
+ check('the Krait runs', krait.state.fleeing);
+ // The mission button, and the trigger held: the computer aims, the pilot fires.
+ const took = fly(g, 60, () => !krait.state.alive || !g.state.world.npcs.includes(krait), true);
+ check('the chase ends in a kill before the edge', !krait.state.alive, `after ${took.toFixed(1)}s, alive ${krait.state.alive}, in the sky ${g.state.world.npcs.includes(krait)}`);
+ eq('...and the station pays the bounty', g.state.commander.missions.done['side-hunt'], 'complete');
+}
diff --git a/test/mission-verbs.test.ts b/test/mission-verbs.test.ts
index de0f98aa..913dd1d4 100644
--- a/test/mission-verbs.test.ts
+++ b/test/mission-verbs.test.ts
@@ -204,11 +204,14 @@ console.log('\nescort and scan, through the machine');
// left to kill.
eq('a target wrecked by somebody else still ends the hunt',
paid(stepMissions(hst, { kind: 'escortLost', tag: htag }, hctx).effects), SIDE_JOB_PAY.hunt);
- // A pirate cannot leave a system today, so the side hunt says it ignores
- // the word, and the lint holds it to that (docs/TODO/213 M5).
- check('a side hunt says it ignores a target that fled',
- SIDE_HUNT.legs[0].ignores?.includes('targetFled') === true);
- eq('...and answers it with nothing', stepMissions(hst, { kind: 'fled', tag: htag }, hctx).state.live.length, 1);
+ // A Krait that is nearly dead runs for the edge (docs/TODO/214 M4). The
+ // side hunt fails on it, and the station says it pays nothing. The leg
+ // said it ignored the word until then (docs/TODO/213 M5).
+ const fled = stepMissions(hst, { kind: 'fled', tag: htag }, hctx);
+ eq('a target that fled fails the side hunt', fled.state.done[SIDE_HUNT.id], 'fail');
+ check('...and the station says it pays nothing',
+ fled.effects.some((e) => e.kind === 'say' && /PAYS NOTHING/.test(e.text)));
+ check('...and the leg no longer says it ignores the word', SIDE_HUNT.legs[0].ignores === undefined);
}
console.log('\nescort, through a real world step');
From fa63c303593f4f1e2edfe2304aae79fe479e9ec4 Mon Sep 17 00:00:00 2001
From: Chris Greening MISSION_HUNT_RANGE | { min: 30, max: 80 } as const | The Constrictor hides this far from where you are briefed, in tenths of a light year. | | [missions.ts:23](./missions.ts#L23) |
| missions | MISSION_COURIER_RANGE | { min: 50, max: 90 } as const | The courier run is longer: the plans matter more than your convenience. | | [missions.ts:26](./missions.ts#L26) |
| missions | CONSTRICTOR_BOUNTY | 25_000 | What a kill of the Constrictor pays — 2,500 Cr, in tenths of a credit. | | [missions.ts:29](./missions.ts#L29) |
-| missions | COURIER_PAYMENT | 15_000 | ...and what a delivery of the plans pays: 1,500 Cr. | | [missions.ts:32](./missions.ts#L32) |
-| missions | MISSION_LIVE_CAP | 3 | How many missions a commander can hold open at one time: three. | missions.liveCap | [missions.ts:48](./missions.ts#L48) |
-| missions | MISSION_REOFFER_DAYS | 7 | Days before a finished side job is offered again: a week. | missions.reofferDays | [missions.ts:64](./missions.ts#L64) |
-| missions | LEAD_RUMOUR_JUMPS | 5 | Inside this many jumps of a lead's world, the station talks about it. | missions.leadRumourJumps | [missions.ts:77](./missions.ts#L77) |
-| missions | LEAD_NAG_DOCKS | 4 | Docks with no mission progress before a patron with a lead writes a second time. | missions.leadNagDocks | [missions.ts:89](./missions.ts#L89) |
-| missions | SIDE_JOB_PAY | { hunt: 8_000, deliver: 3_000, recover: 4_000, rescue: 5_000, ambush: 6_000, smuggle: 7_000, escort: 6_000, scan: 2_500, } as const | What a side job pays, per verb, in tenths of a credit. | missions.sideJobPay | [missions.ts:103](./missions.ts#L103) |
-| missions | RESCUE_SALVAGE_PAY | 1_500 | The lower fee a rescue pays when the pod is lost and the data still arrives, in tenths of a credit. | missions.rescueSalvagePay | [missions.ts:119](./missions.ts#L119) |
-| missions | SIDE_JOB_RANGE | { min: 20, max: 70 } as const | How far a side job sends the commander, in tenths of a light year: two to seven. | missions.sideJobRange | [missions.ts:127](./missions.ts#L127) |
-| missions | SIDE_JOB_DAYS | 14 | Days a side job allows before its deadline passes: two weeks. | missions.sideJobDays | [missions.ts:134](./missions.ts#L134) |
-| missions | DEADLINE_WARNING_DAYS | 3 | How many days before a deadline the console starts to say how many are left. | missions.deadlineWarningDays | [missions.ts:145](./missions.ts#L145) |
-| missions | SCAN_SECONDS | 20 | Seconds a scan target must stay under the scanner lock: twenty, which is a pass and a turn at a trader's speed. | missions.scanSeconds | [missions.ts:153](./missions.ts#L153) |
-| missions | WATCH_CONE | 0.35 | How far off the centre of the view a scan's subject may sit and still count as watched, in radians: 0.35, which is twenty degrees. | missions.watchCone | [missions.ts:167](./missions.ts#L167) |
-| missions | SMUGGLE_TONNES | 3 | Tonnes of the patron's goods on a smuggle job: three, which fits a Cobra's hold beside its own stock. | missions.smuggleTonnes | [missions.ts:179](./missions.ts#L179) |
-| missions | ESCORT_ENEMY_ROLES | ['pirate', 'hunter', 'thargoid', 'thargon'] | The roles that count as an enemy near an escorted ship: the ones that prey. | | [missions.ts:188](./missions.ts#L188) |
-| missions | TOUR_ARCS | 5 | How many arcs the tour holds: five, Chris's number (docs/TODO/190, item 192). | missions.tourArcs | [missions.ts:198](./missions.ts#L198) |
-| missions | TOUR_STEP_JUMPS | { min: 4, max: 6 } as const | How far each arc's start world is from the one before it, in JUMPS on the full-tank graph: four to six. | missions.tourStepJumps | [missions.ts:211](./missions.ts#L211) |
-| missions | ARC_HANDOVER_JUMPS | { min: 2, max: 4 } as const | How far an arc's final leg lies from the next arc's start world, in JUMPS: two to four. | missions.arcHandoverJumps | [missions.ts:220](./missions.ts#L220) |
-| missions | ARC_PAY | { hunt: 12_000, deliver: 5_000, recover: 6_000, rescue: 8_000, ambush: 9_000, smuggle: 10_000, escort: 9_000, scan: 4_000, } as const | What an arc leg pays, per verb, in tenths of a credit. | missions.arcPay | [missions.ts:233](./missions.ts#L233) |
-| missions | ARC_LEG_DAYS | 30 | Days an arc leg allows before its deadline passes: a month, twice a side job's fortnight, because an arc leg may be four jumps out. | missions.arcLegDays | [missions.ts:246](./missions.ts#L246) |
+| missions | COURIER_PAYMENT | 15_000 | ...and what a delivery of the plans pays: 1,500 Cr. | missions.courierPayment | [missions.ts:36](./missions.ts#L36) |
+| missions | MISSION_LIVE_CAP | 3 | How many missions a commander can hold open at one time: three. | missions.liveCap | [missions.ts:52](./missions.ts#L52) |
+| missions | MISSION_REOFFER_DAYS | 7 | Days before a finished side job is offered again: a week. | missions.reofferDays | [missions.ts:68](./missions.ts#L68) |
+| missions | LEAD_RUMOUR_JUMPS | 5 | Inside this many jumps of a lead's world, the station talks about it. | missions.leadRumourJumps | [missions.ts:81](./missions.ts#L81) |
+| missions | LEAD_NAG_DOCKS | 4 | Docks with no mission progress before a patron with a lead writes a second time. | missions.leadNagDocks | [missions.ts:93](./missions.ts#L93) |
+| missions | SIDE_JOB_PAY | { hunt: 8_000, deliver: 3_000, recover: 4_000, rescue: 5_000, ambush: 6_000, smuggle: 7_000, escort: 6_000, scan: 2_500, } as const | What a side job pays, per verb, in tenths of a credit. | missions.sideJobPay | [missions.ts:107](./missions.ts#L107) |
+| missions | GANG_BOUNTY | 15_000 | What the station pays for the whole gang on the side hunt, in tenths of a credit: 1,500 Cr (docs/TODO/217 M1). | missions.gangBounty | [missions.ts:121](./missions.ts#L121) |
+| missions | GANG_BROKEN_BOUNTY | GANG_BOUNTY / 2 | ...and half of it when the gang is gone but its leader ran (docs/TODO/217 M1). | missions.gangBrokenBounty | [missions.ts:129](./missions.ts#L129) |
+| missions | RESCUE_SALVAGE_PAY | 1_500 | The lower fee a rescue pays when the pod is lost and the data still arrives, in tenths of a credit. | missions.rescueSalvagePay | [missions.ts:142](./missions.ts#L142) |
+| missions | SIDE_JOB_RANGE | { min: 20, max: 70 } as const | How far a side job sends the commander, in tenths of a light year: two to seven. | missions.sideJobRange | [missions.ts:150](./missions.ts#L150) |
+| missions | SIDE_JOB_DAYS | 14 | Days a side job allows before its deadline passes: two weeks. | missions.sideJobDays | [missions.ts:157](./missions.ts#L157) |
+| missions | DEADLINE_WARNING_DAYS | 3 | How many days before a deadline the console starts to say how many are left. | missions.deadlineWarningDays | [missions.ts:168](./missions.ts#L168) |
+| missions | SCAN_SECONDS | 20 | Seconds a scan target must stay under the scanner lock: twenty, which is a pass and a turn at a trader's speed. | missions.scanSeconds | [missions.ts:176](./missions.ts#L176) |
+| missions | WATCH_CONE | 0.35 | How far off the centre of the view a scan's subject may sit and still count as watched, in radians: 0.35, which is twenty degrees. | missions.watchCone | [missions.ts:190](./missions.ts#L190) |
+| missions | SMUGGLE_TONNES | 3 | Tonnes of the patron's goods on a smuggle job: three, which fits a Cobra's hold beside its own stock. | missions.smuggleTonnes | [missions.ts:202](./missions.ts#L202) |
+| missions | ESCORT_ENEMY_ROLES | ['pirate', 'hunter', 'thargoid', 'thargon'] | The roles that count as an enemy near an escorted ship: the ones that prey. | | [missions.ts:211](./missions.ts#L211) |
+| missions | TOUR_ARCS | 5 | How many arcs the tour holds: five, Chris's number (docs/TODO/190, item 192). | missions.tourArcs | [missions.ts:221](./missions.ts#L221) |
+| missions | TOUR_STEP_JUMPS | { min: 4, max: 6 } as const | How far each arc's start world is from the one before it, in JUMPS on the full-tank graph: four to six. | missions.tourStepJumps | [missions.ts:234](./missions.ts#L234) |
+| missions | ARC_HANDOVER_JUMPS | { min: 2, max: 4 } as const | How far an arc's final leg lies from the next arc's start world, in JUMPS: two to four. | missions.arcHandoverJumps | [missions.ts:243](./missions.ts#L243) |
+| missions | ARC_PAY | { hunt: 12_000, deliver: 5_000, recover: 6_000, rescue: 8_000, ambush: 9_000, smuggle: 10_000, escort: 9_000, scan: 4_000, } as const | What an arc leg pays, per verb, in tenths of a credit. | missions.arcPay | [missions.ts:256](./missions.ts#L256) |
+| missions | ARC_LEG_DAYS | 30 | Days an arc leg allows before its deadline passes: a month, twice a side job's fortnight, because an arc leg may be four jumps out. | missions.arcLegDays | [missions.ts:269](./missions.ts#L269) |
| npc-gun | NPC_LASER_RANGE | LASER_RANGE | How far an NPC can shoot: the player's reach. | | [npc-gun.ts:17](./npc-gun.ts#L17) |
| npc-gun | NPC_COOLDOWN_LO | 0.9 | Time between an NPC's shots. | | [npc-gun.ts:31](./npc-gun.ts#L31) |
| npc-gun | NPC_COOLDOWN_SPREAD | 0.8 | | | [npc-gun.ts:32](./npc-gun.ts#L32) |
diff --git a/src/constants/missions.ts b/src/constants/missions.ts
index 02f86f5d..06427014 100644
--- a/src/constants/missions.ts
+++ b/src/constants/missions.ts
@@ -28,7 +28,11 @@ export const MISSION_COURIER_RANGE = { min: 50, max: 90 } as const;
/** What a kill of the Constrictor pays — 2,500 Cr, in tenths of a credit. */
export const CONSTRICTOR_BOUNTY = 25_000;
-/** ...and what a delivery of the plans pays: 1,500 Cr. */
+/**
+ * ...and what a delivery of the plans pays: 1,500 Cr.
+ *
+ * @rule missions.courierPayment
+ */
export const COURIER_PAYMENT = 15_000;
/**
@@ -105,6 +109,25 @@ export const SIDE_JOB_PAY = {
ambush: 6_000, smuggle: 7_000, escort: 6_000, scan: 2_500,
} as const;
+/**
+ * What the station pays for the whole gang on the side hunt, in tenths of
+ * a credit: 1,500 Cr (docs/TODO/217 M1). The gang is a Fer-de-Lance with
+ * an Asp, a Cobra Mk III and a Mamba. That is four hulls, three missiles
+ * and two E.C.M. fits, against the lone Krait that paid `SIDE_JOB_PAY.hunt`.
+ * Each kill pays its own bounty on top, as any kill does.
+ *
+ * @rule missions.gangBounty
+ */
+export const GANG_BOUNTY = 15_000;
+
+/**
+ * ...and half of it when the gang is gone but its leader ran (docs/TODO/217
+ * M1). The leader keeps its head, and the commander keeps three kills.
+ *
+ * @rule missions.gangBrokenBounty
+ */
+export const GANG_BROKEN_BOUNTY = GANG_BOUNTY / 2;
+
/**
* The lower fee a rescue pays when the pod is lost and the data still
* arrives, in tenths of a credit. The scientist example in docs/TODO/190:
diff --git a/src/game/course-actions.ts b/src/game/course-actions.ts
index 29884b3b..2edd1d4d 100644
--- a/src/game/course-actions.ts
+++ b/src/game/course-actions.ts
@@ -228,7 +228,7 @@ export class CourseActions {
private missionRow(): CourseWorld['mission'] {
const s = this.state();
const m = missionCourse(s.commander.missions, s.commander.systemIndex,
- s.world.npcs, s.world.cargo.items, s.world.station.position);
+ s.world.npcs, s.world.cargo.items, s.world.station.position, s.player.position);
if (m === null) return null;
const needsScoops = m.how === 'scoop' && !s.commander.equipment.scoops;
return { what: m.what, why: needsScoops ? 'NEEDS FUEL SCOOPS' : null };
diff --git a/src/game/flight-course.ts b/src/game/flight-course.ts
index 1b078c0b..fd6de402 100644
--- a/src/game/flight-course.ts
+++ b/src/game/flight-course.ts
@@ -105,7 +105,7 @@ export class FlightCourse {
// as the target, exactly as a rock is.
const mission = s.course !== 'mission' ? null
: missionCourse(this.state.commander.missions, this.state.commander.systemIndex,
- w.npcs, w.cargo.items, w.station.position);
+ w.npcs, w.cargo.items, w.station.position, p.position);
if (mission?.how === 'fight' && mission.ship !== null
&& pickedTarget(w.npcs) !== mission.ship) {
pickTarget(w.npcs, mission.ship);
diff --git a/src/game/mission-course.ts b/src/game/mission-course.ts
index de1c7e67..ad9bd9aa 100644
--- a/src/game/mission-course.ts
+++ b/src/game/mission-course.ts
@@ -57,6 +57,8 @@ export function missionCourse(
npcs: readonly NpcShip[],
items: readonly Canister[],
stationPos: THREE.Vector3,
+ /** where the commander is, so a gang's row can point at the nearest member */
+ playerPos: THREE.Vector3,
skeletons: readonly Skeleton[] = SKELETONS,
): MissionCourse | null {
for (const { live, leg } of liveLegs(st, skeletons)) {
@@ -70,7 +72,26 @@ export function missionCourse(
: items.find((c) => c.missionTag === live.tag) ?? null;
const name = ship?.object.name.toUpperCase() ?? '';
switch (leg.verb.kind) {
- case 'hunt':
+ case 'hunt': {
+ // A gang's row counts what the record still holds, and it points at
+ // the leader while it lives, then at the nearest member
+ // (docs/TODO/217 M1).
+ if (leg.verb.gang !== undefined && leg.verb.gang.length > 0 && live.tag !== null) {
+ const prefix = `${live.tag}#gang-`;
+ const left = Object.entries(st.entities)
+ .filter(([tag, e]) => (tag === live.tag || tag.startsWith(prefix)) && e.alive).length;
+ const members = npcs.filter((n) => n.state.alive && n.state.missionTag !== null
+ && n.state.missionTag.startsWith(prefix));
+ const target = ship ?? members.reduceCOURSE_ESCORT_CLOSING | 60 | How much faster than its charge the escort course may close, in world units a second, once it is inside three standoffs of it: sixty. | course.escortClosing | [mission-course.ts:52](./mission-course.ts#L52) |
| mission-course | ESCORT_LEASH | SCANNER_RANGE * 2 / 3 | How far the commander may fall behind her charge before it holds for her, in world units (docs/TODO/214 M3). | course.escortLeash | [mission-course.ts:68](./mission-course.ts#L68) |
| mission-course | COURSE_POLICE_CLEARANCE | SCAN_WARN_RANGE | How wide of a police ship the smuggling course flies, in world units (docs/TODO/208 M4). | | [mission-course.ts:81](./mission-course.ts#L81) |
-| missions | MISSION_KILL_THRESHOLD | 16 | Kills before the Navy considers you worth a word: 16, as the original demanded. | missions.killThreshold | [missions.ts:15](./missions.ts#L15) |
-| missions | MISSION_HUNT_RANGE | { min: 30, max: 80 } as const | The Constrictor hides this far from where you are briefed, in tenths of a light year. | | [missions.ts:23](./missions.ts#L23) |
-| missions | MISSION_COURIER_RANGE | { min: 50, max: 90 } as const | The courier run is longer: the plans matter more than your convenience. | | [missions.ts:26](./missions.ts#L26) |
-| missions | CONSTRICTOR_BOUNTY | 25_000 | What a kill of the Constrictor pays — 2,500 Cr, in tenths of a credit. | | [missions.ts:29](./missions.ts#L29) |
-| missions | COURIER_PAYMENT | 15_000 | ...and what a delivery of the plans pays: 1,500 Cr. | missions.courierPayment | [missions.ts:36](./missions.ts#L36) |
-| missions | MISSION_LIVE_CAP | 3 | How many missions a commander can hold open at one time: three. | missions.liveCap | [missions.ts:52](./missions.ts#L52) |
-| missions | MISSION_REOFFER_DAYS | 7 | Days before a finished side job is offered again: a week. | missions.reofferDays | [missions.ts:68](./missions.ts#L68) |
-| missions | LEAD_RUMOUR_JUMPS | 5 | Inside this many jumps of a lead's world, the station talks about it. | missions.leadRumourJumps | [missions.ts:81](./missions.ts#L81) |
-| missions | LEAD_NAG_DOCKS | 4 | Docks with no mission progress before a patron with a lead writes a second time. | missions.leadNagDocks | [missions.ts:93](./missions.ts#L93) |
-| missions | SIDE_JOB_PAY | { hunt: 8_000, deliver: 3_000, recover: 4_000, rescue: 5_000, ambush: 6_000, smuggle: 7_000, escort: 6_000, scan: 2_500, } as const | What a side job pays, per verb, in tenths of a credit. | missions.sideJobPay | [missions.ts:107](./missions.ts#L107) |
-| missions | GANG_BOUNTY | 15_000 | What the station pays for the whole gang on the side hunt, in tenths of a credit: 1,500 Cr (docs/TODO/217 M1). | missions.gangBounty | [missions.ts:121](./missions.ts#L121) |
-| missions | GANG_BROKEN_BOUNTY | GANG_BOUNTY / 2 | ...and half of it when the gang is gone but its leader ran (docs/TODO/217 M1). | missions.gangBrokenBounty | [missions.ts:129](./missions.ts#L129) |
-| missions | RESCUE_SALVAGE_PAY | 1_500 | The lower fee a rescue pays when the pod is lost and the data still arrives, in tenths of a credit. | missions.rescueSalvagePay | [missions.ts:142](./missions.ts#L142) |
-| missions | SIDE_JOB_RANGE | { min: 20, max: 70 } as const | How far a side job sends the commander, in tenths of a light year: two to seven. | missions.sideJobRange | [missions.ts:150](./missions.ts#L150) |
-| missions | SIDE_JOB_DAYS | 14 | Days a side job allows before its deadline passes: two weeks. | missions.sideJobDays | [missions.ts:157](./missions.ts#L157) |
-| missions | DEADLINE_WARNING_DAYS | 3 | How many days before a deadline the console starts to say how many are left. | missions.deadlineWarningDays | [missions.ts:168](./missions.ts#L168) |
-| missions | SCAN_SECONDS | 20 | Seconds a scan target must stay under the scanner lock: twenty, which is a pass and a turn at a trader's speed. | missions.scanSeconds | [missions.ts:176](./missions.ts#L176) |
-| missions | WATCH_CONE | 0.35 | How far off the centre of the view a scan's subject may sit and still count as watched, in radians: 0.35, which is twenty degrees. | missions.watchCone | [missions.ts:190](./missions.ts#L190) |
-| missions | SMUGGLE_TONNES | 3 | Tonnes of the patron's goods on a smuggle job: three, which fits a Cobra's hold beside its own stock. | missions.smuggleTonnes | [missions.ts:202](./missions.ts#L202) |
-| missions | ESCORT_ENEMY_ROLES | ['pirate', 'hunter', 'thargoid', 'thargon'] | The roles that count as an enemy near an escorted ship: the ones that prey. | | [missions.ts:211](./missions.ts#L211) |
-| missions | TOUR_ARCS | 5 | How many arcs the tour holds: five, Chris's number (docs/TODO/190, item 192). | missions.tourArcs | [missions.ts:221](./missions.ts#L221) |
-| missions | TOUR_STEP_JUMPS | { min: 4, max: 6 } as const | How far each arc's start world is from the one before it, in JUMPS on the full-tank graph: four to six. | missions.tourStepJumps | [missions.ts:234](./missions.ts#L234) |
-| missions | ARC_HANDOVER_JUMPS | { min: 2, max: 4 } as const | How far an arc's final leg lies from the next arc's start world, in JUMPS: two to four. | missions.arcHandoverJumps | [missions.ts:243](./missions.ts#L243) |
-| missions | ARC_PAY | { hunt: 12_000, deliver: 5_000, recover: 6_000, rescue: 8_000, ambush: 9_000, smuggle: 10_000, escort: 9_000, scan: 4_000, } as const | What an arc leg pays, per verb, in tenths of a credit. | missions.arcPay | [missions.ts:256](./missions.ts#L256) |
-| missions | ARC_LEG_DAYS | 30 | Days an arc leg allows before its deadline passes: a month, twice a side job's fortnight, because an arc leg may be four jumps out. | missions.arcLegDays | [missions.ts:269](./missions.ts#L269) |
+| missions | MISSION_KILL_THRESHOLD | 16 | Kills before the Navy considers you worth a word: 16, as the original demanded. | missions.killThreshold | [missions.ts:17](./missions.ts#L17) |
+| missions | MISSION_HUNT_RANGE | { min: 30, max: 80 } as const | The Constrictor hides this far from where you are briefed, in tenths of a light year. | | [missions.ts:25](./missions.ts#L25) |
+| missions | MISSION_COURIER_RANGE | { min: 50, max: 90 } as const | The courier run is longer: the plans matter more than your convenience. | | [missions.ts:28](./missions.ts#L28) |
+| missions | CONSTRICTOR_BOUNTY | 25_000 | What a kill of the Constrictor pays — 2,500 Cr, in tenths of a credit. | | [missions.ts:31](./missions.ts#L31) |
+| missions | COURIER_PAYMENT | 15_000 | ...and what a delivery of the plans pays: 1,500 Cr. | missions.courierPayment | [missions.ts:38](./missions.ts#L38) |
+| missions | MISSION_LIVE_CAP | 3 | How many missions a commander can hold open at one time: three. | missions.liveCap | [missions.ts:54](./missions.ts#L54) |
+| missions | MISSION_REOFFER_DAYS | 7 | Days before a finished side job is offered again: a week. | missions.reofferDays | [missions.ts:70](./missions.ts#L70) |
+| missions | LEAD_RUMOUR_JUMPS | 5 | Inside this many jumps of a lead's world, the station talks about it. | missions.leadRumourJumps | [missions.ts:83](./missions.ts#L83) |
+| missions | LEAD_NAG_DOCKS | 4 | Docks with no mission progress before a patron with a lead writes a second time. | missions.leadNagDocks | [missions.ts:95](./missions.ts#L95) |
+| missions | SIDE_JOB_PAY | { hunt: 8_000, deliver: 3_000, recover: 4_000, rescue: 5_000, ambush: 6_000, smuggle: 7_000, escort: 6_000, scan: 2_500, } as const | What a side job pays, per verb, in tenths of a credit. | missions.sideJobPay | [missions.ts:109](./missions.ts#L109) |
+| missions | GANG_BOUNTY | 15_000 | What the station pays for the whole gang on the side hunt, in tenths of a credit: 1,500 Cr (docs/TODO/217 M1). | missions.gangBounty | [missions.ts:123](./missions.ts#L123) |
+| missions | GANG_BROKEN_BOUNTY | GANG_BOUNTY / 2 | ...and half of it when the gang is gone but its leader ran (docs/TODO/217 M1). | missions.gangBrokenBounty | [missions.ts:131](./missions.ts#L131) |
+| missions | GANG_HUNT_KILLS | RATINGS[1][0] | Kills before the board offers the gang hunt (docs/TODO/217 M2). | missions.gangHuntKills | [missions.ts:142](./missions.ts#L142) |
+| missions | LANE_JOB_KILLS | GANG_HUNT_KILLS / 2 | Kills before the board offers a lane to clear or a trader to cover: half the gang hunt's (docs/TODO/217 M2). | missions.laneJobKills | [missions.ts:153](./missions.ts#L153) |
+| missions | RESCUE_SALVAGE_PAY | 1_500 | The lower fee a rescue pays when the pod is lost and the data still arrives, in tenths of a credit. | missions.rescueSalvagePay | [missions.ts:166](./missions.ts#L166) |
+| missions | SIDE_JOB_RANGE | { min: 20, max: 70 } as const | How far a side job sends the commander, in tenths of a light year: two to seven. | missions.sideJobRange | [missions.ts:174](./missions.ts#L174) |
+| missions | SIDE_JOB_DAYS | 14 | Days a side job allows before its deadline passes: two weeks. | missions.sideJobDays | [missions.ts:181](./missions.ts#L181) |
+| missions | DEADLINE_WARNING_DAYS | 3 | How many days before a deadline the console starts to say how many are left. | missions.deadlineWarningDays | [missions.ts:192](./missions.ts#L192) |
+| missions | SCAN_SECONDS | 20 | Seconds a scan target must stay under the scanner lock: twenty, which is a pass and a turn at a trader's speed. | missions.scanSeconds | [missions.ts:200](./missions.ts#L200) |
+| missions | WATCH_CONE | 0.35 | How far off the centre of the view a scan's subject may sit and still count as watched, in radians: 0.35, which is twenty degrees. | missions.watchCone | [missions.ts:214](./missions.ts#L214) |
+| missions | SMUGGLE_TONNES | 3 | Tonnes of the patron's goods on a smuggle job: three, which fits a Cobra's hold beside its own stock. | missions.smuggleTonnes | [missions.ts:226](./missions.ts#L226) |
+| missions | ESCORT_ENEMY_ROLES | ['pirate', 'hunter', 'thargoid', 'thargon'] | The roles that count as an enemy near an escorted ship: the ones that prey. | | [missions.ts:235](./missions.ts#L235) |
+| missions | TOUR_ARCS | 5 | How many arcs the tour holds: five, Chris's number (docs/TODO/190, item 192). | missions.tourArcs | [missions.ts:245](./missions.ts#L245) |
+| missions | TOUR_STEP_JUMPS | { min: 4, max: 6 } as const | How far each arc's start world is from the one before it, in JUMPS on the full-tank graph: four to six. | missions.tourStepJumps | [missions.ts:258](./missions.ts#L258) |
+| missions | ARC_HANDOVER_JUMPS | { min: 2, max: 4 } as const | How far an arc's final leg lies from the next arc's start world, in JUMPS: two to four. | missions.arcHandoverJumps | [missions.ts:267](./missions.ts#L267) |
+| missions | ARC_PAY | { hunt: 12_000, deliver: 5_000, recover: 6_000, rescue: 8_000, ambush: 9_000, smuggle: 10_000, escort: 9_000, scan: 4_000, } as const | What an arc leg pays, per verb, in tenths of a credit. | missions.arcPay | [missions.ts:280](./missions.ts#L280) |
+| missions | ARC_LEG_DAYS | 30 | Days an arc leg allows before its deadline passes: a month, twice a side job's fortnight, because an arc leg may be four jumps out. | missions.arcLegDays | [missions.ts:293](./missions.ts#L293) |
| npc-gun | NPC_LASER_RANGE | LASER_RANGE | How far an NPC can shoot: the player's reach. | | [npc-gun.ts:17](./npc-gun.ts#L17) |
| npc-gun | NPC_COOLDOWN_LO | 0.9 | Time between an NPC's shots. | | [npc-gun.ts:31](./npc-gun.ts#L31) |
| npc-gun | NPC_COOLDOWN_SPREAD | 0.8 | | | [npc-gun.ts:32](./npc-gun.ts#L32) |
diff --git a/src/constants/missions.ts b/src/constants/missions.ts
index 06427014..41e18407 100644
--- a/src/constants/missions.ts
+++ b/src/constants/missions.ts
@@ -6,6 +6,8 @@
// of a credit (invariant 8), and distances are in tenths of a light year, as
// everywhere else.
+import { RATINGS } from './rating.ts';
+
/**
* Kills before the Navy considers you worth a word: 16, as the original demanded.
* It is the one gate that this game keeps from the 1984 mission structure.
@@ -128,6 +130,28 @@ export const GANG_BOUNTY = 15_000;
*/
export const GANG_BROKEN_BOUNTY = GANG_BOUNTY / 2;
+/**
+ * Kills before the board offers the gang hunt (docs/TODO/217 M2). Chris
+ * asked for gates by kills on 2026-09-13. It is the kills of the second
+ * rung, Mostly Harmless. So a commander who reads that word on the status
+ * screen finds the job on the board. A gang of four kills a Harmless one.
+ * The Navy asks for twice as many (`MISSION_KILL_THRESHOLD`).
+ *
+ * @rule missions.gangHuntKills
+ */
+export const GANG_HUNT_KILLS = RATINGS[1][0];
+
+/**
+ * Kills before the board offers a lane to clear or a trader to cover: half
+ * the gang hunt's (docs/TODO/217 M2). Both jobs meet a pack of three on
+ * the way, and both let a commander choose the range. The delivery, the
+ * scan and the smuggle stay open, and the two scoop jobs ask for scoops.
+ *
+ * @domain missions
+ * @rule missions.laneJobKills
+ */
+export const LANE_JOB_KILLS = GANG_HUNT_KILLS / 2;
+
/**
* The lower fee a rescue pays when the pod is lost and the data still
* arrives, in tenths of a credit. The scientist example in docs/TODO/190:
diff --git a/src/missions/skeletons/side.ts b/src/missions/skeletons/side.ts
index 14de28f1..3de5ea7c 100644
--- a/src/missions/skeletons/side.ts
+++ b/src/missions/skeletons/side.ts
@@ -13,8 +13,8 @@
import { NARCOTICS } from '../../constants/commodities.ts';
import {
- GANG_BOUNTY, GANG_BROKEN_BOUNTY, RESCUE_SALVAGE_PAY, SCAN_SECONDS, SIDE_JOB_DAYS, SIDE_JOB_PAY,
- SIDE_JOB_RANGE, SMUGGLE_TONNES,
+ GANG_BOUNTY, GANG_BROKEN_BOUNTY, GANG_HUNT_KILLS, LANE_JOB_KILLS, RESCUE_SALVAGE_PAY, SCAN_SECONDS,
+ SIDE_JOB_DAYS, SIDE_JOB_PAY, SIDE_JOB_RANGE, SMUGGLE_TONNES,
} from '../../constants/missions.ts';
import { SOURCE_DESIGN } from '../../game/ship-specs.ts';
import { shipDesignIdOf } from '../../game/ship-identity.ts';
@@ -35,7 +35,8 @@ const AWAY = { kind: 'band', ...SIDE_JOB_RANGE } as const;
export const SIDE_HUNT: Skeleton = {
...LOCAL, id: 'side-hunt', hail: 'THE STATION HAS A BOUNTY POSTED',
pitch: 'A GANG HAS BEEN TAKING SHIPS ON THE LANE. THE STATION WANTS EVERY ONE OF THEM GONE.',
- offer: {},
+ // The board waits for a Mostly Harmless commander (docs/TODO/217 M2).
+ offer: { minKills: GANG_HUNT_KILLS },
legs: [{
id: 'hunt',
verb: {
@@ -132,7 +133,9 @@ export const SIDE_RESCUE: Skeleton = {
export const SIDE_AMBUSH: Skeleton = {
...LOCAL, id: 'side-ambush', hail: 'THE STATION NEEDS A LANE CLEARED',
pitch: 'PIRATES HOLD THE LANE TO A NEIGHBOUR. FLY IT, FIGHT THROUGH, AND DOCK THERE.',
- offer: {},
+ // A pack of three on the way, so the board waits for four kills
+ // (docs/TODO/217 M2).
+ offer: { minKills: LANE_JOB_KILLS },
legs: [{
id: 'lane', verb: { kind: 'ambush' }, place: AWAY, spawn: [...LANE_PIRATES],
line: 'LANE: FLY TO {TARGET} THROUGH WHATEVER WAITS, AND DOCK', deadlineDays: SIDE_JOB_DAYS,
@@ -164,7 +167,9 @@ export const SIDE_SMUGGLE: Skeleton = {
export const SIDE_ESCORT: Skeleton = {
...LOCAL, id: 'side-escort', hail: 'A TRADER AT THE STATION WANTS COVER',
pitch: 'A PYTHON IS LEAVING FOR A NEIGHBOUR AND WANTS A GUN BESIDE HER. SEE HER INTO STATION RANGE.',
- offer: {},
+ // A pack of three on the way, so the board waits for four kills
+ // (docs/TODO/217 M2).
+ offer: { minKills: LANE_JOB_KILLS },
legs: [{
id: 'cover', verb: { kind: 'escort', ship: shipDesignIdOf(SOURCE_DESIGN.python) }, place: AWAY, spawn: [...PAIR],
line: 'ESCORT: SEE THE PYTHON INTO STATION RANGE AT {TARGET}', deadlineDays: SIDE_JOB_DAYS,
diff --git a/test/fixtures.ts b/test/fixtures.ts
index e04f9a73..4332e506 100644
--- a/test/fixtures.ts
+++ b/test/fixtures.ts
@@ -113,9 +113,12 @@ export const paid = (effects: MissionEffect[]): number =>
/** A world whose board carries `job`, and a context standing there. */
export function boardFor(job: Skeleton, over: PartialPIRATE_WAVE_GAP_JITTER | 90 | ...and the jitter. | | [encounters.ts:98](./encounters.ts#L98) |
| encounters | LAWLESS_GOVERNMENT | 3 | A government at or below this breeds pirate waves at all. 3 is a dictatorship on the 1984 ladder, so waves stop at communist (4) and above. | encounters.lawlessGovernment | [encounters.ts:110](./encounters.ts#L110) |
| encounters | ANARCHY_GOVERNMENT | 1 | ...and a government at or below THIS sends them two at a time: anarchy (0) and feudal (1). | encounters.anarchyGovernment | [encounters.ts:126](./encounters.ts#L126) |
-| encounters | MAX_THARGONS | 2 | How many drones the Thargoids keep in the sky at once, across every mothership. | encounters.maxThargons | [encounters.ts:146](./encounters.ts#L146) |
-| encounters | THARGON_REDEPLOY | 5 | Seconds between one drone and the next, and the wait for the first. | encounters.thargonRedeploy | [encounters.ts:161](./encounters.ts#L161) |
+| encounters | MAX_THARGONS | 2 | How many drones the Thargoids keep in the sky at once, across every mothership. | encounters.maxThargons | [encounters.ts:147](./encounters.ts#L147) |
+| encounters | THARGON_REDEPLOY | 5 | Seconds between one drone and the next, and the wait for the first. | encounters.thargonRedeploy | [encounters.ts:162](./encounters.ts#L162) |
| exercise | OPENING_RANGE | 4500 | The opening range for a fight that you are meant to see coming. | exercise.openingRange | [exercise.ts:33](./exercise.ts#L33) |
| exercise | AMBUSH_RANGE | 2400 | An ambush opens INSIDE their gun, because that is what an ambush is. | | [exercise.ts:40](./exercise.ts#L40) |
| exercise | MIN_OPENING_RANGE | 2 * PASS_FAR | No opening may be closer than this. | | [exercise.ts:49](./exercise.ts#L49) |
@@ -192,9 +192,9 @@ search names, meanings and values with `npm run constants:find -- "HERMIT_SUPPLY_PRICE | 1.3 | Supplies cost a third more: nobody else delivers out here. | | [hermit-market.ts:32](./hermit-market.ts#L32) |
| hermit-market | HERMIT_REFUSES_AT | CHARACTER.find(([, rung]) => rung === 'Dodgy')![0] | The character a hermit will not deal with: the ladder's Dodgy rung. | | [hermit-market.ts:48](./hermit-market.ts#L48) |
| hermit-market | HERMIT_FAVOUR | 0.2 | Mates' rates, at the widest. | hermit.favour | [hermit-market.ts:67](./hermit-market.ts#L67) |
-| hermit-market | HERMIT_HAIL_RANGE | 900 | How near a rock hermit the commander must be to hear its hail. | hermit.hail | [hermit-market.ts:82](./hermit-market.ts#L82) |
-| hermit-market | HERMIT_DOCK_RANGE | 320 | How near the commander must be to actually trade with a hermit. | hermit.dockRange | [hermit-market.ts:93](./hermit-market.ts#L93) |
-| hermit-market | HERMIT_DOCK_SPEED | 40 | How slow the commander must be flying to trade with a hermit. | hermit.dockSpeed | [hermit-market.ts:105](./hermit-market.ts#L105) |
+| hermit-market | HERMIT_HAIL_RANGE | 900 | How near a rock hermit the commander must be to hear its hail. | hermit.hail | [hermit-market.ts:83](./hermit-market.ts#L83) |
+| hermit-market | HERMIT_DOCK_RANGE | 320 | How near the commander must be to actually trade with a hermit. | hermit.dockRange | [hermit-market.ts:94](./hermit-market.ts#L94) |
+| hermit-market | HERMIT_DOCK_SPEED | 40 | How slow the commander must be flying to trade with a hermit. | hermit.dockSpeed | [hermit-market.ts:106](./hermit-market.ts#L106) |
| hull-breach | EQUIPMENT_DAMAGE_CHANCE | 0.25 | The chance that a hit which reaches the hull wrecks cargo or a fitting. | hullbreach.equipmentDamageChance | [hull-breach.ts:21](./hull-breach.ts#L21) |
| hull-breach | CARGO_LOSS_CHANCE | 0.7 | Cargo is lost this often when there is any aboard. | breach.cargoLossChance | [hull-breach.ts:37](./hull-breach.ts#L37) |
| hull-breach | BREAKABLE | [ ['ecm', 'E.C.M. SYSTEM'], ['scoops', 'FUEL SCOOPS'], ['rearLaser', 'REAR LASER'], ['leftLaser', 'LEFT LASER'], ['rightLaser', 'RIGHT LASER'], ['dockingComputer', 'DOCKING COMPUTER'], ['combatComputer', 'COMBAT COMPUTER'], ] as const | The fittings that a hull breach can knock out, in the order they are offered. | | [hull-breach.ts:48](./hull-breach.ts#L48) |
@@ -251,7 +251,7 @@ search names, meanings and values with `npm run constants:find -- "COURSE_WATCH_STANDOFF | 1200 | How far from a ship the scan course holds, in world units (docs/TODO/208 M2). | course.watchStandoff | [mission-course.ts:24](./mission-course.ts#L24) |
| mission-course | COURSE_ESCORT_STANDOFF | 600 | How far from its charge the escort course flies, in world units (docs/TODO/208 M2). | course.escortStandoff | [mission-course.ts:37](./mission-course.ts#L37) |
| mission-course | COURSE_ESCORT_CLOSING | 60 | How much faster than its charge the escort course may close, in world units a second, once it is inside three standoffs of it: sixty. | course.escortClosing | [mission-course.ts:52](./mission-course.ts#L52) |
-| mission-course | ESCORT_LEASH | SCANNER_RANGE * 2 / 3 | How far the commander may fall behind her charge before it holds for her, in world units (docs/TODO/214 M3). | course.escortLeash | [mission-course.ts:68](./mission-course.ts#L68) |
+| mission-course | ESCORT_LEASH | SCANNER_RANGE * 2 / 3 | How far the commander may fall behind their charge before it holds for them, in world units (docs/TODO/214 M3). | course.escortLeash | [mission-course.ts:68](./mission-course.ts#L68) |
| mission-course | COURSE_POLICE_CLEARANCE | SCAN_WARN_RANGE | How wide of a police ship the smuggling course flies, in world units (docs/TODO/208 M4). | | [mission-course.ts:81](./mission-course.ts#L81) |
| missions | MISSION_KILL_THRESHOLD | 16 | Kills before the Navy considers you worth a word: 16, as the original demanded. | missions.killThreshold | [missions.ts:17](./missions.ts#L17) |
| missions | MISSION_HUNT_RANGE | { min: 30, max: 80 } as const | The Constrictor hides this far from where you are briefed, in tenths of a light year. | | [missions.ts:25](./missions.ts#L25) |
@@ -284,13 +284,13 @@ search names, meanings and values with `npm run constants:find -- "NPC_COOLDOWN_LO | 0.9 | Time between an NPC's shots. | | [npc-gun.ts:31](./npc-gun.ts#L31) |
| npc-gun | NPC_COOLDOWN_SPREAD | 0.8 | | | [npc-gun.ts:32](./npc-gun.ts#L32) |
| npc-gun | NPC_MEAN_COOLDOWN | NPC_COOLDOWN_LO + NPC_COOLDOWN_SPREAD / 2 | What a shot costs a gun that never waits to be aimed: the LO plus half the spread, because `npcTriggerPull` draws uniformly across it. | | [npc-gun.ts:44](./npc-gun.ts#L44) |
-| npc-gun | NPC_FIRE_GATE | 0.25 | How near the nose a target must be before an NPC pulls the trigger. | npcgun.fireGate | [npc-gun.ts:63](./npc-gun.ts#L63) |
-| npc-gun | THARGOID_FIRE_RATE | 0.7 | Thargoids reload faster than anything else in the galaxy. | npc.thargoidFireRate | [npc-gun.ts:75](./npc-gun.ts#L75) |
-| npc-gun | NPC_HIT_BASE | 0.9 | Hit chance falls off with range, clamped at both ends. | | [npc-gun.ts:78](./npc-gun.ts#L78) |
-| npc-gun | NPC_HIT_FALLOFF | NPC_LASER_RANGE | The slope of the falloff. | | [npc-gun.ts:86](./npc-gun.ts#L86) |
-| npc-gun | NPC_HIT_CAP | 0.85 | | | [npc-gun.ts:87](./npc-gun.ts#L87) |
-| npc-gun | NPC_HIT_FLOOR | 0.15 | The far end of that curve. | npc.hitFloor | [npc-gun.ts:97](./npc-gun.ts#L97) |
-| npc-gun | NPC_VS_NPC_HIT | 0.5 | Whether one ship's shot at another connects: a coin flip. | | [npc-gun.ts:104](./npc-gun.ts#L104) |
+| npc-gun | NPC_FIRE_GATE | 0.25 | How near the nose a target must be before an NPC pulls the trigger. | npcgun.fireGate | [npc-gun.ts:64](./npc-gun.ts#L64) |
+| npc-gun | THARGOID_FIRE_RATE | 0.7 | Thargoids reload faster than anything else in the galaxy. | npc.thargoidFireRate | [npc-gun.ts:76](./npc-gun.ts#L76) |
+| npc-gun | NPC_HIT_BASE | 0.9 | Hit chance falls off with range, clamped at both ends. | | [npc-gun.ts:79](./npc-gun.ts#L79) |
+| npc-gun | NPC_HIT_FALLOFF | NPC_LASER_RANGE | The slope of the falloff. | | [npc-gun.ts:87](./npc-gun.ts#L87) |
+| npc-gun | NPC_HIT_CAP | 0.85 | | | [npc-gun.ts:88](./npc-gun.ts#L88) |
+| npc-gun | NPC_HIT_FLOOR | 0.15 | The far end of that curve. | npc.hitFloor | [npc-gun.ts:98](./npc-gun.ts#L98) |
+| npc-gun | NPC_VS_NPC_HIT | 0.5 | Whether one ship's shot at another connects: a coin flip. | | [npc-gun.ts:105](./npc-gun.ts#L105) |
| opposition-ring | OPPOSITION_RANGE | 3200 | The default ring radius, in units. | | [opposition-ring.ts:19](./opposition-ring.ts#L19) |
| opposition-ring | OPPOSITION_RANGE_MAX | 20_000 | A ceiling on the ring radius. | | [opposition-ring.ts:26](./opposition-ring.ts#L26) |
| opposition-ring | OPPOSITION_CONE | 0.5 | Half-angle of the cone, in radians, when a facing is known and the caller says no more. | | [opposition-ring.ts:35](./opposition-ring.ts#L35) |
@@ -336,7 +336,7 @@ search names, meanings and values with `npm run constants:find -- "ASSIST_FADE_START | 900 | Where the aim assist begins to fade out, in world units. | gun.assistFadeStart | [player-gun.ts:157](./player-gun.ts#L157) |
| player-gun | ASSIST_FADE_END | 2400 | | | [player-gun.ts:158](./player-gun.ts#L158) |
| player-interest | PLAYER_INTEREST_RANGE | 9000 | A hostile closer than this is engaged with you. | | [player-interest.ts:21](./player-interest.ts#L21) |
-| player-interest | TURN_AND_FIGHT_RANGE | 6000 | How close the commander must be before an armed trader on the run turns and fights her, rather than whoever else is shooting at it. | interest.turnAndFight | [player-interest.ts:39](./player-interest.ts#L39) |
+| player-interest | TURN_AND_FIGHT_RANGE | 6000 | How close the commander must be before an armed trader on the run turns and fights them, rather than whoever else is shooting at it. | interest.turnAndFight | [player-interest.ts:39](./player-interest.ts#L39) |
| pools | MAX_ENERGY | 255 | Released capacity of every flyable hull's energy bank and each shield. | | [pools.ts:6](./pools.ts#L6) |
| pools | MAX_SHIELD | 255 | | | [pools.ts:7](./pools.ts#L7) |
| pools | ENERGY_BANKS | 4 | How many banks the console reads the energy pool as. | pools.energyBanks | [pools.ts:14](./pools.ts#L14) |
@@ -350,7 +350,7 @@ search names, meanings and values with `npm run constants:find -- "GENERATION_SHIP_CHANCE | 0.08 | The chance that a generation ship crosses, on arrival only. | | [population.ts:69](./population.ts#L69) |
| population | ASTEROIDS_MIN | 2 | The fewest rocks a system holds. | population.asteroidsMin | [population.ts:81](./population.ts#L81) |
| population | ASTEROIDS_VARIATION | 3 | ...and how many more it holds, drawn flat: `ASTEROIDS_MIN` plus 0, 1 or 2. | population.asteroidsVariation | [population.ts:90](./population.ts#L90) |
-| population | GENERATION_SIGHT_RANGE | 6000 | How near a derelict generation ship the commander must be to notice it. | population.generationSight | [population.ts:106](./population.ts#L106) |
+| population | GENERATION_SIGHT_RANGE | 6000 | How near a derelict generation ship the commander must be to notice it. | population.generationSight | [population.ts:107](./population.ts#L107) |
| rating | RATINGS | [ [0, 'Harmless'], [8, 'Mostly Harmless'], [16, 'Poor'], [32, 'Below Average'], [64, 'Average'], [128, 'Above Average'], [512, 'Competent'], [2560, 'Dangerous'], [6400, 'Deadly'], [25600, 'E L I T E'], ] | Score thresholds and the name that each one earns, lowest first. | | [rating.ts:23](./rating.ts#L23) |
| recharge | ENERGY_REGEN_FRACTION | 0.025 | The fraction of a full pool that a Cobra Mk III recovers each second. | | [recharge.ts:41](./recharge.ts#L41) |
| recharge | SHIELD_REGEN_FRACTION | 0.012 | The shield's half of the pair above, and the one docs/TODO/139 moved. | | [recharge.ts:44](./recharge.ts#L44) |
@@ -467,7 +467,7 @@ search names, meanings and values with `npm run constants:find -- "MAX_STEPS_PER_FRAME | 5 | ...and the most steps one frame may run, so a stall cannot spiral. | clock.maxStepsPerFrame | [world-clock.ts:37](./world-clock.ts#L37) |
| world-clock | CARRY_LIMIT | 3 | Unread taps of one key that the input carries across busy frames. | clock.carryLimit | [world-clock.ts:47](./world-clock.ts#L47) |
| wreck | ESCAPE_CHANCE | { trader: 0.45, other: 0.2 } as const | How often the pilot punches out before the hull goes. | | [wreck.ts:22](./wreck.ts#L22) |
-| wreck | WRECK_BURST_GRACE | 1.0 | Seconds the commander's beam registers nothing on a bystander, counted from the moment her own shot destroys a ship (GitHub #35). | wreck.wreckBurstGrace | [wreck.ts:67](./wreck.ts#L67) |
+| wreck | WRECK_BURST_GRACE | 1.0 | Seconds the commander's beam registers nothing on a bystander, counted from the moment their own shot destroys a ship (GitHub #35). | wreck.wreckBurstGrace | [wreck.ts:67](./wreck.ts#L67) |
| wreck | POD_LAUNCH_GRACE | 1.5 | Seconds a fresh capsule cannot be shot, counted from the moment it launches (GitHub #28). | wreck.podLaunchGrace | [wreck.ts:91](./wreck.ts#L91) |
| wreck | MINING_YIELD_MIN | 1 | Canisters of ore that a mined asteroid yields: at least the first one, plus a flat draw over the span. | wreck.miningYieldMin | [wreck.ts:110](./wreck.ts#L110) |
| wreck | MINING_YIELD_SPAN | 3 | ...and the span above the minimum: one to four canisters in all. | wreck.miningYieldSpan | [wreck.ts:116](./wreck.ts#L116) |
diff --git a/src/constants/blame.ts b/src/constants/blame.ts
index ddc306c7..dbd50973 100644
--- a/src/constants/blame.ts
+++ b/src/constants/blame.ts
@@ -1,4 +1,4 @@
-// Whether a hit is the commander's own deed, so the ship holds it against her.
+// Whether a hit is the commander's own deed, so the ship holds it against them.
//
// A ship's grudge is `provokedByPlayer` (game/npc-state.ts). It is a private
// quarrel, apart from the law's record, and `game/hostility.ts` reads it for a
@@ -11,9 +11,9 @@
* A warhead and the bomb are aimed, and so is the laser. A ram is CONTACT. The
* geometry reads overlap only (`playerVsNpcs` in game/collisions.ts), so it
* cannot say who moved. A guess against the commander was GitHub #42: a Viper
- * flew into her, and it was hostile for the rest of the flight. So contact
+ * flew into them, and it was hostile for the rest of the flight. So contact
* does not provoke (docs/TODO/194). The ram still costs the ship its points,
- * and the ledger still credits it to her line. Blame is the one thing it
+ * and the ledger still credits it to their line. Blame is the one thing it
* withholds.
*
* The keys are `DealtSource` in game/damage-dealt.ts, and that file indexes
diff --git a/src/constants/encounters.ts b/src/constants/encounters.ts
index 6ea3fd9c..ce463338 100644
--- a/src/constants/encounters.ts
+++ b/src/constants/encounters.ts
@@ -133,10 +133,11 @@ export const ANARCHY_GOVERNMENT = 1;
* IT WAS 4 UNTIL docs/TODO/188, AND CHRIS'S PLAYTEST IS WHY IT MOVED. He flew
* the ambush and said it was too hard (GitHub #39). `npm run ambush-probe`
* then measured the shipped defence over the real step, at two sizes. At 4 the
- * co-pilot died in 7% to 15% of ambushes, and she ended with 54% of her pools.
- * The drones dealt 70% of the damage. At 2 she survived every one, and she
- * ended with 76% to 79%. A slower redeploy alone did nearly as well, but it
- * left the peak count at 3.5, and the count was the complaint.
+ * co-pilot died in 7% to 15% of ambushes, and the commander ended with 54% of
+ * their pools. The drones dealt 70% of the damage. At 2 the commander
+ * survived every one, and ended with 76% to 79%. A slower redeploy alone did
+ * nearly as well, but it left the peak count at 3.5, and the count was the
+ * complaint.
*
* It has its own rule id. `THARGOID_AMBUSH_MIN` (witchspace.ts) is also 2,
* and it is a different rule: that one is how many motherships wait.
diff --git a/src/constants/hermit-market.ts b/src/constants/hermit-market.ts
index ee750fa8..d612fbec 100644
--- a/src/constants/hermit-market.ts
+++ b/src/constants/hermit-market.ts
@@ -69,7 +69,8 @@ export const HERMIT_FAVOUR = 0.2;
/**
* How near a rock hermit the commander must be to hear its hail.
*
- * It is also the range she has to LEAVE to hear it again. One number covers
+ * It is also the range the commander has to LEAVE to hear it again. One
+ * number covers
* both, because a door you enter and a door you leave are the same door. A
* second radius would give a band where the hail neither fires nor resets.
*
diff --git a/src/constants/mission-course.ts b/src/constants/mission-course.ts
index 142b1500..59ad430e 100644
--- a/src/constants/mission-course.ts
+++ b/src/constants/mission-course.ts
@@ -52,15 +52,15 @@ export const COURSE_ESCORT_STANDOFF = 600;
export const COURSE_ESCORT_CLOSING = 60;
/**
- * How far the commander may fall behind her charge before it holds for
- * her, in world units (docs/TODO/214 M3). It is two thirds of the scanner,
+ * How far the commander may fall behind their charge before it holds for
+ * them, in world units (docs/TODO/214 M3). It is two thirds of the scanner,
* which is four thousand.
*
* The charge flew to the station on its own, and the escort was a job the
- * commander watched. It moves while she is inside the leash and holds where
- * it is when she is not. The leash is well outside the escort standoff of
- * 600, so the course never trips it. It derives from the scanner so that a
- * charge that holds is always still on her scanner.
+ * commander watched. It moves while they are inside the leash and holds
+ * where it is when they are not. The leash is well outside the escort
+ * standoff of 600, so the course never trips it. It derives from the
+ * scanner so that a charge that holds is always still on their scanner.
*
* @rule course.escortLeash
* @domain mission-course
diff --git a/src/constants/missions.ts b/src/constants/missions.ts
index 8f200001..554acf01 100644
--- a/src/constants/missions.ts
+++ b/src/constants/missions.ts
@@ -271,8 +271,8 @@ export const ARC_HANDOVER_JUMPS = { min: 2, max: 4 } as const;
*
* One table, as `SIDE_JOB_PAY` is, and a separate rule. An arc pays more
* than a side job of the same verb. An arc sends the commander across the
- * galaxy, and a side job sends her one jump out and back. The
- * same order holds inside the table, for the same reasons. The arcs under
+ * galaxy, and a side job sends them one jump out and back. The same order
+ * holds inside the table, for the same reasons. The arcs under
* `missions/skeletons/arcs/` spend it (docs/TODO/192 M2).
*
* @rule missions.arcPay
@@ -285,8 +285,8 @@ export const ARC_PAY = {
/**
* Days an arc leg allows before its deadline passes: a month, twice a side
* job's fortnight, because an arc leg may be four jumps out. A deadline is
- * what makes `failed` reachable on a delivery. So an arc she cannot finish
- * fails on its own rather than holding a slot for good.
+ * what makes `failed` reachable on a delivery. So an arc the commander cannot
+ * finish fails on its own rather than holding a slot for good.
*
* @rule missions.arcLegDays
*/
diff --git a/src/constants/npc-gun.ts b/src/constants/npc-gun.ts
index 3b1088d4..ad382a57 100644
--- a/src/constants/npc-gun.ts
+++ b/src/constants/npc-gun.ts
@@ -53,9 +53,10 @@ export const NPC_MEAN_COOLDOWN = NPC_COOLDOWN_LO + NPC_COOLDOWN_SPREAD / 2;
*
* In the knife fight the pooled bearing error is 85 degrees, and docs/TODO/139 M3
* took that figure apart by the leg the ship flew. A quarter of the fight is
- * `passing` and `extending`. Those two legs carry the nose past her and away BY
- * DESIGN, at 102 and 142 degrees. The legs that want the nose ON her are
- * `closing`, at 64 degrees, and `on your six`, at 37. So a pooled figure is not
+ * `passing` and `extending`. Those two legs carry the nose past the commander
+ * and away BY DESIGN, at 102 and 142 degrees. The legs that want the nose ON
+ * them are `closing`, at 64 degrees, and `on your six`, at 37. So a pooled
+ * figure is not
* evidence about this gate, and M3 decided against a wider one.
*
* @rule npcgun.fireGate
diff --git a/src/constants/player-interest.ts b/src/constants/player-interest.ts
index 9ad14f5e..2d98832a 100644
--- a/src/constants/player-interest.ts
+++ b/src/constants/player-interest.ts
@@ -22,12 +22,12 @@ export const PLAYER_INTEREST_RANGE = 9000;
/**
* How close the commander must be before an armed trader on the run turns and
- * fights her, rather than whoever else is shooting at it.
+ * fights them, rather than whoever else is shooting at it.
*
* NARROWER THAN `PLAYER_INTEREST_RANGE` ABOVE, ON PURPOSE. That one is where a
- * hostile starts to close on her. This is where a ship that is ALREADY fleeing
- * decides she is the one worth turning on. A trader with a pirate behind it and
- * the commander far off should answer the pirate.
+ * hostile starts to close on the commander. This is where a ship that is
+ * ALREADY fleeing decides the commander is the one worth turning on. A trader
+ * with a pirate behind it and the commander far off should answer the pirate.
*
* `game/npc.ts` spends it twice, on the scripted branch and on the trained
* defence branch, because the choice of prey is the same either way. It was a
diff --git a/src/constants/population.ts b/src/constants/population.ts
index 59d5e002..042bc482 100644
--- a/src/constants/population.ts
+++ b/src/constants/population.ts
@@ -94,7 +94,8 @@ export const ASTEROIDS_VARIATION = 3;
*
* IT IS NOT `GENERATION_SHIP_RANGE` ABOVE, and the two are easy to confuse.
* That one is where the hull is PLACED on an arrival, at 14,000 plus a span.
- * This is how close she has to fly before the console says what it is. So the
+ * This is how close they have to fly before the console says what it is. So
+ * the
* ship is put out of sight, and finding it is the event.
*
* `game/world-step.ts` spends it, once per career, behind `session.genShipSeen`.
diff --git a/src/constants/rating.ts b/src/constants/rating.ts
index ca475238..80d969b3 100644
--- a/src/constants/rating.ts
+++ b/src/constants/rating.ts
@@ -4,7 +4,7 @@
// That makes ten rungs, not nine. The functions that read it (`rating`,
// `ratingLadder`) are in game/rating.ts. The manual renders the chart from the
// same table. That is the fix for the day the manual listed the nine ranks it
-// could remember. A commander could read her own rating off the status screen,
+// could remember. A commander could read their own rating off the status screen,
// and then fail to find it on the chart.
//
// The score that climbs the ladder is `combatScore`: kills weighted by threat
diff --git a/src/constants/recharge.ts b/src/constants/recharge.ts
index 7343a849..ec270ff3 100644
--- a/src/constants/recharge.ts
+++ b/src/constants/recharge.ts
@@ -31,12 +31,12 @@ import { MAX_SHIELD } from './pools.ts';
* knee and not the floor.
*
* THE BANK DID NOT MOVE, and that is a measurement rather than an oversight. 139
- * states the gate: an organised tier-2 gang must be able to drive her to
- * `LOW_ENERGY` in a fight she would otherwise sit through. The shield alone meets
- * it, at 28% of fights before and 50% after, with three attackers. The bank is
- * the pool that keeps her alive. A cut to it compounds a lethality the item did
- * not ask for. 40 seconds is the figure a Cobra flew before the pools
- * grew.
+ * states the gate: an organised tier-2 gang must be able to drive the commander
+ * to `LOW_ENERGY` in a fight they would otherwise sit through. The shield alone
+ * meets it, at 28% of fights before and 50% after, with three attackers. The
+ * bank is the pool that keeps the commander alive. A cut to it compounds a
+ * lethality the item did not ask for. 40 seconds is the figure a Cobra flew
+ * before the pools grew.
*/
export const ENERGY_REGEN_FRACTION = 0.025;
diff --git a/src/constants/wreck.ts b/src/constants/wreck.ts
index 8fd3d2d2..e02e91de 100644
--- a/src/constants/wreck.ts
+++ b/src/constants/wreck.ts
@@ -23,7 +23,7 @@ export const ESCAPE_CHANCE = { trader: 0.45, other: 0.2 } as const;
/**
* Seconds the commander's beam registers nothing on a bystander, counted from
- * the moment her own shot destroys a ship (GitHub #35).
+ * the moment their own shot destroys a ship (GitHub #35).
*
* A **bystander** is a ship that `isHostileToPlayer` says is not already in the
* fight. Only a ship that was minding its own business is covered, and that is
diff --git a/src/encyclopaedia/filters.ts b/src/encyclopaedia/filters.ts
index 9da1e88e..1a84ea00 100644
--- a/src/encyclopaedia/filters.ts
+++ b/src/encyclopaedia/filters.ts
@@ -70,7 +70,7 @@ export function facetsOf(entries: Entry[]): Facets {
return {
// Economy and government keep their 1984 order. The game shows that order
// everywhere else. A reader who moves between the two never has to find
- // her place again.
+ // their place again.
economies: [...eco.entries()].sort((a, b) => a[0] - b[0])
.map(([value, count]) => ({ value, label: ECONOMY_NAMES[value], count })),
governments: [...gov.entries()].sort((a, b) => a[0] - b[0])
diff --git a/src/game/bindings.ts b/src/game/bindings.ts
index c481084b..516c60cc 100644
--- a/src/game/bindings.ts
+++ b/src/game/bindings.ts
@@ -308,7 +308,7 @@ export const BINDINGS: RecordCHART_SPAN_Y | CHART_SPAN_X / CHART_Y_SQUASH | The height — half the width. | | [chart-metric.ts:43](./chart-metric.ts#L43) |
| chart-metric | LOCAL_SCALE | 15 | The console's short-range chart: canvas px per chart unit. | | [chart-metric.ts:51](./chart-metric.ts#L51) |
| chart-metric | LOCAL_CANVAS | 560 | Square, so a light year is the same number of pixels whichever way you go. | | [chart-metric.ts:54](./chart-metric.ts#L54) |
-| chart-metric | CHART_CANVAS_W | 780 | The galactic chart's canvas, in px. | | [chart-metric.ts:66](./chart-metric.ts#L66) |
-| chart-metric | CHART_CANVAS_H | 400 | Its height. | | [chart-metric.ts:74](./chart-metric.ts#L74) |
-| chart-metric | LANE_PICK_PX | 8 | How near the pointer must come to a trade lane to be pointing AT it, in canvas pixels. | chart.lanePickPx | [chart-metric.ts:87](./chart-metric.ts#L87) |
+| chart-metric | CHART_LABEL_PX | 12 | A system's name on the short range chart, in CSS pixels: twelve (docs/TODO/220). | chart.labelPx | [chart-metric.ts:64](./chart-metric.ts#L64) |
+| chart-metric | CHART_CANVAS_W | 780 | The galactic chart's canvas, in px. | | [chart-metric.ts:76](./chart-metric.ts#L76) |
+| chart-metric | CHART_CANVAS_H | 400 | Its height. | | [chart-metric.ts:84](./chart-metric.ts#L84) |
+| chart-metric | LANE_PICK_PX | 8 | How near the pointer must come to a trade lane to be pointing AT it, in canvas pixels. | chart.lanePickPx | [chart-metric.ts:97](./chart-metric.ts#L97) |
| chart-overlay | LANE_FADE_FLOOR | 0.35 | The alpha that the quietest drawn trade lane keeps, with the busiest at 1. | chart.laneFadeFloor | [chart-overlay.ts:25](./chart-overlay.ts#L25) |
| chart-overlay | LANE_CARGO_NAMED | 3 | How many of a lane's commodities the detail line names before it counts the rest ("+2"). | chart.laneCargoNamed | [chart-overlay.ts:37](./chart-overlay.ts#L37) |
| collision | PLAYER_SPEED_KEPT | 0.3 | Speed kept after a collision. | | [collision.ts:10](./collision.ts#L10) |
@@ -420,7 +421,7 @@ search names, meanings and values with `npm run constants:find -- "TACTIC_LAST_STAND_HEALTH | 0.25 | ...and how hurt before a ram is on the table, and nothing else new is. | tactic.lastStandHealth | [tactic-choice.ts:44](./tactic-choice.ts#L44) |
| tactic-choice | TACTIC_WEIGHTS | { spawn: { run: 50, slash: 25, knife: 25, ram: 0 }, sleeper: { run: 40, slash: 30, knife: 30, ram: 0 }, hurt: { run: 20, slash: 40, knife: 40, ram: 0 }, lastStand: { run: 15, slash: 40, knife: 0, ram: 45 }, } as const | How likely each tactic is, per reason. | | [tactic-choice.ts:58](./tactic-choice.ts#L58) |
| tactic-choice | TACTIC_MIN_DWELL | 5 | The least time a ship keeps a tactic before it may take another. | tactic.minDwell | [tactic-choice.ts:76](./tactic-choice.ts#L76) |
-| tactic-choice | TACTIC_SLEEPER_SECONDS | 12 | How long a ship goes without a shot away before it concludes that whatever it does is not working. | | [tactic-choice.ts:84](./tactic-choice.ts#L84) |
+| tactic-choice | TACTIC_SLEEPER_SECONDS | 12 | How long a ship goes without a shot away before it concludes that whatever it does is not working. | tactic.sleeperSeconds | [tactic-choice.ts:86](./tactic-choice.ts#L86) |
| tactics | TACTIC_IDS | ['slash', 'run', 'knife', 'ram'] | Every tactic, least to most committed — the order a readout should list. | | [tactics.ts:23](./tactics.ts#L23) |
| tactics | TACTICS | { run: { id: 'run', missDistance: PASS_MISS_DISTANCE, arcAngle: EXTEND_ARC_ANGLE, throttleFloor: CLOSING_THROTTLE_MIN, aimsToHit: false, }, slash: { id: 'slash', missDistance: 175, arcAngle: (45 * Math.PI) / 180, throttleFloor: 0.72, aimsToHit: false, }, knife: { id: 'knife', missDistance: 100, arcAngle: (70 * Math.PI) / 180, throttleFloor: CLOSING_THROTTLE_MIN, aimsToHit: false, }, ram: { id: 'ram', missDistance: 0, arcAngle: EXTEND_ARC_ANGLE, throttleFloor: 1, aimsToHit: true, }, } | The four tactics, as the three numbers each one overrides. | | [tactics.ts:52](./tactics.ts#L52) |
| tech-level | TECH_MIN | 1 | The lowest tech level any system shows. | tech.levelMin | [tech-level.ts:16](./tech-level.ts#L16) |
@@ -432,12 +433,12 @@ search names, meanings and values with `npm run constants:find -- "DISREPUTE_DRAW | 0.5 | How much a criminal reputation draws challengers, against combat fame's 1. | threat.disreputeDraw | [threat.ts:92](./threat.ts#L92) |
| threat | COURTESY_RATE | 0.15 | Professional courtesy: the share of receptions that never form at all, because somebody recognised a commander they would rather not cross. | | [threat.ts:110](./threat.ts#L110) |
| threat | PRIZE_SATURATION | 25000 | The cargo value at which the prize term saturates, in tenths of a credit: 25,000, which is 2,500 Cr. | | [threat.ts:118](./threat.ts#L118) |
-| threat | DEFENCE_WEIGHT | 12 | The weights on the three fields of `sourceThreatScore`: 1. how much fire a hull survives (`maxEnergy`, weight 1, the base); 2. how much of each hit it shrugs off (`perHitDefence`, a subtraction); 3. how hard it hits back (`laserPower`). | | [threat.ts:131](./threat.ts#L131) |
-| threat | LASER_WEIGHT | 8 | The weight on how hard a hull hits back (`laserPower`), the third field of `sourceThreatScore`. | threat.laserWeight | [threat.ts:139](./threat.ts#L139) |
-| threat | PROFESSIONAL_SCORE | 110 | The tier ladder over `sourceThreatScore`. | | [threat.ts:145](./threat.ts#L145) |
-| threat | GANG_SCORE | 160 | | | [threat.ts:146](./threat.ts#L146) |
-| threat | MAX_TIER | 2 | The ladder's top rung. | threat.maxTier | [threat.ts:157](./threat.ts#L157) |
-| threat | CURATED_TIER | { 'elite-a:design:17': 0, } | Hulls held at a tier that the score alone would not give them. | | [threat.ts:167](./threat.ts#L167) |
+| threat | DEFENCE_WEIGHT | 12 | The weights on the three fields of `sourceThreatScore`: 1. how much fire a hull survives (`maxEnergy`, weight 1, the base); 2. how much of each hit it shrugs off (`perHitDefence`, a subtraction); 3. how hard it hits back (`laserPower`). | threat.defenceWeight | [threat.ts:133](./threat.ts#L133) |
+| threat | LASER_WEIGHT | 8 | The weight on how hard a hull hits back (`laserPower`), the third field of `sourceThreatScore`. | threat.laserWeight | [threat.ts:141](./threat.ts#L141) |
+| threat | PROFESSIONAL_SCORE | 110 | The tier ladder over `sourceThreatScore`. | | [threat.ts:147](./threat.ts#L147) |
+| threat | GANG_SCORE | 160 | | | [threat.ts:148](./threat.ts#L148) |
+| threat | MAX_TIER | 2 | The ladder's top rung. | threat.maxTier | [threat.ts:159](./threat.ts#L159) |
+| threat | CURATED_TIER | { 'elite-a:design:17': 0, } | Hulls held at a tier that the score alone would not give them. | | [threat.ts:169](./threat.ts#L169) |
| threat-lock | THREAT_SWITCH_MARGIN | 2.0 | A rival threat must be this much NEARER than the threat under fire before the defender may switch to it. | threat.switchMargin | [threat-lock.ts:14](./threat-lock.ts#L14) |
| threat-lock | THREAT_MIN_HOLD | 5 | Seconds that the defender fights a threat before it considers a rival. | threat.minHold | [threat-lock.ts:25](./threat-lock.ts#L25) |
| torus | TORUS_MULTIPLIER | 8 | How much faster the torus drive travels than ordinary flight. | torus.multiplier | [torus.ts:19](./torus.ts#L19) |
diff --git a/src/constants/chart-metric.ts b/src/constants/chart-metric.ts
index 8e249340..df2eb6c5 100644
--- a/src/constants/chart-metric.ts
+++ b/src/constants/chart-metric.ts
@@ -53,6 +53,16 @@ export const LOCAL_SCALE = 15;
/** Square, so a light year is the same number of pixels whichever way you go. */
export const LOCAL_CANVAS = 560;
+/**
+ * A system's name on the short range chart, in CSS pixels: twelve
+ * (docs/TODO/220). The canvas is 560 wide and a phone shows it at about
+ * 370, so a name drawn in canvas pixels came out under seven tall. The
+ * painter scales the font by the canvas width over its width on the page.
+ *
+ * @rule chart.labelPx
+ */
+export const CHART_LABEL_PX = 12;
+
/**
* The galactic chart's canvas, in px. It has its own home because two things
* now read it. The first is the markup that sizes the canvas. The second is the
diff --git a/src/constants/tactic-choice.ts b/src/constants/tactic-choice.ts
index 6c2abb23..4d38fe5e 100644
--- a/src/constants/tactic-choice.ts
+++ b/src/constants/tactic-choice.ts
@@ -80,5 +80,7 @@ export const TACTIC_MIN_DWELL = 5;
* does is not working. It is the anti-degeneracy trigger. 12 seconds is
* comfortably past a whole attack run, which is 7.2s at the median and 9.98 at
* the ninetieth.
+ *
+ * @rule tactic.sleeperSeconds
*/
export const TACTIC_SLEEPER_SECONDS = 12;
diff --git a/src/constants/threat.ts b/src/constants/threat.ts
index f112f32a..467dd0d5 100644
--- a/src/constants/threat.ts
+++ b/src/constants/threat.ts
@@ -127,6 +127,8 @@ export const PRIZE_SATURATION = 25000;
* Speed is deliberately absent: a fast hull is harder to catch,
* not harder to beat. The weights are Harmless's. The numbers they multiply are
* the source's.
+ *
+ * @rule threat.defenceWeight
*/
export const DEFENCE_WEIGHT = 12;
/**
diff --git a/src/engine/phone-chrome.ts b/src/engine/phone-chrome.ts
new file mode 100644
index 00000000..a03b968a
--- /dev/null
+++ b/src/engine/phone-chrome.ts
@@ -0,0 +1,46 @@
+// The phone's chrome: no zoom, and no address bar (docs/TODO/220).
+//
+// Platform code, beside `browser-shell.ts`. `main.ts` calls the two at boot,
+// and a headless game never sees them.
+//
+// TWO FIXES FOR EACH FAULT, because the phones differ. The viewport meta
+// and `touch-action` refuse a pinch and a double tap where a browser honours
+// them. The iPhone honours neither in full, so the listeners below cancel
+// the gesture itself. A web app manifest hides the address bar once the page
+// is on the home screen. Android's browser also hides it on a fullscreen
+// request, which the iPhone does not offer, so the request is a try.
+
+/** Cancel a pinch and a double tap that the stylesheet could not refuse. */
+export function holdZoom(doc: Document = document): void {
+ // Safari's proprietary gesture events carry the pinch on the iPhone.
+ doc.addEventListener('gesturestart', (e) => e.preventDefault(), { passive: false });
+ doc.addEventListener('gesturechange', (e) => e.preventDefault(), { passive: false });
+ // A two-finger move is a pinch. The event's `scale` is Safari's, and a
+ // browser without it reports one finger per touch instead.
+ doc.addEventListener('touchmove', (e) => {
+ const scale = (e as TouchEvent & { scale?: number }).scale;
+ if ((scale !== undefined && scale !== 1) || e.touches.length > 1) e.preventDefault();
+ }, { passive: false });
+ // A second tap inside the double tap window is a zoom on the iPhone.
+ let last = 0;
+ doc.addEventListener('touchend', (e) => {
+ const now = Date.now();
+ if (now - last < 300) e.preventDefault();
+ last = now;
+ }, { passive: false });
+}
+
+/**
+ * Ask for the whole screen on the first touch, on a coarse pointer alone. A
+ * desktop click never asks. A refusal is silent, because the manifest is the
+ * fix that holds where the request does not exist.
+ */
+export function askFullscreen(doc: Document = document, win: Window = window): void {
+ if (!win.matchMedia?.('(pointer: coarse)').matches) return;
+ const ask = (): void => {
+ const root = doc.documentElement as HTMLElement & { requestFullscreen?: () => PromiseGANG_BROKEN_BOUNTY | GANG_BOUNTY / 2 | ...and half of it when the gang is gone but its leader ran (docs/TODO/217 M1). | missions.gangBrokenBounty | [missions.ts:131](./missions.ts#L131) |
| missions | GANG_HUNT_KILLS | RATINGS[1][0] | Kills before the board offers the gang hunt (docs/TODO/217 M2). | missions.gangHuntKills | [missions.ts:142](./missions.ts#L142) |
| missions | LANE_JOB_KILLS | GANG_HUNT_KILLS / 2 | Kills before the board offers a lane to clear or a trader to cover: half the gang hunt's (docs/TODO/217 M2). | missions.laneJobKills | [missions.ts:153](./missions.ts#L153) |
-| missions | RESCUE_SALVAGE_PAY | 1_500 | The lower fee a rescue pays when the pod is lost and the data still arrives, in tenths of a credit. | missions.rescueSalvagePay | [missions.ts:166](./missions.ts#L166) |
-| missions | SIDE_JOB_RANGE | { min: 20, max: 70 } as const | How far a side job sends the commander, in tenths of a light year: two to seven. | missions.sideJobRange | [missions.ts:174](./missions.ts#L174) |
-| missions | SIDE_JOB_DAYS | 14 | Days a side job allows before its deadline passes: two weeks. | missions.sideJobDays | [missions.ts:181](./missions.ts#L181) |
-| missions | DEADLINE_WARNING_DAYS | 3 | How many days before a deadline the console starts to say how many are left. | missions.deadlineWarningDays | [missions.ts:192](./missions.ts#L192) |
-| missions | SCAN_SECONDS | 20 | Seconds a scan target must stay under the scanner lock: twenty, which is a pass and a turn at a trader's speed. | missions.scanSeconds | [missions.ts:200](./missions.ts#L200) |
-| missions | WATCH_CONE | 0.35 | How far off the centre of the view a scan's subject may sit and still count as watched, in radians: 0.35, which is twenty degrees. | missions.watchCone | [missions.ts:214](./missions.ts#L214) |
-| missions | SMUGGLE_TONNES | 3 | Tonnes of the patron's goods on a smuggle job: three, which fits a Cobra's hold beside its own stock. | missions.smuggleTonnes | [missions.ts:226](./missions.ts#L226) |
-| missions | ESCORT_ENEMY_ROLES | ['pirate', 'hunter', 'thargoid', 'thargon'] | The roles that count as an enemy near an escorted ship: the ones that prey. | | [missions.ts:235](./missions.ts#L235) |
-| missions | TOUR_ARCS | 5 | How many arcs the tour holds: five, Chris's number (docs/TODO/190, item 192). | missions.tourArcs | [missions.ts:245](./missions.ts#L245) |
-| missions | TOUR_STEP_JUMPS | { min: 4, max: 6 } as const | How far each arc's start world is from the one before it, in JUMPS on the full-tank graph: four to six. | missions.tourStepJumps | [missions.ts:258](./missions.ts#L258) |
-| missions | ARC_HANDOVER_JUMPS | { min: 2, max: 4 } as const | How far an arc's final leg lies from the next arc's start world, in JUMPS: two to four. | missions.arcHandoverJumps | [missions.ts:267](./missions.ts#L267) |
-| missions | ARC_PAY | { hunt: 12_000, deliver: 5_000, recover: 6_000, rescue: 8_000, ambush: 9_000, smuggle: 10_000, escort: 9_000, scan: 4_000, } as const | What an arc leg pays, per verb, in tenths of a credit. | missions.arcPay | [missions.ts:280](./missions.ts#L280) |
-| missions | ARC_LEG_DAYS | 30 | Days an arc leg allows before its deadline passes: a month, twice a side job's fortnight, because an arc leg may be four jumps out. | missions.arcLegDays | [missions.ts:293](./missions.ts#L293) |
+| missions | WHEEL_WHISPER_RUNG | RATINGS.findIndex(([, name]) => name === 'Above Average') | The rung the Dark Wheel's first whisper waits for (docs/TODO/219 M1): Above Average, 128 kills. | missions.wheelWhisperRung | [missions.ts:162](./missions.ts#L162) |
+| missions | LAWLESS_GOVERNMENTS | ['Anarchy', 'Feudal'] | The governments whose boards carry the Wheel's word (docs/TODO/219 M1), by their 1984 names. | missions.lawlessGovernments | [missions.ts:175](./missions.ts#L175) |
+| missions | WHEEL_PAY | { mark: 20_000, blockade: 20_000, pilot: 25_000 } as const | What a Wheel trial pays, in tenths of a credit (docs/TODO/219 M1). | missions.wheelPay | [missions.ts:184](./missions.ts#L184) |
+| missions | RESCUE_SALVAGE_PAY | 1_500 | The lower fee a rescue pays when the pod is lost and the data still arrives, in tenths of a credit. | missions.rescueSalvagePay | [missions.ts:197](./missions.ts#L197) |
+| missions | SIDE_JOB_RANGE | { min: 20, max: 70 } as const | How far a side job sends the commander, in tenths of a light year: two to seven. | missions.sideJobRange | [missions.ts:205](./missions.ts#L205) |
+| missions | SIDE_JOB_DAYS | 14 | Days a side job allows before its deadline passes: two weeks. | missions.sideJobDays | [missions.ts:212](./missions.ts#L212) |
+| missions | DEADLINE_WARNING_DAYS | 3 | How many days before a deadline the console starts to say how many are left. | missions.deadlineWarningDays | [missions.ts:223](./missions.ts#L223) |
+| missions | SCAN_SECONDS | 20 | Seconds a scan target must stay under the scanner lock: twenty, which is a pass and a turn at a trader's speed. | missions.scanSeconds | [missions.ts:231](./missions.ts#L231) |
+| missions | WATCH_CONE | 0.35 | How far off the centre of the view a scan's subject may sit and still count as watched, in radians: 0.35, which is twenty degrees. | missions.watchCone | [missions.ts:245](./missions.ts#L245) |
+| missions | SMUGGLE_TONNES | 3 | Tonnes of the patron's goods on a smuggle job: three, which fits a Cobra's hold beside its own stock. | missions.smuggleTonnes | [missions.ts:257](./missions.ts#L257) |
+| missions | ESCORT_ENEMY_ROLES | ['pirate', 'hunter', 'thargoid', 'thargon'] | The roles that count as an enemy near an escorted ship: the ones that prey. | | [missions.ts:266](./missions.ts#L266) |
+| missions | TOUR_ARCS | 5 | How many arcs the tour holds: five, Chris's number (docs/TODO/190, item 192). | missions.tourArcs | [missions.ts:276](./missions.ts#L276) |
+| missions | TOUR_STEP_JUMPS | { min: 4, max: 6 } as const | How far each arc's start world is from the one before it, in JUMPS on the full-tank graph: four to six. | missions.tourStepJumps | [missions.ts:289](./missions.ts#L289) |
+| missions | ARC_HANDOVER_JUMPS | { min: 2, max: 4 } as const | How far an arc's final leg lies from the next arc's start world, in JUMPS: two to four. | missions.arcHandoverJumps | [missions.ts:298](./missions.ts#L298) |
+| missions | ARC_PAY | { hunt: 12_000, deliver: 5_000, recover: 6_000, rescue: 8_000, ambush: 9_000, smuggle: 10_000, escort: 9_000, scan: 4_000, } as const | What an arc leg pays, per verb, in tenths of a credit. | missions.arcPay | [missions.ts:311](./missions.ts#L311) |
+| missions | ARC_LEG_DAYS | 30 | Days an arc leg allows before its deadline passes: a month, twice a side job's fortnight, because an arc leg may be four jumps out. | missions.arcLegDays | [missions.ts:324](./missions.ts#L324) |
| npc-gun | NPC_LASER_RANGE | LASER_RANGE | How far an NPC can shoot: the player's reach. | | [npc-gun.ts:17](./npc-gun.ts#L17) |
| npc-gun | NPC_COOLDOWN_LO | 0.9 | Time between an NPC's shots. | | [npc-gun.ts:31](./npc-gun.ts#L31) |
| npc-gun | NPC_COOLDOWN_SPREAD | 0.8 | | | [npc-gun.ts:32](./npc-gun.ts#L32) |
diff --git a/src/constants/missions.ts b/src/constants/missions.ts
index 554acf01..dc2dde8b 100644
--- a/src/constants/missions.ts
+++ b/src/constants/missions.ts
@@ -152,6 +152,37 @@ export const GANG_HUNT_KILLS = RATINGS[1][0];
*/
export const LANE_JOB_KILLS = GANG_HUNT_KILLS / 2;
+/**
+ * The rung the Dark Wheel's first whisper waits for (docs/TODO/219 M1):
+ * Above Average, 128 kills. Chris chose it on 2026-09-13. The Wheel finds
+ * a commander worth finding, and a Harmless one is not yet.
+ *
+ * @rule missions.wheelWhisperRung
+ */
+export const WHEEL_WHISPER_RUNG = RATINGS.findIndex(([, name]) => name === 'Above Average');
+
+/**
+ * The governments whose boards carry the Wheel's word (docs/TODO/219 M1),
+ * by their 1984 names. The Wheel posts nothing where a government reads
+ * the boards. `galaxy.ts` owns the names, and the offers filter compares
+ * a world's own against this list. It is a narrower line than
+ * `LAWLESS_GOVERNMENT` in encounters.ts, which breeds pirate waves up to a
+ * dictatorship. A dictatorship reads its boards.
+ *
+ * @domain missions
+ * @rule missions.lawlessGovernments
+ */
+export const LAWLESS_GOVERNMENTS: readonly string[] = ['Anarchy', 'Feudal'];
+
+/**
+ * What a Wheel trial pays, in tenths of a credit (docs/TODO/219 M1). More
+ * than an arc leg of the same verb, because the Wheel asks more. The mark
+ * is a gang with two Asps. The door pays nothing: its reward is a fit.
+ *
+ * @rule missions.wheelPay
+ */
+export const WHEEL_PAY = { mark: 20_000, blockade: 20_000, pilot: 25_000 } as const;
+
/**
* The lower fee a rescue pays when the pod is lost and the data still
* arrives, in tenths of a credit. The scientist example in docs/TODO/190:
diff --git a/src/missions/lookups.ts b/src/missions/lookups.ts
index 21d7264f..846d2b69 100644
--- a/src/missions/lookups.ts
+++ b/src/missions/lookups.ts
@@ -25,6 +25,7 @@ export function legOf(skeleton: Skeleton, id: string): Leg {
export function patronId(skeleton: Skeleton, commander: CommanderFacts, origin?: number): string {
const p = skeleton.patron;
if (p.kind === 'navy') return 'navy';
+ if (p.kind === 'wheel') return 'wheel';
return `world-${p.kind === 'world' ? p.seedSlot : (origin ?? commander.systemIndex)}`;
}
diff --git a/src/missions/model.ts b/src/missions/model.ts
index d9e5ba13..12004771 100644
--- a/src/missions/model.ts
+++ b/src/missions/model.ts
@@ -43,7 +43,13 @@ export type PatronRef =
| { kind: 'navy' }
| { kind: 'world'; seedSlot: number }
/** whoever runs the station the commander stands at: a side job's patron, anywhere */
- | { kind: 'local' };
+ | { kind: 'local' }
+ /**
+ * The Dark Wheel (docs/TODO/219): a society of pilots with no world and
+ * no face. Its jobs are on the board at every Anarchy and every Feudal
+ * world, once the commander's rating opens them.
+ */
+ | { kind: 'wheel' };
/**
* The facts about the commander that a rule can read. A projection of
diff --git a/src/missions/offers.ts b/src/missions/offers.ts
index d2a473b7..37a7e69a 100644
--- a/src/missions/offers.ts
+++ b/src/missions/offers.ts
@@ -12,8 +12,8 @@
// It is read by the machine on `accept` and on `docked`, by the desk that
// lists the MISSIONS screen, and by the tests. It changes nothing.
-import { MISSION_LIVE_CAP, MISSION_REOFFER_DAYS } from '../constants/missions.ts';
-import type { StarSystem } from '../galaxy/galaxy.ts';
+import { MISSION_LIVE_CAP, MISSION_REOFFER_DAYS, LAWLESS_GOVERNMENTS } from '../constants/missions.ts';
+import { GOVERNMENT_NAMES, type StarSystem } from '../galaxy/galaxy.ts';
import { routeEstimate } from '../galaxy/route.ts';
import { ratingRung } from '../game/rating.ts';
import type { CommanderFacts, Gate, MissionState, Skeleton } from './model.ts';
@@ -52,6 +52,15 @@ function localJobHere(s: Skeleton, c: CommanderFacts, ctx: OfferContext): boolea
return sideJobsAt(ctx.systems[c.systemIndex], ctx.skeletons ?? SKELETONS).some((j) => j.id === s.id);
}
+/**
+ * The Wheel's word reaches the lawless worlds alone (docs/TODO/219 M1):
+ * every Anarchy and every Feudal. Without the galaxy, every world is.
+ */
+function lawlessHere(s: Skeleton, c: CommanderFacts, ctx: OfferContext): boolean {
+ if (s.patron.kind !== 'wheel' || !ctx.systems) return true;
+ return LAWLESS_GOVERNMENTS.includes(GOVERNMENT_NAMES[ctx.systems[c.systemIndex].government]);
+}
+
/**
* A world patron waits at home, so her arc is offered there (docs/TODO/192
* M2), and inside `withinJumps` of there when the gate says so (M3). The
@@ -120,14 +129,19 @@ export function canAccept(st: MissionState, id: string, ctx: OfferContext): bool
if (st.live.length >= MISSION_LIVE_CAP) return false;
if (st.live.some((l) => l.skeleton === id)) return false;
// An arc that ended never comes back. `done` says so even when the journal
- // was not written, which a hand-built record can do.
- if (s.kind !== 'side' && id in st.done) return false;
+ // was not written, which a hand-built record can do. The Wheel's trials
+ // come back after a failure, as many times as their `cap` allows, and
+ // never after a pass (docs/TODO/219 M1).
+ const again = s.kind === 'side' || s.patron.kind === 'wheel';
+ if (!again && id in st.done) return false;
+ if (s.patron.kind === 'wheel' && st.done[id] === 'complete') return false;
const ended = endings(st, id);
- if (ended.count >= (s.kind === 'side' ? (s.cap ?? Infinity) : 1)) return false;
+ if (ended.count >= (again ? (s.cap ?? Infinity) : 1)) return false;
if (ended.count > 0 && ctx.commander.day < ended.lastDay + MISSION_REOFFER_DAYS) return false;
if (excluded(st, id, from)) return false;
if (leadHere(st, id, ctx.commander)) return true;
if (!nearHome(s, ctx)) return false;
+ if (!lawlessHere(s, ctx.commander, ctx)) return false;
return localJobHere(s, ctx.commander, ctx) && gateOpen(s.offer, st, ctx.commander);
}
diff --git a/src/missions/patrons.ts b/src/missions/patrons.ts
index 85847e3d..825053e6 100644
--- a/src/missions/patrons.ts
+++ b/src/missions/patrons.ts
@@ -55,6 +55,16 @@ export const NAVY_PATRON: Patron = {
id: 'navy', world: 'navy', name: 'THE NAVY', role: 'the Navy', species: '', voice: '', portrait: '',
};
+/**
+ * The other one (docs/TODO/219). A society of pilots that nobody admits
+ * exists. No world, no face, and a voice that gives nothing away.
+ */
+export const WHEEL_PATRON: Patron = {
+ id: 'wheel', world: 'wheel', name: 'THE DARK WHEEL', role: 'the Wheel', species: '',
+ voice: 'The Wheel writes in short lines, signs nothing, and names itself only at the end.',
+ portrait: '',
+};
+
/**
* The plain title a world's patron takes when no record exists, by
* government index. It is the fallback's own rule, not a copy of the
@@ -79,6 +89,7 @@ export function patronFor(
ref: PatronRef, facts: CommanderFacts, systems: readonly StarSystem[], origin?: number,
): Patron {
if (ref.kind === 'navy') return NAVY_PATRON;
+ if (ref.kind === 'wheel') return WHEEL_PATRON;
const world = ref.kind === 'world' ? ref.seedSlot : (origin ?? facts.systemIndex);
const sys = systems[world];
const base = {
diff --git a/src/missions/queries.ts b/src/missions/queries.ts
index caad25e1..ff9185c9 100644
--- a/src/missions/queries.ts
+++ b/src/missions/queries.ts
@@ -162,6 +162,7 @@ export function missionName(
): string {
const s = skeletonById(live.skeleton, from);
if (!s || s.patron.kind === 'navy') return 'NAVY MISSION';
+ if (s.patron.kind === 'wheel') return 'DARK WHEEL MISSION';
const world = s.patron.kind === 'world' ? s.patron.seedSlot : acceptedAt(st, live.skeleton);
return world === undefined ? 'MISSION' : `${systems[world].name.toUpperCase()} MISSION`;
}
diff --git a/src/missions/skeletons/index.ts b/src/missions/skeletons/index.ts
index 57621d18..8e205fbc 100644
--- a/src/missions/skeletons/index.ts
+++ b/src/missions/skeletons/index.ts
@@ -8,8 +8,9 @@ import type { Skeleton } from '../model.ts';
import { ARCS } from './arcs/index.ts';
import { CONSTRICTOR } from './constrictor.ts';
import { SIDE_JOBS } from './side.ts';
+import { WHEEL } from './wheel/index.ts';
-export const SKELETONS: readonly Skeleton[] = [CONSTRICTOR, ...ARCS, ...SIDE_JOBS];
+export const SKELETONS: readonly Skeleton[] = [CONSTRICTOR, ...ARCS, ...WHEEL, ...SIDE_JOBS];
/**
* The arcs of the tour, in order. The arc at index k starts at the k-th
diff --git a/src/missions/skeletons/wheel/index.ts b/src/missions/skeletons/wheel/index.ts
new file mode 100644
index 00000000..be7fa9dd
--- /dev/null
+++ b/src/missions/skeletons/wheel/index.ts
@@ -0,0 +1,8 @@
+// The Dark Wheel's trials, in order (docs/TODO/219). Not the tour: the tour
+// is five arcs at five worlds, and the Wheel has no world. Each trial waits
+// for the one before it through the flag it set.
+
+import type { Skeleton } from '../../model.ts';
+import { WHEEL_MARK } from './mark.ts';
+
+export const WHEEL: readonly Skeleton[] = [WHEEL_MARK];
diff --git a/src/missions/skeletons/wheel/mark.ts b/src/missions/skeletons/wheel/mark.ts
new file mode 100644
index 00000000..f33ddcd6
--- /dev/null
+++ b/src/missions/skeletons/wheel/mark.ts
@@ -0,0 +1,45 @@
+// The Dark Wheel's first trial: the mark (docs/TODO/219 M1).
+//
+// The whisper is the hail. Somebody watched the commander's last fight, and
+// a note waits with no sender. The job is a pilot: a Fer-de-Lance that works
+// the lanes with two Asps. The Wheel wants the pilot down, and the gang is
+// what stands in the way. A leader that got away is a failed trial, and the
+// Wheel gives one more chance (`cap`), then none.
+
+import { ARC_LEG_DAYS, SIDE_JOB_RANGE, WHEEL_PAY, WHEEL_WHISPER_RUNG } from '../../../constants/missions.ts';
+import { SOURCE_DESIGN } from '../../../game/ship-specs.ts';
+import { shipDesignIdOf } from '../../../game/ship-identity.ts';
+import type { Skeleton } from '../../model.ts';
+
+const AWAY = { kind: 'band', ...SIDE_JOB_RANGE } as const;
+
+export const WHEEL_MARK: Skeleton = {
+ id: 'wheel-mark',
+ kind: 'arc',
+ anchor: 'local',
+ patron: { kind: 'wheel' },
+ hail: 'A NOTE WAITS FOR YOU. NO SENDER, NO NAME.',
+ pitch: 'SOMEBODY WATCHED YOUR LAST FIGHT. A FER-DE-LANCE WORKS THE LANES WITH TWO ASPS. BRING ITS PILOT DOWN, AND WE WILL TALK AGAIN.',
+ offer: { minRating: WHEEL_WHISPER_RUNG },
+ cap: 2,
+ legs: [
+ {
+ id: 'mark',
+ verb: {
+ kind: 'hunt', ship: shipDesignIdOf(SOURCE_DESIGN.ferDeLance), canEscape: true,
+ gang: [shipDesignIdOf(SOURCE_DESIGN.asp), shipDesignIdOf(SOURCE_DESIGN.asp)],
+ },
+ place: AWAY,
+ line: 'THE WHEEL: BRING DOWN THE FER-DE-LANCE AND ITS TWO ASPS NEAR {TARGET}',
+ deadlineDays: ARC_LEG_DAYS,
+ next: [
+ { on: 'targetDestroyed', to: 'complete', settle: { pay: WHEEL_PAY.mark, setFlags: ['wheel.marked'], say: 'THE MARK IS DOWN. {PAY}, AND A WORD: WE WILL FIND YOU AGAIN.' } },
+ { on: 'targetFled', to: 'fail', settle: { pay: 0, say: 'THE PILOT GOT AWAY. THE WHEEL WANTED THE PILOT, NOT THE GANG.' } },
+ { on: 'targetEscaped', to: 'fail', settle: { pay: 0, say: 'THE PILOT JUMPED. THE WHEEL WANTED THE PILOT, NOT THE GANG.' } },
+ { on: 'failed', to: 'fail' },
+ ],
+ },
+ ],
+ complete: { pay: 0, standing: 1 },
+ fail: { pay: 0, standing: -1 },
+};
diff --git a/src/missions/words.ts b/src/missions/words.ts
index 217a4d00..15cad251 100644
--- a/src/missions/words.ts
+++ b/src/missions/words.ts
@@ -9,7 +9,7 @@
export interface Patron {
id: string;
- world: number | 'navy';
+ world: number | 'navy' | 'wheel';
name: string;
role: string;
species: string;
diff --git a/test/constants.test.ts b/test/constants.test.ts
index c45570b6..00995b7b 100644
--- a/test/constants.test.ts
+++ b/test/constants.test.ts
@@ -120,6 +120,9 @@ const OUTSIDE: readonly Group[] = [
],
// ...and the arcs in tour order, which is a list of ids (docs/TODO/192)
'missions/skeletons/index.ts': ['SKELETONS', 'ARC_TOUR'],
+ // the Dark Wheel's trials (docs/TODO/219)
+ 'missions/skeletons/wheel/mark.ts': ['WHEEL_MARK', 'AWAY'],
+ 'missions/skeletons/wheel/index.ts': ['WHEEL'],
// the pirates both lane legs name (docs/TODO/203 M2), and the company
// a job keeps at the jump-in (docs/TODO/214 M1)
'missions/skeletons/lane.ts': ['LANE_PIRATES', 'PAIR', 'LONE_KRAIT'],
@@ -131,7 +134,7 @@ const OUTSIDE: readonly Group[] = [
'missions/story.ts': ['ENDINGS'],
// the committed patron file given a name, the one patron with no world,
// and the plain title a world with no record takes (docs/TODO/191 M1)
- 'missions/patrons.ts': ['FILES', 'NAVY_PATRON', 'PLAIN_ROLE'],
+ 'missions/patrons.ts': ['FILES', 'NAVY_PATRON', 'PLAIN_ROLE', 'WHEEL_PATRON'],
// the generated list of committed dossier files (docs/TODO/191 M2)
'missions/dossiers/index.ts': ['DOSSIER_FILES'],
},
diff --git a/test/wheel.test.ts b/test/wheel.test.ts
new file mode 100644
index 00000000..e07211e0
--- /dev/null
+++ b/test/wheel.test.ts
@@ -0,0 +1,91 @@
+// The Dark Wheel comes for a commander (docs/TODO/219).
+//
+// M1: the Wheel is a patron with no world, its word is on the board at the
+// lawless worlds alone, the first whisper waits for Above Average, and the
+// mark is a gang hunt whose pilot must die.
+
+import { patronFor } from '../src/missions/patrons.ts';
+import { canAccept, offersFor } from '../src/missions/offers.ts';
+import { stepMissions } from '../src/missions/machine.ts';
+import { emptyMissionState } from '../src/missions/state.ts';
+import { missionName } from '../src/missions/queries.ts';
+import { lintSkeleton } from '../src/missions/lint.ts';
+import { SKELETONS } from '../src/missions/skeletons/index.ts';
+import { WHEEL_MARK } from '../src/missions/skeletons/wheel/mark.ts';
+import type { CommanderFacts, MissionEffect } from '../src/missions/model.ts';
+import { GOVERNMENT_NAMES } from '../src/galaxy/galaxy.ts';
+import { RATINGS } from '../src/constants/rating.ts';
+import { WHEEL_PAY, WHEEL_WHISPER_RUNG } from '../src/constants/missions.ts';
+import { g1, paid } from './fixtures.ts';
+import { check, eq } from './harness.ts';
+
+const anarchy = g1.find((s) => GOVERNMENT_NAMES[s.government] === 'Anarchy')!;
+const feudal = g1.find((s) => GOVERNMENT_NAMES[s.government] === 'Feudal')!;
+const democracy = g1.find((s) => GOVERNMENT_NAMES[s.government] === 'Democracy')!;
+const aboveAverage = RATINGS[WHEEL_WHISPER_RUNG][0];
+const facts = (systemIndex: number, combatScore: number): CommanderFacts => ({
+ galaxy: 1, systemIndex, kills: combatScore, combatScore, legalStatus: 0, day: 0, cargo: [], scoops: true,
+});
+const board = (systemIndex: number, combatScore: number): string[] =>
+ offersFor(emptyMissionState(), { commander: facts(systemIndex, combatScore), systems: g1 }).map((s) => s.id);
+
+console.log('\nthe Dark Wheel is a patron with no world and no face');
+{
+ const p = patronFor({ kind: 'wheel' }, facts(7, 0), g1);
+ eq('its name', p.name, 'THE DARK WHEEL');
+ check('...no world, no face, no species', p.world === 'wheel' && p.portrait === '' && p.species === '');
+ eq('a held Wheel mission is named as the Wheel\'s on the chart',
+ missionName({ ...emptyMissionState(), live: [{ skeleton: 'wheel-mark', leg: 'mark', target: 7, tag: null, progress: 0, deadlineDay: null }] },
+ { skeleton: 'wheel-mark', leg: 'mark', target: 7, tag: null, progress: 0, deadlineDay: null }, g1), 'DARK WHEEL MISSION');
+ eq('the lint passes the mark', lintSkeleton(WHEEL_MARK, SKELETONS, g1).join('; '), '');
+ eq(`the whisper waits for the rung of Above Average, ${aboveAverage} kills`, RATINGS[WHEEL_WHISPER_RUNG][1], 'Above Average');
+}
+
+console.log('\nthe Wheel\'s word is on the lawless boards, once the rating opens it');
+{
+ check(`an Above Average commander finds the mark at ${anarchy.name}, an Anarchy`, board(anarchy.index, aboveAverage).includes('wheel-mark'));
+ check(`...and at ${feudal.name}, a Feudal world`, board(feudal.index, aboveAverage).includes('wheel-mark'));
+ check(`...and not at ${democracy.name}, a Democracy`, !board(democracy.index, aboveAverage).includes('wheel-mark'));
+ check('an Average commander finds nothing at the Anarchy', !board(anarchy.index, aboveAverage - 1).includes('wheel-mark'));
+ check('the mark is the only Wheel job on the board', board(anarchy.index, aboveAverage).filter((id) => id.startsWith('wheel-')).join() === 'wheel-mark');
+ // The whisper is the hail: the dock says it, by name, as an arc's patron
+ // does. It queues behind the Navy's line, since a commander with the
+ // Wheel's rating has the Navy's kills too.
+ const docked = stepMissions(emptyMissionState(), { kind: 'docked' }, { commander: facts(anarchy.index, aboveAverage), systems: g1, rng: () => 0.5 });
+ check('the dock at the Anarchy says the whisper',
+ docked.effects.some((e) => (e.kind === 'say' || e.kind === 'later') && e.text === 'A NOTE WAITS FOR YOU. NO SENDER, NO NAME.'));
+}
+
+console.log('\nthe mark is a gang hunt whose pilot must die');
+{
+ const ctx = { commander: facts(anarchy.index, aboveAverage), systems: g1, rng: () => 0.5 };
+ const st = stepMissions(emptyMissionState(), { kind: 'accept', skeleton: 'wheel-mark' }, ctx).state;
+ const live = st.live[0];
+ const tag = live.tag as string;
+ check('accepted, with a Fer-de-Lance and two Asps on the record',
+ live !== undefined && st.entities[tag]?.alive === true
+ && st.entities[`${tag}#gang-1`]?.alive === true && st.entities[`${tag}#gang-2`]?.alive === true);
+ const said = (e: MissionEffect[]): string[] => e.flatMap((x) => (x.kind === 'say' ? [x.text] : []));
+ let r = stepMissions(st, { kind: 'destroyed', tag: `${tag}#gang-1` }, ctx);
+ r = stepMissions(r.state, { kind: 'destroyed', tag: `${tag}#gang-2` }, ctx);
+ r = stepMissions(r.state, { kind: 'destroyed', tag }, ctx);
+ eq('the pilot down last completes the trial', r.state.done['wheel-mark'], 'complete');
+ eq('...and pays the mark', paid(r.effects), WHEEL_PAY.mark);
+ check('...and marks the commander', r.state.flags.includes('wheel.marked'));
+ check('...with a word from the Wheel', said(r.effects).some((t) => /WE WILL FIND YOU AGAIN/.test(t)));
+ check('...and the Wheel does not offer the mark again', !canAccept(r.state, 'wheel-mark', { commander: facts(anarchy.index, aboveAverage), systems: g1 }));
+
+ // The pilot got away: the trial fails, and the Wheel gives one more chance.
+ let f = stepMissions(st, { kind: 'fled', tag }, ctx);
+ f = stepMissions(f.state, { kind: 'destroyed', tag: `${tag}#gang-1` }, ctx);
+ f = stepMissions(f.state, { kind: 'destroyed', tag: `${tag}#gang-2` }, ctx);
+ eq('a pilot that ran fails the trial, whatever became of the gang', f.state.done['wheel-mark'], 'fail');
+ check('...and the Wheel says what it wanted', said(f.effects).some((t) => /WANTED THE PILOT/.test(t)));
+ const later = { commander: { ...facts(anarchy.index, aboveAverage), day: 30 }, systems: g1 };
+ check('...and offers the mark once more, later', canAccept(f.state, 'wheel-mark', later));
+ let f2 = stepMissions(f.state, { kind: 'accept', skeleton: 'wheel-mark' }, { ...later, rng: () => 0.5 });
+ const tag2 = f2.state.live[0].tag as string;
+ f2 = stepMissions(f2.state, { kind: 'fled', tag: tag2 }, { ...later, rng: () => 0.5 });
+ const much = { commander: { ...facts(anarchy.index, aboveAverage), day: 60 }, systems: g1 };
+ check('...and after a second failure, never', !canAccept(f2.state, 'wheel-mark', much));
+}
diff --git a/tools/dossier-prompts.ts b/tools/dossier-prompts.ts
index 11139f29..343a451f 100644
--- a/tools/dossier-prompts.ts
+++ b/tools/dossier-prompts.ts
@@ -148,6 +148,12 @@ function patronLine(s: Skeleton): { line: string; own: string } {
}
const facts = { galaxy: 1, systemIndex: 0, kills: 0, combatScore: 0, legalStatus: 0, day: 0, cargo: [], scoops: false };
const p = patronFor(s.patron, facts, generateGalaxy(1));
+ if (s.patron.kind === 'wheel') {
+ return {
+ line: `Patron: ${p.name}, a society of pilots that nobody admits exists. It has no world and no face. ${p.voice} Its note reaches the commander at a lawless world, {HERE}, which it never names. Write {PATRON} for it, and only in the closing line.`,
+ own: '',
+ };
+ }
if (s.patron.kind === 'navy') {
return { line: `Patron: ${p.name}, in service signals: rank, no courtesy, no name, no world. Use neither {PATRON} nor {HERE}.`, own: '' };
}
From 3756833bf6cec53c419979814762d606b147488d Mon Sep 17 00:00:00 2001
From: Chris Greening WHEEL_WHISPER_RUNG | RATINGS.findIndex(([, name]) => name === 'Above Average') | The rung the Dark Wheel's first whisper waits for (docs/TODO/219 M1): Above Average, 128 kills. | missions.wheelWhisperRung | [missions.ts:162](./missions.ts#L162) |
| missions | LAWLESS_GOVERNMENTS | ['Anarchy', 'Feudal'] | The governments whose boards carry the Wheel's word (docs/TODO/219 M1), by their 1984 names. | missions.lawlessGovernments | [missions.ts:175](./missions.ts#L175) |
| missions | WHEEL_PAY | { mark: 20_000, blockade: 20_000, pilot: 25_000 } as const | What a Wheel trial pays, in tenths of a credit (docs/TODO/219 M1). | missions.wheelPay | [missions.ts:184](./missions.ts#L184) |
-| missions | RESCUE_SALVAGE_PAY | 1_500 | The lower fee a rescue pays when the pod is lost and the data still arrives, in tenths of a credit. | missions.rescueSalvagePay | [missions.ts:197](./missions.ts#L197) |
-| missions | SIDE_JOB_RANGE | { min: 20, max: 70 } as const | How far a side job sends the commander, in tenths of a light year: two to seven. | missions.sideJobRange | [missions.ts:205](./missions.ts#L205) |
-| missions | SIDE_JOB_DAYS | 14 | Days a side job allows before its deadline passes: two weeks. | missions.sideJobDays | [missions.ts:212](./missions.ts#L212) |
-| missions | DEADLINE_WARNING_DAYS | 3 | How many days before a deadline the console starts to say how many are left. | missions.deadlineWarningDays | [missions.ts:223](./missions.ts#L223) |
-| missions | SCAN_SECONDS | 20 | Seconds a scan target must stay under the scanner lock: twenty, which is a pass and a turn at a trader's speed. | missions.scanSeconds | [missions.ts:231](./missions.ts#L231) |
-| missions | WATCH_CONE | 0.35 | How far off the centre of the view a scan's subject may sit and still count as watched, in radians: 0.35, which is twenty degrees. | missions.watchCone | [missions.ts:245](./missions.ts#L245) |
-| missions | SMUGGLE_TONNES | 3 | Tonnes of the patron's goods on a smuggle job: three, which fits a Cobra's hold beside its own stock. | missions.smuggleTonnes | [missions.ts:257](./missions.ts#L257) |
-| missions | ESCORT_ENEMY_ROLES | ['pirate', 'hunter', 'thargoid', 'thargon'] | The roles that count as an enemy near an escorted ship: the ones that prey. | | [missions.ts:266](./missions.ts#L266) |
-| missions | TOUR_ARCS | 5 | How many arcs the tour holds: five, Chris's number (docs/TODO/190, item 192). | missions.tourArcs | [missions.ts:276](./missions.ts#L276) |
-| missions | TOUR_STEP_JUMPS | { min: 4, max: 6 } as const | How far each arc's start world is from the one before it, in JUMPS on the full-tank graph: four to six. | missions.tourStepJumps | [missions.ts:289](./missions.ts#L289) |
-| missions | ARC_HANDOVER_JUMPS | { min: 2, max: 4 } as const | How far an arc's final leg lies from the next arc's start world, in JUMPS: two to four. | missions.arcHandoverJumps | [missions.ts:298](./missions.ts#L298) |
-| missions | ARC_PAY | { hunt: 12_000, deliver: 5_000, recover: 6_000, rescue: 8_000, ambush: 9_000, smuggle: 10_000, escort: 9_000, scan: 4_000, } as const | What an arc leg pays, per verb, in tenths of a credit. | missions.arcPay | [missions.ts:311](./missions.ts#L311) |
-| missions | ARC_LEG_DAYS | 30 | Days an arc leg allows before its deadline passes: a month, twice a side job's fortnight, because an arc leg may be four jumps out. | missions.arcLegDays | [missions.ts:324](./missions.ts#L324) |
+| missions | WITCHSPACE_TARGET | -1 | The target a leg placed in witchspace carries (docs/TODO/219 M3). | missions.witchspaceTarget | [missions.ts:195](./missions.ts#L195) |
+| missions | RESCUE_SALVAGE_PAY | 1_500 | The lower fee a rescue pays when the pod is lost and the data still arrives, in tenths of a credit. | missions.rescueSalvagePay | [missions.ts:208](./missions.ts#L208) |
+| missions | SIDE_JOB_RANGE | { min: 20, max: 70 } as const | How far a side job sends the commander, in tenths of a light year: two to seven. | missions.sideJobRange | [missions.ts:216](./missions.ts#L216) |
+| missions | SIDE_JOB_DAYS | 14 | Days a side job allows before its deadline passes: two weeks. | missions.sideJobDays | [missions.ts:223](./missions.ts#L223) |
+| missions | DEADLINE_WARNING_DAYS | 3 | How many days before a deadline the console starts to say how many are left. | missions.deadlineWarningDays | [missions.ts:234](./missions.ts#L234) |
+| missions | SCAN_SECONDS | 20 | Seconds a scan target must stay under the scanner lock: twenty, which is a pass and a turn at a trader's speed. | missions.scanSeconds | [missions.ts:242](./missions.ts#L242) |
+| missions | WATCH_CONE | 0.35 | How far off the centre of the view a scan's subject may sit and still count as watched, in radians: 0.35, which is twenty degrees. | missions.watchCone | [missions.ts:256](./missions.ts#L256) |
+| missions | SMUGGLE_TONNES | 3 | Tonnes of the patron's goods on a smuggle job: three, which fits a Cobra's hold beside its own stock. | missions.smuggleTonnes | [missions.ts:268](./missions.ts#L268) |
+| missions | ESCORT_ENEMY_ROLES | ['pirate', 'hunter', 'thargoid', 'thargon'] | The roles that count as an enemy near an escorted ship: the ones that prey. | | [missions.ts:277](./missions.ts#L277) |
+| missions | TOUR_ARCS | 5 | How many arcs the tour holds: five, Chris's number (docs/TODO/190, item 192). | missions.tourArcs | [missions.ts:287](./missions.ts#L287) |
+| missions | TOUR_STEP_JUMPS | { min: 4, max: 6 } as const | How far each arc's start world is from the one before it, in JUMPS on the full-tank graph: four to six. | missions.tourStepJumps | [missions.ts:300](./missions.ts#L300) |
+| missions | ARC_HANDOVER_JUMPS | { min: 2, max: 4 } as const | How far an arc's final leg lies from the next arc's start world, in JUMPS: two to four. | missions.arcHandoverJumps | [missions.ts:309](./missions.ts#L309) |
+| missions | ARC_PAY | { hunt: 12_000, deliver: 5_000, recover: 6_000, rescue: 8_000, ambush: 9_000, smuggle: 10_000, escort: 9_000, scan: 4_000, } as const | What an arc leg pays, per verb, in tenths of a credit. | missions.arcPay | [missions.ts:322](./missions.ts#L322) |
+| missions | ARC_LEG_DAYS | 30 | Days an arc leg allows before its deadline passes: a month, twice a side job's fortnight, because an arc leg may be four jumps out. | missions.arcLegDays | [missions.ts:335](./missions.ts#L335) |
| npc-gun | NPC_LASER_RANGE | LASER_RANGE | How far an NPC can shoot: the player's reach. | | [npc-gun.ts:17](./npc-gun.ts#L17) |
| npc-gun | NPC_COOLDOWN_LO | 0.9 | Time between an NPC's shots. | | [npc-gun.ts:31](./npc-gun.ts#L31) |
| npc-gun | NPC_COOLDOWN_SPREAD | 0.8 | | | [npc-gun.ts:32](./npc-gun.ts#L32) |
diff --git a/src/constants/missions.ts b/src/constants/missions.ts
index dc2dde8b..f51feb15 100644
--- a/src/constants/missions.ts
+++ b/src/constants/missions.ts
@@ -183,6 +183,17 @@ export const LAWLESS_GOVERNMENTS: readonly string[] = ['Anarchy', 'Feudal'];
*/
export const WHEEL_PAY = { mark: 20_000, blockade: 20_000, pilot: 25_000 } as const;
+/**
+ * The target a leg placed in witchspace carries (docs/TODO/219 M3). A
+ * system is an index from 0 to 255, and witchspace is none of them. So the
+ * record holds this, and every reader of a target knows the word. A save
+ * carries it as any target.
+ *
+ * @domain missions
+ * @rule missions.witchspaceTarget
+ */
+export const WITCHSPACE_TARGET = -1;
+
/**
* The lower fee a rescue pays when the pod is lost and the data still
* arrives, in tenths of a credit. The scientist example in docs/TODO/190:
diff --git a/src/game/course-actions.ts b/src/game/course-actions.ts
index 2edd1d4d..f4a46cca 100644
--- a/src/game/course-actions.ts
+++ b/src/game/course-actions.ts
@@ -25,7 +25,7 @@ import type { ListFold } from './list-fold.ts';
import { hostilesNear, hostilesOnScanner } from './hostility.ts';
import { SKIP_SPEED } from '../constants/course.ts';
import { SCANNER_RANGE } from '../constants/console.ts';
-import { missionCourse } from './mission-course.ts';
+import { missionCourse, missionHere } from './mission-course.ts';
/**
* What the course buttons show in flight: the list, or the course under way.
@@ -227,7 +227,7 @@ export class CourseActions {
*/
private missionRow(): CourseWorld['mission'] {
const s = this.state();
- const m = missionCourse(s.commander.missions, s.commander.systemIndex,
+ const m = missionCourse(s.commander.missions, missionHere(s.session, s.commander),
s.world.npcs, s.world.cargo.items, s.world.station.position, s.player.position);
if (m === null) return null;
const needsScoops = m.how === 'scoop' && !s.commander.equipment.scoops;
diff --git a/src/game/flight-course.ts b/src/game/flight-course.ts
index fd6de402..a9b0f0f0 100644
--- a/src/game/flight-course.ts
+++ b/src/game/flight-course.ts
@@ -34,7 +34,7 @@ import { hostilesNear, hostilesOnScanner } from './hostility.ts';
import { pickTarget, pickedTarget } from './targets.ts';
import { derelictReport } from './derelict.ts';
import type { StarSystem } from '../galaxy/galaxy.ts';
-import { missionCourse } from './mission-course.ts';
+import { missionCourse, missionHere } from './mission-course.ts';
import { SCANNER_RANGE } from '../constants/console.ts';
import { DOCK_COMPUTER_RANGE } from '../constants/docking-computer.ts';
import { COURSE_DOCK_HANDOVER } from '../constants/course.ts';
@@ -104,7 +104,7 @@ export class FlightCourse {
// (docs/TODO/208 M1). A hunt is a fight, so the ship it names is picked
// as the target, exactly as a rock is.
const mission = s.course !== 'mission' ? null
- : missionCourse(this.state.commander.missions, this.state.commander.systemIndex,
+ : missionCourse(this.state.commander.missions, missionHere(s, this.state.commander),
w.npcs, w.cargo.items, w.station.position, p.position);
if (mission?.how === 'fight' && mission.ship !== null
&& pickedTarget(w.npcs) !== mission.ship) {
diff --git a/src/game/mission-arrival.ts b/src/game/mission-arrival.ts
index eb18d995..06a0592b 100644
--- a/src/game/mission-arrival.ts
+++ b/src/game/mission-arrival.ts
@@ -11,6 +11,7 @@
// It is pure. The arrival (hyperspace-actions.ts) gathers the sightings and
// hands them in, so a test needs no world.
+import { WITCHSPACE_TARGET } from '../constants/missions.ts';
import type * as THREE from 'three';
import type { StarSystem } from '../galaxy/galaxy.ts';
import { routeTable } from '../galaxy/route.ts';
@@ -77,6 +78,12 @@ export function arrivalLines(
const s = skeletonById(live.skeleton, skeletons);
if (!s) continue;
const leg = legOf(s, live.leg);
+ // A leg in witchspace has no jumps to count: the way there is an armed
+ // mis-jump (docs/TODO/219 M3).
+ if (live.target === WITCHSPACE_TARGET) {
+ out.push('YOUR JOB IS IN WITCHSPACE. PAUSE, ARM THE MIS-JUMP, AND JUMP.');
+ continue;
+ }
if (live.target !== null && live.target !== here) {
routes ??= routeTable(systems, here);
const jumps = routes.jumps[live.target];
diff --git a/src/game/mission-course.ts b/src/game/mission-course.ts
index ad9bd9aa..cd323bbc 100644
--- a/src/game/mission-course.ts
+++ b/src/game/mission-course.ts
@@ -27,6 +27,15 @@ import type { Canister } from './cargo.ts';
import { liveLegs } from '../missions/queries.ts';
import type { MissionState, Skeleton } from '../missions/model.ts';
import { SKELETONS } from '../missions/skeletons/index.ts';
+import { WITCHSPACE_TARGET } from '../constants/missions.ts';
+
+/**
+ * Where the commander is, as a leg's target names it: the system's index,
+ * or the witchspace sentinel while the ship is in limbo (docs/TODO/219 M3).
+ */
+export function missionHere(session: { witchspace: boolean }, commander: { systemIndex: number }): number {
+ return session.witchspace ? WITCHSPACE_TARGET : commander.systemIndex;
+}
/** What the ship does about this leg. */
export type MissionHow = 'fight' | 'hold' | 'escort' | 'scoop' | 'slip';
diff --git a/src/game/world-build.ts b/src/game/world-build.ts
index b0ca32c2..92cb5a72 100644
--- a/src/game/world-build.ts
+++ b/src/game/world-build.ts
@@ -30,7 +30,9 @@ import { specsForSet } from './set-roster.ts';
import { missionItems, missionOverride, missionSpawns } from '../missions/queries.ts';
import { planPopulation } from './population.ts';
import { markOf, pirateThreat } from './threat.ts';
-import { spawnPopulation } from './spawning.ts';
+import { spawnPopulation, spawnTaggedShips } from './spawning.ts';
+import { WITCHSPACE_TARGET } from '../constants/missions.ts';
+import { MISSION_TARGET_RANGE, MISSION_TARGET_RANGE_SPAN } from '../constants/spawn-placement.ts';
import { random, randomDirection } from './rng.ts';
import type { NpcShip } from './npc.ts';
import type { NpcRole } from './ship-roles.ts';
@@ -147,6 +149,16 @@ export class WorldBuild {
.multiplyScalar(THARGOID_AMBUSH_RANGE + random() * THARGOID_AMBUSH_RANGE_SPAN), i);
}
this.state.encounterTimers.thargon = THARGON_REDEPLOY;
+ // A leg placed in witchspace spawns its ships and its things here, at a
+ // mission target's reach, beside the trap (docs/TODO/219 M3).
+ const missions = this.state.commander.missions;
+ spawnTaggedShips(this.state.world, this.state.player.position,
+ missionSpawns(missions, WITCHSPACE_TARGET), MISSION_TARGET_RANGE, MISSION_TARGET_RANGE_SPAN);
+ for (const item of missionItems(missions, WITCHSPACE_TARGET)) {
+ const pos = randomDirection(new THREE.Vector3())
+ .multiplyScalar(MISSION_TARGET_RANGE + random() * MISSION_TARGET_RANGE_SPAN);
+ this.state.world.cargo.spawnMission(pos, item.kind, item.tag);
+ }
this.host.hyperspaceSound();
this.host.startTunnel(1.1);
this.host.showMessage('WITCH-SPACE — THARGOID AMBUSH', 6);
diff --git a/src/missions/lint.ts b/src/missions/lint.ts
index 7363d0f5..fa6a4430 100644
--- a/src/missions/lint.ts
+++ b/src/missions/lint.ts
@@ -60,6 +60,11 @@ export function lintSkeleton(
for (const s of [...(leg.spawn ?? []), ...(leg.ambush?.ships ?? [])]) {
if (!specForDesign(jobRole(s.job), s.ship)) out.push(`${at}: no ${jobRole(s.job)} row for the spawned ${s.ship}`);
}
+ // A leg in witchspace has no station, so its verb must end in the sky
+ // (docs/TODO/219 M3).
+ if (leg.place.kind === 'witchspace' && !['hunt', 'recover', 'rescue', 'scan'].includes(leg.verb.kind)) {
+ out.push(`${at}: a ${leg.verb.kind} leg cannot end in witchspace`);
+ }
if (!leg.next.some((b) => b.on === 'failed')) out.push(`${at}: no failed branch`);
for (const t of verbModule(leg.verb.kind) ? verbTriggers(leg.verb) : []) {
const answered = leg.next.some((b) => sameTrigger(b.on, t))
diff --git a/src/missions/machine.ts b/src/missions/machine.ts
index b5229a13..6a2f62ce 100644
--- a/src/missions/machine.ts
+++ b/src/missions/machine.ts
@@ -37,7 +37,7 @@ import { applySettlement } from './settlement.ts';
import { offerLead } from './leads.ts';
import { sameTrigger, triggerLabel, wordKind } from './triggers.ts';
import { verbItem, verbModule, verbNeedsShip } from './verbs/registry.ts';
-import { DEADLINE_WARNING_DAYS } from '../constants/missions.ts';
+import { DEADLINE_WARNING_DAYS, WITCHSPACE_TARGET } from '../constants/missions.ts';
export interface MissionContext {
commander: CommanderFacts;
@@ -203,7 +203,8 @@ function deadlines(st: MissionState, ctx: MissionContext, effects: MissionEffect
for (const live of [...st.live]) {
if (live.deadlineDay === null) continue;
const left = live.deadlineDay - ctx.commander.day;
- const where = live.target === null ? 'ANY STATION' : ctx.systems[live.target].name.toUpperCase();
+ const where = live.target === null ? 'ANY STATION'
+ : live.target === WITCHSPACE_TARGET ? 'WITCHSPACE' : ctx.systems[live.target].name.toUpperCase();
if (left < 0) {
effects.push({ kind: 'say', text: `THE JOB AT ${where} RAN OUT OF TIME, AND IT IS LOST.` });
fire(st, live, 'deadlinePassed', ctx, effects);
diff --git a/src/missions/model.ts b/src/missions/model.ts
index 3800fbb6..8fdfbe32 100644
--- a/src/missions/model.ts
+++ b/src/missions/model.ts
@@ -121,7 +121,13 @@ export type Placement =
| { kind: 'band'; min: number; max: number }
| { kind: 'world'; seedSlot: number }
| { kind: 'entity'; tag: string }
- | { kind: 'handover'; toward: string; min: number; max: number };
+ | { kind: 'handover'; toward: string; min: number; max: number }
+ /**
+ * Witchspace itself (docs/TODO/219 M3): the leg's target is
+ * `WITCHSPACE_TARGET`, not a system, and an armed mis-jump is the way
+ * there. The Thargoids wait there already.
+ */
+ | { kind: 'witchspace' };
export type Trigger =
| 'success' | 'failed' | 'targetEscaped' | 'targetDestroyed' | 'targetFled'
diff --git a/src/missions/placement.ts b/src/missions/placement.ts
index 48c6dd38..9db57ec4 100644
--- a/src/missions/placement.ts
+++ b/src/missions/placement.ts
@@ -5,6 +5,7 @@
// band has a candidate. A draw advances the shared world stream, so an extra
// one changes every result after it.
+import { WITCHSPACE_TARGET } from '../constants/missions.ts';
import type { StarSystem } from '../galaxy/galaxy.ts';
import { distanceTenths } from '../galaxy/navigation.ts';
import { routeTable } from '../galaxy/route.ts';
@@ -77,6 +78,7 @@ export function placeLeg(
return target === null ? { ok: false } : { ok: true, target };
}
case 'world': return { ok: true, target: place.seedSlot };
+ case 'witchspace': return { ok: true, target: WITCHSPACE_TARGET };
case 'entity': {
const e = state.entities[place.tag];
return e ? { ok: true, target: e.lastWorld } : { ok: false };
diff --git a/src/missions/queries.ts b/src/missions/queries.ts
index ff9185c9..72dd2d4b 100644
--- a/src/missions/queries.ts
+++ b/src/missions/queries.ts
@@ -6,6 +6,7 @@
// answer reads the skeleton through the live leg, so a stage number lives
// nowhere (docs/TODO/190).
+import { WITCHSPACE_TARGET } from '../constants/missions.ts';
import type { BlueprintOverride } from '../game/blueprint-set.ts';
import type { StarSystem } from '../galaxy/galaxy.ts';
import { legOf } from './lookups.ts';
@@ -170,6 +171,7 @@ export function missionName(
/** Every world a live leg sends the commander to. */
export function missionDestinations(st: MissionState): ReadonlySet