From 81fcb2b0f340edf8e53239980b71607049cf3319 Mon Sep 17 00:00:00 2001 From: Chris Greening Date: Thu, 10 Sep 2026 22:06:30 +0100 Subject: [PATCH 001/110] 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/110] 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/110] 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/110] 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/110] 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/110] 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/110] 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/110] 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/110] 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/110] 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/110] 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
    - ` : `
    ` + + ` class="hud-button${b.note || b.dim ? ' dim' : ''}${b.lit ? ' lit' : ''}${b.icon ? ' icon' : ''}${b.hostile ? ' hostile' : ''}">` + `${text(b.label)}${b.note ? `${text(b.note)}` : ''}` + `${b.hint ? `${text(b.hint)}` : ''}
    `)).join(''); if (html === this.shown) return; diff --git a/src/style.css b/src/style.css index 8ae4b66f..8271cfe2 100644 --- a/src/style.css +++ b/src/style.css @@ -474,7 +474,7 @@ body.screen-open #courses { visibility: hidden; } } #course-row::-webkit-scrollbar, #target-row::-webkit-scrollbar { display: none; } #course-row { top: 100px; } -#target-row { bottom: calc(var(--console-h, 180px) + 14px + 58px); } +#target-row { bottom: calc(var(--console-h, 180px) + 14px + 36px); } #course-row .hud-button, #target-row .hud-button { flex: 0 0 auto; min-width: 150px; max-width: 70vw; @@ -506,9 +506,16 @@ body.screen-open #course-row, body.screen-open #target-row { visibility: hidden; position: absolute; left: 12px; bottom: calc(var(--console-h, 180px) + 14px); z-index: 21; - display: flex; flex-direction: column; align-items: stretch; gap: 6px; + display: flex; flex-direction: column; align-items: flex-start; gap: 6px; width: min(46vw, 260px); } +/* The header is an icon with the count, small (docs/TODO/222). */ +#targets .hud-button.icon { min-height: 30px; padding: 2px 12px; font-size: 16px; letter-spacing: 2px; } +/* A ship that attacks the commander reads red, in the row and on the icon. */ +#targets .hud-button.hostile, #target-row .hud-button.hostile { + color: var(--hud-red); border-color: rgba(var(--hud-red-rgb), 0.6); +} +#targets .hud-button.hostile.lit, #target-row .hud-button.hostile.lit { color: var(--hud-amber); } /* * The guns (docs/TODO/215 M1): a row at the bottom of the console. Chris * drew it there on 2026-09-13, so the sky is the sky. The laser is the wide @@ -1056,7 +1063,7 @@ body.screen-open #exercise { display: none; } * writes `--console-h` at each resize. The desktop keeps its own numbers, * which were placed by eye against a console of a fixed height. */ - #message { font-size: 13px; letter-spacing: 2px; bottom: calc(var(--console-h, 0px) + 6dvh); } + #message { font-size: 13px; letter-spacing: 2px; bottom: calc(var(--console-h, 0px) + 56px); } /* * The button row of a long table sticks to the bottom of the panel. The * market has 17 rows, and on a phone BUY and SELL sat below the fold. The @@ -1118,6 +1125,9 @@ body.screen-open #exercise { display: none; } .hud-left .gauge:nth-child(6) { grid-column: 2; grid-row: 8; } #guns { grid-column: 1 / -1; grid-row: 9; margin-top: 4px; } #guns .hud-button { font-size: 12px; padding: 4px 4px; } + /* A phone's gun row keeps its notes and drops its hints, or the row is a + third of the screen (docs/TODO/222). The manual carries the hints. */ + #guns .hud-button .hint { display: none; } /* 64 pixels holds LASER TEMP on one line at this size. 62 broke it in two. */ .gauge label { width: 64px; font-size: 9px; letter-spacing: 0.5px; } /* Four items in one row do not fit. They wrap by item, never inside one. */ diff --git a/src/ui/briefing.ts b/src/ui/briefing.ts index 04584ffa..e8233a13 100644 --- a/src/ui/briefing.ts +++ b/src/ui/briefing.ts @@ -114,9 +114,9 @@ export const BRIEFING: { title: string; body: string }[] = [ body: `Sooner or later somebody opens fire. The computer takes the stick and lines the ship up. The shooting is yours: hold FIRE LASER at the bottom right, and keep the bursts short, - because lasers overheat. TARGETS above it lists what is out + because lasers overheat. The \u25CE button above it lists what is out there, and a row sends the computer after that one.

    - ARM A MISSILE arms one, and it locks when a ship crosses your + ARM MISSILE arms one, and it locks when a ship crosses your sights. Press it again to fire. A missile costs money, so the computer never spends one for you. E.C.M. destroys a missile coming at you.

    diff --git a/test/close-pass.test.ts b/test/close-pass.test.ts index 7a2ae2e0..c2a3df15 100644 --- a/test/close-pass.test.ts +++ b/test/close-pass.test.ts @@ -35,7 +35,7 @@ const lines = (toStation = 60_000): string[] => closePassLines({ const first = lines(); eq('one line, for the one trader inside the lock radius', first.length, 1); check('...and it names the ship', first[0].includes(trader.object.name.toUpperCase())); -check('...and it says what to do with it', first[0].includes('TARGETS')); +check('...and it says what to do with it', first[0].includes('TAP \u25CE')); check('the pirate gets no line, because it announces itself, and it is not a trader', world.npcs.some((n) => n.role === 'pirate') && first.length === 1); check('...the rock gets none either', rock.role === 'asteroid' && first.length === 1); diff --git a/test/fight-buttons.test.ts b/test/fight-buttons.test.ts index 4ffdcd12..9feb61ed 100644 --- a/test/fight-buttons.test.ts +++ b/test/fight-buttons.test.ts @@ -27,9 +27,9 @@ const base = { const b = gunButtonsFor(base); // Five since docs/TODO/219 M5: the cloak, which no shop sells, keeps its place // so a thumb learns the row. - eq('the row is five buttons, left to right', b.map((x) => x.label).join('|'), 'FIRE LASER|ARM A MISSILE|FIRE THE MISSILE|E.C.M.|CLOAK'); + eq('the row is five buttons, left to right', b.map((x) => x.label).join('|'), 'FIRE LASER|ARM MISSILE|FIRE MISSILE|E.C.M.|CLOAK'); check('the laser button is the first, and it holds the fire key rather than tapping it', b[0].hold === true && b[0].code === 'KeyA'); - eq('an unarmed missile button arms one', b.find((x) => x.code === 'KeyT')?.label, 'ARM A MISSILE'); + eq('an unarmed missile button arms one', b.find((x) => x.code === 'KeyT')?.label, 'ARM MISSILE'); eq('...and says how many are left', b.find((x) => x.code === 'KeyT')?.hint, '3 LEFT'); eq('...and the fire button waits, dim', b[2].note, 'NOT ARMED'); const armed = gunButtonsFor({ ...base, armed: true, locked: true }); @@ -46,15 +46,22 @@ const base = { check('...and with one it is live', gunButtonsFor({ ...base, ecmKey: 'KeyE' })[3].code === 'KeyE'); check('...and lit while a missile is inbound', gunButtonsFor({ ...base, ecmKey: 'KeyE', cloakKey: null, cloaked: false, missileInbound: true })[3].lit === true); // The target list is its own column, on the left (Chris, 2026-09-13). - const row = { code: 'VirtTarget1', row: { ship: {} as never, name: 'KRAIT', range: 1000, standing: 'HOSTILE', picked: false, cost: '' } }; - const listed = { ...base, targets: { open: false, rows: [row], picked: null } }; - check('the TARGETS button is in its own column, and not among the guns', + const row = { code: 'VirtTarget1', row: { ship: {} as never, name: 'KRAIT', range: 1000, standing: 'HOSTILE', hostile: true, picked: false, cost: '' } }; + const trader = { code: 'VirtTarget2', row: { ship: {} as never, name: 'PYTHON', range: 2000, standing: 'TRADER', hostile: false, picked: false, cost: '' } }; + const listed = { ...base, targets: { open: false, rows: [row, trader], picked: null } }; + check('the targets button is in its own column, and not among the guns', targetButtonsFor(listed).some((x) => x.code === TARGETS_KEY) && !gunButtonsFor(listed).some((x) => x.code === TARGETS_KEY)); - eq('closed, the header says how many are on the scanner', targetButtonsFor(listed)[0]?.hint, '1 ON THE SCANNER'); + // An icon with the count (docs/TODO/222), red while a hostile is on the scanner. + const header = targetButtonsFor(listed)[0]; + check('closed, the header is an icon with the count', header?.icon === true && header.label === '\u25CE 2' && header.hint === undefined); + check('...red, because the Krait attacks', header?.hostile === true); + check('...and green with the trader alone', targetButtonsFor({ ...base, targets: { open: false, rows: [trader], picked: null } })[0]?.hostile === false); eq('...and the row is empty', targetRowFor(listed).length, 0); - const open = { ...base, targets: { open: true, rows: [row], picked: null } }; - eq('open, the header folds, and the row lists the ship (docs/TODO/215 M2)', - targetButtonsFor(open)[0]?.label + ' / ' + targetRowFor(open).map((x) => x.label).join('|'), 'TARGETS ▴ / KRAIT'); + const open = { ...base, targets: { open: true, rows: [row, trader], picked: null } }; + eq('open, the header is lit, and the row lists the ships (docs/TODO/215 M2)', + String(targetButtonsFor(open)[0]?.lit) + ' / ' + targetRowFor(open).map((x) => x.label).join('|'), 'true / KRAIT|PYTHON'); + check('...and the hostile row reads red, and the trader\'s does not', + targetRowFor(open)[0].hostile === true && targetRowFor(open)[1].hostile === false); eq('...and while the pilot flies the slot, both are empty', targetButtonsFor({ ...open, trial: true }).length + targetRowFor({ ...open, trial: true }).length, 0); } diff --git a/test/key-help.test.ts b/test/key-help.test.ts index 3159aae6..ef925569 100644 --- a/test/key-help.test.ts +++ b/test/key-help.test.ts @@ -318,7 +318,7 @@ console.log('\nthe briefing surfaces the whole first journey'); // The fast forward button is an icon since docs/TODO/221, so its glyph is // what the briefing teaches. const buttons = ['JUMP TO', 'FLY TO THE STATION', '\u25B6\u25B6', - 'FIRE LASER', 'TARGETS', 'ARM A MISSILE', 'E.C.M.', 'RUN FOR IT']; + 'FIRE LASER', '\u25CE', 'ARM MISSILE', 'E.C.M.', 'RUN FOR IT']; const untaught = buttons.filter((b) => !text.includes(`${b}`)); check(`...and every button of the trip is named (${buttons.length})`, untaught.length === 0, untaught.join(', ')); From eb5a3b0852942e44aecb964e9df19ede5724e22e Mon Sep 17 00:00:00 2001 From: Chris Greening Date: Mon, 14 Sep 2026 12:25:00 +0100 Subject: [PATCH 102/110] Plan 222 lands: the targets header is an icon, and a hostile reads red The plan moves to the archive with what it found. The queue is empty. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Cpi86XPSQzZQhVhRnZED58 --- docs/TODO/README.md | 6 ++ ...ader-is-an-icon-and-a-hostile-reads-red.md | 94 +++++++++++++++++++ docs/TODO/completed/README.md | 1 + 3 files changed, 101 insertions(+) create mode 100644 docs/TODO/completed/222-the-targets-header-is-an-icon-and-a-hostile-reads-red.md diff --git a/docs/TODO/README.md b/docs/TODO/README.md index 440c533d..c4c01b01 100644 --- a/docs/TODO/README.md +++ b/docs/TODO/README.md @@ -15,6 +15,12 @@ active context: Nothing is queued. +**222 CAME FROM CHRIS ON 2026-09-14**, with a picture from his phone. The +targets header stood on the console, every target read green, and the gun +row took a third of the console. 222 measures the console again when it +changes, makes the header an icon with the count, and reads a hostile red. +**222 landed the same day**, and it is below. + **221 CAME FROM CHRIS ON 2026-09-13**, from his phone. The fast forward button is a bar as wide as the course header. 221 makes it an icon. **221 landed the same day**, and it is below. diff --git a/docs/TODO/completed/222-the-targets-header-is-an-icon-and-a-hostile-reads-red.md b/docs/TODO/completed/222-the-targets-header-is-an-icon-and-a-hostile-reads-red.md new file mode 100644 index 00000000..e1a96f96 --- /dev/null +++ b/docs/TODO/completed/222-the-targets-header-is-an-icon-and-a-hostile-reads-red.md @@ -0,0 +1,94 @@ +# 222 — The targets header is an icon, and a hostile reads red + +**Kind:** fix · **Severity:** medium · **Size:** small · **Depends on:** +nothing · **Blocks:** nothing · **GitHub:** none + +## Where we are + +Chris, 2026-09-14, with a picture from his phone: *"The targets button +overlaps the console. Also - on the list of targets - hostile targets +should be red not green. Maybe the targets button could be an icon? Think +carefully about the UI and available screen space."* + +The picture shows three things. The TARGETS header stands on the +console's scanner, and the console line TORUS DRIVE ENGAGED runs across +it. The gun row takes a third of the console, because each button wraps +its words over three lines with a hint under them. And every ship in the +target row is green, whether it attacks the commander or not. + +The overlap has a cause the picture does not show. The shell measures the +console's height at a window resize and writes it for the stylesheet. On +the phone the console grew after that measure, when the fonts landed and +the gun row wrapped, and nothing measured it again. So the header and the +message line stood on a console that was no longer there. + +## What to do + +One milestone. + +- The shell watches the console itself, and measures it again whenever it + changes size, where the browser can watch an element. +- The targets header is an icon with the count: a bullseye and how many + are on the scanner. It is small, at the bottom left, lit while the row + is open, and red while any ship on the scanner attacks the commander. So + the icon is the threat tell too. +- A ship that attacks the commander reads red in the row. A picked ship + keeps its amber. +- The message line on a phone stands above the icon. +- The gun row on a phone keeps its notes and drops its hints, and the two + missile buttons say ARM MISSILE and FIRE MISSILE. The manual carries the + hints. +- The briefing and the manual name the icon where they named the words. + +## Decisions already made + +- **An icon with the count, and red for a hostile** (Chris, 2026-09-14). +- **The course header keeps its words.** It says what the ship is doing, + and that is the one line a pilot reads on the sky. +- **The gun row's hints go on a phone alone.** A desktop has the room, and + a thumb learns five buttons in a row. + +## Open questions + +None. + +## Watch out for + +- **The briefing test lists the buttons it teaches by their words.** The + icon and the shorter labels take the words' places in that list. +- **The target row sits above the header**, and the icon is shorter than + the header was, so the row moves down with it. + +## Verification + +The gates always run: `npm run check`. + +Evidence: + +- A test reads the icon with the count, red with a hostile on the scanner + and green without, and lit when the row is open. +- A test reads a hostile row red and a trader's row not. +- Checked by eye in a 390 by 844 frame. + +## What the milestones found + +- **The console was 265 pixels tall and measured at 203** in the phone + frame, which is the overlap in the picture. A resize observer on the + console closes that gap on a phone. A hidden tab gets no rendering step, + so the frame here needed a resize event to show the same. +- **The icon is lit when open, and red when closed with a hostile near.** + Open, the row itself shows the red, so the icon's amber says the one + thing the row cannot: that a tap folds it. +- **The gun row is two lines on a phone**, from three or four, with the + hints gone and the missile words shorter. The notes stay, because NOT + ARMED and NOT FITTED are why a button does nothing. +- **A console line named the old header**, A SHIP IS CLOSE — OPEN TARGETS, + and it names the icon now. + +## Outcome + +Landed 2026-09-14, in one milestone. The targets header is a bullseye +with the count, red while a hostile is on the scanner. A hostile reads +red in the row. The console is measured again whenever it changes, so +nothing stands on it. The gun row on a phone is two lines. 6,163 +assertions. diff --git a/docs/TODO/completed/README.md b/docs/TODO/completed/README.md index e3989ece..c839886d 100644 --- a/docs/TODO/completed/README.md +++ b/docs/TODO/completed/README.md @@ -225,6 +225,7 @@ Supporting records: [Elite-A alignment plan](ELITE-A-COMBAT-PLAN.md), - [x] 213 — [The mission rules hold, and a hit trader goes back to work](213-the-mission-rules-hold-and-a-hit-trader-goes-back-to-work.md) — Chris, 2026-09-13. Two reviews read the missions and flew every kind of job. **M1** makes a hit trader go back to work, and the escort ends. **M2** makes a side job come back, unloads a smuggling run, gates every arc to galaxy 1, and makes a scoop job wait for the scoops. **M3** keeps the gun warning for the Constrictor, and lets a lead's message through a board of side jobs. **M4** drops a ghost at the loader, gives the journal a galaxy, keys standing by the origin, and restores a mission ship under its leg's role. **M5** holds every trigger a verb can emit in the lint. 6,029 assertions. - [x] 215 — [The gun row lives in the console, and a list runs sideways](215-the-gun-row-lives-in-the-console-and-a-list-runs-sideways.md) — Chris, 2026-09-13. He flew the game on his phone, and three columns of buttons covered the fight. He drew the shape. **M1** puts the guns in a row at the bottom of the console, the missile as two buttons, and drops the missile squares. **M2** folds each list to a header, closed by default, that opens as one row that scrolls sideways, with one fold for both. **M3** says so in the manual. 6,072 assertions. - [x] 214 — [A job has a surprise in it](214-a-job-has-a-surprise-in-it.md) — Chris, 2026-09-13. He said the missions were too easy, and asked for surprises. **M1** puts pirates at the jump-in of every job that sends the commander somewhere. **M2** springs an ambush on a scoop and at a scan's end. **M3** makes the escort's charge hold for the commander, asks for her presence at the fee, and sends a pack for the charge. **M4** makes a hunt's target run when it is nearly dead, and measures that the run alone moves no number. 6,153 assertions. +- [x] 222 — [The targets header is an icon, and a hostile reads red](222-the-targets-header-is-an-icon-and-a-hostile-reads-red.md) — Chris, 2026-09-14, with a picture from his phone. The header stood on a console measured before it grew, so the shell watches the console now. The header is a bullseye with the count, red while a hostile is on the scanner, and a hostile row reads red. The gun row on a phone is two lines. 6,163 assertions. - [x] 221 — [The fast forward button is an icon](221-the-fast-forward-button-is-an-icon.md) — Chris, 2026-09-13, from his phone. The button was a bar as wide as the course header. It is two arrows now, small, under the header, lit while it runs and dim while it cannot start. 6,159 assertions. - [x] 219 — [The Dark Wheel comes for a commander](219-the-dark-wheel-comes-for-a-commander.md) — Chris, 2026-09-13. A secret society at the higher ratings, with the old names. **M1** makes the Wheel a patron with no world, on the lawless boards, with the mark at Above Average. **M2** flies police as company and puts a parcel past three Vipers. **M3** places a leg in witchspace, and a pod among the Thargoids. **M4** puts a Thargoid at the door at Competent, and grants the cloaking device. **M5** makes the cloak work: nobody sees the ship, the bank pays, and a shot gives it away. 6,159 assertions. - [x] 220 — [The phone holds its zoom, hides its bar, and reads the chart](220-the-phone-holds-its-zoom-hides-its-bar-and-reads-the-chart.md) — Chris, 2026-09-13, from his phone. No zoom on a double tap or a pinch, by the viewport, the root's touch rule and the shell's own listeners. A manifest and the app meta tags hide the address bar from the home screen, and the first touch asks for fullscreen where a phone offers it. The short range chart's names are drawn in CSS pixels, twelve of them, and an edge name sits left of its dot. 6,153 assertions. From 8dac9be89313ac47bf4f56340848e69987e67cc9 Mon Sep 17 00:00:00 2001 From: Chris Greening Date: Mon, 14 Sep 2026 14:16:57 +0100 Subject: [PATCH 103/110] The landing page speaks to a player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/TODO/223 M1. The note under LAUNCH said "keyboard and mouse". The game plays by touch since docs/TODO/202, 216 and 220 to 222, so it says "keyboard, mouse or touch". A Fly bullet says the ship flies a course to the station, the rocks or the star, and that ▶▶ skips the quiet part. A bullet says the game plays on a phone and opens from the home screen. The "Nobody in the game tells you where to go" line says what the game does tell you. The title did not fit a phone. In a 390-pixel frame it needed 409 pixels in a 338-pixel column, and the page scrolled sideways. The stylesheet lowers the floors of its size and its spacing, and the body never scrolls sideways. Measured again: the title fills the column, and the document is as wide as the viewport. docs/PAGES-REVIEW.md is the review that found all of it, and plan 223 is the clean-up Chris asked for. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Cpi86XPSQzZQhVhRnZED58 --- docs/PAGES-REVIEW.md | 312 ++++++++++++++++++ ...me-is-and-leave-the-history-to-the-logs.md | 107 ++++++ docs/TODO/QUEUE.json | 4 +- docs/TODO/README.md | 8 +- index.html | 12 +- src/landing.css | 9 +- 6 files changed, 444 insertions(+), 8 deletions(-) create mode 100644 docs/PAGES-REVIEW.md create mode 100644 docs/TODO/223-the-pages-say-what-the-game-is-and-leave-the-history-to-the-logs.md diff --git a/docs/PAGES-REVIEW.md b/docs/PAGES-REVIEW.md new file mode 100644 index 00000000..12696062 --- /dev/null +++ b/docs/PAGES-REVIEW.md @@ -0,0 +1,312 @@ +# The pages are right where a test reads them, and stale where none does + +Review date: 14 September 2026. Source revision: `eb5a3b0`, branch +`the-ship-flies-itself`. The two pages are [`README.md`](../README.md) and +[`index.html`](../index.html), the landing page at +[harmless.atomic14.com](https://harmless.atomic14.com/). + +No page changed during this review. Both pages are Chris's. CLAUDE.md says the README opening is his own +writing, and that the landing page is a player page. +This document lists what the code contradicts, what the code has that the pages +do not say, and what reads badly. A proposed plan is at the end, with the +questions only Chris can answer. + +The short version. Every table and every number that a test reads is correct. +The Commands table, the footer line and the phone metas each have a gate. The +prose around them drifted. The README describes the missions of July, and it says a +survivor becomes cargo. It never says that the ship flies a course, or that +the game plays on a phone. The landing page tells a phone visitor that the +game needs a keyboard and a mouse, and its title does not fit a phone screen. + +## Scope and measurement + +The review read both pages in full. It checked every claim against the code +that runs: + +- the scripts in `package.json` and the page inputs in `vite.config.ts`; +- the key map (`src/engine/keymap.ts`), the binding table + (`src/game/bindings.ts`) and the command help (`src/game/command-help.ts`); +- the constants the pages quote, by name; +- the mission skeletons under `src/missions/skeletons/`; +- the wave ramp (`src/game/combat-sim-scenarios.ts`), driven with Node to get + the wave numbers; +- the hull catalogue (`docs/ELITE-A.md`) and every file the README links. + +It rendered the landing page in Chrome at 1512 pixels wide and in a 390-pixel +frame, and it measured the title's width in the frame. + +Four tests read the pages. `test/key-help.test.ts` holds the README's Commands +table to the binding table in both directions. `test/site-footer.test.ts` +holds the footer line. `test/phone-page.test.ts` holds the phone metas. +`test/tour-page.test.ts` holds the missions page that the landing page links. +Everything those tests read is correct. Every finding below is in text that no +test reads. + +## README.md + +### High priority: the code contradicts the page + +**1. The missions paragraph describes one mission. The game has fifteen.** +[`README.md:387`](../README.md#L387) says: "Navy missions — prove yourself +(16+ kills, galaxy 1) for the Constrictor hunt and the classified courier +run." That arc is still there +([`constrictor.ts:34`](../src/missions/skeletons/constrictor.ts#L34)). Beside +it the game now has: + +- five arcs of the tour, one patron on each of five worlds across the first + galaxy, seventeen legs in all + ([`skeletons/arcs/`](../src/missions/skeletons/arcs/)); +- eight side jobs, one per verb, drawn two or three per station from the seed + ([`side.ts`](../src/missions/skeletons/side.ts)). Four of them wait for the + commander. The gang hunt needs eight kills. The lane and the escort jobs + need four. The recover and rescue jobs need fuel scoops + ([`side.ts:42`](../src/missions/skeletons/side.ts#L42), + [`:141`](../src/missions/skeletons/side.ts#L141), + [`:175`](../src/missions/skeletons/side.ts#L175), + [`:80`](../src/missions/skeletons/side.ts#L80)); +- the Dark Wheel, four missions that open at Above Average and end at + Competent, with a reward no shop sells + ([`skeletons/wheel/`](../src/missions/skeletons/wheel/), + [`constants/missions.ts:162`](../src/constants/missions.ts#L162)). + +The page also never names the MISSIONS screen (R), the COMMANDER'S LOG (⇧R) or +the site's own [missions page](https://harmless.atomic14.com/missions), which +the landing page does link. The Commands table has the two rows, so the +reader meets the keys with no idea what they open. + +**2. A survivor is not cargo, and the page says it is.** +[`README.md:393`](../README.md#L393): "scoop one and the survivor becomes, +regrettably, cargo". [`src/game/survivors.ts:12`](../src/game/survivors.ts#L12) +states the rule in capitals: a survivor is not cargo and must not become cargo +(docs/TODO/108). A survivor rides in the crew spaces, and the dock forces the +choice of what becomes of them (`screens/survivors.ts`). The sentence was true +once, and the code moved. + +**3. The page never says that the ship flies a course.** That is this +branch's subject. The cockpit offers a course list +([`courses.ts:39`](../src/game/courses.ts#L39)). The list holds the jump, +the mission target, the station, a derelict, the rocks, the hermit, the +star, a run, and a canister to collect. A +computer flies the one the pilot picks, and a header over the view names it. +The `▶▶` button runs time forward while nothing hostile is near. The docking +computer is one button on that header. The manual says all of this. The +README's Docking section ([`:309`](../README.md#L309)) and its C key row +describe the game before the course list, and neither is wrong. The section +lacks the one thing the manual now leads with. + +### Medium priority: the game has it, and the page does not say + +**4. The game plays on a phone. The README does not say so.** Since plans 202 +and 216 every command is a tap. That covers the docked rows, the screen +buttons, the gun row, the course header and the `◎` targets button. Plan 220 holds the zoom, +hides the address bar as a home-screen app and enlarges the chart. The word +"phone" appears once in the README, in the Docked section, and only as "tap or +click a row". A reader on a phone learns nothing about the flight buttons. + +**5. The Architecture section is a July snapshot.** It lists six places under +`src/`. The tree has twelve directories. The section does not name these: + +- `src/missions/`, the mission machine; +- `src/engine/`, the platform seam: the shell, the keymap, the phone chrome; +- `src/constants/`, every tunable, with a generated catalogue; +- `src/encyclopaedia/` and `src/viewer/`; +- `src/game/screens/`, twenty-one screen modules. + +[`README.md:431`](../README.md#L431) still says the full-page +screens are "station menu, market, chart, status". +[`docs/ARCHITECTURE.md`](ARCHITECTURE.md) maps all of it. The README could +point there and list the directories in one line each. + +**6. The docs index omits the two documents an agent needs first.** +[`README.md:48`](../README.md#L48) lists ten documents. +[`docs/INVARIANTS.md`](INVARIANTS.md), [`docs/PROCESS.md`](PROCESS.md), +[`docs/TODO/README.md`](TODO/README.md) and +[`src/constants/CATALOG.md`](../src/constants/CATALOG.md) are not in it. Nor +are the three reviews: [`COMBAT-COMPUTER-REVIEW.md`](COMBAT-COMPUTER-REVIEW.md), +[`MISSIONS-REVIEW.md`](MISSIONS-REVIEW.md) and +[`MISSIONS-FLIGHT-REVIEW.md`](MISSIONS-FLIGHT-REVIEW.md). The README's opening says the agent is the contributor. INVARIANTS and +PROCESS are the two documents that contributor needs first. + +**7. The Run block lists five of the eight pages.** +[`README.md:68`](../README.md#L68) names `/manual · /novella`. The build also +makes `/missions` and `/encyclopaedia` +([`vite.config.ts:133`](../vite.config.ts#L133)), and the landing page links +both. + +**8. `npm run build` runs the whole gate, and the page says "lint + tests".** +[`README.md:69`](../README.md#L69) and [`:121`](../README.md#L121). `prebuild` +is `npm run check`. That gate runs lint, the tests, the size ceiling, the +constants, the palette, the claims, the plans and the map. It also runs the +titles, STE and five generator drift checks. A reader who runs `npm run build` should expect all of it, and its +run time. + +### Low priority: it reads badly + +**9. One fact appears five times.** "Nothing neural ships" is at +[`:30`](../README.md#L30), [`:125`](../README.md#L125), +[`:403`](../README.md#L403), [`:444`](../README.md#L444) and +[`:491`](../README.md#L491), each time with its own wording and its own +history. One home is enough, and the training log holds the history. + +**10. The combat trainer essay sits inside Controls.** Lines +[`231`](../README.md#L231) to [`283`](../README.md#L283) are about fifty lines +of method and measurement, between the Docked table and the Market keys. +[`docs/COMBAT-SIM.md`](COMBAT-SIM.md) exists for that essay. The Controls +section needs the keys and one paragraph. + +**11. The Run block holds the portrait pipeline.** Three commands for a GPU +box and a sibling repo sit between `npm run campaign` and the editor note. A +reader who wants to run the game trips over them. They belong in their own +short section, or in `tools/`. + +**12. The sentences are long, and the house style is not applied.** The +README's AI part is outside the STE gate by Chris's decision. Still, the +sentences run to fifty and sixty words, with dashes and nested parentheses. +The rest of the repo reads differently now. Whether the AI part takes the +house style is Chris's call; it is one of the open questions. + +**13. The screenshots are from 28 July.** `docs/images/` and `public/images/` +carry the same four files, unchanged since the landing page commit. The +cockpit since gained the course header, the gun row, the targets button and +the phone layout. The shots are not wrong. They show a cockpit a new player +does not get. + +### Verified correct + +These claims were checked against the code, and they hold: + +- the Flight table for both layouts (`keymap.ts`), and the Commands table + (gated); +- the Docked table, every row and the order + ([`bindings.ts:265`](../src/game/bindings.ts#L265)); +- the trainer keys, and the wave numbers, computed with `waveStage`. The + count stops at 11. Missiles come at 12, E.C.M. at 14, a bounty hunter at + 16 and Thargoids at 18; +- the Market keys, the Chart keys (D, M, F, T, ENTER, ESC), the docking + marker words, the S and E console lights; +- 100.0 Cr, 3 missiles, 7.0 LY, the 5-second countdown and the 20-tonne + hold. 17 commodities, and three flight autosaves every 20 seconds. The + ±25% price band, the Trumble at 2 credits, 38 hulls and 15 flyable. 16 + kills for the Navy; +- the TypeScript 7 editor note (`typescript ^7.0.2`); +- every linked file and image exists, and every `npm run` command exists; +- the Roadmap's "remaining" items: no gamepad code and no shipyard exist. + +## index.html + +### High priority + +**1. The page tells a phone visitor the game needs a keyboard and a mouse.** +[`index.html:60`](../index.html#L60): "Free · in your browser · no account · +keyboard and mouse". Plans 202, 216 and 220 to 222 made every control a tap. +A phone visitor reads that line under the LAUNCH button and leaves. Nothing +else on the page says the game plays by touch, or that it opens as an app from +the home screen. The metas say so to the phone; the copy does not say so to +the person. + +**2. The title does not fit a phone screen.** In a 386-pixel viewport the +`h1` needs 409 pixels in a 338-pixel column. The last S is clipped, and the +document is 432 pixels wide, so the page scrolls sideways. The cause is +[`landing.css:38`](../src/landing.css#L38). The font size has a 56-pixel +floor, the letter spacing has a 10-pixel floor, and the text indent matches +the spacing. Eight letters at those floors need more than the column. A lower floor +on both (about 34 pixels and 6 pixels) fits, and `overflow-x: hidden` on the +body stops the sideways scroll as a belt. The stylesheet is not a player page, +so an agent can make that change. + +### Medium priority + +**3. "Nobody in the game tells you where to go" is now half true.** +[`index.html:120`](../index.html#L120). The new pilot's briefing opens by +itself on the first dock. The course header names the next thing to do, and +the console says where a mission target is. The three pages still help. The +sentence before them now undersells the game. + +**4. The Dock bullet stops before the ship flies itself.** +[`index.html:83`](../index.html#L83): "Line up with the slot and go in slowly. +Buy a docking computer when you can afford one." True. The page never says +that a tap on the station, the rocks or the star flies the ship there. It +never says that `▶▶` runs the trip forward. The manual leads with it. For a visitor with a +phone it is the reason to launch. + +**5. The zoom lock is on a reading page.** +[`index.html:5`](../index.html#L5) carries `maximum-scale=1.0, +user-scalable=no`. The game page needs it; a double tap in flight must not +zoom the cockpit. The landing page is prose, and that meta stops a reader's pinch +zoom of the text. Whether to keep it on the landing page is Chris's +call; the manifest and the app metas can stay either way. + +**6. The missions page the card links has one stale line.** +[`tour-page.ts:54`](../src/missions/tour-page.ts#L54): "A Krait is taking +ships on the lane. Destroy it." The hunt is a gang of four since plan 217, led +by a Fer-de-Lance. Plan 199 decided that these summaries are hand-written, so +the test holds the table complete but not true. The page also says nothing +about the gates: a reader takes the hunt at zero kills and never sees it +offered. + +**7. The screenshots are from July.** The same four files as the README. The +docking shot shows the cockpit with no buttons. A phone shot of the course +header would carry finding 1 on its own. + +### Verified correct + +The routes `/play`, `/manual`, `/novella`, `/missions` and `/encyclopaedia` +exist and build. The sitemap lists all eight pages. The metas, the Open Graph +tags and the JSON-LD agree with each other and with the page. The footer line +is gated. "Five people across the first galaxy", 2,048 worlds, 256 portraits, +seven light years, 100 credits, the Cobra Mk III and Lave's description all +hold. The desktop layout renders cleanly at 1512 pixels, and every section +below the title reads well in the phone frame. + +## Adjacent findings + +These are outside the two pages, and a reader reaches them from the pages. + +- **The `?` guide says "toggle with B when docked".** + [`keymap.ts:120`](../src/engine/keymap.ts#L120) writes that line into the + help panel. Since plan 202 the docked menu has no letter keys, and the + layout switch is the KEYBOARD LAYOUT row. It is code, not a player page, so + an agent can fix it. +- **`MOBILE_CONVERSION.md`** sits untracked at the repo root. It is Chris's + own notes on the phone and on a story-led game. This review did not use it. + Its subject overlaps the landing page copy in finding 1 and finding 4. + +## Proposed plan + +One plan under `docs/TODO/`, kind docs, four milestones. The order puts the +two wrong claims first, then the phone, then shape. + +- **M1. The landing page says what the game is today.** Replace the + keyboard-and-mouse note. Add one bullet to "What's in it" for the phone and + the home screen. Rewrite the "Nobody tells you" sentence. Extend the Dock + bullet, or add a Fly bullet, for the course list. Fix the title's floors in + `landing.css` and measure it again in the 390-pixel frame. Decide the zoom + lock. Chris writes or approves every sentence of copy. +- **M2. The README's facts match the code.** Rewrite the missions bullet for + the tour, the side jobs and their gates, and the R and ⇧R screens. Fix the + survivor sentence. Add a Courses paragraph to Docking, and a Phone paragraph + to Controls. Add `/missions` and `/encyclopaedia` to the Run block. Say what + `npm run build` runs. Add the absent documents to the docs index. Rewrite + the Architecture list from `docs/ARCHITECTURE.md`. Sentences of fact only; + the opening stays as Chris wrote it. +- **M3. The README has one home per fact.** Move the trainer essay to + `docs/COMBAT-SIM.md` and leave the keys. Collapse the five "nothing neural" + passages to one. Move the portrait pipeline out of the Run block. Apply the + house style to the AI part if Chris says yes. +- **M4. The adjacent lines.** Fix the hunt summary in `tour-page.ts` and show + each side job's gate on the missions page. Fix the help panel line in + `keymap.ts`. Take new screenshots, desktop and phone, if Chris wants them. + +### Open questions for Chris + +1. Does the README name the Dark Wheel? It is a secret in the game. The README + is the developer's page, and its key table already says "no shop sells it". + The landing page should not name it. +2. Does the landing page keep the zoom lock? The game page keeps it either + way. +3. Who writes the landing copy? The agent can propose the sentences in a plan + for Chris to edit, or Chris writes them and the agent measures the layout. +4. Does the trainer essay leave the README for `docs/COMBAT-SIM.md`? +5. Does the AI part of the README take the house style, and the STE gate with + it? +6. New screenshots now, or after the phone layout settles? diff --git a/docs/TODO/223-the-pages-say-what-the-game-is-and-leave-the-history-to-the-logs.md b/docs/TODO/223-the-pages-say-what-the-game-is-and-leave-the-history-to-the-logs.md new file mode 100644 index 00000000..d8a04f3d --- /dev/null +++ b/docs/TODO/223-the-pages-say-what-the-game-is-and-leave-the-history-to-the-logs.md @@ -0,0 +1,107 @@ +# 223 — The pages say what the game is, and leave the history to the logs + +**Kind:** docs · **Severity:** medium · **Size:** medium · **Depends on:** +nothing · **Blocks:** nothing · **GitHub:** none + +## Where we are + +[`docs/PAGES-REVIEW.md`](../PAGES-REVIEW.md) reviewed `README.md` and +`index.html` on 2026-09-14. Every table and number that a test reads is +correct. The prose that no test reads drifted. The README describes the +missions of July, says a survivor becomes cargo, and never says that the +ship flies a course or that the game plays on a phone. The landing page says +"keyboard and mouse" under the LAUNCH button, and its title does not fit a +phone screen. + +Chris, 2026-09-14: *"Ok let's clean things up. There's a lot of old content +in there. We don't need all the old history. The landing pages are aimed at +people who want to play the game. The readme file is aimed at people who +might want to understand the project. They don't need war and peace on all +the decisions we made and things that we discarded."* + +## What to do + +Three milestones. + +- **M1. The landing page speaks to a player.** The note under LAUNCH says + the game plays with a keyboard, a mouse or a touch. One bullet says the + ship flies a course to what you tap. One bullet says the game plays on a + phone and opens from the home screen. The "Nobody in the game tells you + where to go" sentence says what the game does tell you. The title fits a + phone: the stylesheet lowers the floors of its size and its spacing, and + the body never scrolls sideways. The frame at 390 pixels is measured again. +- **M2. The README says what the project is.** Chris's opening stays as he + wrote it. The part after the rule is written again, shorter. It says what + the game is, how to run it, the controls, the systems, the missions as they + are, the course list, the phone, the shape of the source, the documents, + the legal notice and what remains. The history of the neural pilots, the + trainer essay, the playtest harnesses and the portrait pipeline leave the + README. The training log and the trainer document hold them. The sentences + that tests read stay, in the same words. +- **M3. The two lines the pages reach.** The missions page says the hunt is + a gang, and each side job says what it waits for. The `?` guide stops + telling a pilot to press B when docked. + +## Decisions already made + +- **The landing page is for a player, and the README is for a reader of the + project** (Chris, 2026-09-14). Neither carries history. +- **The README's opening is Chris's own writing.** It does not change. +- **The README stays the home of the Commands table.** `test/key-help.test.ts` + holds it to the binding table in both directions, and it stays where the + test reads it. +- **The Dark Wheel keeps its secret on both pages.** The README says a secret + organisation exists at the higher ratings, and no more. The landing page + does not name it. +- **The trainer keeps one paragraph in the README, with its keys.** Three + tests read the README for those keys, and the paragraph keeps their words. + +## Open questions + +- **The zoom lock on the landing page.** `index.html` carries + `user-scalable=no`, which the game page needs and a reading page does not. + `test/phone-page.test.ts` asserts it on both. This plan leaves it, because + it is behaviour and not content. Chris decides. +- **New screenshots.** The four images are from 2026-07-28. This plan does + not replace them. + +## Watch out for + +- **Four tests read README prose.** `test/key-help.test.ts` reads the + Commands table under its exact heading, the Docked section between its + heading and the trainer heading, and two sentences of the "New to Elite?" + section. `test/combat-sim.test.ts`, `test/combat-sim-panel.test.ts` and + `test/combat-sim-compare.test.ts` read the COMBAT TRAINING row and four + phrases of the trainer paragraph. The new text keeps those words. +- **Five tests read the landing page.** The phone metas, the footer marker, + the clean links to `/missions` and `/encyclopaedia`, and the ladder words. + A new sentence on the page never uses "reputation" for the combat rating. +- **The house style does not gate the README or the landing page.** The + README's new part still follows it, because it is the agent's writing. +- **The `?` guide's layout line is in two places.** `keymap.ts` writes it, + and `play.html` holds its first value. Both change. +- **The summaries on the missions page are hand-written** (docs/TODO/199). + The hunt line changes in `tour-page.ts`, and the test's table changes with + it. + +## Verification + +- `npm run check` passes. +- The landing page in a 390-pixel frame shows the whole title, and the + document is no wider than the viewport. +- Every claim of fact in the new README is checked against the code, and + the review names the file for each. + +## What the milestones found + +**M1.** In the 390-pixel frame the title needed 409 pixels in a 338-pixel +column, and the document was 432 pixels wide. With the floors at 34 pixels +and 5 pixels the title is 42 pixels tall with 12 pixels of spacing, it +fills the column exactly, and the document is 386 pixels wide, which is +the viewport. The desktop size is unchanged: 11vw and 3vw reach the same +ceilings at 1512 pixels that 13vw and 4vw did. The five tests that read the +page pass. + +## Outcome + +(filled when the plan lands) diff --git a/docs/TODO/QUEUE.json b/docs/TODO/QUEUE.json index 189020e1..7b07235a 100644 --- a/docs/TODO/QUEUE.json +++ b/docs/TODO/QUEUE.json @@ -1,4 +1,6 @@ { "version": 1, - "items": [] + "items": [ + "223" + ] } diff --git a/docs/TODO/README.md b/docs/TODO/README.md index c4c01b01..f94ac9a9 100644 --- a/docs/TODO/README.md +++ b/docs/TODO/README.md @@ -13,7 +13,13 @@ active context: ## Execution queue -Nothing is queued. +1. [223 — The pages say what the game is, and leave the history to the logs](223-the-pages-say-what-the-game-is-and-leave-the-history-to-the-logs.md) + +**223 CAME FROM CHRIS ON 2026-09-14**, after `docs/PAGES-REVIEW.md`. The +README describes the missions of July, and the landing page says the game +needs a keyboard and a mouse. Chris asked for a clean-up: the landing page +is for a player, the README is for a reader of the project, and neither +carries the history. 223 rewrites both, and fixes two lines the pages reach. **222 CAME FROM CHRIS ON 2026-09-14**, with a picture from his phone. The targets header stood on the console, every target read green, and the gun diff --git a/index.html b/index.html index 6246a8fb..93b5a2c1 100644 --- a/index.html +++ b/index.html @@ -10,7 +10,7 @@ HARMLESS: a browser tribute to Elite (1984) - + @@ -57,7 +57,7 @@

    HARMLESS

    LAUNCH ► - Free · in your browser · no account · keyboard and mouse + Free · in your browser · no account · keyboard, mouse or touch
    @@ -80,6 +80,9 @@

    What you do

    world on the chart and go.
  • Fight. Pirates want your cargo. Your laser fires straight ahead, and a missile locks on to whatever is in your sights.
  • +
  • Fly. Choose the station, the rocks or the star, and the + ship flies there by itself. Skip the quiet part of the trip with + ▶▶. Take the stick when pirates arrive.
  • Dock. Line up with the slot and go in slowly. Buy a docking computer when you can afford one.
  • Climb. Every kill counts toward your combat rating. The @@ -117,7 +120,8 @@

    The 1984 galaxy

    New to Elite?

    - Nobody in the game tells you where to go. These three pages help. + The game briefs you on your first landing, and the console says + what to do next. These three pages go further.

    diff --git a/src/landing.css b/src/landing.css index 723d4bba..d301a260 100644 --- a/src/landing.css +++ b/src/landing.css @@ -18,6 +18,7 @@ body { margin: 0; + overflow-x: hidden; background: radial-gradient(ellipse 80% 50% at 50% -10%, #0d2410 0%, transparent 70%), var(--paper); @@ -35,11 +36,13 @@ a { color: var(--hud-green); } .hero { text-align: center; padding: 80px 0 44px; } h1 { font-family: Menlo, Consolas, 'DejaVu Sans Mono', monospace; - font-size: clamp(56px, 13vw, 128px); - letter-spacing: clamp(10px, 4vw, 40px); + /* The floors fit eight letters in a 320-pixel phone: at 56px and 10px the + title needed 409 pixels in a 338-pixel column (docs/TODO/223 M1). */ + font-size: clamp(34px, 11vw, 128px); + letter-spacing: clamp(5px, 3vw, 40px); margin: 0 0 8px; /* the letter-spacing pads the right side only; nudge back to optical centre */ - text-indent: clamp(10px, 4vw, 40px); + text-indent: clamp(5px, 3vw, 40px); color: var(--hud-green); text-shadow: 0 0 40px rgba(var(--hud-green-rgb), 0.45); } From 3ddc9f1d574d16beb7baecf5337a14dadb48d50b Mon Sep 17 00:00:00 2001 From: Chris Greening Date: Mon, 14 Sep 2026 14:21:24 +0100 Subject: [PATCH 104/110] The README says what the project is docs/TODO/223 M2. Chris's opening stays as he wrote it. The part after the rule is new, and shorter: what the game is, how to run it, the controls, the course list, the phone, the systems, the missions as they are, the shape of the source, the documents, the legal notice and what remains. The history of the neural pilots, the trainer essay, the playtest harnesses and the portrait pipeline leave the README. The training log and the trainer document hold them. Two claims were wrong, and both are fixed. The missions paragraph named one Navy mission; the game has the Constrictor, five arcs, eight gated side jobs and a secret organisation. A survivor "becomes, regrettably, cargo"; the code says a survivor is a person, and the dock asks. The three tables the tests read stay in the same words, and so do the seven phrases four tests pin. The four tests pass. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Cpi86XPSQzZQhVhRnZED58 --- README.md | 579 +++++++----------- ...me-is-and-leave-the-history-to-the-logs.md | 10 + 2 files changed, 232 insertions(+), 357 deletions(-) diff --git a/README.md b/README.md index ce9636ee..ca6b6b7b 100644 --- a/README.md +++ b/README.md @@ -19,18 +19,16 @@ And now I'll hand you over to the AI. # HARMLESS *An unofficial browser tribute to **Elite** (1984) by David Braben and Ian -Bell. Harmless is the combat rating you start at; the ladder ends at +Bell. Harmless is the combat rating you start at. The ladder ends at E L I T E.* -Authentic wireframe ships, the -original byte-accurate procedural galaxy (Lave is system 7, as it should -be), modern shader-driven suns and planets — and two hand-written ship-AI -pilots: a pursuit dogfighter flown by the pirates that chase onto your six -and by the combat computer you can buy (on your side of the fight), and a -three-phase attack run for the traders that fight back. Nothing neural -ships; the neuroevolution trainer still -lives in `train/`, but its trained policies were retired (see the -[training log](docs/TRAINING-LOG.md)). +It runs in a browser, on a desktop or a phone, with no install and no +account. The galaxy is the original: the 1984 seeds make all 2,048 worlds, +and Lave is system 7. The ships are wireframes from the released ship data. +The suns and the planets are shaders. Two hand-written pilots fly the other +ships: a pursuit dogfighter for the pirates, and a three-phase attack run for +the traders that fight back. The combat computer you can buy flies the +pursuit pilot on your side. ![Approaching a Coriolis station with the docking aid live](docs/images/station-docking.jpg) @@ -38,123 +36,65 @@ lives in `train/`, but its trained policies were retired (see the | --- | --- | | ![A seeded shader planet](docs/images/planet.jpg) | ![The Short Range Chart](docs/images/short-range-chart.jpg) | -*Every planet is generated from the 1984 seeds — Diso's violet coastlines -above, "Population: 4.1 Billion (Black Furry Felines)", exactly as the -original's data tables intend. Below: three pirates converging in -the [combat viewer](docs/TRAINING-LOG.md).* - -![Three pirates converging on a trader in the combat viewer](docs/images/combat-viewer.jpg) - -**Docs index:** -[Development log](docs/DEVLOG.md) · -[Architecture tour](docs/ARCHITECTURE.md) · -[AI design](docs/AI-TRAINING.md) · -[Training runs & results](docs/TRAINING-LOG.md) · -[Reproducing the training](train/README.md) · -[The reference ship catalogue](docs/ELITE-A.md) · -[Every damage path](docs/DAMAGE-PATHS.md) · -[The combat trainer](docs/COMBAT-SIM.md) · -[Browser play trials](docs/BROWSER-TRIALS.md) · -[The Jameson Trials](docs/JAMESON-TRIALS.md) +*Every planet comes from the 1984 seeds. Diso's violet coastlines above, and +"Population: 4.1 Billion (Black Furry Felines)", as the original's data +tables intend.* + +## Play + +Play it at https://harmless.atomic14.com/. You start docked at Lave Station +with 100.0 Cr, a full tank and 3 missiles. + +**Saving.** COMMANDER FILE on the station menu names a save. The same name +again replaces it, and it asks first. The game also keeps its own saves. A +station autosave is written at every dock and every launch. The last three +flight autosaves are kept, one every 20 seconds. Close the tab mid-fight and you resume where +you were. Die, and the station autosave puts you back where you launched +from. + +## New to Elite? + +- The **[Flight Training Manual](https://harmless.atomic14.com/manual)**: how + to trade, jump, dock and survive, with a first run worked against the + game's own market. +- **[The Long Way Out](https://harmless.atomic14.com/novella)**: an original + novella, with papers from the eight galaxies. +- The **[missions page](https://harmless.atomic14.com/missions)**: who has + work for you, and what each job is. +- The **[encyclopaedia](https://harmless.atomic14.com/encyclopaedia)**: the + 256 worlds of the first galaxy, each with a portrait of its inhabitants. + +In-game, the new pilot's briefing opens by itself the first time a commander +docks. **NEW PILOT'S BRIEFING** on the station menu reopens it, and **?** +shows the controls at any time. ## Run ```sh npm install -npm run dev # http://localhost:5173 (landing page) - # http://localhost:5173/play (the game) - # http://localhost:5173/viewer (AI combat viewer) - # http://localhost:5173/gallery (all 38 released hulls) - # /manual · /novella (manual and story) -npm run build # lint + tests (via prebuild), then production build to dist/ -npm run train -- attack --gens 400 # breed a pirate policy for research — nothing trained ships; see train/README.md (Node ≥ 22.6) -npm run evaluate # held-out tournament for the current brains -npm test # invariant + simulation tests (no framework) -npm run elite-a # the reference-catalogue alignment gate (<1s) -npm run campaign # headless balance playtest: 40 careers × 60 legs -npm run campaign -- 4 45000 all # three career strategies, all the way to E L I T E - -# inhabitant portraits (offline; images are committed, nothing runs in the browser) -node --experimental-strip-types tools/species-prompts.ts 1 --style crt --json > /tmp/g1.json - # styles: crt, lit, ink, plain — the model does the look -uv run tools/generate-species.py /tmp/g1.json --repo ../ultra-fast-image-gen --only Lave,Diso - # ^ starts that repo's server.py and keeps the model resident -uv run tools/posterise.py --size 256 --tones 6 # re-crush, no GPU needed +npm run dev # http://localhost:5173 the landing page + # /play · /manual · /novella · /missions · /encyclopaedia + # /viewer the combat viewer · /gallery all 38 hulls +npm test # the tests alone (no framework) +npm run check # the whole gate: lint, tests, sizes, constants, prose, generators +npm run build # runs the gate first (prebuild), then builds to dist/ ``` +The live site deploys from Cloudflare Pages with `npm run build`, so a commit +that fails the gate fails the deploy. + > **Editor and agent setup.** This project uses TypeScript 7. That version is > a native compiler. It ships no `tsserver.js`. An editor or a coding agent > that starts `typescript-language-server` therefore fails with "Could not > find a valid TypeScript installation". Point the tool at the TypeScript 7 > language server instead. The command is `npx tsc --lsp --stdio`. -Two playtest harnesses back this up. `npm run campaign` plays hundreds of -full commander careers headlessly — real galaxy, market, living-galaxy and -contract code, with only flight abstracted — and reports whether the economy -actually works (wealth curve, bankruptcy rate, time to first upgrade, -equipment progression, piracy losses), failing the build if it doesn't. It -can also play a commander all the way to **E L I T E** (25,600 kills) in -about 20 seconds, under three different strategies — `trader`, `hunter`, -`privateer` — which is how the combat ladder below was measured. - -The docked **combat trainer** (COMBAT TRAINING on any station menu) replaced three console -harnesses. It logs a fight you actually flew — your accuracy and theirs, -damage by source, the geometry that decides whether an NPC can shoot at all, -and how you fly — and exports it as JSON. Scenarios are repeatable from a -seed, so the same fight can be flown against two different brains and -compared. - -It exists because every bot-flown measurement in this project turned out to be -shaped by the bot: flying straight flatters one kind of AI, flying the defence -policy flatters another. `npm run survivability` is still the bot answer to -"can I survive a gang?" — a floor measured in the training world, stand-in -pilots on both sides, so the live game sits above its rows; the trainer's -**waves** mode is the human one. - -There's also an **autonomous playtest agent** (`test/playtest.js`): paste it -into the browser console with the game open and `await __playtest.run({ -legs: 8 })` sends a commander off to take contracts, trade, fight, jump and -dock on its own, asserting invariants as it goes and printing a report of -everything it exercised. It's how gameplay changes get regression-tested. - -CI lints, tests, builds and runs the balance playtest on every push. The -live site deploys from Cloudflare Pages (build `npm run build`, output -`dist`) — and since npm runs `prebuild` before `build`, a commit that fails -lint or tests fails the deploy build rather than shipping. - -> `src/ai-training/brains/` ships empty (only a `.gitkeep`, no weight files), -> and the game imports none — it flies hand-written code pilots. Retraining -> writes candidate weights there for research; nothing loads them. - -You start docked at Lave Station with 100.0 Cr, a full tank and 3 missiles. - -**Saving** is a deliberate act and an automatic safety net, and the two can -never overwrite each other. **COMMANDER FILE** on the station menu opens it: name a -save and it is yours to keep — the same name again replaces it, and it asks -first. Alongside it the game keeps its own: a **station autosave** written every -time you dock and every time you launch, and the last three **flight -autosaves**, taken every 20 seconds. Close the tab mid-fight and you resume -where you left off; die, and the station autosave puts you back where you -launched from. Loading over the run you are in says so before it happens, names -what it is about to cost, and can be backed out of. - -## New to Elite? - -There is a **[Space Trader's Flight Training Manual](https://harmless.atomic14.com/manual)** — how to -trade, jump, dock and survive, with a first run worked out against the game's -own market model — and **[The Long Way Out](https://harmless.atomic14.com/novella)**, an original -novella with papers from the eight galaxies. - -In-game, the new pilot's briefing opens by itself the first time a commander -docks; **NEW PILOT'S BRIEFING** on the station menu reopens it whenever you -want it back, and **?** shows the controls at any time. - ## Controls -Two flight layouts ship. **CLASSIC — the authentic 1984 keys — is the -default**; **KEYBOARD LAYOUT** on the station menu switches to MODERN (WASD), -which is remembered per browser. Press **?** any time for the in-game guide, which -always shows the active layout. +Two flight layouts ship. **CLASSIC**, the 1984 keys, is the default. +**KEYBOARD LAYOUT** on the station menu switches to MODERN (WASD), and the +browser remembers the choice. **?** shows the in-game guide for the active +layout. ### Flight @@ -166,13 +106,12 @@ always shows the active layout. | `/` | X or `/` | decelerate | | A (or F) | F | fire laser (watch the temperature) | -The original's `<` `>` roll and `/` slow-down are live in both layouts, so -muscle memory from 1984 mostly survives either choice. Arrow keys always fly. +The original's `<` `>` roll and `/` slow-down work in both layouts. The arrow +keys always fly. -**Mouse flight**: press **V** in flight to pointer-lock the mouse into a -self-centring analogue stick (left button fires). Touching the keyboard -overrides it; ESC or V releases. This is the closest thing to the -joystick the original supported. +**Mouse flight**: **V** in flight pointer-locks the mouse as a self-centring +stick, and the left button fires. A flight key overrides it. ESC or V +releases it. ### Commands (identical in both layouts) @@ -202,9 +141,32 @@ joystick the original supported. | V | mouse flight — pointer-locked analogue stick, left button fires | | ? | controls guide | -Views on 1-4 (the original used F0-F3) and screens on letters (were F4-F9), +Views are on 1-4 (the original used F0-F3) and screens on letters (F4-F9), because browsers claim the function keys. +### The ship flies a course + +A course is one thing the ship does next by itself. LAUNCH offers the jump, +the rocks and the star. In flight, the buttons over the view offer what is in +the sky. FLY TO THE STATION is always there. The mission target, a derelict, +the rock hermit and a canister to collect show when they exist. RUN FOR IT +shows when ships attack you. Tap one and a computer flies it. A header names what the ship is doing, and **▶▶** runs +time forward while nothing hostile is near. A flight key takes the stick +back. + +FLY TO THE STATION lines the ship up on the slot. A fitted docking computer +then takes it in. Without one, the rails hold you on the slot's axis, and you +match the slot's roll and the speed yourself. + +### On a phone + +Every command is a button. The gun row under the view is FIRE LASER, ARM +MISSILE, FIRE MISSILE, E.C.M. and CLOAK. The **◎** icon with a count opens +the target list, and it reads red while a ship on the scanner attacks you. +The station menu is rows, and every screen has its buttons. Add the page to +the home screen and it opens as an app with no address bar. A double tap or a +pinch never zooms the cockpit. + ### Docked The station has no letter keys. Every command is a row on the menu: tap or @@ -230,56 +192,19 @@ in order: ### Combat training simulator -Free, at every station, from the menu. Pick a mode (one scored fight · endless -sparring against one hull · escalating waves), a scenario, a threat tier, an -optional seed, and optionally build the opposition yourself — hull, count, -tier, how they fly and fit, per group — plus a fit-out override for your own -ship. ENTER launches; **L** re-opens the last report; **ESC** or **Q** ends the -exercise. The panel is grouped — the fight, who flies what, your ship — with -**LIVE BRAINS (COMMANDER)** fenced off at the foot, because that one is still set -when you undock. A brain is picked by how it flies — `FIGHTS BACK`, -`MAKES ATTACK RUNS`, `GETS ON YOUR SIX` — with the pilot behind the name for -anyone reading the training log. - -**Waves** escalates twice. First the numbers — one ship becomes six, an -opportunist becomes an organised gang — and at wave 11 they stop, deliberately, -because a ramp that keeps adding ships makes the score a fact about arithmetic -rather than about flying. Then the *fight* keeps changing: they carry missiles -at 12, E.C.M. at 14, one of them is a bounty hunter at 16, and at 18 two of the -pirates stand down for a Thargoid and its Thargon — the toughest hull in the -game, whose drone goes inert the moment it dies. From 18 on every wave is the -same, so surviving three of them is a fact about flying. The cockpit strip -carries what is out there, the banner names each new thing as it arrives, and -the report quotes both with the reason. The furthest wave you ever reach is -kept with the commander and shown on the panel; it is the one number a run -leaves behind, and nothing in the career reads it. - -A row over a list says where in it you are (`5/12`) and **HOME/END** go to -either end without walking there. Selecting a brain row prints what that brain -does in a fight, with the measured number that shows it — the attack-run pilot -lands 58% of its shots at ~5 runs a minute and sits 31.8s on a hauler's six — -so choosing one is a choice about behaviour rather than a filename. The shipped -`pursuit` pirate post-dates the evaluation tournament, so it is unprobed there. -The figures that exist come from `train/flight-probe.ts` or the evaluation -tournament, both archived under `train/logs/`. - -It is the real game: real flight model, real brains, real guns. But -**nothing that happens in it leaves it** — no kills, no combat rating, no -credits, no legal status, no save write, and death ends the exercise rather -than the career. The single exception is stated as one: the furthest wave a -waves run reached, which is saved with the commander and read by nothing else. Afterwards you get a report (accuracy both ways, damage by -source, engagement ranges, time on each other's six, your own flight -envelope) which exports as JSON to the clipboard or a file, and lands on -`window.__simLog` for a console session or an agent to read. - -**←→** walks the recent records and **ENTER** holds two of them side by side — -this, that, and the difference — because the method is an A/B: same seed, same -scenario, two brains. It only subtracts them when they really are one fight -flown twice. Two records on different seeds, scenarios, modes, waves, hulls or -fit-outs are not an A/B, so it names exactly which fields differ and shows no -difference column at all; different **brains** is the point, different anything -else is a confound. **C** and **X** then take the pair, since the pair is the -finding. +Free, at every station, from the menu. Pick a mode: one scored fight, endless +sparring against one hull, or escalating waves. Then pick a scenario, a +threat tier and an optional seed, or build the opposition yourself. ENTER launches. +**L** re-opens the last report. **ESC** or **Q** ends the exercise. +**HOME/END** go to either end of a list. + +It is the real game: real flight model, real pilots, real guns. But +**nothing that happens in it leaves it**: no kills, no rating, no credits, no +legal status, no save. The one exception is the furthest wave you reached, +kept with the commander and read by nothing else. Afterwards you get a +report, and it exports as JSON. **←→** walks the recent records. +**ENTER** holds two of them side by side when they are one fight flown twice. +[docs/COMBAT-SIM.md](docs/COMBAT-SIM.md) is the full description. ### Market @@ -287,184 +212,144 @@ finding. ### Charts -**Click a system to target it** · arrows move the cursor · ENTER set -hyperspace target · **D data on system** (the full statistics page with the -original's procedurally generated planet description) · M market estimate · -F find a system by name · ESC exit - -A **red ring** marks a system merchants are reporting heavy pirate activity -in — the living galaxy's own losses, on the same threshold that world's data -screen reports in words, so the map and the news can never disagree. - -**T** cycles the trade overlay: **routes in flight** draws the lanes with more -than one convoy on them (~45 of the ~175 pairs trading at any moment), and -**prices** arrows the worlds trading well off the 1984 baseline — up amber for -dear, down green for cheap. Both charts share the mode, and the rings stay -under all of it. - -Lanes fade with how much freight is on them. Point at one — mouse or cursor — -and the chart names both worlds, the convoys on it, their tonnage, their cargo -and when the next load lands. +| Key | Action | +| --- | --- | +| click, or the arrows | move the cursor to a system | +| ENTER | set the hyperspace target | +| D | data on system | +| M | market estimate | +| F | find a system by name | +| T | cycle the trade overlay | +| ESC | exit | + +A **red ring** marks a system where merchants report heavy pirate activity. +The trade overlay draws the lanes with more than one convoy on them, and then +the worlds whose prices sit well off the 1984 baseline. ### Docking and the console -Fly into the station's docking port with your wings matched to its rotation. -An amber marker shows where the port is, with an arrow at the screen edge -when it's behind you; it turns green and reads DOCKING PORT — LINED UP when -you're on the axis and rolled to match. Get it wrong and you'll bounce off -with shield damage — or buy the docking computer. +Fly into the docking port with your wings matched to its rotation. An amber +marker shows the port, with an arrow at the screen edge when it is behind +you. It turns green and reads DOCKING PORT — LINED UP when you are on the +axis and rolled to match. Get it wrong and you bounce off with shield damage. In a fight, a red arrow at the screen edge points at the nearest hostile you -can't currently see. - -The console lights an **S** while the station is in scanner range (its -defences cover you there) and an **E** when an E.C.M. broadcast is -detected — as on the original's dashboard. +cannot see. The console lights an **S** while the station is in scanner +range, and an **E** when an E.C.M. broadcast is detected. ## Game systems -- **Trading** — 17 commodities with the original price/quantity model; - economies matter (buy food cheap at agriculturals, sell computers dear). - 20t hold; precious metals/gems don't take hold space. -- **Combat** — pulse/beam/military lasers with heat, four mounts, homing - missiles you arm and then lock by putting the target in your sights, - on-screen target brackets with a lead marker, hull hits that cost you - cargo and fittings, bounties, kill ratings from Harmless to E L I T E. - Damage is the released game's own arithmetic: what a laser is worth is - decoded from its byte, what it costs a target is that target's own defence, - and your fore shield, aft shield and energy bank are three 255-point pools — - see [docs/ELITE-A.md](docs/ELITE-A.md) and - [docs/DAMAGE-PATHS.md](docs/DAMAGE-PATHS.md). Pirate numbers scale with the - government type; shoot police or traders and you become a fugitive (police - attack; fine on docking). -- **Hyperspace** — 7.0 LY fuel range, per-jump fuel cost by real chart - distance, 5-second countdown. -- **Death** — ship destroyed → ENTER takes you back to the station autosave - you launched from, with what you left with (unless an escape pod saves you, - at the cost of your cargo), or S opens the commander file to pick a save of - your own. The in-flight autosaves are dropped: dying costs you the run. -- **Legal system** — CLEAN → OFFENDER → FUGITIVE; police scan for contraband - (slaves, narcotics, firearms), bounty hunters stalk offenders, fines on - docking. -- **A living system** — traders warp in, do business at the station and jump - out; pirates hunt them for cargo you can scoop; police hunt the pirates. -- **Witch-space** — mis-jumps drop you among Thargoids and their Thargon - drones. High bounties, if you live. Out of fuel out there? Broadcast a - distress beacon and GalCop will tow you clear — they'll take your cargo - as the salvage fee. -- **Mining & scooping** — blast asteroids (mining laser drops ore canisters) - and scoop drifting cargo with fuel scoops; sun-skim to refuel, watching the - cabin temperature. -- **A living galaxy** — trade runs between all 256 systems while you play. - Convoys depart from productive worlds, get taken by pirates in lawless - space, and arrive in your system as real ships. Prices drift with supply, - pirate hotspots emerge along dangerous routes, and the system data screen - reports the news. The 1984 economy stays the baseline underneath — this - layer only ever nudges it ±25%. -- **Pirates as businesses** — what waits for you on the way in depends on - what you're visibly worth. An empty Cobra draws a couple of opportunists in - Sidewinders; a full hold draws professionals; a fat, notorious one draws an - organised gang. A gang isn't five - Fer-de-Lances, though — it's two ringleaders plus hangers-on in whatever - they could afford, which is why they can be common without being hopeless. Only - what a pirate can *see* counts — cargo, hold size, fitted laser, your - reputation — never your bank balance, so banking the money and flying clean - is a real strategy. Threat grows far slower than your ship does, so upgrades - are felt rather than cancelled out. And since they came for the cargo, - **jettisoning it (Y) buys them off** — proportionally: opportunists want a - little, a gang that organised for you wants about a third of the prize. - Selling big or dirty loads raises your profile here and in neighbouring - systems, and it fades if you lie low. Your **reputation cuts both ways**: - it scares off thieves after easy cargo, but once you're Dangerous roughly a - third of the ships waiting for you came for the name rather than the hold. - Ratings count difficulty too — a gang's Fer-de-Lance is worth five - Sidewinders — so the ladder rewards the fights worth having. -- **Contracts** — every station runs a bulletin board with cargo runs, - courier jobs and pirate-clearing bounties, available from your very first - landing. Deadlines are measured in days, which pass as you jump. (The - original made you earn your first mission with 16 kills; a new commander - deserves somewhere to be.) -- **Navy missions** — prove yourself (16+ kills, galaxy 1) for the - Constrictor hunt and the classified courier run. -- **Don't shoot the station.** Its hull shrugs off a laser, but GalCop - notices: you're marked an offender and the station scrambles Vipers from - the slot. Shooting *those* is how you become a fugitive. -- **Encounters** — destroyed ships eject escape capsules (scoop one and the - survivor becomes, regrettably, cargo); stations scramble Vipers if you - misbehave in their sight; rock hermits hide among the asteroids, dealing - ore and asking no questions; derelict generation ships drift between the - stars; and someone will sell you a Trumble for 2 credits, which is one of - the worst decisions available to you. -- **Two hand-written ship AIs** — pirates fly `pursuit`, the dogfighter that - chases onto your six and holds there, breaking into a slashing attack run the - moment you turn your nose onto it; armed traders turn and fight with - `attack-run`, the three-phase run (close, fire through the pass, swing out - and come round again); and the combat computer you can buy flies the pursuit - dogfighter on your own ship, onto your attacker's six. All of it is code, not - neural nets: the policies - self-play kept finding were turrets that hung in space and sniped, so the - trained line was retired in 2026-08 (docs/TRAINING-LOG.md keeps every figure). - Watch either fight in the combat viewer. +- **Trading**: 17 commodities with the original price and quantity model. A + 20-tonne hold. Precious metals and gems take no hold space. +- **Combat**: pulse, beam and military lasers with heat, on four mounts. + Homing missiles lock on what is in your sights, and an E.C.M. answers + theirs. Kill ratings run from Harmless to E L I T E. Damage is the released game's own arithmetic + ([docs/ELITE-A.md](docs/ELITE-A.md), [docs/DAMAGE-PATHS.md](docs/DAMAGE-PATHS.md)). +- **Hyperspace**: a 7.0 LY tank, fuel by chart distance, a 5-second countdown. +- **Death**: ENTER takes you back to the station autosave you launched from. + An escape pod saves you, at the cost of the ship and the cargo. S opens the + commander file instead. +- **The law**: CLEAN, OFFENDER, FUGITIVE. Police scan for slaves, narcotics + and firearms. Bounty hunters stalk offenders. Fines on docking. Shoot the + station and it scrambles Vipers. +- **A living galaxy**: traders, pirates and police work every system while + you are in it. Convoys run between all 256 worlds while you are not. Prices + drift up to 25% from the 1984 baseline, pirate hotspots emerge on dangerous + routes, and the system data screen reports the news. +- **Pirates as businesses**: what waits for you depends on what you are + visibly worth: cargo, hold, laser, reputation, and never your bank balance. + An empty Cobra draws opportunists. A full hold draws professionals. They + came for the cargo, so **jettisoning it (Y) buys them off**. +- **Witch-space**: a mis-jump drops you among Thargoids and their drones. Out + of fuel out there, a distress beacon (B) brings GalCop, for your cargo. +- **Mining, scooping and survivors**: a mining laser breaks asteroids into + ore. Fuel scoops take drifting cargo, and a sun skim refuels the tank. A + scooped escape capsule holds a person, not cargo, and the dock asks what + becomes of them. +- **Contracts**: every station board has cargo runs, courier jobs and + bounties from your first landing, with deadlines in days. +- **Missions**: the Navy's Constrictor hunt after 16 kills in galaxy 1, as in + 1984. Five people on five worlds across the first galaxy each give you a + chain of jobs, and each chain leads to the next. Every station board holds + two or three side jobs of eight: hunt a gang, deliver, recover, rescue, + clear a lane, smuggle, escort, scan. A gang hunt waits for eight kills, a + lane or an escort for four, a recovery or a rescue for fuel scoops. At the + higher ratings a secret organisation sends for you, with a reward no shop + sells. **R** lists your missions and the offers, and **⇧R** tells each one + as a story. +- **Encounters**: rock hermits deal ore among the asteroids, derelict + generation ships drift between the stars, and someone sells a Trumble for + 2 credits. Buy it and see. +- **Two ship pilots**: pirates fly `pursuit`, which chases onto your six and + breaks into an attack run when you turn on it. Armed traders fly + `attack-run`, in three phases: close, fire through the pass, come round. + Both are code. The neural pilots the project once trained were retired, + and [docs/TRAINING-LOG.md](docs/TRAINING-LOG.md) keeps that history. ## Architecture -- `src/galaxy/` — the genuine Elite galaxy algorithm: three twisted 16-bit - seed words generate all 256 systems per galaxy (names, economy, government, - tech level, market). Galaxy 1 is byte-identical to the original — system 7 - is Lave. -- `src/ships/` — all 38 released hulls as explicit vertex/edge/face tables in - the style of the original BBC data, generated from the vendored reference - pack and exact to the source rather than drawn by eye. Thirty-one of them - turn up in play (Cobra Mk III and Mk I, Sidewinder, Viper, Adder, Krait, - Mamba, Fer-de-Lance, Python, Anaconda, Boa, Gecko, Moray, Worm, Shuttle and - Shuttle Mk II, Transporter, Dragon, Monitor, Ophidian, Ghavial, Bushmaster, - Rattler, Iguana, Chameleon, Thargoid, Thargon, Constrictor, missile, - canister, escape capsule); the Coriolis and Dodo stations are the same - tables at a larger presentation scale. All drawn as wireframe edges over a - black occluding hull (classic hidden-line look), and all 38 browsable at - `/gallery`. -- `src/world/` — shader sun (animated fbm surface, limb darkening, corona), - shader planet (coastline contours, graticule, terminator, atmosphere rim — - seeded per system), starfield, space dust, per-system scene assembly. -- `src/game/` — game orchestrator (modes, docking, hyperspace, combat), - NPC AI (traders/pirates/police), commander state + saves. -- `src/hud/`, `src/ui/` — scanner/compass/gauges console and the full-page - screens (station menu, market, chart, status). -- `src/audio.ts` — WebAudio synth in the spirit of the BBC sound chip. The - docking waltz is `src/music.ts`, a three-voice SID player, and - `src/music-danube.ts`, the arrangement it plays. The Commodore 64 Elite - played *An der schönen blauen Donau* while you docked, and what plays here is - that performance: Julie Dunn's 1985 arrangement for three SID voices, decoded - from the C64 release's music data and re-synthesised through Web Audio. No - audio is shipped, but the arrangement is not ours and is not public domain — - the 1866 waltz underneath it is. See - [reference/danube/README.md](reference/danube/README.md). -- `src/ai-training/` + `train/` — render-free combat simulator, tiny MLP policies - (1.9k params, keyboard-style discrete actions) and a neuroevolution - self-play trainer. `src/ai-training/brains/` ships empty (only a `.gitkeep`): - the game flies three hand-written code pilots — `pursuit` (the dogfighter - every pirate uses, and the purchasable combat computer's own flight, turned - to your defence), `attack-run` (the defence name: the three-phase run an - armed trader turns and fights with, and the name the co-pilot is selected - under) and `scripted` (an A/B control that reverts the pirates to the attack - run and switches the defence off). The trained line — two pirate policies and - the `jameson-defend` defence policy — was deleted 2026-08-05 after three - retrains optimised their way out of fighting; `docs/TRAINING-LOG.md` keeps - every figure, and `train/evolve.ts` can still breed a candidate for research. - A test asserts the weights directory matches what `brains.ts` imports, which - today is nothing. The combat viewer (`/viewer`) replays matchups with the real - wireframe ships, and every row in it flies one of those code pilots or a - stated control. See `docs/AI-TRAINING.md` and `docs/TRAINING-LOG.md`. -- Rendering: three.js + UnrealBloom for the phosphor glow. +One world state exists. A fixed, seeded step advances it, and the renderer +only reads it. A module decides and returns an event, and the orchestrator +applies it. The platform stays behind `src/engine/shell.ts`, so the whole +game runs headlessly under Node for the tests. +[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) is the map. The directories: + +- `src/galaxy/`: the 1984 galaxy algorithm, and the trade that runs between + its worlds while you play. +- `src/ships/`: all 38 released hulls as vertex, edge and face tables, + generated from the vendored reference pack ([docs/ELITE-A.md](docs/ELITE-A.md)). +- `src/world/`: the shader sun and planet, the starfield, the scene. +- `src/game/`: the orchestrator, the world, the flight loop, the NPC pilots, + the courses, the law, the market, the commander and the saves. + `src/game/screens/` is one module per full-page screen. +- `src/missions/`: the mission machine. A skeleton is one mission's rules. A + dossier is its generated words. One pure step reads an event and returns + the effects the game applies. +- `src/hud/`, `src/ui/`: the console, the buttons over the view, and the + screens' markup. +- `src/engine/`: the platform seam: the shell, the render stack, the key map, + the phone chrome. +- `src/constants/`: every tunable, each with its rule beside it. + `CATALOG.md` there is generated from them. +- `src/encyclopaedia/`, `src/viewer/`: the encyclopaedia page and the combat + viewer. +- `src/ai-training/` and `train/`: the render-free combat simulator and the + trainer that once bred neural pilots. The game loads no weights. +- `src/audio.ts`: a WebAudio synth in the spirit of the BBC sound chip. + `src/music.ts` and `src/music-danube.ts` play the docking waltz. +- Rendering: three.js with UnrealBloom for the phosphor glow. + +## Documents + +Read first: [docs/INVARIANTS.md](docs/INVARIANTS.md) (the rules that must +not break), [docs/PROCESS.md](docs/PROCESS.md) (how work is delivered) and +[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). +[docs/TODO/README.md](docs/TODO/README.md) indexes the active plans. + +Reference: [the ship catalogue](docs/ELITE-A.md) · +[every damage path](docs/DAMAGE-PATHS.md) · +[the combat trainer](docs/COMBAT-SIM.md) · +[browser play trials](docs/BROWSER-TRIALS.md) · +[the Jameson trials](docs/JAMESON-TRIALS.md) · +[the constants catalogue](src/constants/CATALOG.md) + +History: [the development log](docs/DEVLOG.md) · +[the AI design](docs/AI-TRAINING.md) · +[the training log](docs/TRAINING-LOG.md) · +[reproducing the training](train/README.md) + +Reviews: [the combat computer](docs/COMBAT-COMPUTER-REVIEW.md) · +[the missions](docs/MISSIONS-REVIEW.md) · +[the missions in flight](docs/MISSIONS-FLIGHT-REVIEW.md) · +[the README and the landing page](docs/PAGES-REVIEW.md) ## Acknowledgements & legal This is a non-commercial fan homage, released under the MIT license (see LICENSE). Elite (1984) was created by Ian Bell and David Braben and published -by Acornsoft; the "Elite" trademark belongs to Frontier Developments plc. -This project is affiliated with none of them. The galaxy generator and the -ship tables both follow long-published descriptions of the original data — -the hulls were hand-made approximations until 2026-08, and are now generated +by Acornsoft. The "Elite" trademark belongs to Frontier Developments plc. +This project is affiliated with none of them. The galaxy generator follows +long-published descriptions of the original data. The hulls are generated from a vendored analysis pack of the released ship files ([docs/ELITE-A.md](docs/ELITE-A.md)). @@ -477,29 +362,9 @@ separate work and is not. It is included as a deliberate decision, and [reference/danube/README.md](reference/danube/README.md) sets out what it is and where it came from. -## Roadmap - -Feature-by-feature parity with the original manual is essentially done — -including the things this section used to list as outstanding: side laser -mounts, mouse flight, the two-level living galaxy, and the purchasable combat -computer. Where this game deliberately differs from 1984, the reasoning is -recorded beside the constant that makes the difference. - -Remaining: gamepad support, and a player shipyard (all 15 flyable hulls are -imported and resolve; nothing can yet change which one you fly — see the -deferred list in [docs/ELITE-A.md](docs/ELITE-A.md)). Pirates already fly the -`pursuit` dogfighter by default, solo and in a gang alike; the trained pack -brain that once flew the gangs was retired with the rest of the neural line. - -Combat balance is **not settled**, and this file quotes no figure for it on -purpose: the damage model moved to the released game's own arithmetic in -2026-08, so every number measured before that describes a different game. -Measure it, don't cite it — `npm run campaign`, `npm run survivability`, and -the docked combat trainer's JSON export. - -One finding worth reading before touching combat AI, from -[docs/TRAINING-LOG.md](docs/TRAINING-LOG.md): in a recorded human fight, -pirates lined up on the player only a small fraction of the time and landed -most of the shots they did take, so the balance rests on pursuit being -imperfect — "better" pursuit is not automatically better. That is a shape, and -it survives the change of units; the percentages in the log entry do not. +## What remains + +Gamepad support, and a shipyard: all 15 flyable hulls are in, and nothing +yet changes which one you fly. Combat balance is measured, not cited. The +damage model is the released game's own since 2026-08, and `npm run campaign`, +`npm run survivability` and the trainer's report are how to measure it. diff --git a/docs/TODO/223-the-pages-say-what-the-game-is-and-leave-the-history-to-the-logs.md b/docs/TODO/223-the-pages-say-what-the-game-is-and-leave-the-history-to-the-logs.md index d8a04f3d..358af8e4 100644 --- a/docs/TODO/223-the-pages-say-what-the-game-is-and-leave-the-history-to-the-logs.md +++ b/docs/TODO/223-the-pages-say-what-the-game-is-and-leave-the-history-to-the-logs.md @@ -102,6 +102,16 @@ the viewport. The desktop size is unchanged: 11vw and 3vw reach the same ceilings at 1512 pixels that 13vw and 4vw did. The five tests that read the page pass. +**M2.** The README went from 505 lines to 370, and Chris's opening did not +change. The part after the rule is new. It keeps the three tables the tests +read, in the same words, and the seven phrases four tests pin. The trainer +essay, the two playtest harnesses, the portrait pipeline and four of the +five passages on the retired neural pilots left the README. The training +log and the trainer document hold them. Every number and every name in the +new part was checked against the code during the review. The Charts keys +are a table now, as the Flight keys are. The four tests that read the +README pass. + ## Outcome (filled when the plan lands) From 29a79ea426ab13ffc2861ca4326d1ddcae9fb931 Mon Sep 17 00:00:00 2001 From: Chris Greening Date: Mon, 14 Sep 2026 14:25:08 +0100 Subject: [PATCH 105/110] The missions page and the ? guide say what the game does today docs/TODO/223 M3, and the plan lands. The missions page said the hunt was one Krait; it is a gang of four, led by a Fer-de-Lance, since docs/TODO/217. Each side job on that page now says what its board waits for, in the gate's own words: 8 kills for the hunt, 4 for the lane and the escort, fuel scoops for the recovery and the rescue, and nothing for the four open jobs. The ? guide's layout line said "toggle with B when docked" in play.html and in engine/keymap.ts; the station has no letter keys since docs/TODO/202, so both name the KEYBOARD LAYOUT row. Two tests hold the changes. The full gate passes: 6,168 assertions. The plan moves to completed/, with the zoom lock on the landing page and the July screenshots left open for Chris. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Cpi86XPSQzZQhVhRnZED58 --- docs/PAGES-REVIEW.md | 2 +- docs/TODO/QUEUE.json | 4 +--- docs/TODO/README.md | 7 +++--- ...me-is-and-leave-the-history-to-the-logs.md | 16 ++++++++++++- docs/TODO/completed/README.md | 1 + play.html | 2 +- src/engine/keymap.ts | 3 ++- src/missions/tour-html.ts | 2 +- src/missions/tour-page.ts | 24 ++++++++++++++++--- test/key-help.test.ts | 6 +++++ test/tour-page.test.ts | 11 +++++++++ 11 files changed, 64 insertions(+), 14 deletions(-) rename docs/TODO/{ => completed}/223-the-pages-say-what-the-game-is-and-leave-the-history-to-the-logs.md (86%) diff --git a/docs/PAGES-REVIEW.md b/docs/PAGES-REVIEW.md index 12696062..d1fabdbf 100644 --- a/docs/PAGES-REVIEW.md +++ b/docs/PAGES-REVIEW.md @@ -5,7 +5,7 @@ Review date: 14 September 2026. Source revision: `eb5a3b0`, branch [`index.html`](../index.html), the landing page at [harmless.atomic14.com](https://harmless.atomic14.com/). -No page changed during this review. Both pages are Chris's. CLAUDE.md says the README opening is his own +No page changed during this review. Plan 223 acted on it the same day. Both pages are Chris's. CLAUDE.md says the README opening is his own writing, and that the landing page is a player page. This document lists what the code contradicts, what the code has that the pages do not say, and what reads badly. A proposed plan is at the end, with the diff --git a/docs/TODO/QUEUE.json b/docs/TODO/QUEUE.json index 7b07235a..189020e1 100644 --- a/docs/TODO/QUEUE.json +++ b/docs/TODO/QUEUE.json @@ -1,6 +1,4 @@ { "version": 1, - "items": [ - "223" - ] + "items": [] } diff --git a/docs/TODO/README.md b/docs/TODO/README.md index f94ac9a9..1dbc06c9 100644 --- a/docs/TODO/README.md +++ b/docs/TODO/README.md @@ -13,13 +13,14 @@ active context: ## Execution queue -1. [223 — The pages say what the game is, and leave the history to the logs](223-the-pages-say-what-the-game-is-and-leave-the-history-to-the-logs.md) +Nothing is queued. **223 CAME FROM CHRIS ON 2026-09-14**, after `docs/PAGES-REVIEW.md`. The README describes the missions of July, and the landing page says the game -needs a keyboard and a mouse. Chris asked for a clean-up: the landing page -is for a player, the README is for a reader of the project, and neither +needs a keyboard and a mouse. Chris asked for a clean-up. The landing page +is for a player, and the README is for a reader of the project. Neither carries the history. 223 rewrites both, and fixes two lines the pages reach. +**223 landed the same day**, and it is below. **222 CAME FROM CHRIS ON 2026-09-14**, with a picture from his phone. The targets header stood on the console, every target read green, and the gun diff --git a/docs/TODO/223-the-pages-say-what-the-game-is-and-leave-the-history-to-the-logs.md b/docs/TODO/completed/223-the-pages-say-what-the-game-is-and-leave-the-history-to-the-logs.md similarity index 86% rename from docs/TODO/223-the-pages-say-what-the-game-is-and-leave-the-history-to-the-logs.md rename to docs/TODO/completed/223-the-pages-say-what-the-game-is-and-leave-the-history-to-the-logs.md index 358af8e4..087f6bea 100644 --- a/docs/TODO/223-the-pages-say-what-the-game-is-and-leave-the-history-to-the-logs.md +++ b/docs/TODO/completed/223-the-pages-say-what-the-game-is-and-leave-the-history-to-the-logs.md @@ -112,6 +112,20 @@ new part was checked against the code during the review. The Charts keys are a table now, as the Flight keys are. The four tests that read the README pass. +**M3.** The missions page said the hunt was one Krait. It says a gang of +four now, led by a Fer-de-Lance. Each side job on that page says what its +board waits for: the hunt needs 8 kills, the lane and the escort need 4, +the recovery and the rescue need fuel scoops, and the four open jobs say +nothing. The words come from the skeleton's gate, so a changed gate changes +the page. The `?` guide's layout line said "toggle with B when docked" in +two places, and both name the KEYBOARD LAYOUT row now. Two tests hold +each change. + ## Outcome -(filled when the plan lands) +Landed on 2026-09-14, in three commits. The landing page speaks to a +player: keyboard, mouse or touch, a course the ship flies, a phone, and a +title that fits one. The README says what the project is in 370 lines, +where it said it in 505, and the history stays in the logs. The missions +page and the `?` guide say what the game does today. The zoom lock on the +landing page and the July screenshots stay open, for Chris. diff --git a/docs/TODO/completed/README.md b/docs/TODO/completed/README.md index c839886d..877903af 100644 --- a/docs/TODO/completed/README.md +++ b/docs/TODO/completed/README.md @@ -225,6 +225,7 @@ Supporting records: [Elite-A alignment plan](ELITE-A-COMBAT-PLAN.md), - [x] 213 — [The mission rules hold, and a hit trader goes back to work](213-the-mission-rules-hold-and-a-hit-trader-goes-back-to-work.md) — Chris, 2026-09-13. Two reviews read the missions and flew every kind of job. **M1** makes a hit trader go back to work, and the escort ends. **M2** makes a side job come back, unloads a smuggling run, gates every arc to galaxy 1, and makes a scoop job wait for the scoops. **M3** keeps the gun warning for the Constrictor, and lets a lead's message through a board of side jobs. **M4** drops a ghost at the loader, gives the journal a galaxy, keys standing by the origin, and restores a mission ship under its leg's role. **M5** holds every trigger a verb can emit in the lint. 6,029 assertions. - [x] 215 — [The gun row lives in the console, and a list runs sideways](215-the-gun-row-lives-in-the-console-and-a-list-runs-sideways.md) — Chris, 2026-09-13. He flew the game on his phone, and three columns of buttons covered the fight. He drew the shape. **M1** puts the guns in a row at the bottom of the console, the missile as two buttons, and drops the missile squares. **M2** folds each list to a header, closed by default, that opens as one row that scrolls sideways, with one fold for both. **M3** says so in the manual. 6,072 assertions. - [x] 214 — [A job has a surprise in it](214-a-job-has-a-surprise-in-it.md) — Chris, 2026-09-13. He said the missions were too easy, and asked for surprises. **M1** puts pirates at the jump-in of every job that sends the commander somewhere. **M2** springs an ambush on a scoop and at a scan's end. **M3** makes the escort's charge hold for the commander, asks for her presence at the fee, and sends a pack for the charge. **M4** makes a hunt's target run when it is nearly dead, and measures that the run alone moves no number. 6,153 assertions. +- [x] 223 — [The pages say what the game is, and leave the history to the logs](223-the-pages-say-what-the-game-is-and-leave-the-history-to-the-logs.md) — Chris, 2026-09-14, after `docs/PAGES-REVIEW.md`. The landing page is for a player, and the README is for a reader of the project. **M1** says keyboard, mouse or touch, adds a course bullet and a phone bullet, and fits the title on a phone. **M2** writes the README again after Chris's opening, 505 lines to 370, with the missions as they are and a survivor as a person. **M3** makes the missions page say the hunt is a gang and what each side job waits for, and sends the `?` guide to the KEYBOARD LAYOUT row. - [x] 222 — [The targets header is an icon, and a hostile reads red](222-the-targets-header-is-an-icon-and-a-hostile-reads-red.md) — Chris, 2026-09-14, with a picture from his phone. The header stood on a console measured before it grew, so the shell watches the console now. The header is a bullseye with the count, red while a hostile is on the scanner, and a hostile row reads red. The gun row on a phone is two lines. 6,163 assertions. - [x] 221 — [The fast forward button is an icon](221-the-fast-forward-button-is-an-icon.md) — Chris, 2026-09-13, from his phone. The button was a bar as wide as the course header. It is two arrows now, small, under the header, lit while it runs and dim while it cannot start. 6,159 assertions. - [x] 219 — [The Dark Wheel comes for a commander](219-the-dark-wheel-comes-for-a-commander.md) — Chris, 2026-09-13. A secret society at the higher ratings, with the old names. **M1** makes the Wheel a patron with no world, on the lawless boards, with the mark at Above Average. **M2** flies police as company and puts a parcel past three Vipers. **M3** places a leg in witchspace, and a pod among the Thargoids. **M4** puts a Thargoid at the door at Competent, and grants the cloaking device. **M5** makes the cloak work: nobody sees the ship, the bank pays, and a shot gives it away. 6,159 assertions. diff --git a/play.html b/play.html index de0b3ef1..4a6c884f 100644 --- a/play.html +++ b/play.html @@ -200,7 +200,7 @@

    IN SCREENS

    -

    ACTIVE LAYOUT: CLASSIC — toggle with B when docked
    +

    ACTIVE LAYOUT: CLASSIC — KEYBOARD LAYOUT on the station menu switches it
    VIEWS ON 1-4 (WERE F0-F3) AND SCREENS ON LETTERS (WERE F4-F9) IN BOTH LAYOUTS