From 81fcb2b0f340edf8e53239980b71607049cf3319 Mon Sep 17 00:00:00 2001 From: Chris Greening Date: Thu, 10 Sep 2026 22:06:30 +0100 Subject: [PATCH 001/100] docs/TODO/204 is planned: the ship flies by touch The docked screens and the cockpit fit a phone, and nothing on the flight screen takes a touch. The brainstorm of 2026-09-10 set the shape: drag anywhere on the view to steer, hold a button to fire, a throttle slider, a row of five command buttons along the console's top edge, tappable prompts, and a flight menu of rows. No tilt, no swipe and no pinch. Five milestones, with the wanted-speed rule and the held-key methods first, because both run under node. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Cpi86XPSQzZQhVhRnZED58 --- docs/TODO/204-the-ship-flies-by-touch.md | 151 +++++++++++++++++++++++ docs/TODO/QUEUE.json | 4 +- docs/TODO/README.md | 8 +- 3 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 docs/TODO/204-the-ship-flies-by-touch.md diff --git a/docs/TODO/204-the-ship-flies-by-touch.md b/docs/TODO/204-the-ship-flies-by-touch.md new file mode 100644 index 00000000..8ad9f544 --- /dev/null +++ b/docs/TODO/204-the-ship-flies-by-touch.md @@ -0,0 +1,151 @@ +# 204 — The ship flies by touch + +**Kind:** enhancement · **Severity:** medium · **Size:** large · **Depends on:** +197, 200, 202 · **Blocks:** nothing · **GitHub:** none + +## Where we are + +**Chris asked for the flight screen on a phone on 2026-09-10, after the +docked screens and the cockpit fitted one.** The brainstorm of that day is +in the conversation, and this plan is its result. He likes portrait. + +**The ship reads its controls through one small interface.** `FlightControls` +in `engine/flight-controls.ts` has five inputs: which keys are held, a stick +on two axes, and whether the trigger is down. `flightDemand` is pure. It +turns those five into a roll rate, a pitch rate, a throttle of -1, 0 or 1, +and a fire flag. The mouse-flight mode already fills the stick from pointer +movement, and `Input.decayMouse` centres it when the pointer stops. + +**Every command in flight is a key in the binding table.** A docked menu +row turns a tap into a keystroke through `Input.injectPress`, since +docs/TODO/202 with no letter on the row. `game/prompts.ts` decides which +commands matter right now and returns a command, never a letter. The HUD +paints the prompt line as text, through `paintPrompts` in `hud/hud.ts`. + +**Nothing on the flight screen takes a touch.** The console has +`pointer-events: none`. The view is the canvas. A phone player can dock, +trade and read every screen, and cannot fly. + +**Two things a phone lacks.** Pointer lock, which the mouse-flight command +asks for. A held key: `Input.down` is private, and only a keydown fills it. + +## What to do + +Five milestones. + +### M1 — the input takes a wanted speed and a held button + +`Input` gains `press(code)` and `release(code)`, so a button can hold a key +as a keyboard does. It also gains `wantedSpeed: number | null`. A throttle +slider sets it, as a fraction of the ship's top speed. `FlightControls` +carries the wanted speed. `flightDemand` gains one rule, for a wanted speed +with no speed key held. The throttle is 1 below the wanted speed, and -1 +above it. It is 0 inside a small band. The band is `THROTTLE_BAND` in +`constants/player-flight.ts`. +`test/flight.test.ts` pins the rule at both sides of the band. + +### M2 — the touch overlay: drag to steer, hold to fire, slide to set speed + +A new `engine/touch.ts` lives behind the platform seam, beside the browser +shell. It listens to pointer events on one overlay element over the view. A +finger that lands on empty view steers. Its offset from where it landed is +the stick, with `TOUCH_STICK_TRAVEL` pixels for full deflection. The stick +decays when the finger lifts, as the mouse stick does. It writes +`Input.mouseFlight`, `mouseX` and `mouseY`, and nothing in `flightDemand` +changes. A second finger may hold the FIRE button in the bottom right corner +of the view, which presses the fire key. A finger on the throttle slider at +the left edge of the view sets the wanted speed. Each finger is tracked by +its pointer id, so steering and firing happen at once. + +The overlay shows only on a coarse pointer, through the media query +docs/TODO/197 uses. The keyboard keeps working beside it. The mouse-flight +command hides where pointer lock does not exist. + +### M3 — the command row and the tappable prompts + +A row of five buttons sits along the top edge of the console: MISSILE, +E.C.M., JUMP, TORUS and DOCK. Each carries the key it presses, from the +binding table, and a tap injects it. MISSILE arms on one tap and launches on +the next, because that is what the two keys do. A button is lit when the HUD +already knows its state is on: a missile armed, the torus engaged, the +docking computer engaged. + +The prompt line becomes buttons on a coarse pointer. A prompt carries a +command. The edge looks the key up through the binding table, as it does +for the text, and the button injects it. The words stay the prompt's own. + +### M4 — the flight menu + +A MENU button opens a screen with rows, as the station menu is. The rows +are these: + +1. LOCAL CHART, GALACTIC CHART; +2. COMMANDER STATUS, MISSIONS, COMMANDER'S LOG, CONTRACTS; +3. FRONT VIEW, REAR VIEW, LEFT VIEW, RIGHT VIEW; +4. PAUSE, and ESCAPE POD. + +Each row carries the flight key it stands for, and the screen host's +row cursor and tap path serve it. The escape pod row asks first, on a +confirmation with buttons. The world keeps flying while the menu is up, as +it does under the charts. + +### M5 — fullscreen, and the manual + +The first touch on the view asks the browser for fullscreen. The manual and +the briefing gain one paragraph each on how to fly by touch. + +## Decisions already made + +- **Drag anywhere to steer, with no drawn stick** (Chris, 2026-09-10). A + drawn stick invites the thumb to one spot. The decay is the mouse stick's. +- **No tilt in this plan.** iOS asks permission through a dialog, and a + player on a bus cannot tilt. It may come later as an option. +- **No swipe and no pinch.** A swipe fights the steering drag, and a pinch + fights two thumbs. The views are rows on the flight menu. +- **The console is read, never touched.** The controls sit on the view and + along the console's top edge. +- **Portrait is the phone's layout.** Landscape gets the same overlay over + the desktop console. + +## Open questions + +None. + +## Watch out for + +- **`Input.down` is private, and `held` reads it.** `press` and `release` + add and delete a code there. A test drives them with no window. +- **The overlay must not swallow a tap on a screen.** `#screen` sits above it + in the stacking order, and the overlay hides while a screen is open, as the + console does. +- **`pointer-events: none` on the console.** The command row sits outside + the console element, so the console's rule does not reach it. +- **The prompts line is rebuilt only when the text changes.** The buttons + follow the same rule, or a tap lands on a button that was just repainted. +- **A held key and `endFrame`.** A pressed code is held, not tapped, so the + carry rule does not apply. The fire key is `held`, as it always was. +- **`keyIfBound` answers null for a virtual code.** Every flight command is + a real key, so the buttons and the menu rows find one. + +## Verification + +The gates always run: `npm run check`. `npm run generate:constants` runs +first, because M1 and M2 add constants. + +The tier: a rule that changes how a flight goes, the throttle. `npm run +flight-probe` runs once. + +Evidence: + +- `test/flight.test.ts`: the wanted speed drives the throttle up, down and + not at all inside the band, and a speed key overrides it. +- `test/input.test.ts`: `press` holds a key until `release`, and a pressed + key is never carried as a tap. +- A new `test/touch.test.ts`: the stick maths from a finger's offset, the + decay after a lift, and two fingers tracked apart. +- A new `test/flight-menu.test.ts`: the rows carry the flight keys, and a + tap on each asks for its command. +- In Chrome at 390 by 844, in flight, synthetic pointer events steer the + ship, hold the trigger and set the speed. Each button opens what it + names. +- Chris flies the preview on his phone. diff --git a/docs/TODO/QUEUE.json b/docs/TODO/QUEUE.json index 189020e1..6d913873 100644 --- a/docs/TODO/QUEUE.json +++ b/docs/TODO/QUEUE.json @@ -1,4 +1,6 @@ { "version": 1, - "items": [] + "items": [ + 204 + ] } diff --git a/docs/TODO/README.md b/docs/TODO/README.md index 58522d56..16232a43 100644 --- a/docs/TODO/README.md +++ b/docs/TODO/README.md @@ -13,7 +13,13 @@ active context: ## Execution queue -The queue is empty. +1. [204](204-the-ship-flies-by-touch.md) — the ship flies by touch. + +**204 CAME FROM CHRIS ON 2026-09-10.** The docked screens and the cockpit fit +a phone, and nothing on the flight screen takes a touch. The brainstorm set +the shape. Drag anywhere to steer, and hold to fire. A throttle slider, a +row of command buttons, tappable prompts, and a flight menu of rows. No +tilt, no swipe and no pinch. The plan is written, and the work waits on his word. **203 CAME FROM CHRIS'S PLAYTEST ON 2026-09-10.** He played three side jobs and could not tell how to finish any of them. The review found two signals From aceb0fc427f665272e65cddaeb56194dbe0b2d80 Mon Sep 17 00:00:00 2001 From: Chris Greening Date: Fri, 11 Sep 2026 08:36:33 +0100 Subject: [PATCH 002/100] docs/TODO/204 M1: the input takes a wanted speed and a held button A touch slider needs to ask for a speed, and a touch button needs to hold a key. Input gains press and release, which hold a key as a keyboard does and never make a tap, and wantedSpeed, a fraction of the top speed or null. flightDemand gains one rule: with a wanted speed and no speed key held, the throttle opens below it, brakes above it, and coasts inside PLAYER_FLIGHT.throttleBand of it. A keyboard never sets a wanted speed, so nothing changes for it. test/flight.test.ts pins the rule on both sides of the band, and the key's override. test/input.test.ts pins that a pressed key is held and is never a tap. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Cpi86XPSQzZQhVhRnZED58 --- src/constants/CATALOG.md | 2 +- src/constants/player-flight.ts | 10 ++++++++++ src/engine/flight-controls.ts | 28 ++++++++++++++++++++++++++-- src/engine/input.ts | 20 ++++++++++++++++++++ test/flight.test.ts | 29 +++++++++++++++++++++++++++-- test/input.test.ts | 13 +++++++++++++ 6 files changed, 97 insertions(+), 5 deletions(-) diff --git a/src/constants/CATALOG.md b/src/constants/CATALOG.md index 7d6d1476..ee6ebf21 100644 --- a/src/constants/CATALOG.md +++ b/src/constants/CATALOG.md @@ -281,7 +281,7 @@ search names, meanings and values with `npm run constants:find -- ""`. | 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) | | 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.maxSpeed
flight.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.maxSpeed
flight.player.throttleBand
flight.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 Date: Fri, 11 Sep 2026 08:41:05 +0100 Subject: [PATCH 003/100] docs/TODO/204 M2: the touch overlay: drag to steer, hold to fire, slide to set speed A new engine/touch.ts has two halves. TouchTracker is pure: a finger's down, move and up by pointer id, and the stick, the trigger and the wanted speed it writes into the input. attachTouch binds the overlay's pointer events to it, and Input calls it when the page has the overlay. A drag anywhere on the view is the mouse stick, with TOUCH_STICK_TRAVEL pixels for full deflection. A held finger holds its deflection, which stickHeld tells decayMouse, and a lifted finger decays to centre. FIRE holds the layout's fire key with press. The slider sets wantedSpeed. Two fingers are tracked apart. The overlay shows on a coarse pointer only, never under a screen, and the mouse-flight command steps aside while a finger has the stick. test/touch.test.ts drives the tracker with plain numbers. In Chrome at 390 by 844 with synthetic pointer events, the slider took the speed from 30% to 93%, a full drag right rolled the ship, FIRE heated the laser while the other finger steered, and the stick decayed after the lift. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Cpi86XPSQzZQhVhRnZED58 --- play.html | 10 +++ src/constants/CATALOG.md | 3 +- src/constants/touch.ts | 13 +++ src/engine/input.ts | 14 ++- src/engine/touch.ts | 152 +++++++++++++++++++++++++++++++++ src/game/flight-instruments.ts | 1 + src/style.css | 50 +++++++++++ test/run.ts | 1 + test/touch.test.ts | 86 +++++++++++++++++++ 9 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 src/constants/touch.ts create mode 100644 src/engine/touch.ts create mode 100644 test/touch.test.ts diff --git a/play.html b/play.html index b7224a57..af7b7855 100644 --- a/play.html +++ b/play.html @@ -28,6 +28,16 @@ CONDITION: GREEN
+ +
+
+
FIRE
+
-423 exported constants. Regenerate with `npm run generate:constants`; +424 exported constants. Regenerate with `npm run generate:constants`; search names, meanings and values with `npm run constants:find -- ""`. | Domain | Symbol | Literal / expression | Purpose | Rule ID | Source | @@ -402,6 +402,7 @@ search names, meanings and values with `npm run constants:find -- ""`. | 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) | +| 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: Set } { + const held = new Set(); + return { + mouseFlight: false, mouseX: 0, mouseY: 0, stickHeld: false, wantedSpeed: null, held, + press: (code) => { held.add(code); }, + release: (code) => { held.delete(code); }, + }; +} +const FIRE = 'KeyA'; +const tracker = (t: TouchTarget) => new TouchTracker(t, () => FIRE, () => ({ top: 100, height: 200 })); + +console.log('\nthe stick is the finger\'s offset from where it landed'); +{ + eq('half the travel is half deflection', stickFromDrag(100, 100, 100 + TOUCH_STICK_TRAVEL / 2, 100).x, 0.5); + eq('...and it clamps at one', stickFromDrag(100, 100, 100 + TOUCH_STICK_TRAVEL * 3, 100).x, 1); + eq('down is positive, as the mouse stick has it', stickFromDrag(0, 0, 0, TOUCH_STICK_TRAVEL).y, 1); + eq('the slider is one at its top', sliderFraction(100, 100, 200), 1); + eq('...zero at its bottom', sliderFraction(300, 100, 200), 0); + eq('...and clamps past either end', sliderFraction(500, 100, 200), 0); +} + +console.log('\na finger on the view steers, holds, and lets the stick decay when it lifts'); +{ + const t = target(); + const k = tracker(t); + k.down(1, 'view', 200, 300); + check('a landed finger takes the stick and holds it', t.mouseFlight && t.stickHeld && t.mouseX === 0 && t.mouseY === 0); + k.move(1, 200 + TOUCH_STICK_TRAVEL / 2, 300 - TOUCH_STICK_TRAVEL / 4); + eq('a drag right is half a stick right', t.mouseX, 0.5); + eq('...and a drag up is a quarter of a stick up', t.mouseY, -0.25); + k.down(2, 'view', 50, 50); + k.move(2, 150, 50); + eq('a second finger on the view does not take the stick', t.mouseX, 0.5); + k.up(1); + check('the lifted finger leaves the stick to decay', !t.stickHeld && t.mouseX === 0.5); +} + +console.log('\nFIRE holds the trigger while another finger steers'); +{ + const t = target(); + const k = tracker(t); + k.down(1, 'view', 200, 300); + k.down(2, 'fire', 350, 600); + check('the fire finger holds the layout\'s fire key', t.held.has(FIRE)); + k.move(1, 200 + TOUCH_STICK_TRAVEL / 2, 300); + eq('...while the first finger still steers', t.mouseX, 0.5); + k.up(2); + check('lifting the fire finger releases the key and leaves the stick', !t.held.has(FIRE) && t.stickHeld && t.mouseX === 0.5); +} + +console.log('\nthe throttle slider sets the wanted speed'); +{ + const t = target(); + const k = tracker(t); + k.down(3, 'throttle', 30, 150); + eq('a touch a quarter of the way down asks for three quarters of top speed', t.wantedSpeed, 0.75); + k.move(3, 30, 300); + eq('...and a slide to the bottom asks for a stop', t.wantedSpeed, 0); + k.up(3); + eq('the wanted speed holds after the finger lifts', t.wantedSpeed, 0); +} + +console.log('\nthe input holds the stick for a finger, and decays it for a mouse'); +{ + const i = new Input(); + i.mouseFlight = true; + i.mouseX = 1; + i.stickHeld = true; + i.decayMouse(1); + eq('a held stick does not decay', i.mouseX, 1); + i.stickHeld = false; + i.decayMouse(0.5); + check('...and a released one does', i.mouseX < 1); +} From db05c9df97ddac206ec1b984b769517bd6ae526e Mon Sep 17 00:00:00 2001 From: Chris Greening Date: Fri, 11 Sep 2026 08:45:10 +0100 Subject: [PATCH 004/100] docs/TODO/204 M3: the command row and the tappable prompts Five buttons sit along the top edge of the console: MISSILE, E.C.M., JUMP, TORUS and DOCK. ui/key-help.ts paints them at boot from the binding table, so each carries the flight key it presses, and a tap takes the same path as a menu row. MISSILE carries both of its keys, and the HUD swaps the launch key in once a missile is armed and lights the button. TORUS and DOCK light while their state is on. The prompt line carries the code of each offer beside its words, and on a coarse pointer each prompt is a button. The browser shell listens on the row and the prompt line as it does on a screen, and the overlay leaves a tap on the row to that seam. test/key-help.test.ts holds the five buttons and their keys. In the 390 by 844 frame, a tap on MISSILE armed one, swapped the key and lit the button, and a tap on TORUS was refused as MASS LOCKED beside the station, which is right. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Cpi86XPSQzZQhVhRnZED58 --- play.html | 2 ++ src/engine/browser-shell.ts | 13 +++++++--- src/engine/touch.ts | 18 ++++++++----- src/game/cockpit-view.ts | 31 ++++++++++++++++++++--- src/game/game.ts | 3 ++- src/hud/hud-binding.ts | 8 ++++++ src/hud/hud.ts | 50 +++++++++++++++++++++++++++++++------ src/style.css | 46 ++++++++++++++++++++++++++++++++++ src/ui/key-help.ts | 41 ++++++++++++++++++++++++++++++ test/console-plate.test.ts | 2 +- test/key-help.test.ts | 24 ++++++++++++++++-- 11 files changed, 212 insertions(+), 26 deletions(-) diff --git a/play.html b/play.html index af7b7855..d259764b 100644 --- a/play.html +++ b/play.html @@ -37,6 +37,8 @@
FIRE
+ +
+ -
-
-
FIRE
- -
- -
-424 exported constants. Regenerate with `npm run generate:constants`; +423 exported constants. Regenerate with `npm run generate:constants`; search names, meanings and values with `npm run constants:find -- ""`. | Domain | Symbol | Literal / expression | Purpose | Rule ID | Source | @@ -281,7 +281,7 @@ search names, meanings and values with `npm run constants:find -- ""`. | 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) | | 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.maxSpeed
flight.player.throttleBand
flight.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.maxSpeed
flight.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 -- ""`. | 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) | -| 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('.touch-ask')) ask.classList.remove('open'); - }; - commands?.addEventListener('click', (e) => { - if ((e.target as HTMLElement).closest('[data-menu]')) show(menu.classList.contains('hidden')); - }); - menu.addEventListener('click', (e) => { - const el = (e.target as HTMLElement).closest('[data-ask],[data-key]'); - if (!el) return; - if (el.dataset.ask !== undefined) { - for (const ask of menu.querySelectorAll('.touch-ask')) { - ask.classList.toggle('open', ask.dataset.asks === el.dataset.ask); - } - if (el.dataset.ask === '') show(false); - return; - } - show(false); // the key is on its way through the seam - }); - } - const tracker = new TouchTracker(target, undefined, () => { - const r = throttle.getBoundingClientRect(); - return { top: r.top, height: r.height }; - }); - const within = (box: HTMLElement | null, el: EventTarget | null): boolean => - box !== null && (el === box || (el instanceof Node && box.contains(el))); - const placeOf = (el: EventTarget | null): TouchPlace => - within(fire, el) ? 'fire' - : within(throttle, el) ? 'throttle' - : within(commands, el) || within(menu, el) ? 'command' - : 'view'; - view.addEventListener('pointerdown', (e) => { - const place = placeOf(e.target); - if (place === 'command') return; // let the click through to the seam - e.preventDefault(); - // The first touch on the view asks for the whole screen (docs/TODO/204 - // M5). A phone's browser bars take a fifth of it otherwise. The browser - // may refuse, and a refusal costs nothing. - if (!doc.fullscreenElement && doc.documentElement.requestFullscreen) { - doc.documentElement.requestFullscreen().catch(() => { /* refused */ }); - } - 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/cockpit-view.ts b/src/game/cockpit-view.ts index 3ccc9c92..d73c896a 100644 --- a/src/game/cockpit-view.ts +++ b/src/game/cockpit-view.ts @@ -31,7 +31,7 @@ import { Hud } from '../hud/hud.ts'; import { flightPrompts, type Prompt } from './prompts.ts'; import { hitCone } from './gunnery.ts'; import { viewDirection } from './views.ts'; -import { keyCodeIfBound, keyIfBound } from '../ui/key-help.ts'; +import { keyIfBound } from '../ui/key-help.ts'; import type { ControlMode } from './controls.ts'; import type { ExerciseStrip } from './combat-sim-strip.ts'; import type { Ordnance } from './ordnance.ts'; @@ -185,29 +185,6 @@ export class CockpitView { * way. */ keyPrompts(): string[] { - return this.offers().flatMap((p) => { - const line = this.renderPrompt(p); - return line ? [line] : []; - }); - } - - /** - * The same offers as buttons: the code each presses beside the line - * (docs/TODO/204 M3). A phone taps the prompt, and the tap injects the code - * a keyboard would send. Aligned with `keyPrompts`, one for one. - */ - keyPromptButtons(): { code: string; shift: boolean; text: string }[] { - const mode = this.host.controlMode(); - if (!mode) return []; - return this.offers().flatMap((p) => { - const line = this.renderPrompt(p); - const key = keyCodeIfBound(mode, p.command); - return line && key ? [{ ...key, text: line }] : []; - }); - } - - /** The commands worth an offer right now, only in flight. */ - private offers(): Prompt[] { const mode = this.host.controlMode(); if (!this.host.inFlight() || !mode) return []; return flightPrompts({ @@ -225,6 +202,9 @@ export class CockpitView { stationDistance: this.state.player.position .distanceTo(this.state.world.station.position), dcEngaged: this.state.session.dcEngaged, + }).flatMap((p) => { + const line = this.renderPrompt(p); + return line ? [line] : []; }); } @@ -277,9 +257,6 @@ export class CockpitView { messageText: this.state.session.messageText, messageTimer: this.state.session.messageTimer, prompts: this.keyPrompts(), - promptButtons: this.keyPromptButtons(), - torus: this.state.session.torusEngaged, - docking: this.state.session.dcEngaged, // Null in career flight. It is gated on the same `active` that gives the // exercise the keyboard (controlMode). The strip is the exercise's own // view of itself, not a second opinion about one. diff --git a/src/game/flight-instruments.ts b/src/game/flight-instruments.ts index b91909f6..a379d8b3 100644 --- a/src/game/flight-instruments.ts +++ b/src/game/flight-instruments.ts @@ -130,7 +130,6 @@ 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/game/game.ts b/src/game/game.ts index be9794ae..6178b674 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -114,7 +114,7 @@ import { characterVerdict } from './character.ts'; import { CHARACTER_LINE_SECONDS } from '../constants/character.ts'; import { hideScreen } from '../ui/screens.ts'; import { renderNewGameConfirm } from '../ui/screens-career.ts'; -import { boundKey, keyPointer, paintCommandGuide, paintTouchCommands } from '../ui/key-help.ts'; +import { boundKey, keyPointer, paintCommandGuide } from '../ui/key-help.ts'; import { freshState, type GameState } from './state.ts'; type Mode = 'docked' | 'flight' | 'market' | 'chart' | 'local' | 'equip' | 'status' | 'data' | 'contracts' | 'saves' | 'save-name' | 'naming' | 'briefing' | 'dead'; @@ -705,10 +705,6 @@ export class Game { // same in both layouts, so the binding table paints them once. refreshHelpPanel(); paintCommandGuide(); - // The thumb's buttons and the flight menu, from the same table - // (docs/TODO/204). - paintTouchCommands(); - // ...and the key this line names comes from that same table rather than // from the sentence (docs/TODO/128 M3). The guide is a global binding. The // layout toggle is a row on the station menu, so the line points there diff --git a/src/hud/hud-binding.ts b/src/hud/hud-binding.ts index 8e9841bb..e8ee1132 100644 --- a/src/hud/hud-binding.ts +++ b/src/hud/hud-binding.ts @@ -69,11 +69,6 @@ export interface HudSources { * `ui/`, so a rule module cannot reach it. The dashboard is handed the line. */ readonly prompts: readonly string[]; - /** the same offers with the code each button presses (docs/TODO/204 M3) */ - readonly promptButtons: readonly { code: string; shift: boolean; text: string }[]; - /** the torus drive and the docking computer, for the touch row's lit buttons */ - readonly torus: boolean; - readonly docking: boolean; /** * The training exercise in progress, or null in career flight. * @@ -182,9 +177,6 @@ export function buildHudFrame(s: HudSources, scratch: HudScratch): HudFrame { messageText: s.messageText, messageTimer: s.messageTimer, prompts: s.prompts, - promptButtons: s.promptButtons, - torus: s.torus, - docking: s.docking, playerPos: s.playerPos, playerQuat: s.playerQuat, contacts: scannerContacts( diff --git a/src/hud/hud.ts b/src/hud/hud.ts index 4b527ad3..7af274ba 100644 --- a/src/hud/hud.ts +++ b/src/hud/hud.ts @@ -77,11 +77,6 @@ export interface HudState { * `boundKey`. Neither is a question a painter may answer. */ prompts: readonly string[]; - /** the same offers with the code each presses; a coarse pointer taps them (docs/TODO/204 M3) */ - promptButtons: readonly { code: string; shift: boolean; text: string }[]; - /** the torus drive and the docking computer are engaged: the touch row lights them */ - torus: boolean; - docking: boolean; speedFrac: number; rollFrac: number; // -1..1 pitchFrac: number; // -1..1 @@ -237,7 +232,6 @@ export class Hud { private readonly dayEl = byId('day-display'); private readonly messageEl = byId('message'); private readonly promptsEl = byId('prompts'); - private readonly touchRowEl = byId('touch-commands'); /** what the prompt line currently says, so a steady list is not repainted */ private promptsShown = ''; private readonly flashEl = byId('damage-flash'); @@ -271,8 +265,7 @@ export class Hud { render(_dt: number, frame: HudFrame): void { this.messageEl.textContent = frame.messageTimer > 0 ? frame.messageText : ''; - this.paintPrompts(frame.promptButtons); - this.paintTouchRow(frame); + this.paintPrompts(frame.prompts); this.speedEl.style.width = `${frame.speedFrac * 100}%`; this.rollEl.style.left = `${50 + clampUnit(frame.rollFrac) * 45}%`; this.pitchEl.style.left = `${50 + clampUnit(frame.pitchFrac) * 45}%`; @@ -323,47 +316,20 @@ export class Hud { * the only reason this is markup rather than `textContent`. The strings * themselves are built upstream, and never here. */ - private paintPrompts(prompts: readonly { code: string; shift: boolean; text: string }[]): void { - const line = prompts.map((p) => p.text).join('\u2003'); // em space: a gap, not a bullet + private paintPrompts(prompts: readonly string[]): void { + const line = prompts.join(' '); // em space: a gap, not a bullet if (line === this.promptsShown) return; this.promptsShown = line; - // Each prompt is a button too: `data-key` is the code a tap injects, - // through the same click seam a menu row uses (docs/TODO/204 M3). It is - // rebuilt only when the words change, so a thumb never lands on a - // button that was just repainted. this.promptsEl.innerHTML = prompts .map((p) => { - const gap = p.text.indexOf(' '); - const key = gap < 0 ? p.text : p.text.slice(0, gap); - const what = gap < 0 ? '' : p.text.slice(gap); - return `` - + `${key}${what}`; + const gap = p.indexOf(' '); + const key = gap < 0 ? p : p.slice(0, gap); + const what = gap < 0 ? '' : p.slice(gap); + return `${key}${what}`; }) - .join('\u2003'); + .join(' '); } - /** - * The touch command row's state (docs/TODO/204 M3). MISSILE presses the - * launch key once a missile is armed. A button for a state that can be on - * is lit while it is. The row's markup is ui/key-help.ts's, painted at boot. - */ - private paintTouchRow(frame: HudState): void { - const row = this.touchRowEl; - for (const el of row.querySelectorAll('[data-command]')) { - const command = el.dataset.command; - if (command === 'armMissile') { - const armed = frame.armed || frame.locked; - el.dataset.key = armed ? (el.dataset.launch ?? el.dataset.key ?? '') : (el.dataset.arm ?? el.dataset.key ?? ''); - el.classList.toggle('lit', armed); - } else if (command === 'toggleTorus') { - el.classList.toggle('lit', frame.torus); - } else if (command === 'toggleDockingComputer') { - el.classList.toggle('lit', frame.docking); - } - } - } - - /** * The energy gauge: one bank per segment, and red once you are into the last. * diff --git a/src/style.css b/src/style.css index e3435b8f..7f171f12 100644 --- a/src/style.css +++ b/src/style.css @@ -976,125 +976,3 @@ 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; - inset: 0; /* the whole screen steers, the console included (Chris, 2026-09-11) */ - 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); -} - -/* - * The touch command row, along the top edge of the console (docs/TODO/204 - * M3). Five buttons, each with the flight key it presses, painted at boot - * from the binding table. A lit one says its state is on. The FIRE button and - * the throttle sit above the row. - */ -#touch-commands { - position: absolute; - left: 0; right: 0; bottom: var(--console-h, 0px); - height: 48px; - display: flex; - gap: 6px; - padding: 0 6px; - align-items: stretch; - background: rgba(0, 14, 2, 0.55); - border-top: 1px solid var(--hud-dim); -} -.touch-command { - flex: 1 1 0; - display: flex; align-items: center; justify-content: center; - border: 1px solid var(--hud-dim); - margin: 4px 0; - color: var(--hud-green); - font-size: 11px; - letter-spacing: 1px; -} -.touch-command:active { background: rgba(var(--hud-green-rgb), 0.2); } -.touch-command.lit { border-color: var(--hud-amber); color: var(--hud-amber); } -#touch-fire { bottom: calc(var(--console-h, 0px) + 62px); } -#touch-throttle { bottom: calc(var(--console-h, 0px) + 62px); } -/* The prompts are buttons on a coarse pointer, above the overlay and the row. */ -@media (pointer: coarse) { - #prompts { pointer-events: auto; white-space: normal; } - #prompts .prompt { - display: inline-block; - padding: 8px 12px; - margin: 3px; - border: 1px solid var(--hud-amber); - background: rgba(0, 14, 2, 0.72); - } -} -@media (max-width: 700px) { - #prompts { bottom: calc(var(--console-h, 0px) + 54px); } - #message { bottom: calc(var(--console-h, 0px) + 110px); } -} - -/* - * The flight menu (docs/TODO/204 M4): a list over the view that MENU opens. - * Its rows carry flight keys, painted at boot from the binding table. The - * escape pod's confirmation is a block inside it that opens on its row. - */ -#touch-menu { - position: absolute; - left: 8px; right: 8px; top: 8%; bottom: calc(var(--console-h, 0px) + 56px); - overflow-y: auto; - background: rgba(0, 10, 2, 0.92); - border: 1px solid var(--hud-dim); - padding: 6px; -} -#touch-menu.hidden { display: none; } -.touch-row { - padding: 12px 10px; - margin: 4px 0; - border-left: 2px solid transparent; - color: var(--hud-green); - font-size: 14px; - letter-spacing: 1px; -} -.touch-row:active { background: rgba(var(--hud-green-rgb), 0.2); border-left-color: var(--hud-green); } -.touch-ask { display: none; margin: 4px 0 8px 10px; border-left: 3px solid var(--hud-amber); padding-left: 8px; } -.touch-ask.open { display: block; } -.touch-ask p { color: var(--hud-amber); font-size: 12px; margin: 6px 0; } diff --git a/src/ui/briefing.ts b/src/ui/briefing.ts index 9523b40f..1de9cd7c 100644 --- a/src/ui/briefing.ts +++ b/src/ui/briefing.ts @@ -71,10 +71,7 @@ export const BRIEFING: { title: string; body: string }[] = [ Tap a row on this menu, or move to it with ↑ ↓ and press ENTER. ${KEY.help} shows every control, here and in flight. ${ROW.briefing} on the menu reopens this briefing - whenever you want it back.

- 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 `
${label}
`; - }).join(''); -} - -/** - * The flight menu a MENU button opens on a phone (docs/TODO/204 M4): the - * screens, the views, pause and the escape pod, as rows. Each row carries - * the flight key it presses, so a tap takes the path a menu row takes. The - * escape pod asks first: its row opens a confirmation, and only YES carries - * the key, because the pod costs the ship and the cargo. - */ -export const TOUCH_MENU: readonly { command: Command; label: string }[] = [ - { command: 'openLocalChart', label: 'LOCAL CHART' }, - { command: 'openChart', label: 'GALACTIC CHART' }, - { command: 'openStatus', label: 'COMMANDER STATUS' }, - { command: 'openMissions', label: 'MISSIONS' }, - { command: 'openLog', label: "COMMANDER'S LOG" }, - { command: 'openContracts', label: 'CONTRACTS' }, - { command: 'view0', label: 'FRONT VIEW' }, - { command: 'view1', label: 'REAR VIEW' }, - { command: 'view2', label: 'LEFT VIEW' }, - { command: 'view3', label: 'RIGHT VIEW' }, - { command: 'togglePause', label: 'PAUSE' }, -]; - -export function touchMenuHtml(): string { - const rows = TOUCH_MENU.map(({ command, label }) => { - const key = keyCodeIfBound('flight', command); - return key - ? `
${label}
` - : ''; - }).join(''); - const pod = keyCodeIfBound('flight', 'launchEscapePod'); - const podRows = pod ? ` -
ESCAPE POD
-
-

LAUNCH THE ESCAPE POD? YOU LOSE THE SHIP AND EVERYTHING IN THE HOLD.

-
YES, LAUNCH THE POD
-
NO, STAY WITH THE SHIP
-
` : ''; - return `${rows}${podRows}
CLOSE
`; -} - -/** Paint the touch command row and the flight menu at boot, as the guide is painted. Inert with no host. */ -export function paintTouchCommands(): void { - elementById('touch-commands').innerHTML = touchCommandsHtml() - + '
MENU
'; - elementById('touch-menu').innerHTML = touchMenuHtml(); -} - /** What to print for a `KeyboardEvent.code`, with the modifier the table wants. */ export function keyLabel(code: string, shift = false): string { if (isVirtualKey(code)) return ''; diff --git a/test/console-plate.test.ts b/test/console-plate.test.ts index 5ce7efe6..4aad8ea0 100644 --- a/test/console-plate.test.ts +++ b/test/console-plate.test.ts @@ -80,7 +80,7 @@ console.log('\nthe console plate goes with its words'); viewDir: V(0, 0, -1), missiles: [], canisters: [], targetLock: null, inFlight: false, exercise: null, - prompts: [], promptButtons: [], + prompts: [], messageText, messageTimer, } as unknown as Parameters[0], { diff --git a/test/constants.test.ts b/test/constants.test.ts index 7c0e3032..91f35c36 100644 --- a/test/constants.test.ts +++ b/test/constants.test.ts @@ -437,8 +437,7 @@ const OUTSIDE: readonly Group[] = [ 'ui/briefing.ts': ['KEY', 'BRIEFING', 'BRIEFING_PAGES', 'ROW'], 'ui/screens-career.ts': ['LEVERS_OFF'], // the one sentence the guide and the manual say about the station menu (docs/TODO/202) - // ...and the two lists of buttons a thumb presses in flight (docs/TODO/204) - 'ui/key-help.ts': ['LABELS', 'ALL_BINDINGS', 'STATION_MENU_NOTE', 'TOUCH_COMMANDS', 'TOUCH_MENU'], + 'ui/key-help.ts': ['LABELS', 'ALL_BINDINGS', 'STATION_MENU_NOTE'], 'game/command-help.ts': ['COMMAND_HELP'], 'game/bindings.ts': [ 'GLOBAL_BINDINGS', 'FLIGHT_BINDINGS', 'NOT_IN_THE_SIMULATOR', 'BINDINGS', diff --git a/test/flight.test.ts b/test/flight.test.ts index 3efe19d1..c4bebaed 100644 --- a/test/flight.test.ts +++ b/test/flight.test.ts @@ -54,7 +54,6 @@ 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. */ diff --git a/test/input.test.ts b/test/input.test.ts index c26cf972..72adafa6 100644 --- a/test/input.test.ts +++ b/test/input.test.ts @@ -151,7 +151,7 @@ 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) ------------ +// --- a button holds a key, and is never a tap (docs/TODO/204 M1, kept by 205 M1) { const i = new Input(); i.press('KeyA'); diff --git a/test/run.ts b/test/run.ts index 4abc5c12..fa68c888 100644 --- a/test/run.ts +++ b/test/run.ts @@ -169,8 +169,6 @@ import './screen-buttons.test.ts'; import './key-prose.test.ts'; import './site-footer.test.ts'; import './input.test.ts'; -import './touch.test.ts'; -import './touch-controls.test.ts'; import './hud-binding.test.ts'; import './console-plate.test.ts'; import './elapsed-day.test.ts'; diff --git a/test/touch-controls.test.ts b/test/touch-controls.test.ts deleted file mode 100644 index 08fcc122..00000000 --- a/test/touch-controls.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -// The thumb's buttons in flight: the command row and the flight menu -// (docs/TODO/204 M3 and M4). -// -// Both are painted from the binding table, so a button cannot press a key -// the table does not have, and a rebound key moves its button with it. -// These checks lived in test/key-help.test.ts until that file crossed the -// size ceiling, and they are one subject. - -import { - TOUCH_COMMANDS, TOUCH_MENU, keyCodeIfBound, touchCommandsHtml, touchMenuHtml, -} from '../src/ui/key-help.ts'; -import { check, eq } from './harness.ts'; - -console.log('\nthe touch command row presses the flight keys it names (docs/TODO/204 M3)'); -{ - const row = touchCommandsHtml(); - const missing = TOUCH_COMMANDS.filter(({ command }) => { - const key = keyCodeIfBound('flight', command); - return !key || !row.includes(`data-command="${command}" data-key="${key.code}"`); - }); - check('every button carries the flight key of its command', missing.length === 0, - missing.map((m) => m.command).join(', ')); - const launch = keyCodeIfBound('flight', 'launchMissile'); - check('...and MISSILE carries the launch key too, for the HUD to swap in once armed', - launch !== null && row.includes(`data-launch="${launch.code}"`)); - eq('five buttons', (row.match(/class="touch-command"/g) ?? []).length, 5); - check('a station row has no code for a button, because it is a row already', - keyCodeIfBound('docked', 'openMarket') === null); - const shifted = keyCodeIfBound('flight', 'openLog'); - check('a shifted key carries its modifier', shifted !== null && shifted.shift && shifted.code === 'KeyR'); -} - -console.log('\nthe flight menu\'s rows press the flight keys they name (docs/TODO/204 M4)'); -{ - const menu = touchMenuHtml(); - const missing = TOUCH_MENU.filter(({ command }) => { - const key = keyCodeIfBound('flight', command); - return !key || !menu.includes(`data-command="${command}" data-key="${key.code}"`); - }); - check('every row carries the flight key of its command', missing.length === 0, - missing.map((m) => m.command).join(', ')); - check('a shifted key travels with its row', /data-command="openLog" data-key="KeyR" data-shift="1"/.test(menu)); - const pod = keyCodeIfBound('flight', 'launchEscapePod'); - check('the escape pod row asks first, and only YES carries the key', - pod !== null && menu.includes('data-ask="pod">ESCAPE POD') && menu.includes(`data-command="launchEscapePod" data-key="${pod.code}"`) - && !/data-ask="pod"[^>]*data-key/.test(menu)); - check('...and the menu closes on CLOSE', menu.includes('data-ask="">CLOSE')); -} - diff --git a/test/touch.test.ts b/test/touch.test.ts deleted file mode 100644 index ef4f2e45..00000000 --- a/test/touch.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -// 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 { flightDemand, throttleToward, type FlightControls } from '../src/engine/flight-controls.ts'; -import { keymap } from '../src/engine/keymap.ts'; -import { PLAYER_FLIGHT } from '../src/constants/player-flight.ts'; -import { check, eq } from './harness.ts'; - -/** A target that records what the tracker writes, and the keys it holds. */ -function target(): TouchTarget & { held: Set } { - const held = new Set(); - return { - mouseFlight: false, mouseX: 0, mouseY: 0, stickHeld: false, wantedSpeed: null, held, - press: (code) => { held.add(code); }, - release: (code) => { held.delete(code); }, - }; -} -const FIRE = 'KeyA'; -const tracker = (t: TouchTarget) => new TouchTracker(t, () => FIRE, () => ({ top: 100, height: 200 })); - -console.log('\nthe stick is the finger\'s offset from where it landed'); -{ - eq('half the travel is half deflection', stickFromDrag(100, 100, 100 + TOUCH_STICK_TRAVEL / 2, 100).x, 0.5); - eq('...and it clamps at one', stickFromDrag(100, 100, 100 + TOUCH_STICK_TRAVEL * 3, 100).x, 1); - eq('the raw drag reads down as positive; the tracker turns it over for the nose', stickFromDrag(0, 0, 0, TOUCH_STICK_TRAVEL).y, 1); - eq('the slider is one at its top', sliderFraction(100, 100, 200), 1); - eq('...zero at its bottom', sliderFraction(300, 100, 200), 0); - eq('...and clamps past either end', sliderFraction(500, 100, 200), 0); -} - -console.log('\na finger on the view steers, holds, and lets the stick decay when it lifts'); -{ - const t = target(); - const k = tracker(t); - k.down(1, 'view', 200, 300); - check('a landed finger takes the stick and holds it', t.mouseFlight && t.stickHeld && t.mouseX === 0 && t.mouseY === 0); - k.move(1, 200 + TOUCH_STICK_TRAVEL / 2, 300 - TOUCH_STICK_TRAVEL / 4); - eq('a drag right is half a stick right', t.mouseX, 0.5); - eq('...and a drag up raises the nose a quarter, because a finger points where it wants to go', t.mouseY, 0.25); - k.down(2, 'view', 50, 50); - k.move(2, 150, 50); - eq('a second finger on the view does not take the stick', t.mouseX, 0.5); - k.up(1); - check('the lifted finger leaves the stick to decay', !t.stickHeld && t.mouseX === 0.5); -} - -console.log('\nFIRE holds the trigger while another finger steers'); -{ - const t = target(); - const k = tracker(t); - k.down(1, 'view', 200, 300); - k.down(2, 'fire', 350, 600); - check('the fire finger holds the layout\'s fire key', t.held.has(FIRE)); - k.move(1, 200 + TOUCH_STICK_TRAVEL / 2, 300); - eq('...while the first finger still steers', t.mouseX, 0.5); - k.up(2); - check('lifting the fire finger releases the key and leaves the stick', !t.held.has(FIRE) && t.stickHeld && t.mouseX === 0.5); -} - -console.log('\nthe throttle slider sets the wanted speed'); -{ - const t = target(); - const k = tracker(t); - k.down(3, 'throttle', 30, 150); - eq('a touch a quarter of the way down asks for three quarters of top speed', t.wantedSpeed, 0.75); - k.move(3, 30, 300); - eq('...and a slide to the bottom asks for a stop', t.wantedSpeed, 0); - k.up(3); - eq('the wanted speed holds after the finger lifts', t.wantedSpeed, 0); -} - -console.log('\nthe input holds the stick for a finger, and decays it for a mouse'); -{ - const i = new Input(); - i.mouseFlight = true; - i.mouseX = 1; - i.stickHeld = true; - i.decayMouse(1); - eq('a held stick does not decay', i.mouseX, 1); - i.stickHeld = false; - i.decayMouse(0.5); - check('...and a released one does', i.mouseX < 1); -} - -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); -} From a94932acc23a5bfdb5c5b97c0da8dc4944980710 Mon Sep 17 00:00:00 2001 From: Chris Greening Date: Fri, 11 Sep 2026 21:35:55 +0100 Subject: [PATCH 012/100] docs/TODO/205 M2: the course list says what the ship can do next A course is one thing the ship does next with no hand on the stick. The new game/courses.ts decides which courses the situation allows, in order. It follows prompts.ts: a flat view in, a ranked list out, and no key. The jump's refusal words stay in hyperspace.ts. A row whose object exists shows even when the ship cannot fly it, and it says what the ship needs. A row whose object is absent never shows. The launch list cannot show the hermit. The sky is cleared while the ship is docked, and the launch draws the hermit. So the launch offers the jump, the rocks and the star, and the hermit shows in flight. The plan says so. 5,656 assertions, from 5,624. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cpi86XPSQzZQhVhRnZED58 --- docs/ARCHITECTURE.md | 5 + ...the-ship-goes-and-the-ship-flies-itself.md | 21 ++- src/game/courses.ts | 154 ++++++++++++++++++ test/courses.test.ts | 118 ++++++++++++++ test/run.ts | 1 + 5 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 src/game/courses.ts create mode 100644 test/courses.test.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4f103fa6..f066220b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -33,6 +33,11 @@ rules. This file is a map. It returns a `Command` and words. It never returns a letter. `cockpit-view.ts` looks the label up, through `ui/key-help.ts`. That is how invariant 9 reaches prose. A prompt is derived state, and the code saves nothing about it. +- `src/game/courses.ts` decides what the ship can do next with no hand on the + stick (docs/TODO/205). A course is one such thing, such as a trip to the + station or a skim of the star. It follows the shape of `prompts.ts`: a flat + view in, a ranked list out, and no key. The list is derived state. The code + saves the course that the pilot picks, and never the list. - The console is one line, so `SessionState.queued` is the line that waits for it (`session.ts`). Some consequences make sense only after their cause: what a scan cost your legal record, or what a deed cost your reputation. The console diff --git a/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md b/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md index 101b56b3..d46aef2d 100644 --- a/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md +++ b/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md @@ -85,7 +85,7 @@ The course list for each situation: | situation | courses, in order | | --- | --- | -| a launch | jump to the chosen system; mine the asteroids; visit the hermit; skim the star | +| a launch | jump to the chosen system; mine the asteroids; skim the star. M2 found that the hermit cannot show here | | an arrival | the mission objective; fly to the station; investigate the derelict; mine the asteroids; visit the hermit; skim the star; jump on | | a course ends | the arrival list, less the work that is done | @@ -144,8 +144,9 @@ starts the countdown at the launch. A local course launches the ship on that course. When no course is available, the launch refuses, and the console says why. An example is *"SET A HYPERSPACE TARGET ON THE GALACTIC CHART"*. -A commander with no fuel and no money is not stuck. The local courses offer -work: the rocks, the hermit and the star. +A commander with no fuel and no money is not stuck. The launch courses offer +work: the rocks and the star. Once the ship is in flight, the hermit shows too, +where the system has one. ### M5 — the course list on screen, for every player @@ -250,3 +251,17 @@ Evidence: the touch code. - **The suite has 5,624 assertions.** That is 5,619 from before 204, plus the five checks of the kept pair. + +### M2 + +- **The launch list cannot show the hermit.** The station clears the sky + while the ship is docked, and the launch builds it again. The hermit is a + draw at the launch, on 30% of worlds. So nothing can know about it on the + pad. The rocks are certain, because every system holds `ASTEROIDS_MIN` or + more. The hermit shows on the list in flight. +- **A row whose object exists shows, even when the ship cannot fly it.** It + then says what the ship needs, for example `NEEDS FUEL SCOOPS`. A row whose + object does not exist never shows. The plan did not state this rule. +- **In flight, a chart with no target shows no jump row.** At a launch, the + jump row always shows, and its reason answers why the ship cannot leave. +- **The suite has 5,656 assertions,** from 5,624. diff --git a/src/game/courses.ts b/src/game/courses.ts new file mode 100644 index 00000000..b530997a --- /dev/null +++ b/src/game/courses.ts @@ -0,0 +1,154 @@ +// What the ship can do next, with no hand on the stick (docs/TODO/205 M2). +// +// A COURSE is one thing that the ship does next by itself. "Fly to the +// station" is a course, and so is "skim the star". The pilot picks one from +// a short list, and a computer flies it. This file decides what is on that +// list. `course-pilot.ts` flies a course, and that is a different subject. +// +// THE WORD IS "COURSE", AND NOT "ORDER". `orders.ts` and invariant 16 already +// use "order" for a signed contract or a live mission. +// +// The list is derived state, as a prompt is (`prompts.ts`). The code saves the +// course that the pilot picks. It never saves the list. Two rules hold it: +// +// - **A course carries a kind and words, and never a key.** The key that +// opens the list lives in the binding table (invariant 9). +// - **It is pure.** It reads a flat view of the situation, so a test can +// raise a situation with no world behind it. +// +// A ROW THAT THE SHIP CANNOT FLY STILL SHOWS WHEN ITS OBJECT EXISTS. The rocks +// are there and the ship has no mining laser: the row shows, and it says what +// the ship needs. A row whose object does not exist never shows. A hermit that +// is not in the sky is not a course. +// +// THE LAUNCH LIST IS SHORTER THAN THE PLAN'S TABLE. The station clears the sky +// while the ship is docked, and the launch builds it again. The rocks are +// certain, because every system holds `ASTEROIDS_MIN` or more. The hermit is a +// draw at the launch, so nothing can know about it before. So the launch offers +// the jump, the rocks and the star. The hermit shows on the list in flight. + +import type { CommanderData } from './commander.ts'; +import type { NpcRole } from './ship-roles.ts'; +import { refusalMessage, type Refusal } from './hyperspace.ts'; +import { MAX_FUEL } from '../constants/commander.ts'; +import { ASTEROIDS_MIN } from '../constants/population.ts'; + +/** What the ship can do by itself. */ +export type CourseKind = + 'jump' | 'mission' | 'station' | 'derelict' | 'mine' | 'hermit' | 'skim'; + +/** + * When the list is asked for. At a launch the ship is still on the pad, and + * the sky is not built. In flight the sky is there to read. + */ +export type CourseSituation = 'launch' | 'flight'; + +/** One row of the list. */ +export interface Course { + readonly kind: CourseKind; + /** the row's words, upper case, as a cockpit line is */ + readonly what: string; + /** null when the ship can fly it; otherwise what stops it, in words */ + readonly why: string | null; +} + +/** + * Everything the list is raised from. + * + * A flat view rather than `GameState`, for the reason `PromptWorld` gives. The + * caller asks `checkJump` for the jump, so the refusal keeps its one home. + */ +export interface CourseWorld { + readonly situation: CourseSituation; + readonly commander: CommanderData; + /** `checkJump`'s answer for the chart's target */ + readonly jump: { ok: true; cost: number } | { ok: false; reason: Refusal }; + /** the target system's name, or null when the chart has none */ + readonly targetName: string | null; + readonly witchspace: boolean; + /** the role of every ship and rock in the sky. Empty at a launch */ + readonly sky: readonly NpcRole[]; + /** the objective's words, when a live leg has its target in this system */ + readonly mission: string | null; + /** the courses this visit already finished, so they leave the list */ + readonly done: ReadonlySet; +} + +/** + * The courses that the situation allows, in the order that ranks them. + * + * In flight, the mission leads, because the missions are the spine of a + * career now (docs/TODO/208). The station follows, because it is where most + * trips end. The jump comes last in flight, and first at a launch. + */ +export function courseList(w: CourseWorld): Course[] { + if (w.situation === 'launch') { + return [jumpRow(w, true), mineRow(w, true), skimRow(w)] + .filter((c): c is Course => c !== null); + } + return [ + w.mission !== null && !w.done.has('mission') + ? { kind: 'mission', what: w.mission, why: null } as const : null, + w.witchspace ? null : { kind: 'station', what: 'FLY TO THE STATION', why: null } as const, + present(w, 'generation', 'derelict') + ? { kind: 'derelict', what: 'INVESTIGATE THE DERELICT', why: null } as const : null, + mineRow(w, false), + present(w, 'hermit', 'hermit') + ? { kind: 'hermit', what: 'VISIT THE HERMIT', why: null } as const : null, + skimRow(w), + jumpRow(w, false), + ].filter((c): c is Course => c !== null); +} + +/** Is the thing in the sky, and is its course not done yet? */ +function present(w: CourseWorld, role: NpcRole, kind: CourseKind): boolean { + return !w.done.has(kind) && w.sky.includes(role); +} + +/** + * The jump. At a launch it always shows, because it is why a ship leaves. A + * row that says why the ship cannot go answers "why can I not launch?". In + * flight it shows only for a chart that has a target. A jump under way is not + * a course to pick, so it never shows. + */ +function jumpRow(w: CourseWorld, launch: boolean): Course | null { + if (!w.jump.ok && w.jump.reason === 'alreadyJumping') return 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()}`, + why: w.jump.ok ? null : refusalMessage(w.jump.reason, w.witchspace), + }; +} + +/** + * The rocks. The ore needs a mining laser to cut it and fuel scoops to take it + * aboard. Without the scoops the ore breaks on the hull (`world-step.ts`), so + * a course without them would only waste the rocks. + */ +function mineRow(w: CourseWorld, launch: boolean): Course | null { + const rocks = launch ? ASTEROIDS_MIN > 0 : w.sky.includes('asteroid'); + if (!rocks || w.done.has('mine')) return null; + const needs = [ + w.commander.equipment.miningLaser ? null : 'A MINING LASER', + w.commander.equipment.scoops ? null : 'FUEL SCOOPS', + ].filter((n): n is string => n !== null); + return { + kind: 'mine', + what: 'MINE THE ASTEROIDS', + why: needs.length ? `NEEDS ${needs.join(' AND ')}` : null, + }; +} + +/** + * The star. The scoops take fuel from it, so a full tank has nothing to gain. + * In witch-space the star is out of reach, as the station is. + */ +function skimRow(w: CourseWorld): Course | null { + if (w.witchspace || w.commander.fuel >= MAX_FUEL || w.done.has('skim')) return null; + return { + kind: 'skim', + what: 'SKIM THE STAR FOR FUEL', + why: w.commander.equipment.scoops ? null : 'NEEDS FUEL SCOOPS', + }; +} diff --git a/test/courses.test.ts b/test/courses.test.ts new file mode 100644 index 00000000..efb24d2a --- /dev/null +++ b/test/courses.test.ts @@ -0,0 +1,118 @@ +// What the ship can do next, with no hand on the stick (docs/TODO/205 M2). +// +// The rule is pure, so no world stands behind it. Each row of the plan's two +// tables is raised here on both sides of its condition. A row that the ship +// cannot fly shows with its reason. A row whose object is not there never +// shows. + +import { newCommander, type CommanderData } from '../src/game/commander.ts'; +import { courseList, type CourseKind, type CourseWorld } from '../src/game/courses.ts'; +import { MAX_FUEL } from '../src/constants/commander.ts'; +import { check, eq } from './harness.ts'; + +console.log('\nthe course list'); + +const fitted = (): CommanderData => { + const c = newCommander(); + c.equipment.miningLaser = true; + c.equipment.scoops = true; + c.fuel = MAX_FUEL - 10; + return c; +}; + +const world = (over: Partial = {}): CourseWorld => ({ + situation: 'flight', + commander: fitted(), + jump: { ok: true, cost: 30 }, + targetName: 'Lave', + witchspace: false, + sky: ['asteroid', 'hermit', 'generation', 'trader'], + mission: null, + done: new Set(), + ...over, +}); + +/** A list or a row compared by its content, which `eq` does not do. */ +const same = (name: string, actual: unknown, expected: unknown): void => + eq(name, JSON.stringify(actual), JSON.stringify(expected)); + +const kinds = (w: CourseWorld): CourseKind[] => courseList(w).map((c) => c.kind); +const row = (w: CourseWorld, kind: CourseKind) => courseList(w).find((c) => c.kind === kind); + +// --- the order --------------------------------------------------------------- +same('in flight, the list runs mission, station, derelict, rocks, hermit, star, jump', + kinds(world({ mission: 'HUNT THE KRAIT' })), + ['mission', 'station', 'derelict', 'mine', 'hermit', 'skim', 'jump']); +same('at a launch, the list runs jump, rocks, star', + kinds(world({ situation: 'launch', sky: [] })), ['jump', 'mine', 'skim']); + +// --- the jump ---------------------------------------------------------------- +same('a jump with fuel names the target and is free to fly', + row(world(), 'jump'), { kind: 'jump', what: 'JUMP TO LAVE', why: null }); +eq('at a launch, a chart with no target still shows the jump, with the reason', + row(world({ situation: 'launch', sky: [], jump: { ok: false, reason: 'noTarget' }, targetName: null }), 'jump')?.why, + 'NO HYPERSPACE TARGET SET'); +check('...and in flight, a chart with no target shows no jump', + !kinds(world({ jump: { ok: false, reason: 'noTarget' }, targetName: null })).includes('jump')); +eq('a jump the tank cannot cover shows, and says so', + row(world({ jump: { ok: false, reason: 'noFuel' } }), 'jump')?.why, 'TARGET OUT OF FUEL RANGE'); +check('a jump under way is not a course to pick', + !kinds(world({ situation: 'launch', sky: [], jump: { ok: false, reason: 'alreadyJumping' } })).includes('jump')); + +// --- the mission ------------------------------------------------------------- +same('a live leg with its target here leads the list, in its own words', + courseList(world({ mission: 'SCAN THE COBRA' }))[0], { kind: 'mission', what: 'SCAN THE COBRA', why: null }); +check('...no leg here, no row', !kinds(world()).includes('mission')); +check('...and a finished one leaves', !kinds(world({ mission: 'X', done: new Set(['mission']) })).includes('mission')); +check('...and no mission row at a launch', !kinds(world({ situation: 'launch', sky: [], mission: 'X' })).includes('mission')); + +// --- the station ------------------------------------------------------------- +check('in flight, the station is a course', kinds(world()).includes('station')); +check('...but not in witch-space, where the station is out of reach', + !kinds(world({ witchspace: true })).includes('station')); +check('...and not at a launch, which leaves it', !kinds(world({ situation: 'launch', sky: [] })).includes('station')); + +// --- the derelict and the hermit --------------------------------------------- +check('a generation ship in the sky is a course', kinds(world()).includes('derelict')); +check('...no generation ship, no row', !kinds(world({ sky: ['asteroid', 'hermit'] })).includes('derelict')); +check('...and one investigated leaves', !kinds(world({ done: new Set(['derelict']) })).includes('derelict')); +check('a hermit in the sky is a course', kinds(world()).includes('hermit')); +check('...no hermit, no row', !kinds(world({ sky: ['asteroid'] })).includes('hermit')); +check('...one visited leaves', !kinds(world({ done: new Set(['hermit']) })).includes('hermit')); +check('...and the launch never guesses at one, because the launch draws it', + !kinds(world({ situation: 'launch', sky: ['hermit'] })).includes('hermit')); + +// --- the rocks --------------------------------------------------------------- +eq('rocks with a mining laser and scoops are free to fly', row(world(), 'mine')?.why, null); +{ + const c = fitted(); c.equipment.miningLaser = false; + eq('...with no mining laser, the row says so', row(world({ commander: c }), 'mine')?.why, 'NEEDS A MINING LASER'); +} +{ + const c = fitted(); c.equipment.scoops = false; + eq('...with no scoops, the row says so', row(world({ commander: c }), 'mine')?.why, 'NEEDS FUEL SCOOPS'); +} +{ + const c = newCommander(); + eq('...with neither, it names both', row(world({ commander: c }), 'mine')?.why, + 'NEEDS A MINING LASER AND FUEL SCOOPS'); +} +check('no rocks in the sky, no row', !kinds(world({ sky: ['hermit'] })).includes('mine')); +check('...but a launch always has rocks, because every system holds some', + kinds(world({ situation: 'launch', sky: [] })).includes('mine')); + +// --- the star ---------------------------------------------------------------- +eq('a tank that is not full, with scoops, can skim', row(world(), 'skim')?.why, null); +{ + const c = fitted(); c.equipment.scoops = false; + eq('...with no scoops, the row says so', row(world({ commander: c }), 'skim')?.why, 'NEEDS FUEL SCOOPS'); +} +{ + const c = fitted(); c.fuel = MAX_FUEL; + check('...a full tank has nothing to gain', !kinds(world({ commander: c })).includes('skim')); +} +check('...and witch-space has no star in reach', !kinds(world({ witchspace: true })).includes('skim')); + +// --- witch-space ------------------------------------------------------------- +same('in witch-space, with nothing in the sky, the jump is the only course', + kinds(world({ witchspace: true, sky: ['thargoid'] })), ['jump']); diff --git a/test/run.ts b/test/run.ts index fa68c888..13b52e16 100644 --- a/test/run.ts +++ b/test/run.ts @@ -71,6 +71,7 @@ import './jettison.test.ts'; import './bribe.test.ts'; import './bribe-flight.test.ts'; import './prompts.test.ts'; +import './courses.test.ts'; import './world.test.ts'; import './docking.test.ts'; import './docking-computer.test.ts'; From 09ea4e7cc71ef76a496e02cfc022fbcafa810b73 Mon Sep 17 00:00:00 2001 From: Chris Greening Date: Fri, 11 Sep 2026 21:48:01 +0100 Subject: [PATCH 013/100] docs/TODO/205 and 206: Chris's answers of 2026-09-11 - The flight keys stay. A key takes control during a fight, and a new button takes a mouse click on a desktop. - The bought combat computer never launches a missile. A missile costs money, so the pilot targets and fires one by hand. - The rocks and the star are launch courses. Chris confirmed the reading. - MOBILE_CONVERSION.md stays out of the repository, so the plans quote it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cpi86XPSQzZQhVhRnZED58 --- ...the-ship-goes-and-the-ship-flies-itself.md | 17 +++++---- ...ter-flies-the-fight-and-the-pilot-fires.md | 37 ++++++++++++------- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md b/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md index d46aef2d..ff011ecd 100644 --- a/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md +++ b/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md @@ -11,7 +11,8 @@ words: *"The current approach does not work - it's not possible to fly the ship using mobile controls."* He saw a larger chance in that failure: *"This is chance to make the game much more than just a combat space fighting game. We can make it an immersive adventure game."* The brainstorm of that day is in the -conversation. Plans 205 to 208 are its result. +conversation. Plans 205 to 208 are its result. The note itself is not in the +repository, so the plans quote it. **Most of the parts exist already.** Each part below flies the player's ship through one `FlightDemand`. A pair of hands makes the same thing, and @@ -183,13 +184,15 @@ own style, and the house prose rules do not govern them. - **"Jump on" is a course when the tank holds the fuel** (Chris, 2026-09-11). "Skim the star" needs fuel scoops, and the ship flies to the star. - **The home system's work stays, as courses** (Chris, 2026-09-11: *"maybe we - keep these, they could be presented as options"*). The rocks, the hermit and - the star are launch courses too. That answers the commander with no fuel - money. + keep these, they could be presented as options"*). The rocks and the star are + launch courses too, and the hermit shows in flight (M2). That answers the + commander with no fuel money. Chris confirmed this reading the same day. - **Camera views wait.** They are GitHub #50. -- **The flight keys stay as an override on a keyboard.** This plan made that - call. The combat trainer, the flight probe and the dock probe fly by those - keys. The two computers already hand the ship back on a touch. +- **The flight keys stay as an override on a keyboard** (Chris, 2026-09-11: + *"keep the keys - we'll use mouse for any new buttons and hitting the + keyboard will take control during combat"*). So a new button takes a mouse + click on a desktop, and a tap on a phone. The two computers already hand the + ship back on a key. - **M1 lands on the branch of 204.** This plan made that call too. Pull request #49 then carries the attempt and its removal together, so the record stays in one place. diff --git a/docs/TODO/206-the-computer-flies-the-fight-and-the-pilot-fires.md b/docs/TODO/206-the-computer-flies-the-fight-and-the-pilot-fires.md index ab5eb46c..38e97300 100644 --- a/docs/TODO/206-the-computer-flies-the-fight-and-the-pilot-fires.md +++ b/docs/TODO/206-the-computer-flies-the-fight-and-the-pilot-fires.md @@ -83,25 +83,30 @@ units across. The standoff must keep the ship outside ### M3 — the pilot's hands -Four controls, as buttons on the flight view and as the existing keys: +Four controls, as buttons on the flight view and as the existing keys. A +button takes a mouse click on a desktop, and a tap on a phone: 1. the laser, which fires while it is held; -2. the missile, which arms on one press and launches on the next; +2. the missile, which arms on one press and launches on the next. It locks on + the target that the pilot picked; 3. the E.C.M.; 4. the target list, which opens as rows over the view. The laser heat stays the pilot's to manage. The trigger goes through the path it uses today, because a shot has legal consequences, and those are the Game's. -### M4 — the bought combat computer flies all of it +### M4 — the bought combat computer flies all of it, except the missiles -With `equipment.combatComputer`, the computer also pulls the trigger, launches -the missiles and picks the targets. The pilot watches. The pilot may still pick -a target, and the computer then fights that one. +With `equipment.combatComputer`, the computer also pulls the trigger, reaches +for the E.C.M. and picks the targets. The pilot watches. The pilot may still +pick a target, and the computer then fights that one. -The missile launch is new. The rule must say when a missile is worth it, for -example a hostile inside a range with a missile armed. Measure it with the -defence probe before the constant is written. +**The computer never launches a missile.** A missile costs money, so the +pilot decides when to spend one. The pilot arms it and fires it by hand, at +the picked target, as in M3. + +**A key takes the stick back.** During a fight, a flight key hands the ship +to the pilot, as it does to the co-pilot today. ### M5 — the ways out, as courses @@ -130,8 +135,11 @@ without the pick. - **Timing and tactics are the pilot's** (Chris, 2026-09-11). The pilot owns the laser, the missiles, the E.C.M. and the choice of target. -- **The bought combat computer takes control of everything** (Chris, - 2026-09-11). The pilot sits back and watches. +- **The bought combat computer takes control of everything but the missiles** + (Chris, 2026-09-11). The pilot sits back and watches. The pilot targets and + fires a missile by hand, because *"missiles cost money"*. +- **A flight key takes control during a fight, and a new button takes a mouse + click** (Chris, 2026-09-11). - **A trader is on the target list** (Chris, 2026-09-11). No hail or demand comes first. The law answers as it does today. - **Collection needs fuel scoops** (Chris, 2026-09-11). The ship flies round the @@ -164,8 +172,8 @@ and the Verification section names it. ## Verification -The gates always run: `npm run check`. `npm run generate:constants` runs first, -because M4 adds a constant. +The gates always run: `npm run check`. `npm run generate:constants` runs first +when a milestone adds a constant. The tier: a rule that changes how a fight goes. `survivability`, `aim-probe`, `defence-probe` and `ambush-probe` run before M2 and after M4. Each sampled @@ -177,7 +185,8 @@ Evidence: pick. - A headless fight shows that the co-pilot follows the pilot's pick, and that it fires only when the pilot's trigger is down. -- A headless fight with a combat computer shows a missile launch. +- A headless fight with a combat computer shows no missile launch, and a + missile that the pilot fires locks on the picked target. - A headless run from a police Viper opens the range, and the torus engages when the lock clears. - A headless collection scoops every canister in range, and it stops on the From dc72f61b67dd536407d1c041118772c14d93d000 Mon Sep 17 00:00:00 2001 From: Chris Greening Date: Fri, 11 Sep 2026 22:05:39 +0100 Subject: [PATCH 014/100] docs/TODO/205 M3: the station course flies the ship into the slot A picked course now flies the ship. The new game/course-pilot.ts decides one frame and reports a FlightDemand, as the two computers do. The station course points at the station and runs the torus whenever the drive is free. Inside the docking computer's range it hands over, with no computer fitted, which is the stopgap until 207. The jump course flies straight while the countdown runs. The switch lives in flight-instruments.ts, beside the other switches that change who flies the ship. A flight key suspends the course (Chris: "keep the keys"). The course and the finished courses are session state, so a save carries them, and a dock or an arrival ends the visit. Measured over 20 systems: every trip docked, with a median of 148 s. The traffic holds the torus down for 111 s of it, three stops at the median, one in three with a hostile ship. Chris chose a skip forward button at one fixed speed, and the plan gains it as M7. 5,676 assertions, from 5,656. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cpi86XPSQzZQhVhRnZED58 --- ...the-ship-goes-and-the-ship-flies-itself.md | 78 +++++++++++- src/constants/CATALOG.md | 3 +- src/constants/course.ts | 26 ++++ src/game/autopilot.ts | 20 +++ src/game/course-pilot.ts | 110 ++++++++++++++++ src/game/flight-instruments.ts | 39 +++++- src/game/flight.ts | 11 +- src/game/hyperspace-actions.ts | 2 + src/game/session.ts | 19 +++ src/game/state.ts | 2 + src/game/station.ts | 2 + test/constants.test.ts | 10 ++ test/course-pilot.test.ts | 119 ++++++++++++++++++ test/run.ts | 1 + test/snapshot.test.ts | 3 + 15 files changed, 438 insertions(+), 7 deletions(-) create mode 100644 src/constants/course.ts create mode 100644 src/game/course-pilot.ts create mode 100644 test/course-pilot.test.ts diff --git a/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md b/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md index ff011ecd..6a8898dd 100644 --- a/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md +++ b/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md @@ -55,7 +55,7 @@ use "order" for a signed contract or a live mission. One word has one meaning. ## What to do -Six milestones. +Seven milestones. ### M1 — the touch controls leave @@ -126,6 +126,10 @@ from the seeded stream. Until 207 lands, the hand-over at the docking range goes to the docking computer, free of charge. So a course to the station always ends in a dock. +**M3 is two commits.** The first flies the station course and the jump +course, and it adds the saved course and the key override. The second flies the +derelict, the hermit and the star, and it takes the skim measurement below. + **The skim altitude is a measurement, and M3 takes it first.** The heat starts to rise at 110,000 units, and the scoop works inside 80,000. A ship inside 21,000 dies at once. M3 measures a hold distance where the tank fills and the @@ -169,6 +173,32 @@ Rewrite the flight sections of the manual and of the briefing. Update the landing page where it describes how you fly. The player-facing pages keep their own style, and the house prose rules do not govern them. +### M7 — the skip forward button + +A skip forward button runs the world faster while nothing needs the pilot. It +runs the same fixed step eight times in each screen frame, and it draws the +last one. The mass lock, the traffic and the encounters behave as they do at +normal speed. So the skip shortens the wait and changes no rule. + +The button works only while no hostile ship is in scanner range. The skip +stops by itself in these cases: + +1. a hostile ship comes into scanner range; +2. the course ends, or no course is picked; +3. a new line comes to the console; +4. the pilot presses a flight key, or taps the button again. + +The skip works for the whole trip, and that includes the docking computer's +approach. A key and a button start it, and the key lives in the binding table. + +**The game loop caps the steps in one frame.** `MAX_STEPS_PER_FRAME` is 5 and +`MAX_FRAME_TIME` is 0.25 s. The skip needs eight steps in a frame, so it raises +the cap for its own frames only. A slow device that cannot keep up skips +slower. It never skips a rule. + +`test/skip.test.ts` proves that a skipped trip and a normal trip end in the +same world state, step for step. It proves each of the four stops above. + ## Decisions already made - **One flow for every player** (Chris, 2026-09-11). A keyboard player and a @@ -180,7 +210,14 @@ own style, and the house prose rules do not govern them. - **A countdown that starts runs to the jump** (Chris, 2026-09-11), as it does today. A fight during the countdown does not stop it. - **The torus drive is enough for the trip to the station** (Chris, - 2026-09-11). No course skips time. + 2026-09-11). The measurement of M3 then showed a median trip of 148 s, and + Chris chose a skip forward button the same day (M7). It runs the fixed step + faster, at one fixed speed, and it changes no rule (*"instead of being able + to cheat the mass lock we just skip time forward"*). He first chose a "carry + on" tap past the mass lock, and the skip replaced it. +- **The skip has one fixed speed** (Chris, 2026-09-11: *"Fixed speed - let's + keep it simple"*). It is eight times normal speed, and it is tuned after he + plays it. - **"Jump on" is a course when the tank holds the fuel** (Chris, 2026-09-11). "Skim the star" needs fuel scoops, and the ship flies to the star. - **The home system's work stays, as courses** (Chris, 2026-09-11: *"maybe we @@ -268,3 +305,40 @@ Evidence: - **In flight, a chart with no target shows no jump row.** At a launch, the jump row always shows, and its reason answers why the ship cannot leave. - **The suite has 5,656 assertions,** from 5,624. + +### M3, first commit: the station course and the jump course + +- **The course switch lives in `flight-instruments.ts`.** That file already + holds the switches that change who flies the ship. `flight.ts` gains four + lines, and it stays under its ceiling of 400. +- **The hand-over needs a new door, `Autopilot.handOverToDock`.** It engages + the docking computer with none fitted. It is the stopgap until 207, and its + doc comment says so. +- **A save carries the course with no new code in the snapshot.** The session + is walked generically, and an old save restores the field at its default. +- **The trip is long, and the traffic makes it long.** Measured on 2026-09-11 + over 20 systems, from the witchpoint to the dock, with no hand on the stick: + +| the sky | the trip, p10 / p50 / p90 | the torus runs | a mass lock holds | +| --- | --- | --- | --- | +| the arrival's own traffic | 116 / 148 / 164 s | 5 / 9 / 16 s | 73 / 111 / 126 s | +| an empty sky | 53 / 55 / 60 s | 19 / 21 / 26 s | 4 s | + + The course engages the torus whenever the drive is free. The time with + neither is under 3 s at p90. So the lock is the cost, and the lock is any + live ship within 4,500 units. The arrival puts traders and police along the + corridor. The last 30 s of each trip is the docking computer's approach. + All 20 trips docked, and 30 trips in one system docked too. + + **The stops, measured the same day.** A trip stops at a mass lock three + times at the median, and five at most. About one stop in three has a hostile + ship in it: + +| sample | stops per trip, median | most in one trip | stops with a hostile ship | +| --- | --- | --- | --- | +| 20 trips | 3 | 4 | 16 of 50 | +| 40 trips | 3 | 5 | 32 of 103 | + + **The step is cheap.** With no graphics, the world step ran about 2,000 + times faster than real time on the development Mac, over 5 trips and over + 10. So a skip at eight times speed costs little. That number led to M7. diff --git a/src/constants/CATALOG.md b/src/constants/CATALOG.md index 7d6d1476..0b0dad45 100644 --- a/src/constants/CATALOG.md +++ b/src/constants/CATALOG.md @@ -2,7 +2,7 @@ -423 exported constants. Regenerate with `npm run generate:constants`; +424 exported constants. Regenerate with `npm run generate:constants`; search names, meanings and values with `npm run constants:find -- ""`. | Domain | Symbol | Literal / expression | Purpose | Rule ID | Source | @@ -114,6 +114,7 @@ search names, meanings and values with `npm run constants:find -- ""`. | contracts | 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: Partial = {}): CourseView => ({ + course: 'station', + position: new THREE.Vector3(), + quaternion: new THREE.Quaternion(), + pitchRate: 0, + rollRate: 0, + stationPos: station, + dcEngaged: false, + ...over, +}); + +{ + const pilot = new CoursePilot(); + const ahead = pilot.step(view(new THREE.Vector3(0, 0, -50_000)), 1 / 60); + check('a station dead ahead: the throttle opens', ahead.demand?.throttle === 1); + check('...and the pilot asks for the torus', ahead.torus); + check('...and it keeps the ship, rather than hand it over', !ahead.handOver); + + const abeam = new CoursePilot().step(view(new THREE.Vector3(50_000, 0, 0)), 1 / 60); + check('a station abeam: no torus until the nose is round', !abeam.torus); + check('...and the sticks move to bring it round', + (abeam.demand?.rollRate ?? 0) !== 0 || (abeam.demand?.pitchRate ?? 0) !== 0); + + const near = pilot.step(view(new THREE.Vector3(0, 0, -(DOCK_COMPUTER_RANGE - 1))), 1 / 60); + check('inside the docking computer\'s range, the course hands over', near.handOver); + eq('...and asks for no flight of its own', near.demand, null); + check('...and drops the torus', !near.torus); + + const docking = pilot.step(view(new THREE.Vector3(0, 0, -50_000), { dcEngaged: true }), 1 / 60); + check('once the docking computer has the ship, the course asks for nothing', + docking.demand === null && !docking.handOver && !docking.torus); + + const jump = pilot.step(view(new THREE.Vector3(50_000, 0, 0), { course: 'jump', rollRate: 1 }), 1 / 60); + check('the jump course flies on as the ship points: the throttle opens', jump.demand?.throttle === 1); + check('...and the roll dies away', Math.abs(jump.demand?.rollRate ?? 1) < 1); + check('...with no torus', !jump.torus); + + eq('a course with no flight yet asks for nothing', + pilot.step(view(new THREE.Vector3(), { course: 'hermit' }), 1 / 60).demand, null); +} + +console.log('\nthe station course, flown from the witchpoint'); + +/** A commander who arrives at the witchpoint with an empty sky. */ +function arrived(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(); + return g; +} + +{ + const g = arrived(20_260_911); + const start = g.state.player.position.distanceTo(g.state.world.station.position); + check('the ship starts far outside the station\'s mass lock', start > MASS_LOCK_STATION * 4, + `${Math.round(start)} units`); + g.state.session.course = 'station'; + + let torusSeen = false; + let handOverAt = -1; + let dockedAt = -1; + const dt = 1 / 60; + const limit = 180 / dt; + withoutSaving(() => { + for (let f = 0, at = 0; f < limit; f++) { + g.step(dt, at += dt); + if (g.state.session.torusEngaged) torusSeen = true; + if (handOverAt < 0 && g.state.session.dcEngaged) { + handOverAt = g.state.player.position.distanceTo(g.state.world.station.position); + } + if (g.mode !== 'flight') { dockedAt = f * dt; break; } + } + }); + check('the course ran the torus on the way in', torusSeen); + check('...it handed over inside the docking computer\'s range, with none fitted', + handOverAt > 0 && handOverAt <= DOCK_COMPUTER_RANGE + 50, `${Math.round(handOverAt)} units`); + check('...and the ship docked inside three minutes, with no hand on the stick', + dockedAt > 0 && g.mode === 'docked', `mode ${g.mode}, ${dockedAt.toFixed(1)} s`); + eq('...and the dock ends the visit, so no course is left', g.state.session.course, null); +} + +console.log('\n...and a flight key takes the ship back'); +{ + const g = arrived(20_260_912); + g.state.session.course = 'station'; + for (let f = 0, at = 0; f < 30; f++) g.step(1 / 60, at += 1 / 60); + check('the course holds the ship', g.state.session.course === 'station'); + g.input.press('ArrowUp'); + g.step(1 / 60, 1); + g.input.release('ArrowUp'); + eq('a flight key suspends the course', g.state.session.course, null); +} diff --git a/test/run.ts b/test/run.ts index 13b52e16..5fc2b2d1 100644 --- a/test/run.ts +++ b/test/run.ts @@ -72,6 +72,7 @@ import './bribe.test.ts'; import './bribe-flight.test.ts'; import './prompts.test.ts'; import './courses.test.ts'; +import './course-pilot.test.ts'; import './world.test.ts'; import './docking.test.ts'; import './docking-computer.test.ts'; diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index ad822ed3..2ca628f3 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -391,6 +391,9 @@ console.log('\nsnapshot round trip'); // waiting their turn (docs/TODO/129). A save taken between the deed and // the line that explains it must still say it on the other side. else if (Array.isArray(v)) session[k] = [{ text: `dirty-${k}`, seconds: (n += 1) }]; + // ...and a field that starts empty, such as the picked course + // (docs/TODO/205 M3). A null left as it was would round-trip for free. + else if (v === null) session[k] = `dirty-${k}`; } const dirty = structuredClone(session); const wireSession = JSON.stringify(serialiseState(session)); From ea9c5f7ba473cea2a73364d83c84da99a6a7d0a7 Mon Sep 17 00:00:00 2001 From: Chris Greening Date: Fri, 11 Sep 2026 22:27:26 +0100 Subject: [PATCH 015/100] docs/TODO/205 M3: the derelict, hermit and star courses, and no ship inside the planet An arrival course flies to a standoff from its target and stops there. The derelict course matches the generation ship's drift beside it. The hermit course arrives slow enough for his trade to open. The star course holds inside the scoop range until the tank is full. Every line goes round the planet at a clearance above its mass lock. The work found a hermit 1,545 units inside the planet. The scatter round the station never checked the planet: 2 of about 2,300 ships over 128 systems appeared inside it. The traffic placement now lifts such a ship out to SPAWN_PLANET_ALTITUDE, and it draws nothing from the seeded stream. The spawning test keeps lifted ships out of its scatter band, and checks that nothing sits lower. Measured with the arrival's traffic: 20 of 20 hermit trips, 20 of 20 derelict trips and 40 of 40 star trips arrived. The cabin peaked at 0.489. 5,694 assertions, from 5,676. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cpi86XPSQzZQhVhRnZED58 --- docs/ARCHITECTURE.md | 4 + ...the-ship-goes-and-the-ship-flies-itself.md | 33 ++++ src/constants/CATALOG.md | 27 ++- src/constants/commander.ts | 8 +- src/constants/course.ts | 103 +++++++++++ src/constants/spawn-placement.ts | 19 ++ src/game/course-pilot.ts | 174 ++++++++++++++++-- src/game/flight-instruments.ts | 38 +++- src/game/spawning.ts | 46 +++-- test/constants.test.ts | 8 + test/course-pilot.test.ts | 146 ++++++++++++++- test/spawning.test.ts | 12 +- 12 files changed, 572 insertions(+), 46 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f066220b..384f2e83 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,6 +38,10 @@ rules. This file is a map. station or a skim of the star. It follows the shape of `prompts.ts`: a flat view in, a ranked list out, and no key. The list is derived state. The code saves the course that the pilot picks, and never the list. + `course-pilot.ts` flies the picked course, one frame at a time, and it + reports a `FlightDemand`. `flight-instruments.ts` throws the switches that a + course asks for: the torus drive, the hand-over to the docking computer, and + the end of the course. - The console is one line, so `SessionState.queued` is the line that waits for it (`session.ts`). Some consequences make sense only after their cause: what a scan cost your legal record, or what a deed cost your reputation. The console diff --git a/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md b/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md index 6a8898dd..a60fdfdb 100644 --- a/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md +++ b/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md @@ -342,3 +342,36 @@ Evidence: **The step is cheap.** With no graphics, the world step ran about 2,000 times faster than real time on the development Mac, over 5 trips and over 10. So a skip at eight times speed costs little. That number led to M7. + +### M3, second commit: the derelict, the hermit and the star + +- **An arrival flies to a standoff and stops there.** The speed follows the + distance that is left. The torus drops 8,000 units short. +- **Every line goes round the planet.** A launch, the star and a rock far out + have no promise that the planet is out of the way. A line that dips below + 5,000 units of height aims beside the planet instead. +- **The detour at first trapped a ship.** A hermit sat low over the planet, so + the line to it always counted as blocked. The ship circled the detour point. + The rule now ignores a line whose nearest point to the planet is the target. +- **A HERMIT CAN APPEAR INSIDE THE PLANET, and one did.** The scatter round the + station never checks the planet. Over 128 systems, at arrival and at launch, + 2 of about 2,300 ships appeared inside it: a hermit and a police ship. The + hermit course then flew the ship into the ground. The traffic placement in + `spawning.ts` now lifts such a ship out to `SPAWN_PLANET_ALTITUDE`, which is + 1,000 units. The lift draws nothing from the seeded stream. After the fix, + the same count found none. The rule first went into `World.spawn`. Four + combat tests put a ship at the origin, where the planet sits, and the lift + moved them. So the rule lives where the traffic is placed. +- **The derelict drifts at 25 u/s.** A course that asked for a stop beside it + trailed it at 19 u/s and never arrived. The arrival now matches the target's + own speed. +- **The measurements, with the arrival's own traffic in the sky:** + +| course | sample | arrived | time, median | worst | +| --- | --- | --- | --- | --- | +| the hermit | 10 / 20 systems | 10 / 20 | 119 / 114 s | 161 s | +| the derelict | 10 / 20 systems | 10 / 20 | 26 / 25 s | 32 s | +| the star | 15 / 40 systems | 15 / 40 | 112 / 105 s | 131 s | + + On the star course, the cabin peaked at 0.489 in every sample, against a + fatal 0.99. No ship came within 68,000 units of the star. diff --git a/src/constants/CATALOG.md b/src/constants/CATALOG.md index 0b0dad45..16b81482 100644 --- a/src/constants/CATALOG.md +++ b/src/constants/CATALOG.md @@ -2,7 +2,7 @@ -424 exported constants. Regenerate with `npm run generate:constants`; +433 exported constants. Regenerate with `npm run generate:constants`; search names, meanings and values with `npm run constants:find -- ""`. | Domain | Symbol | Literal / expression | Purpose | Rule ID | Source | @@ -88,13 +88,13 @@ search names, meanings and values with `npm run constants:find -- ""`. | 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) | | 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 -- ""`. | contracts | 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 -- ""`. | 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) | | 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: Partial> = { + derelict: 'HOLDING BESIDE THE DERELICT', + skim: 'TANK FULL — SKIM COMPLETE', +}; import { massLocked } from './world-step.ts'; import { boundKey } from '../ui/key-help.ts'; import { defenceBrain } from './brains.ts'; @@ -125,20 +136,45 @@ export class Instruments { return null; } const p = this.state.player; + const w = this.state.world; + const live = (role: string) => w.npcs.find((n) => n.state.alive && n.role === role) ?? null; + const derelict = live('generation'); 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, + speed: p.speed, + stationPos: w.station.position, + planetPos: w.planetPos, + planetRadius: w.planetRadius, + sunPos: w.sunPos, + derelictPos: derelict?.object.position ?? null, + derelictSpeed: derelict?.state.speed ?? 0, + hermitPos: live('hermit')?.object.position ?? null, + tankFull: this.state.commander.fuel >= MAX_FUEL, dcEngaged: s.dcEngaged, }, dt); if (step.handOver) this.applyAutopilot(this.autopilot.handOverToDock()); if (step.torus !== s.torusEngaged && (!step.torus || !this.massLocked())) this.toggleTorus(); + if (step.done) this.endCourse(s.course); return step.demand; } + /** + * 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 { + const s = this.state.session; + s.course = null; + if (!s.coursesDone.includes(kind)) s.coursesDone.push(kind); + this.coursePilot.reset(); + const said = COURSE_ENDS[kind]; + if (said) this.host.showMessage(said, 3); + } + /** 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/spawning.ts b/src/game/spawning.ts index 5e067c31..7a0cf308 100644 --- a/src/game/spawning.ts +++ b/src/game/spawning.ts @@ -20,7 +20,8 @@ import type { World } from './world.ts'; import type { PopulationPlan } from './population.ts'; import type { NpcShip } from './npc.ts'; import { steerQuatToward } from './flight-maths.ts'; -import { pirateSpecForTier, specForDesign } from './ship-specs.ts'; +import { pirateSpecForTier, specForDesign, type NpcSpec } from './ship-specs.ts'; +import type { NpcRole } from './ship-roles.ts'; import type { TaggedItem, TaggedShip } from '../missions/model.ts'; import { memberTier } from './threat.ts'; import { slotNormal } from '../world/slot.ts'; @@ -34,7 +35,7 @@ import { MISSION_TARGET_RANGE, MISSION_TARGET_RANGE_SPAN, PIRATE_SCATTER, POLICE_PATROL_RANGE, POLICE_SCATTER, STATION_DEFENCE_JITTER, STATION_DEFENCE_MIN, STATION_DEFENCE_SPAN, - STATION_DEFENCE_STACK, STATION_DEFENCE_STANDOFF, TRADER_SCATTER, + SPAWN_PLANET_ALTITUDE, STATION_DEFENCE_STACK, STATION_DEFENCE_STANDOFF, TRADER_SCATTER, } from '../constants/spawn-placement.ts'; /** A random offset of up to `range`, biased outward. */ @@ -75,6 +76,26 @@ export interface SpawnResult { * @param playerPos where the commander is — the reception is scattered along * the corridor between them and the station, not dumped on top of them. */ +/** + * Where a ship of the traffic may appear. It is the asked position, unless + * that position is below `SPAWN_PLANET_ALTITUDE`. Then it is the same line + * from the planet's centre, at that height. + * + * The scatter round the station has no idea where the planet is. + * docs/TODO/205 M3 counted 2 ships of about 2,300 inside the planet, over 128 + * systems: a hermit and a police ship. A course then flew the ship into the + * ground after the hermit. The lift draws nothing from the seeded stream, so + * no other outcome moves. It returns a new vector. + */ +export function aboveGround(world: World, position: THREE.Vector3): THREE.Vector3 { + const at = position.clone(); + const lowest = world.planetRadius + SPAWN_PLANET_ALTITUDE; + const up = at.clone().sub(world.planetPos); + if (up.length() >= lowest) return at; + if (up.lengthSq() < 1e-6) up.set(0, 1, 0); + return at.copy(world.planetPos).addScaledVector(up.normalize(), lowest); +} + export function spawnPopulation( world: World, plan: PopulationPlan, @@ -86,6 +107,9 @@ export function spawnPopulation( ): SpawnResult { const home = world.station.position; const arriving = situation === 'arrival'; + // Every ship of the traffic appears above the ground (docs/TODO/205 M3). + const place = (role: NpcRole, pos: THREE.Vector3, seed: number, spec?: NpcSpec): NpcShip => + world.spawn(role, aboveGround(world, pos), seed, spec); // The lane the commander flies in on. A point `spread` off it, `CORRIDOR_START` // to `CORRIDOR_START + CORRIDOR_SPAN` of the way from the witchpoint to the @@ -105,10 +129,10 @@ export function spawnPopulation( // a pirate has somebody to prey on. The `arriving` phase steers them to the // station on its own (`stepTrader`, game/trader-flight.ts). if (arriving && i % 2 === 0) { - const trader = world.spawn('trader', corridorPos(TRADER_SCATTER), i + sys.index); + const trader = place('trader', corridorPos(TRADER_SCATTER), i + sys.index); trader.state.traderPhase = 'arriving'; } else { - world.spawn('trader', home.clone().add(scatter(TRADER_SCATTER)), i + sys.index); + place('trader', home.clone().add(scatter(TRADER_SCATTER)), i + sys.index); } } for (let i = 0; i < plan.police; i++) { @@ -116,7 +140,7 @@ export function spawnPopulation( // Scattered across the system for a launch, and never on the slot itself. const pos = arriving ? corridorPos(POLICE_SCATTER) : home.clone().add(scatter(POLICE_PATROL_RANGE)); - world.spawn('police', pos, i); + place('police', pos, i); } for (let i = 0; i < plan.asteroids; i++) { // On an arrival the rocks line the lane the commander flies down, so the run @@ -126,7 +150,7 @@ export function spawnPopulation( // rocks on every visit. const pos = arriving ? corridorPos(ASTEROID_LANE_SCATTER) : home.clone().add(scatter(ASTEROID_SCATTER)); - world.spawn('asteroid', pos, sys.seed[0] + i * 37); + place('asteroid', pos, sys.seed[0] + i * 37); } if (plan.threat && plan.pirates > 0) { @@ -137,17 +161,17 @@ export function spawnPopulation( const tier = memberTier(plan.threat.tier, i); // The tier table is the set's, not the catalogue's. This is the pirate // band, and a system's blueprint set narrows that band (TODO 138). - const npc = world.spawn('pirate', pos, seed, pirateSpecForTier(tier, seed, world.roster)); + const npc = place('pirate', pos, seed, pirateSpecForTier(tier, seed, world.roster)); npc.state.organised = plan.threat.organised; npc.state.threatTier = tier; } } if (plan.hunter) { - world.spawn('hunter', home.clone().add(scatter(HUNTER_SCATTER)), sys.index); + place('hunter', home.clone().add(scatter(HUNTER_SCATTER)), sys.index); } if (plan.hermit) { - world.spawn('hermit', + place('hermit', home.clone().add(scatter(HERMIT_SCATTER).addScaledVector(scatter(1), 2)), sys.index); } @@ -156,7 +180,7 @@ export function spawnPopulation( const pos = playerPos.clone() .add(randomDirection(new THREE.Vector3()) .multiplyScalar(GENERATION_SHIP_RANGE + random() * GENERATION_SHIP_RANGE_SPAN)); - generationShip = world.spawn('generation', pos, 0); + generationShip = place('generation', pos, 0); // steerQuatToward, not lookAt. Object3D.lookAt aims +Z at its target, and a // hull's nose is -Z (invariant 7). So `lookAt(home)` would point the // derelict exactly away from the station. @@ -185,7 +209,7 @@ export function spawnPopulation( const pos = playerPos.clone() .add(randomDirection(new THREE.Vector3()) .multiplyScalar(MISSION_TARGET_RANGE + random() * MISSION_TARGET_RANGE_SPAN)); - const ship = world.spawn(role, pos, 0, spec); + const ship = place(role, pos, 0, spec); ship.state.missionTag = tagged.tag; if (tagged.job === 'escort') ship.state.traderPhase = 'arriving'; // A scan's subject waits until it is scanned (docs/TODO/203 M2). A diff --git a/test/constants.test.ts b/test/constants.test.ts index cb648777..b294b31d 100644 --- a/test/constants.test.ts +++ b/test/constants.test.ts @@ -400,6 +400,14 @@ const OUTSIDE: readonly Group[] = [ }, }, + { + why: 'STAYS: words, not a number. What the console says when a course' + + ' finishes its work (docs/TODO/205 M3), beside the switch that says it', + files: { + 'game/flight-instruments.ts': ['COURSE_ENDS'], + }, + }, + { 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 index 430e432a..7c14a3bd 100644 --- a/test/course-pilot.test.ts +++ b/test/course-pilot.test.ts @@ -11,7 +11,13 @@ 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 { CoursePilot, clearOfPlanet, type CourseView } from '../src/game/course-pilot.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'; +import { MAX_FUEL } from '../src/constants/commander.ts'; +import { SPAWN_PLANET_ALTITUDE } from '../src/constants/spawn-placement.ts'; +import { aboveGround } from '../src/game/spawning.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'; @@ -25,7 +31,15 @@ const view = (station: THREE.Vector3, over: Partial = {}): CourseVie quaternion: new THREE.Quaternion(), pitchRate: 0, rollRate: 0, + speed: 0, stationPos: station, + planetPos: new THREE.Vector3(0, 1e7, 0), + planetRadius: 5000, + sunPos: new THREE.Vector3(1e7, 0, 0), + derelictPos: null, + derelictSpeed: 0, + hermitPos: null, + tankFull: false, dcEngaged: false, ...over, }); @@ -56,8 +70,10 @@ const view = (station: THREE.Vector3, over: Partial = {}): CourseVie check('...and the roll dies away', Math.abs(jump.demand?.rollRate ?? 1) < 1); check('...with no torus', !jump.torus); + const gone = pilot.step(view(new THREE.Vector3(), { course: 'hermit' }), 1 / 60); + check('a hermit course with no hermit in the sky ends at once', gone.done && gone.demand === null); eq('a course with no flight yet asks for nothing', - pilot.step(view(new THREE.Vector3(), { course: 'hermit' }), 1 / 60).demand, null); + pilot.step(view(new THREE.Vector3(), { course: 'mine' }), 1 / 60).demand, null); } console.log('\nthe station course, flown from the witchpoint'); @@ -117,3 +133,129 @@ console.log('\n...and a flight key takes the ship back'); g.input.release('ArrowUp'); eq('a flight key suspends the course', g.state.session.course, null); } + +console.log('\nevery line goes round the planet'); +{ + const planet = new THREE.Vector3(0, 0, -50_000); + const out = new THREE.Vector3(); + const to = new THREE.Vector3(0, 0, -100_000); + clearOfPlanet(new THREE.Vector3(), to, planet, 5000, out); + const alt = out.distanceTo(planet) - 5000; + check('a line through the planet aims beside it instead', !out.equals(to)); + check('...above the clearance, so the detour holds no mass lock', + alt > COURSE_PLANET_CLEARANCE, `${Math.round(alt)} units up`); + const clear = new THREE.Vector3(40_000, 0, -100_000); + clearOfPlanet(new THREE.Vector3(40_000, 0, 0), clear, planet, 5000, out); + check('a line that clears the planet aims at the target itself', out.equals(clear)); +} + +/** A commander at the witchpoint of a system whose sky holds this role. */ +function arrivedWith(role: string): Game { + for (let i = 0; i < 200; i++) { + const g = withoutSaving(() => { + seedWorld(4000 + i); + const game = new Game(() => headlessShell()); + dismissBriefing(game); + game.launch(); + game.state.commander.systemIndex = (i * 29) % 256; + game.arriveInSystem(); + return game; + }).value; + if (g.state.world.npcs.some((n) => n.role === role)) { + // Only the target stays, so that no fight or mass lock blurs the claim. + for (const n of g.state.world.npcs) if (n.role !== role) n.state.alive = false; + return g; + } + } + throw new Error(`no system in the sample holds a ${role}`); +} + +/** Fly the picked course until it ends, the ship dies, or the time runs out. */ +function fly(g: Game, seconds: number, until: () => boolean): number { + const dt = 1 / 60; + let f = 0; + withoutSaving(() => { + for (let at = 0; f < seconds / dt; f++) { + g.step(dt, at += dt); + if (until() || g.mode === 'dead') break; + } + }); + return f * dt; +} + +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')); +} + +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')); +} + +console.log('\nthe station course goes round a planet in its way'); +{ + const g = arrived(20_260_914); + const w = g.state.world; + // Put the planet square between the ship and the station. + const through = w.station.position.clone().sub(w.planetPos).normalize(); + g.state.player.position.copy(w.planetPos).addScaledVector(through, -(w.planetRadius * 8)); + g.state.session.course = 'station'; + let lowest = Infinity; + fly(g, 400, () => { + lowest = Math.min(lowest, g.state.player.position.distanceTo(w.planetPos) - w.planetRadius); + return g.mode !== 'flight'; + }); + check('the ship never flies lower than the clearance', lowest > COURSE_PLANET_CLEARANCE * 0.9, + `${Math.round(lowest)} units up`); + eq('...and it still docks', g.mode, 'docked'); +} + +console.log('\nnothing appears inside the planet'); +{ + // docs/TODO/205 M3 found a hermit 1,545 units inside the planet, and a + // course flew the ship into the ground after it. The world lifts such a + // spawn out to a set height, and only such a spawn. + const g = arrived(20_260_915); + const w = g.state.world; + const inside = w.planetPos.clone().add(new THREE.Vector3(w.planetRadius * 0.5, 0, 0)); + const lifted = aboveGround(w, inside); + const alt = lifted.distanceTo(w.planetPos) - w.planetRadius; + check('a ship placed inside the planet appears above it', Math.abs(alt - SPAWN_PLANET_ALTITUDE) < 1, + `${Math.round(alt)} units up`); + const high = w.planetPos.clone().add(new THREE.Vector3(w.planetRadius * 3, 0, 0)); + check('...and a ship placed well clear of it appears where it was placed (the control)', + aboveGround(w, high).distanceTo(high) < 1e-6); +} diff --git a/test/spawning.test.ts b/test/spawning.test.ts index 3c3f0980..a715c71d 100644 --- a/test/spawning.test.ts +++ b/test/spawning.test.ts @@ -23,7 +23,7 @@ import { ASTEROID_LANE_SCATTER, ASTEROID_SCATTER, CORRIDOR_SPAN, CORRIDOR_START, HERMIT_SCATTER, HUNTER_SCATTER, PIRATE_SCATTER, POLICE_PATROL_RANGE, POLICE_SCATTER, STATION_DEFENCE_JITTER, STATION_DEFENCE_MIN, STATION_DEFENCE_SPAN, STATION_DEFENCE_STACK, STATION_DEFENCE_STANDOFF, - TRADER_SCATTER, + TRADER_SCATTER, SPAWN_PLANET_ALTITUDE, } from '../src/constants/spawn-placement.ts'; import { SCANNER_RANGE } from '../src/constants/console.ts'; import { BANISHED } from '../src/constants/witchspace.ts'; @@ -85,6 +85,12 @@ console.log('\nwhere a system puts its traffic'); spawnPopulation(world, PLAN, sys, player, [], situation); route.copy(home).sub(player).normalize(); for (const npc of world.npcs) { + // A ship the scatter put inside the planet is lifted out to one set + // height (docs/TODO/205 M3). It is the lift's ship, not the scatter's, + // so it stays out of the band. It is counted, and nothing sits lower. + const alt = npc.object.position.distanceTo(world.planetPos) - world.planetRadius; + lowest = Math.min(lowest, alt); + if (Math.abs(alt - SPAWN_PLANET_ALTITUDE) < 1) { lifted += 1; continue; } const d = npc.object.position.distanceTo(home); const b = out.band[npc.role] ?? (out.band[npc.role] = { lo: Infinity, hi: -Infinity }); b.lo = Math.min(b.lo, d); b.hi = Math.max(b.hi, d); @@ -101,8 +107,12 @@ console.log('\nwhere a system puts its traffic'); return out; }; + let lowest = Infinity; + let lifted = 0; const arrival = sweep('arrival'); const launch = sweep('launch'); + check(`no ship of the traffic sits below the lift height (${lifted} lifted, lowest ${Math.round(lowest)})`, + lowest >= SPAWN_PLANET_ALTITUDE - 1); const { band, along, off } = arrival; /** the measured spread of a role, against the nominal it was spawned from. */ From 071492e6da8e7bfc151ae6e0d1f6341159556954 Mon Sep 17 00:00:00 2001 From: Chris Greening Date: Fri, 11 Sep 2026 22:33:46 +0100 Subject: [PATCH 016/100] docs/TODO/205 M4: the LAUNCH row asks where the ship goes Chris's rule: no ship leaves without somewhere to go. The LAUNCH row now opens the launch list. A jump leaves with its countdown running. The rocks and the star leave on their work. A row the ship cannot fly refuses, and the console says why, so a commander with no target and no work stays on the pad. The last row opens the galactic chart. course-actions.ts joins the course list to the Game: it builds the view and applies a pick. The course rows send virtual codes from COURSE_KEYS, which the flight buttons of M5 will share. jumpCheck gives the jump key and the list one question. 5,708 assertions, from 5,694. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cpi86XPSQzZQhVhRnZED58 --- docs/ARCHITECTURE.md | 4 +- ...the-ship-goes-and-the-ship-flies-itself.md | 26 +++++- src/game/bindings.ts | 23 +++++ src/game/course-actions.ts | 89 +++++++++++++++++++ src/game/game.ts | 23 ++++- src/game/hyperspace-actions.ts | 18 ++-- src/game/screens/courses.ts | 56 ++++++++++++ src/ui/screen-host.ts | 2 +- src/ui/screens-courses.ts | 36 ++++++++ test/constants.test.ts | 3 +- test/course-launch.test.ts | 89 +++++++++++++++++++ test/run.ts | 1 + 12 files changed, 357 insertions(+), 13 deletions(-) create mode 100644 src/game/course-actions.ts create mode 100644 src/game/screens/courses.ts create mode 100644 src/ui/screens-courses.ts create mode 100644 test/course-launch.test.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 384f2e83..c431e954 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -41,7 +41,9 @@ rules. This file is a map. `course-pilot.ts` flies the picked course, one frame at a time, and it reports a `FlightDemand`. `flight-instruments.ts` throws the switches that a course asks for: the torus drive, the hand-over to the docking computer, and - the end of the course. + the end of the course. `course-actions.ts` joins the list to the Game: it + builds the flat view, and it applies a pick. At the station, the LAUNCH row + opens `screens/courses.ts`, and a pick leaves on the course. - The console is one line, so `SessionState.queued` is the line that waits for it (`session.ts`). Some consequences make sense only after their cause: what a scan cost your legal record, or what a deed cost your reputation. The console diff --git a/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md b/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md index a60fdfdb..63f2c333 100644 --- a/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md +++ b/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md @@ -155,10 +155,12 @@ where the system has one. ### M5 — the course list on screen, for every player -The course list is a screen with rows, behind `ui/screen-host.ts`. The world -keeps flying while it is up, as it does under the charts. A tap picks a row, -and so does the row cursor with Enter. The screen returns the picked course as -an outcome, and the Game applies it (invariant 15). +In flight, the course list is a row of buttons over the view, and not a +screen. M4 found why. Under any screen the flight world stops stepping, and +many tests expect flight at once after an arrival. 204 found the same shape +for its flight menu. Each button sends its course's code from `COURSE_KEYS`, +the flight binding table answers it, and the Game applies the pick +(invariant 15). The screen opens by itself when the ship has no course: at a launch, at an arrival, and when a course ends. A key and a button open it at other times. @@ -375,3 +377,19 @@ Evidence: On the star course, the cabin peaked at 0.489 in every sample, against a fatal 0.99. No ship came within 68,000 units of the star. + +### M4 + +- **The LAUNCH row opens a screen, `screens/courses.ts`.** Each row is a + course, and a pick leaves the station on it. A row the ship cannot fly says + why, and a pick of it refuses with the reason on the console. The last row + opens the galactic chart, and ESC from the chart returns to the list. +- **`Game.launch()` is still the transition itself.** Only the LAUNCH row's + command changed. The tests that press `launch` by name still leave at once. +- **A course row sends a virtual code, `COURSE_KEYS` in `bindings.ts`.** The + launch screen and the flight buttons of M5 share the codes, so the two + cannot disagree. +- **The jump key and the course list ask one question.** `jumpCheck` in + `hyperspace-actions.ts` holds the arguments that `startHyperspace` used to + pass by hand. +- **The suite has 5,708 assertions,** from 5,694. diff --git a/src/game/bindings.ts b/src/game/bindings.ts index ec336ff6..22dcca64 100644 --- a/src/game/bindings.ts +++ b/src/game/bindings.ts @@ -33,8 +33,31 @@ // menu's rows straight from it. import type { Binding, Command, ControlMode } from './controls.ts'; +import type { CourseKind } from './courses.ts'; /** Bindings that answer whatever is on screen, overlays included. */ +/** + * The code each course row sends (docs/TODO/205 M4). + * + * A row of the course list is a click target, as a station row is. So each + * course has a virtual code: `Virt`, then `Course`, then the course. No + * keyboard produces one, so no letter is spent. The launch screen and the + * course buttons in flight send the same codes. This record is their one + * home, so the two surfaces cannot disagree about a course's code. + */ +export const COURSE_KEYS: Readonly> = { + jump: 'VirtCourseJump', + mission: 'VirtCourseMission', + station: 'VirtCourseStation', + derelict: 'VirtCourseDerelict', + mine: 'VirtCourseMine', + hermit: 'VirtCourseHermit', + skim: 'VirtCourseSkim', +}; + +/** The launch list's row for the galactic chart, where a pilot sets a target. */ +export const COURSE_CHART_KEY = 'VirtCourseChart'; + 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/course-actions.ts b/src/game/course-actions.ts new file mode 100644 index 00000000..0cd39383 --- /dev/null +++ b/src/game/course-actions.ts @@ -0,0 +1,89 @@ +// What the Game does with a course: the list it offers, and the pick it +// applies (docs/TODO/205 M4). +// +// Three files hold a course, and each holds one part: +// +// - `courses.ts` decides which courses the situation allows. It is pure; +// - `course-pilot.ts` flies the picked course, one frame at a time; +// - this file joins the two to the Game. It builds the flat view that the +// list reads, and it applies a pick. +// +// A PICK IS A CONSEQUENCE, so the orchestrator applies it (invariant 15). At a +// launch it leaves the station. For a jump it starts the countdown. So this +// reaches the Game through `CourseHost`, and it names no screen and no key. +// +// A row that says why the ship cannot fly it refuses the pick, and the console +// says the reason. That is how a launch with no target refuses: Chris's rule +// of 2026-09-11 is that no ship leaves without somewhere to go. + +import { courseList, type Course, type CourseKind, type CourseSituation, type CourseWorld } from './courses.ts'; +import type { checkJump } from './hyperspace.ts'; +import type { GameState } from './state.ts'; + +/** What a pick reaches back for. */ +export interface CourseHost { + /** the jump key's own check (`HyperspaceActions.jumpCheck`) */ + jumpCheck(): ReturnType; + /** the station's transition to flight */ + launch(): void; + startHyperspace(): void; + /** close every screen, back to the base state */ + closeScreens(): void; + showMessage(text: string, seconds: number): void; + refused(): void; +} + +export class CourseActions { + private readonly state: () => GameState; + private readonly host: CourseHost; + + constructor(state: () => GameState, host: CourseHost) { + this.state = state; + this.host = host; + } + + /** The courses the situation allows, in order. */ + list(situation: CourseSituation): Course[] { + return courseList(this.view(situation)); + } + + /** + * Pick a course. At a launch the ship leaves the station on it. A jump + * starts the countdown. + * + * @returns whether the pick stood. A row with a reason refuses, and says it. + */ + pick(kind: CourseKind, situation: CourseSituation): boolean { + const row = this.list(situation).find((c) => c.kind === kind); + if (!row || row.why !== null) { + if (row?.why) this.host.showMessage(row.why, 3); + this.host.refused(); + return false; + } + if (situation === 'launch') { + this.host.closeScreens(); + this.host.launch(); + } + this.state().session.course = kind; + if (kind === 'jump') this.host.startHyperspace(); + return true; + } + + /** The flat view `courseList` reads, built from the live state. */ + private view(situation: CourseSituation): CourseWorld { + const s = this.state(); + const target = s.chart.targetIndex; + return { + situation, + commander: s.commander, + jump: this.host.jumpCheck(), + targetName: target === null ? null : s.systems[target]?.name ?? null, + witchspace: s.session.witchspace, + // 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, + done: new Set(s.session.coursesDone), + }; + } +} diff --git a/src/game/game.ts b/src/game/game.ts index 6178b674..49c62699 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -108,6 +108,8 @@ import { CombatSimScreen, type CombatSimContext } from './screens/combat-sim.ts' import { TestModeScreen, type TestModeContext } from './screens/test-mode.ts'; import { QuitScreen, type QuitContext } from './screens/quit.ts'; import { SurvivorsScreen, type SurvivorsContext } from './screens/survivors.ts'; +import { CoursesScreen, type CoursesContext } from './screens/courses.ts'; +import { CourseActions, type CourseHost } from './course-actions.ts'; import { ScreenHost } from '../ui/screen-host.ts'; import { characterVerdict } from './character.ts'; @@ -372,6 +374,19 @@ export class Game { misjumpArmed: (armed) => { if (armed) sfx.misjumpArmed(); else sfx.misjumpDisarmed(); }, } satisfies HyperspaceHost); + /** + * The courses a pilot picks from, and the pick applied (docs/TODO/205 M4). + * The state is read through a function, because a respawn replaces it. + */ + private readonly courses_ = new CourseActions(() => this.state, { + jumpCheck: () => this.jump_.jumpCheck(), + launch: () => this.docked_.launch(), + startHyperspace: () => this.startHyperspace(), + closeScreens: () => this.screens.exit(), + showMessage: (text, seconds) => this.showMessage(text, seconds), + refused: () => sfx.refused(), + } satisfies CourseHost); + /** * What a career keeps when a flight ends (docs/TODO/150 M5). * @@ -691,6 +706,10 @@ export class Game { sell: () => this.docked_.answerForSurvivors('sold'), release: () => this.docked_.answerForSurvivors('released'), } satisfies SurvivorsContext)), + new CoursesScreen(() => ({ + rows: () => this.courses_.list('launch'), + pick: (kind) => { this.courses_.pick(kind, 'launch'); }, + } satisfies CoursesContext)), ]) this.screens.register(screen); // A boot enters a system too, so it chooses a roster like any arrival. A @@ -1118,7 +1137,9 @@ export class Game { // --- global ----------------------------------------------------------- toggleHelp: () => { this.helpOpen = !this.helpOpen; this.shell.toggleHelp(); }, // --- the station menu ------------------------------------------------- - launch: () => this.docked_.launch(), + // The LAUNCH row asks where to go first (docs/TODO/205 M4). `launch()` + // below is still the transition itself, which the tests press by name. + launch: () => this.screens.open('courses'), openMarket: () => this.screens.open('market'), openEquip: () => this.screens.open('equip'), openBriefing: () => this.screens.open('briefing'), diff --git a/src/game/hyperspace-actions.ts b/src/game/hyperspace-actions.ts index be07b5cf..285af250 100644 --- a/src/game/hyperspace-actions.ts +++ b/src/game/hyperspace-actions.ts @@ -92,6 +92,18 @@ export class HyperspaceActions { this.host = host; } + /** + * May the drive spin up now, for the chart's target? The jump key asks it, + * and so does the course list (docs/TODO/205 M4), so the two cannot differ. + */ + jumpCheck(): ReturnType { + return checkJump(this.state.commander, this.state.systems, this.state.chart.targetIndex, + this.state.session.witchspace, this.state.session.hyperCountdown >= 0, + // JUMP ANYWHERE (docs/TODO/121): the flag goes IN, and the refusal stays + // where it was decided. Nothing here reads the tank. + this.state.cheat); + } + /** @internal — driven by src/game/game.ts, which delegates to it. */ startHyperspace(): void { // The simulator is a room at the station, not a place you can leave. The @@ -102,11 +114,7 @@ export class HyperspaceActions { this.host.refused(); return; } - const check = checkJump(this.state.commander, this.state.systems, this.state.chart.targetIndex, - this.state.session.witchspace, this.state.session.hyperCountdown >= 0, - // JUMP ANYWHERE (docs/TODO/121): the flag goes IN, and the refusal stays - // where it was decided. Nothing here reads the tank. - this.state.cheat); + const check = this.jumpCheck(); if (!check.ok) { if (check.reason === 'alreadyJumping') return; this.host.showMessage(refusalMessage(check.reason, this.state.session.witchspace), 4); diff --git a/src/game/screens/courses.ts b/src/game/screens/courses.ts new file mode 100644 index 00000000..49e8bb02 --- /dev/null +++ b/src/game/screens/courses.ts @@ -0,0 +1,56 @@ +// The launch list: the station asks where the ship goes (docs/TODO/205 M4). +// +// The station menu's LAUNCH row opens it. Each row is one course from +// `courses.ts`, and a pick leaves the station on it. A row the ship cannot fly +// says why, and a pick of it refuses. So a ship with no target and no work +// cannot leave. The last row opens the galactic chart, where the pilot sets a +// target, and ESC from the chart comes back here. +// +// The screen decides nothing itself. `course-actions.ts` applies a pick, the +// way the survivors screen hands its answer to `game/survivors.ts`. + +import type { Screen, ScreenOutcome } from '../../ui/screen-host.ts'; +import type { Input } from '../../engine/input.ts'; +import type { Course, CourseKind } from '../courses.ts'; +import { COURSE_CHART_KEY, COURSE_KEYS } from '../bindings.ts'; +import { renderLaunchCourses } from '../../ui/screens-courses.ts'; + +/** The slice of the Game this screen is allowed to see. */ +export interface CoursesContext { + /** the launch courses, in order */ + rows(): readonly Course[]; + /** pick one; a refusal says why and leaves the screen up */ + pick(kind: CourseKind): void; +} + +export class CoursesScreen implements Screen { + readonly id = 'courses' as const; + private readonly ctx: () => CoursesContext; + + constructor(ctx: () => CoursesContext) { + this.ctx = ctx; + } + + open(): void { + this.render(); + } + + render(): void { + renderLaunchCourses( + this.ctx().rows().map((course) => ({ code: COURSE_KEYS[course.kind], course })), + COURSE_CHART_KEY); + } + + input(i: Input): ScreenOutcome { + const ctx = this.ctx(); + for (const course of ctx.rows()) { + if (i.pressed(COURSE_KEYS[course.kind])) { + ctx.pick(course.kind); + return 'stay'; + } + } + if (i.pressed(COURSE_CHART_KEY)) return { open: 'chart' }; + if (i.pressed('Escape')) return 'back'; + return 'stay'; + } +} diff --git a/src/ui/screen-host.ts b/src/ui/screen-host.ts index d6f418ab..66b8c674 100644 --- a/src/ui/screen-host.ts +++ b/src/ui/screen-host.ts @@ -23,7 +23,7 @@ import type { Input } from '../engine/input.ts'; export type ScreenId = | 'market' | 'equip' | 'contracts' | 'status' | 'data' | 'missions' | 'log' | 'chart' | 'local' | 'saves' | 'save-name' | 'naming' | 'new-name' - | 'briefing' | 'combat-sim' | 'test-mode' | 'quit' | 'survivors'; + | 'briefing' | 'combat-sim' | 'test-mode' | 'quit' | 'survivors' | 'courses'; /** What a screen asks the host to do next. */ export type ScreenOutcome = diff --git a/src/ui/screens-courses.ts b/src/ui/screens-courses.ts new file mode 100644 index 00000000..6184c34d --- /dev/null +++ b/src/ui/screens-courses.ts @@ -0,0 +1,36 @@ +// The launch list: where the ship goes when it leaves the station +// (docs/TODO/205 M4). +// +// It is a render function, as every screen's is. `game/screens/courses.ts` +// owns the input and the state. Each row is a click target with the code that +// `COURSE_KEYS` gives its course. A tap and the menu cursor's Enter send the +// same code. + +import type { Course } from '../game/courses.ts'; +import { show } from './screen-shell.ts'; + +/** One row, and the code it sends. */ +export interface CourseRow { + readonly code: string; + readonly course: Course; +} + +export function renderLaunchCourses(rows: readonly CourseRow[], chartCode: string): void { + const line = (r: CourseRow): string => r.course.why === null + ? `
${r.course.what}
` + : `
${r.course.what}` + + ` — ${r.course.why}
`; + show(` +

LAUNCH

+
+
+ WHERE DOES THE SHIP GO WHEN IT LEAVES? +
+ +
+
TAP A ROW · ↑ ↓ SELECT · ENTER CHOOSE · ESC STAY DOCKED
+ `); +} diff --git a/test/constants.test.ts b/test/constants.test.ts index b294b31d..9be582f9 100644 --- a/test/constants.test.ts +++ b/test/constants.test.ts @@ -457,9 +457,10 @@ const OUTSIDE: readonly Group[] = [ // the one sentence the guide and the manual say about the station menu (docs/TODO/202) 'ui/key-help.ts': ['LABELS', 'ALL_BINDINGS', 'STATION_MENU_NOTE'], 'game/command-help.ts': ['COMMAND_HELP'], + // ...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', + 'WHILE_PAUSED', 'COURSE_KEYS', 'COURSE_CHART_KEY', ], 'game/screens/save-transfer.ts': ['NOT_A_SAVE', 'WRONG_VERSION', 'STORE_FULL'], 'engine/keymap.ts': ['LAYOUTS', 'STORAGE_KEY'], diff --git a/test/course-launch.test.ts b/test/course-launch.test.ts new file mode 100644 index 00000000..8c640569 --- /dev/null +++ b/test/course-launch.test.ts @@ -0,0 +1,89 @@ +// The LAUNCH row asks where the ship goes (docs/TODO/205 M4). +// +// Chris's rule of 2026-09-11: no ship leaves without somewhere to go. So the +// row opens a list of courses. A jump starts its countdown at the launch. A +// local course leaves on its work. A row the ship cannot fly refuses, and the +// console says why. These run the real Game through the row's own code. + +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 { COURSE_CHART_KEY, COURSE_KEYS } from '../src/game/bindings.ts'; +import { checkJump } from '../src/game/hyperspace.ts'; +import { check, dismissBriefing, eq } from './harness.ts'; + +console.log('\nthe LAUNCH row asks where the ship goes'); + +/** A fresh commander on the pad, with the menu up. */ +function docked(): Game { + return withoutSaving(() => { + seedWorld(20_260_916); + const game = new Game(() => headlessShell()); + dismissBriefing(game); + return game; + }).value; +} + +/** Press one code, and give the game one frame to answer it. */ +function press(g: Game, code: string): void { + g.input.injectPress(code); + withoutSaving(() => g.step(1 / 60, 1)); +} + +/** The first system the tank can reach, as the chart would set it. */ +function reachable(g: Game): number { + const c = g.state.commander; + const i = g.state.systems.findIndex((_, n) => + checkJump(c, g.state.systems, n, false, false).ok); + if (i < 0) throw new Error('no system in range of a full tank'); + return i; +} + +{ + const g = docked(); + press(g, 'VirtLaunch'); + eq('the LAUNCH row opens the course list rather than leaving', g.mode, 'courses'); + + press(g, COURSE_KEYS.jump); + eq('a jump with no target refuses, and the ship stays on the pad', g.mode, 'courses'); + eq('...and the console says why', g.state.session.messageText, 'NO HYPERSPACE TARGET SET'); + + press(g, COURSE_CHART_KEY); + eq('the chart row opens the galactic chart', g.mode, 'chart'); + press(g, 'Escape'); + eq('...and ESC from the chart comes back to the list', g.mode, 'courses'); + + press(g, 'Escape'); + eq('ESC from the list stays docked', g.mode, 'docked'); +} + +{ + const g = docked(); + g.state.chart.targetIndex = reachable(g); + press(g, 'VirtLaunch'); + press(g, COURSE_KEYS.jump); + eq('a jump with a target leaves the station', g.mode, 'flight'); + eq('...on the jump course', g.state.session.course, 'jump'); + check('...with the countdown already running', g.state.session.hyperCountdown >= 0); +} + +{ + const g = docked(); + g.state.commander.equipment.scoops = true; + g.state.commander.fuel = 10; + press(g, 'VirtLaunch'); + press(g, COURSE_KEYS.skim); + eq('a commander with no target but scoops can leave to skim the star', g.mode, 'flight'); + eq('...on the star course', g.state.session.course, 'skim'); + check('...and no countdown runs', g.state.session.hyperCountdown < 0); +} + +{ + const g = docked(); + press(g, 'VirtLaunch'); + press(g, COURSE_KEYS.mine); + eq('the rocks with no mining laser refuse', g.mode, 'courses'); + eq('...and the console names what is missing', g.state.session.messageText, + 'NEEDS A MINING LASER AND FUEL SCOOPS'); +} diff --git a/test/run.ts b/test/run.ts index 5fc2b2d1..a7c050fd 100644 --- a/test/run.ts +++ b/test/run.ts @@ -73,6 +73,7 @@ import './bribe-flight.test.ts'; import './prompts.test.ts'; import './courses.test.ts'; import './course-pilot.test.ts'; +import './course-launch.test.ts'; import './world.test.ts'; import './docking.test.ts'; import './docking-computer.test.ts'; From ce76026abc969a326e4204d14ada133b598eca38 Mon Sep 17 00:00:00 2001 From: Chris Greening Date: Fri, 11 Sep 2026 22:42:55 +0100 Subject: [PATCH 017/100] docs/TODO/205 M5: the course list flies with the ship, as buttons over the view In flight the course list is a set of buttons at the top right of the view. It shows by itself when the ship has nowhere to go: after an arrival, when a course ends, and when a key takes the ship. A lit button says what the ship is doing, such as HEADING TO THE STATION, and a click or a tap on it opens the list again. A button the ship cannot use says what to do about it. It is not a screen, because the flight world stops under a screen. Each button sends a code, as a menu row does, and the course actions read the codes in flight. No key is bound (Chris: "We don't need to use the keyboard. We have mouse and touch."). The words are the player's (Chris: "A user does not have all our context."). 5,721 assertions, from 5,708. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cpi86XPSQzZQhVhRnZED58 --- docs/ARCHITECTURE.md | 10 +- ...the-ship-goes-and-the-ship-flies-itself.md | 29 ++++- play.html | 7 ++ src/engine/browser-shell.ts | 13 ++- src/game/bindings.ts | 7 ++ src/game/cockpit-view.ts | 35 +++++- src/game/course-actions.ts | 51 +++++++++ src/game/courses.ts | 33 +++++- src/game/flight-instruments.ts | 6 +- src/game/game.ts | 19 +++- src/hud/hud-binding.ts | 3 + src/hud/hud-buttons.ts | 49 +++++++++ src/hud/hud.ts | 9 ++ src/style.css | 31 ++++++ src/ui/key-help.ts | 11 ++ src/ui/screens-courses.ts | 4 +- test/console-plate.test.ts | 2 +- test/constants.test.ts | 9 +- test/course-buttons.test.ts | 101 ++++++++++++++++++ test/course-launch.test.ts | 3 +- test/courses.test.ts | 4 +- test/run.ts | 1 + 22 files changed, 410 insertions(+), 27 deletions(-) create mode 100644 src/hud/hud-buttons.ts create mode 100644 test/course-buttons.test.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c431e954..546f9d85 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -24,9 +24,11 @@ rules. This file is a map. seams cross the platform seam. `onScreenClick` is input. `onScreenMove` reports only: a screen may repaint what it describes, but it must never select or spend. -- The HUD is a read-only painter, and it is three files. `hud/hud-model.ts` +- The HUD is a read-only painter, and it is four files. `hud/hud-model.ts` works out where a marker goes. `hud-binding.ts` turns the state into a - dashboard. `hud.ts` paints one. A screen lives behind `ui/screen-host.ts`, and + dashboard. `hud.ts` paints one. `hud-buttons.ts` paints the buttons over the + flight view, such as the course buttons, and a click on one sends its code + (docs/TODO/205). A screen lives behind `ui/screen-host.ts`, and it reaches the page through `ui/screen-shell.ts`. A screen owns its own rendering, its own input and its own local state. - `src/game/prompts.ts` decides what a key can do about the situation right now. @@ -43,7 +45,9 @@ rules. This file is a map. course asks for: the torus drive, the hand-over to the docking computer, and the end of the course. `course-actions.ts` joins the list to the Game: it builds the flat view, and it applies a pick. At the station, the LAUNCH row - opens `screens/courses.ts`, and a pick leaves on the course. + opens `screens/courses.ts`, and a pick leaves on the course. In flight, the + list is a set of buttons over the view, and not a screen, because the flight + world stops under a screen. - The console is one line, so `SessionState.queued` is the line that waits for it (`session.ts`). Some consequences make sense only after their cause: what a scan cost your legal record, or what a deed cost your reputation. The console diff --git a/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md b/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md index 63f2c333..44ee89a0 100644 --- a/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md +++ b/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md @@ -162,9 +162,10 @@ for its flight menu. Each button sends its course's code from `COURSE_KEYS`, the flight binding table answers it, and the Game applies the pick (invariant 15). -The screen opens by itself when the ship has no course: at a launch, at an -arrival, and when a course ends. A key and a button open it at other times. -Invariant 9 holds: the key lives in the binding table and nowhere else. +The list shows by itself when the ship has no course: at an arrival, when a +course ends, and when a key takes the ship. A button opens it over a course +that flies. No new key does (Chris, 2026-09-11: *"We don't need to use the +keyboard. We have mouse and touch."*). The existing flight keys stay. The course list also carries two fixed rows: the galactic chart and the pause. A phone needs the chart in flight to pick the next jump. @@ -203,6 +204,10 @@ same world state, step for step. It proves each of the four stops above. ## Decisions already made +- **A new control takes a mouse click or a tap, and no key** (Chris, + 2026-09-11: *"We don't need to use the keyboard. We have mouse and + touch."*). The flight keys of today stay as they are. + - **One flow for every player** (Chris, 2026-09-11). A keyboard player and a phone player pick from the same course list. - **The touch controls of 204 go, all five milestones of them** (Chris, @@ -393,3 +398,21 @@ Evidence: `hyperspace-actions.ts` holds the arguments that `startHyperspace` used to pass by hand. - **The suite has 5,708 assertions,** from 5,694. + +### M5 + +- **The course buttons read their codes as a screen reads its own.** + `CourseActions.read` takes the codes of `COURSE_KEYS` in flight. So no key + table spends a row on a button, and the `?` guide keeps no blank rows. +- **Chris ruled out a new key the same day.** The first draft bound Z to open + the list. His words: *"We don't need to use the keyboard. We have mouse and + touch."* The button that opens the list sends its own code instead. +- **The words on the buttons are the player's, and not the plan's.** Chris + asked for plain words the same day: *"A user does not have all our + context."* So no button says "course". A button under way says what the + ship does, such as HEADING TO THE STATION. A reason says what to do, such as + CHOOSE A SYSTEM ON THE GALACTIC CHART FIRST. +- **A launch and an arrival each play a tunnel.** The cockpit reads only a few + keys while one runs, so a button pressed then does nothing. That is the game + of today, and the test waits for the tunnel. +- **The suite has 5,721 assertions,** from 5,708. diff --git a/play.html b/play.html index b7224a57..4a08bf60 100644 --- a/play.html +++ b/play.html @@ -36,6 +36,13 @@ console is deliberately one line. -->
+ +
-433 exported constants. Regenerate with `npm run generate:constants`; +434 exported constants. Regenerate with `npm run generate:constants`; search names, meanings and values with `npm run constants:find -- ""`. | Domain | Symbol | Literal / expression | Purpose | Rule ID | Source | @@ -54,7 +54,7 @@ search names, meanings and values with `npm run constants:find -- ""`. | 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-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 | 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 -- ""`. | 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) | -| 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 -- ""`. | 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 | | | [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 Date: Fri, 11 Sep 2026 22:54:08 +0100 Subject: [PATCH 019/100] docs/TODO/205 M6 lands 205: the manual and the briefing teach the buttons The manual's first hour and the briefing's FLY THERE page now teach the launch list, FLY TO THE STATION and FAST FORWARD, in place of the jump key and the torus key. The manual says that a steering key takes the controls back. The briefing's journey test now asks for the buttons. The browser run found two faults. The station menu's two columns split the launch list, so the list has one column. The course buttons showed during a tunnel, where a press does nothing, so none show then. 205 lands: the flight probe runs, and the dock probe docks 504 of 504 approaches with no scrape. 5,733 assertions. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cpi86XPSQzZQhVhRnZED58 --- docs/TODO/QUEUE.json | 1 - docs/TODO/README.md | 17 +++++++--- ...the-ship-goes-and-the-ship-flies-itself.md | 31 +++++++++++++++++ docs/TODO/completed/README.md | 1 + manual.html | 33 ++++++++++++++----- src/game/courses.ts | 2 +- src/game/game.ts | 6 ++-- src/style.css | 9 +++++ src/ui/briefing.ts | 31 +++++++++-------- src/ui/screens-courses.ts | 6 ++-- test/key-help.test.ts | 9 ++++- 11 files changed, 110 insertions(+), 36 deletions(-) rename docs/TODO/{ => completed}/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md (93%) diff --git a/docs/TODO/QUEUE.json b/docs/TODO/QUEUE.json index 22d804bd..d82fa14b 100644 --- a/docs/TODO/QUEUE.json +++ b/docs/TODO/QUEUE.json @@ -1,7 +1,6 @@ { "version": 1, "items": [ - 205, 206, 207, 208 diff --git a/docs/TODO/README.md b/docs/TODO/README.md index 20dae05a..a96af9e3 100644 --- a/docs/TODO/README.md +++ b/docs/TODO/README.md @@ -13,10 +13,9 @@ active context: ## Execution queue -1. [205](205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md) — the pilot picks where the ship goes, and the ship flies itself. -2. [206](206-the-computer-flies-the-fight-and-the-pilot-fires.md) — the computer flies the fight, and the pilot fires. -3. [207](207-the-pilot-takes-the-ship-into-the-slot-by-hand.md) — the pilot takes the ship into the slot by hand. -4. [208](208-every-kind-of-mission-has-a-way-to-fly-it.md) — every kind of mission has a way to fly it. +1. [206](206-the-computer-flies-the-fight-and-the-pilot-fires.md) — the computer flies the fight, and the pilot fires. +2. [207](207-the-pilot-takes-the-ship-into-the-slot-by-hand.md) — the pilot takes the ship into the slot by hand. +3. [208](208-every-kind-of-mission-has-a-way-to-fly-it.md) — every kind of mission has a way to fly it. **205 TO 208 CAME FROM CHRIS ON 2026-09-11, the day 204 landed.** He flew the touch controls of 204 on a phone, and he could not fly the ship. He @@ -26,7 +25,7 @@ pilot fires the laser, the missiles and the E.C.M. The dock stays a challenge. The missions become part of the flight. 205 also takes out the touch controls of 204. He played four docking concepts the same day, and he picked Match the spin for 207. The plans are written, and the work waits -on his word. +on his word. **205 landed the same day**, and it is below. **204 CAME FROM CHRIS ON 2026-09-10.** The docked screens and the cockpit fit a phone, and nothing on the flight screen takes a touch. The brainstorm set @@ -298,6 +297,14 @@ it: *"display is good"*. **#23** closed with 134, as #22 did with 127, #18 with ## What landed on 2026-09-11 +**205 — the pilot picks where the ship goes, and the ship flies itself.** +The LAUNCH row asks where to go, and a ship with nowhere to go stays on the +pad. In flight, buttons over the view show where the ship can go: the +station, a derelict, the rock hermit and the star. A click or a tap sends the +ship, and a flight key takes it back. FAST FORWARD runs the world eight times +faster while nothing hostile is near. The work found a hermit that could +appear inside the planet, and nothing does now. 5,733 assertions, from 5,624. + **204 — the ship flies by touch.** Nothing on the flight screen took a touch. A drag anywhere on the view is the mouse stick now, and a held finger holds it. FIRE holds the trigger. A slider sets a wanted speed, and diff --git a/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md b/docs/TODO/completed/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md similarity index 93% rename from docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md rename to docs/TODO/completed/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md index 00c0c5cf..888e37d3 100644 --- a/docs/TODO/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md +++ b/docs/TODO/completed/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md @@ -438,3 +438,34 @@ Evidence: - **The skip stops at the condition light's own rule.** `hostilesNear` turns the light red, so the pilot sees the same reason on the dashboard. - **The suite has 5,732 assertions,** from 5,721. + +### M6 + +- **The manual and the briefing now teach the buttons.** The launch list, + FLY TO THE STATION and FAST FORWARD replace the jump key and the torus key + in the first journey. The manual says that a steering key takes the + controls back. +- **The briefing's own test pinned the old journey.** `test/key-help.test.ts` + asked the briefing to quote the jump key and the torus key. It now asks it + to name the three buttons instead. +- **The fight text and the docking text wait for 206 and 207.** Those plans + change what they describe. +- **The browser run on 2026-09-11 found two faults, and both are mended.** + The station menu's two columns split the launch list, so the list has one + column. The course buttons showed during a tunnel, where a press does + nothing, so the cockpit shows none then. + +## Outcome + +205 landed on 2026-09-11. The pilot picks where the ship goes, and the ship +flies itself. The launch list asks where to go, and a ship with nowhere to go +stays on the pad. In flight, buttons over the view show the courses: the +station, the derelict, the hermit and the star. FAST FORWARD runs the world +eight times faster while nothing hostile is near, and it changes no step. The +work found and mended a hermit that could appear inside the planet. + +The measurements: the station course docked 50 of 50 trips, with a median of +148 s in the arrival's traffic. The hermit, derelict and star courses arrived +20 of 20, 20 of 20 and 40 of 40 times. The dock probe docked 504 of 504 +approaches with no scrape. The suite has 5,733 assertions, from 5,619 before +204. diff --git a/docs/TODO/completed/README.md b/docs/TODO/completed/README.md index 5d6964a9..547490a3 100644 --- a/docs/TODO/completed/README.md +++ b/docs/TODO/completed/README.md @@ -214,3 +214,4 @@ Supporting records: [Elite-A alignment plan](ELITE-A-COMBAT-PLAN.md), - [x] 201 — [A held mission keeps its briefing](201-a-held-mission-keeps-its-briefing.md) — Chris's playtest, 2026-09-10. An offer row showed the dossier's title and briefing, and a held row showed the current order alone. **M1** gives a held row the title and the pages, filled with the world it was accepted at, and draws the order under them in amber. 5,567 assertions. - [x] 202 — [The station menu takes a tap or the cursor](202-the-station-menu-takes-a-tap-or-the-cursor.md) — Chris, 2026-09-10. ⇧R opened the missions when the game missed the Shift keydown. **M1** makes a real key press carry the event's own modifier. **M2** makes every station command a row on a virtual code, with no letter and no shift, nineteen rows in two columns. **M3** and **M4** take the letters out of the briefing, the boot plate, the README and the manual. Every flight key stays. 5,576 assertions. - [x] 203 — [A mission tells the player where to go](203-a-mission-tells-the-player-where-to-go.md) — Chris's playtest, 2026-09-10. The game never sent the machine an arrival or a day, so one job could not complete and no deadline passed. **M1** sends both. **M2** spawns a target inside the scanner, keeps a scan's subject, and puts pirates on the lane. **M3** marks the target on the scanner and the screen. **M4** makes the console speak at every change, in full sentences. **M5** counts a scan while the subject is in view, with no missile. **M6** says how a job plays in the manual. 5,619 assertions. +- [x] 205 — [The pilot picks where the ship goes, and the ship flies itself](205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md) — Chris, 2026-09-11. **M1** takes out the touch controls of 204. **M2** is the course list, **M3** the pilot that flies it, **M4** the launch list and **M5** the buttons in flight. **M6** is the manual and the briefing, and **M7** is FAST FORWARD. 5,733 assertions. diff --git a/manual.html b/manual.html index bcaebf69..6e8488be 100644 --- a/manual.html +++ b/manual.html @@ -45,12 +45,16 @@

Your First Flight

  • Pick somewhere to sell it. LOCAL CHART on the station menu opens the short range chart. Anything inside the dashed circle is within your fuel. Industrial worlds pay well for what agricultural worlds grow.
  • -
  • Target it and jump. Move the cursor with the arrow keys, - press ENTER to target, then LAUNCH from the station - menu and H to make the jump.
  • -
  • Fly to the station. You arrive a long way out. Point at the - planet and press J for the torus drive; it cuts out near - anything solid, including pirates.
  • +
  • Target it and jump. Click the world on the chart (or move + the cursor with the arrow keys and press ENTER). Then + press LAUNCH on the station menu. The station asks where you + want to go: choose JUMP TO your target, and the ship leaves + and makes the jump by itself.
  • +
  • Fly to the station. You arrive a long way out. The buttons + at the top right show where the ship can go. Choose FLY TO THE + STATION and it flies there on the torus drive, which cuts out + near anything solid, including pirates. FAST FORWARD speeds + up the wait while nobody hostile is near.
  • Dock. Line up with the rotating port and go in slowly. See Docking. It is the hardest thing you will do today.
  • @@ -133,6 +137,15 @@

    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…

    @@ -234,9 +247,11 @@

    Hyperspace & the Charts

    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.

    The dashed circle is how far your fuel will take you — ${MAX_FUEL / 10} light years on a full tank. Anything inside it you can reach.

    - Move the cursor with the arrow keys, press ENTER to set your - target, D for a full report on a world, and F to search by - name. Look for an economy opposite to this one.`, + Click or tap a world to set your target. You can also move the cursor + with the arrow keys and press ENTER. D gives a full + report on a world, and F searches by name. Look for an economy + opposite to this one.`, }, { title: 'FLY THERE', - body: `${ROW.launch} from the station menu, then ${KEY.jump} - to jump once you are clear of the station. The game saves on its own: a checkpoint at - every docking, and an autosave every ${AUTOSAVE_INTERVAL} seconds in - flight.

    - You come out of hyperspace a long way from the planet. Point at it and - press ${KEY.torus} for the torus drive — ${TORUS_MULTIPLIER} times speed. It cuts out near - anything with mass: a planet, a station, or somebody who has come to meet - you.

    + body: `Press ${ROW.launch} on the station menu. The station asks + where you want to go: choose JUMP TO your target, and the ship + leaves and makes the jump by itself. The game saves on its own: a + checkpoint at every docking, and an autosave every ${AUTOSAVE_INTERVAL} + seconds in flight.

    + You come out of hyperspace a long way from the planet. The buttons at + the top right show where the ship can go. Choose + FLY TO THE STATION and it flies there on the torus drive, at + ${TORUS_MULTIPLIER} times speed. The drive cuts out near anything with mass: a planet, a + station, or somebody who has come to meet you. FAST FORWARD makes + the trip quicker while nothing hostile is near.

    Watch the scanner in the middle of the console. You are the centre. Red - contacts are hostile.`, + contacts are hostile. Press a steering key at any time and you fly the + ship by hand.`, }, { title: 'A FIGHT', diff --git a/src/ui/screens-courses.ts b/src/ui/screens-courses.ts index 1caa1cf7..56b93a2a 100644 --- a/src/ui/screens-courses.ts +++ b/src/ui/screens-courses.ts @@ -18,15 +18,15 @@ export interface CourseRow { export function renderLaunchCourses(rows: readonly CourseRow[], chartCode: string): void { const line = (r: CourseRow): string => r.course.why === null ? `
    ${r.course.what}
    ` - : `
    ${r.course.what}` - + ` — ${r.course.why}
    `; + : `
    ${r.course.what}` + + `${r.course.why}
    `; show(`

    LAUNCH

    CHOOSE WHERE TO GO
    -