diff --git a/README.md b/README.md index 554b739d..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) @@ -181,6 +120,7 @@ joystick the original supported. | 1 2 3 4 | front / rear / left / right view | | T / M / U | arm missile (locks in your sights) / fire / unarm | | E / TAB | E.C.M. / energy bomb (if fitted) | +| Z | cloaking device — nobody sees you while it runs (if you ever come by one; no shop sells it) | | J | torus jump drive (8×, stars streak; cuts out when mass-locked) | | C | docking computer — flies you in; press again or touch the controls to take over | | ⇧C | contracts — the work you have signed for; the board itself is a station's | @@ -201,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 @@ -229,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 @@ -286,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)). @@ -476,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/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4f103fa6..3e4e018a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -23,16 +23,40 @@ rules. This file is a map. on the GPU. The controls read an input interface, not the browser. Two pointer 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` + spend. `engine/hold-buttons.ts` is the third: a button that holds a key down + while it is held, such as the laser (docs/TODO/206). +- 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. 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. + `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. A commander with no docking computer gets the docking + mini game (docs/TODO/212). EVERY commander gets the same line-up first. The + computer flies the ship to the right distance from the station, stops it, and + turns it to face the port. `world-step.ts` then asks the one question: a + fitted docking computer takes the ship in, and a commander with none gets the + rails and the game. `dock-rails.ts` holds the ship on the slot axis while the + pilot matches the slot and the speed. It is the one file that moves the + commander's ship other than by flying it, and it only ever takes up a residue. + The last turn is flown, with `bankToTurn` and both sticks. `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. 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 @@ -51,13 +75,16 @@ rules. This file is a map. **Two halves split what a commander does.** Neither half reaches into the other. -- `docked.ts` — what she does once the ship stops. The arrival, the menu, the +- `docked.ts` — what the commander does once the ship stops. The arrival, the menu, the market, the outfitters and the board are here. -- `flight.ts` — what she does in the sky. That is one slice of time advanced, +- `flight.ts` — what the commander does in the sky. That is one slice of time advanced, and who is at the controls for it. It has two children of its own: - `flight-weapons.ts` — what the ship spends, and what it takes. Laser fire is here. - `flight-instruments.ts` — the switches that change who flies the ship. + - `flight-course.ts` — the course at the controls. Three things end it. The + work is done, or the pilot taps the lit button, or a hostile ship turns up. + It left `flight-instruments.ts` on 2026-09-12. A step that ends in a dock, a jump, a tow or a death reports that end to `game.ts`. The orchestrator then decides what the game becomes. @@ -68,7 +95,7 @@ spends: - `world-build.ts` — what is in the sky when you arrive. - `cockpit-view.ts` — what the cockpit shows about the world. - `law-actions.ts` — what the law does to a commander. It applies a record. -- `hyperspace-actions.ts` — how a commander leaves a system, and how she arrives +- `hyperspace-actions.ts` — how a commander leaves a system, and how they arrive in one. The jump is here. - `career.ts` — what a career keeps when a flight ends. - `persistence.ts` — the world written down, and put back. @@ -156,6 +183,20 @@ Two quirks are deliberate: both callers agree. `combat.ts` takes each ingredient separately, so a test can drive it. `combat-player.ts` is the assembly step that builds the player's own trigger out of one `GameState`. +- `src/game/targets.ts` lists what the ship can fight, and holds the pilot's + pick (docs/TODO/206). The pick is one flag on the picked ship's own state, + so a save carries it. It sits on top of `threat-lock.ts`, and never changes + that rule. `target-actions.ts` joins the list to its buttons, and gives each + ship a code that names it for as long as it lives. `close-pass.ts` names a + neutral trader that comes close, one time, so the pilot knows the chance is + there (docs/TODO/209). It never stops the ship. + `cockpit-buttons.ts` says what every button over the flight view reads, in + words, and it holds no world (docs/TODO/206 M5). `game/mission-course.ts` + says what a live mission asks the ship to do here (docs/TODO/208). There + are five shapes: a fight, a hold, an escort, a scoop, and a slip past the + police. + `game/derelict.ts` says what a scan of a derelict reports, read off the + world's own seed. - `src/game/threat.ts` computes the pirate count, the group tier and the organisation from the visible value and the reputation. `ship-specs.ts` maps a tier to a hull. The campaign simulator calls the same rules. @@ -196,22 +237,27 @@ Two quirks are deliberate: - `src/game/mission-bridge.ts` is the game's side of the mission machine. It runs one input, installs the record on the commander, and applies the effects that are the commander's: credits, reputation and legal status. It - hands the words back, and the caller says them. `src/game/mission-desk.ts` + hands the words back, and the caller says them. It hands a spawn order + back too, and the world step spawns an ambush (docs/TODO/214). `src/game/mission-desk.ts` is the MISSIONS screen's two actions, acceptance and abandonment, and the - offers it lists. `src/game/hunt-warning.ts` prices her gun against the ship + offers it lists. `src/game/hunt-warning.ts` prices the commander's gun against the ship a hunt names, through the combat oracle. - `src/missions/` is the mission machine (docs/TODO/190). `model.ts` holds the - types. A skeleton under `skeletons/` is one mission's rules, written by a + types, `effects.ts` the consequences the game applies, and `words.ts` the + shape of a patron and a dossier. A skeleton under `skeletons/` is one mission's rules, written by a developer. A dossier is its generated words, and the machine never reads one. `machine.ts` is one pure step. It takes the record, one input and the facts it may read. It returns a new record with the effects the game applies. + `settlement.ts` applies a branch's settlement, and `leads.ts` saves a lead + (docs/TODO/217). A verb module under `verbs/` decides what an input means for one leg, and the machine takes the branch. `triggers.ts` says what a trigger is called, and which dossier line a branch may speak with. `lookups.ts` reads a skeleton: a leg by id, a patron's key, and where a skeleton is offered. `placement.ts` picks a leg's world with one draw. A band measures tenths on the chart. A handover measures jumps on the full-tank graph. `tour.ts` places the five arc starts from the seed, - and a galactic jump moves a lead by the same rule (docs/TODO/192). `queries.ts` answers the game's questions without a change. `lint.ts` + and an arc is offered in galaxy 1 alone. So a lead keeps its galaxy across + a galactic jump (docs/TODO/213). `queries.ts` answers the game's questions without a change. `lint.ts` holds the five failure rules as data checks, and `test/mission-skeletons.test.ts` runs it over every skeleton. `repair.ts` reads a saved record for both loaders, and drops the old stage number. diff --git a/docs/COMBAT-COMPUTER-REVIEW.md b/docs/COMBAT-COMPUTER-REVIEW.md new file mode 100644 index 00000000..9c877207 --- /dev/null +++ b/docs/COMBAT-COMPUTER-REVIEW.md @@ -0,0 +1,143 @@ +# The combat computer acquires targets but loses its aim + +Review date: 12 September 2026. Source revision: `5abc879`. + +The main weakness is sustained tracking. The computer often acquires a target within three seconds, then rolls around it without a useful shot. This happens with one selected target, so target switching cannot explain it. + +The best first improvement is to control when pitch starts during a bank. An isolated experiment raised average tracking from **53.9% to 98.1%** across 54 additional orbit cases. Those figures describe prescribed target paths, not full combat or win rates. No production code changed during this review. + +## Scope and measurement + +The shipped pilot is [`ScriptedCoPilot`](../src/game/scripted-co-pilot.ts). The trained controller in [`combat-computer.ts`](../src/game/combat-computer.ts) is dormant. + +The tracking probe uses the real player flight model, the shipped controller, and a pirate with radius 15. The player starts at speed 200. The target follows a prescribed path. Its position, orientation, and speed agree with that path. + +Each run holds the same target throughout. This isolates steering from target selection. **Time on the gun** means the target is inside both `hitCone(radius, distance)` and `LASER_RANGE`, after the player moves. The first ten seconds are excluded from this measure. A tracking gap is consecutive time without that firing geometry. + +The first grid contains 18 orbits and three straight or weaving paths. Runs last 30 and 60 seconds. The separate validation grid contains 54 orbits with different radii, speeds, and starting phases. Those runs last 45 and 90 seconds. These are deterministic grids, not estimates of encounter frequency. + +## 1. High priority: stop the persistent roll around a target + +**Evidence:** [`scripted-co-pilot.ts:184`](../src/game/scripted-co-pilot.ts#L184), [`pitch-roll-steer.ts:176`](../src/game/pitch-roll-steer.ts#L176), [`pitch-roll-steer.ts:200`](../src/game/pitch-roll-steer.ts#L200). + +The co-pilot permits pitch before its bank aligns with the target. The steering rule reduces pitch by the cosine of the bank error. It does not stop pitch completely. Pitch can then move the target's bearing while roll tries to catch that bearing. The resulting motion can circle the target indefinitely. + +The module already describes this failure for the course pilot. Its comment at [`pitch-roll-steer.ts:107`](../src/game/pitch-roll-steer.ts#L107) says combat never encounters it. The measurements contradict that claim. + +These are the shipped controller's results. Orbit radius describes the target's path around a fixed centre, not its distance from the moving player. + +| Target path | Time on gun, 30-second run | Time on gun, 60-second run | Longest gap in the 60-second run | +| --- | ---: | ---: | ---: | +| Horizontal orbit, radius 400, speed 300 | 28.5% | 32.2% | 3.70 s | +| Vertical orbit, radius 400, speed 300 | 100.0% | 100.0% | 0.00 s | +| Tilted orbit, radius 800, speed 300 | 17.6% | 17.3% | 1.60 s | +| Straight crossing, speed 150 | 34.5% | 13.8% | 42.88 s | +| Weaving away | 7.5% | 7.5% | 3.67 s | +| Straight away, speed 280 | 100.0% | 100.0% | 0.00 s | + +Every case remains inside laser range throughout its measurement window. The vertical orbit requires pitch without roll and tracks well. Horizontal and tilted paths expose the coupled steering failure. + +The straight-crossing case first enters firing geometry at 1.98 seconds. Later, it settles around 10.9 degrees off target. It accumulates 10.7 full turns of absolute roll travel per minute. This is a persistent miss after successful acquisition. + +**Recommended change:** evaluate the existing hard bank gate for the combat pilot. The experiment passes `0.1` radians, about 5.7 degrees, as `bankToTurn`'s final argument. This withholds the pitch request until the bank error falls below that threshold. The current rate ramp still applies. + +The experiment changes only that argument. Lead, throttle, flight limits, and the gun cone retain their shipped values. + +| Measurement | Shipped controller | Hard bank gate | +| --- | ---: | ---: | +| Initial grid mean, 30-second runs | 68.9% | 98.3% | +| Initial grid mean, 60-second runs | 64.9% | 97.7% | +| Separate grid mean, 45-second runs | 56.7% | 98.1% | +| Separate grid mean, 90-second runs | 53.9% | 98.1% | +| Separate grid worst case, 90-second runs | 3.8% | 81.3% | + +No case in the separate grid lost more than one percentage point of tracking. However, the longest initial acquisition increased from 4.90 to 5.47 seconds. The candidate also retains a 9.97-second gap in the original straight-crossing case. + +Treat `0.1` as a promising candidate, not a final calibrated value. Check neighbouring thresholds, more initial orientations, and actual NPC fights before release. Include target changes and close passes. Preserve the player's pitch-and-roll flight model. + +## 2. High priority: make tracking tests reject weak tracking + +**Evidence:** [`scripted-co-pilot.test.ts:157`](../test/scripted-co-pilot.test.ts#L157), [`scripted-co-pilot.test.ts:373`](../test/scripted-co-pilot.test.ts#L373). + +The existing circling test accepts anything above **20%** time inside the gun cone. It passes at roughly 29% and 32%. That threshold protects an earlier improvement but accepts poor tracking today. + +The straight-crossing test has two further weaknesses: + +- It moves the target sideways without aligning its quaternion with that motion. The controller derives velocity from that quaternion, so its predicted motion differs from the test path. +- It measures a fixed `0.1`-radian angle instead of the actual gun cone. +- It stops after 20 seconds. The corrected 60-second probe exposes a sustained miss that a short run can conceal. + +**Recommended change:** make target position, orientation, and speed consistent in every motion fixture. Measure actual firing geometry, acquisition time, longest tracking gap, and roll travel separately. Include horizontal, vertical, and tilted paths. Set acceptance limits per case after validating the controller change. A high average must not hide a case that loses the target for 40 seconds. + +## 3. Medium priority: refine lead after the bank problem is fixed + +**Evidence:** [`scripted-co-pilot.ts:180`](../src/game/scripted-co-pilot.ts#L180). + +The aim point uses target velocity multiplied by the remaining angle and `PURSUIT_LEAD_GAIN`. The lead depends on heading error, but not on whether the target flies straight or turns. + +Removing lead globally is a poor remedy. In the fast horizontal orbit, the shipped controller falls from 32.2% to 4.1% tracking without lead. Reducing the gain to `0.5` also performs poorly across the first grid. + +After the hard gate, the tradeoff becomes clearer: + +| 60-second case | Gate with current lead | Gate without lead | +| --- | ---: | ---: | +| Fast horizontal orbit | 90.0% | 46.8% | +| Straight crossing | 74.3% | 98.7% | +| Weaving away | 99.7% | 87.0% | + +**Recommended change:** keep the current lead for the first bank-control experiment. Then investigate a separate rule for settled pursuit of a straight target. Target angular motion and relative velocity are useful candidate inputs. These are follow-up hypotheses; this review does not establish a final lead rule. + +## 4. Medium priority: make target commitment reflect progress + +**Evidence:** [`scripted-co-pilot.ts:143`](../src/game/scripted-co-pilot.ts#L143), [`threat-lock.ts:75`](../src/game/threat-lock.ts#L75). + +The computer treats a target within `ENGAGED_CONE`, about 34 degrees, as an attack in progress. That condition blocks a target switch. It checks neither gun range nor recent firing geometry. + +A controller-level reproduction holds a target 3,000 units away and 23 degrees off the nose. A second hostile sits 500 units directly ahead. After ten seconds, the existing lock still refuses the easy shot. A fresh controller chooses it. This reproduction holds geometry fixed to test the selection rule alone. + +**Recommended change:** expose the held target and the reason it remains selected in probe output. After correcting steering, measure whether a timed lack of progress should release the commitment. Retain the existing hold time and switching margin unless the measurements justify a change. Frequent target switching would introduce another tracking failure. + +## 5. Medium priority: preserve the correct controller state at handover + +**Evidence:** [`scripted-co-pilot.ts:83`](../src/game/scripted-co-pilot.ts#L83), [`scripted-co-pilot.ts:103`](../src/game/scripted-co-pilot.ts#L103), [`persistence.ts:141`](../src/game/persistence.ts#L141). + +Three state defects affect tracking after an interruption: + +- Manual override returns without clearing the stored turn rates. In a headless Game, the player stops rolling during manual flight. Re-engagement restores an old roll at **−1.13 rad/s**, despite a target directly ahead. +- `reset()` leaves `steerMem.side` unchanged. A reset controller can choose the opposite bank from a fresh controller at identical geometry. +- Persistence saves the dormant trained controller, not the scripted controller's rates or steering memory. A fresh Game restores the player's saved roll correctly, then replaces it on the next step. The reproduction changes a continued **−1.48 rad/s** roll to **−0.17 rad/s**. + +**Recommended change:** define one handover contract. Seed a resumed controller from the player's current rates. Clear engagement-specific steering memory when an engagement ends. Save the scripted controller state required to resume a turn. Test continued flight after restore, not just equality immediately after restore. + +## Other confirmed findings + +These are separate from the tracking recommendation: + +- **Automatic fire can use the wrong mount.** A rear-view command leaves combat steering engaged. The computer aims at a front pirate but fires the rear laser. In a one-frame Game reproduction, it hits a trader behind the player and changes legal status to Offender. Pass an explicit mount with the automatic shot request, or suppress that request outside its intended view. See [`flight.ts:351`](../src/game/flight.ts#L351) and [`combat-player.ts:34`](../src/game/combat-player.ts#L34). +- **Automatic fire does not check the first object the shot will hit.** A trader between the player and a pirate takes the automatic shot. The reproduction again changes legal status to Offender. Use the shared shot trace before authorizing an automatic shot. Preserve explicitly selected targets and the human trigger's own behavior. See [`scripted-co-pilot.ts:215`](../src/game/scripted-co-pilot.ts#L215) and [`shot.ts`](../src/game/shot.ts). +- **Missile defence ends when no ship target remains.** With a hostile missile still inbound, `combatSteer` reports `AREA CLEAR` and requests no E.C.M. Evaluate the missile response independently of whether steering has a ship target. See [`scripted-co-pilot.ts:151`](../src/game/scripted-co-pilot.ts#L151) and [`autopilot.ts:270`](../src/game/autopilot.ts#L270). + +## Evidence and reproduction + +The focused existing suite passed **87 assertions**. It covers the scripted co-pilot, steering, targets, threat lock, and aim integration. + +The review artifacts contain the independent experiments: + +- [Tracking probe](reviews/combat-computer-2026-09-12/probe.mjs) +- [426 tracking measurements](reviews/combat-computer-2026-09-12/measurements.jsonl), including lead-removal and reduced-lead experiments +- [State, selection, and firing reproductions](reviews/combat-computer-2026-09-12/integration-probe.mjs) +- [Integration results](reviews/combat-computer-2026-09-12/integration-results.jsonl) + +Run these from the repository root: + +```sh +node --experimental-strip-types --no-warnings docs/reviews/combat-computer-2026-09-12/probe.mjs +node --experimental-strip-types --no-warnings docs/reviews/combat-computer-2026-09-12/probe.mjs --hard-gate +node --experimental-strip-types --no-warnings docs/reviews/combat-computer-2026-09-12/probe.mjs --holdout +node --experimental-strip-types --no-warnings docs/reviews/combat-computer-2026-09-12/probe.mjs --holdout --hard-gate +node --experimental-strip-types --no-warnings docs/reviews/combat-computer-2026-09-12/integration-probe.mjs +``` + +The hard-gate option creates a temporary controller module and removes it after import. It does not modify the game's source. The checked-in probe reproduced the original baseline and separate candidate results byte for byte. + +The probes measure control geometry without shots, damage, or an opponent that reacts to the player. They establish a steering defect and a strong candidate improvement. Actual combat balance and cockpit feel still require validation when that improvement is implemented. diff --git a/docs/DAMAGE-PATHS.md b/docs/DAMAGE-PATHS.md index d190b682..eadedfa8 100644 --- a/docs/DAMAGE-PATHS.md +++ b/docs/DAMAGE-PATHS.md @@ -25,7 +25,7 @@ other: | `PlayerPoolPoints` | the commander's 255-point facing shield, then the 255-point bank | 0–510 to strip both | **There is no normalized scale.** An episode's target is the commander, with -`game/systems.ts`'s three 255-point pools. `applyDamage` hits her for +`game/systems.ts`'s three 255-point pools. `applyDamage` hits the commander for `npcLaserDamageToPlayer` points, off the firing build's own packed byte. `TARGET_DAMAGE_LO`, `TARGET_DAMAGE_SPREAD`, `VICTIM_RAM_DAMAGE`, `targetShotDamage` and `targetHullForPoolPoints` do not exist anywhere. @@ -59,7 +59,7 @@ has exactly two damage scales, and both belong to the released game. | 20 | headless campaign | — | — | — | `test/campaign.ts` | flight is abstracted; it never applies damage | | 21 | combat simulator | commander and opponents | — | rows 1–10, unchanged | `combat-sim.ts` via `exerciseStepHost` | it flies the **real** step; there is no simulator damage model | | 22 | training episode → pirate | a real `NpcShip` | normalized, converted | `NpcEnergyPoints` (rows 3 and 7) | `ai-training/scenario.ts` → `fire-resolution.ts` | as the live game, through the live resolver | -| 23 | training episode → its target | the episode's `TargetShip` | normalized | `PlayerPoolPoints` (rows 2 and 8) | `ai-training/scenario.ts` → `gunnery.ts`, `impact-damage.ts`, `systems.ts` `applyDamage` | **as the live game**, recovery included. The target holds `freshSystems()`, takes `npcLaserDamageToPlayer` points on the facing shield through `applyDamage`, and gets them back through `systems.ts` `regenerate()` — the whole rule, hull recharge rating and energy unit included. That includes **row 5**: an episode's pirates launch the missiles they carry, through `NpcShip.chooseWeapon` and `ordnance.ts`'s own `Ordnance`, and a warhead reaching her is `IMPACT.warhead`'s 250 points on the facing shield like any other hit. And she can answer it: the target carries an E.C.M. (`EcmFit`), a defence policy has a thirteenth output that presses it, and the burst spends `ECM_ENERGY_COST` off her bank through row 18's own `fireEcm` — the same call the player's key makes. The episode resolves none of it: rows 2 and 3 are spent by `game/fire-resolution.ts`, the one resolver both this file and `world-step.ts` call, and which face takes a hit is `game/shield-face.ts` in both. | +| 23 | training episode → its target | the episode's `TargetShip` | normalized | `PlayerPoolPoints` (rows 2 and 8) | `ai-training/scenario.ts` → `gunnery.ts`, `impact-damage.ts`, `systems.ts` `applyDamage` | **as the live game**, recovery included. The target holds `freshSystems()`, takes `npcLaserDamageToPlayer` points on the facing shield through `applyDamage`, and gets them back through `systems.ts` `regenerate()` — the whole rule, hull recharge rating and energy unit included. That includes **row 5**: an episode's pirates launch the missiles they carry, through `NpcShip.chooseWeapon` and `ordnance.ts`'s own `Ordnance`, and a warhead that reaches the commander is `IMPACT.warhead`'s 250 points on the facing shield like any other hit. And the commander can answer it: the target carries an E.C.M. (`EcmFit`), a defence policy has a thirteenth output that presses it, and the burst spends `ECM_ENERGY_COST` off the commander's bank through row 18's own `fireEcm` — the same call the player's key makes. The episode resolves none of it: rows 2 and 3 are spent by `game/fire-resolution.ts`, the one resolver both this file and `world-step.ts` call, and which face takes a hit is `game/shield-face.ts` in both. | | 24 | debug / console | — | — | — | `console.ts` | the handles are write-only: `__game`, `__policyKit`, `__simLog`. Nothing there applies damage. | | 25 | in-flight missile as a target | — | — | none | — | Harmless **cannot** damage a missile in flight: `shot.ts` traces ships, cargo and the station only, and the E.C.M. destroys missiles outright. The pack's profile for design 15 (2 energy, 2 defence) is available the day it becomes a target. | diff --git a/docs/ELITE-A.md b/docs/ELITE-A.md index b3031ed8..890b289e 100644 --- a/docs/ELITE-A.md +++ b/docs/ELITE-A.md @@ -288,7 +288,7 @@ rules: changes the world they were trained in. 4. **Save atomically.** The commander blob and the world blob are two keys (`storage.ts`). A purchase that wrote one and not the other would leave a - commander who flies a hull that her world does not agree about. Write the + commander who flies a hull that their world does not agree about. Write the commander first. Then let the ordinary dock-time save handle the rest. Three things do NOT change. `perHitShieldArmour` comes off the hull record, so diff --git a/docs/MISSIONS-FLIGHT-REVIEW.md b/docs/MISSIONS-FLIGHT-REVIEW.md new file mode 100644 index 00000000..6fe0919d --- /dev/null +++ b/docs/MISSIONS-FLIGHT-REVIEW.md @@ -0,0 +1,308 @@ +# The ship finishes every job, and the job is over before the player arrives + +Review date: 13 September 2026. Source revision: `cb7785b`. Part two of +[`MISSIONS-REVIEW.md`](MISSIONS-REVIEW.md), which reviewed the machine and its +data. This part asks two questions about the flight. Can a player succeed? Is +it fun? + +**The mechanisms are there.** A fresh commander with a pulse laser can finish +every one of the eight kinds of job with the ship's own automation. The sky +is as shipped, with ordinary traffic. The probe flew each kind eight times and +finished 92 runs of 96. The four that did not finish are all escorts. + +**The fun is the problem.** A hunt is over in four to thirteen seconds. The +target comes to the commander, the computer aims, and a pulse laser kills a +Krait before the arrival line leaves the console. Five of the eight kinds +resolve in under thirty seconds with no decision in them. Every leg then ends +with the same two-minute flight to the station. The escort is the one long +job, and it is long because a Python is slow. No production code changed +during this review. + +## Scope and measurement + +Two probes sit beside part one's in +[`reviews/missions-2026-09-12/`](reviews/missions-2026-09-12/). + +[`flight-probe.ts`](reviews/missions-2026-09-12/flight-probe.ts) runs a real +headless game. A fresh commander holds one live leg whose work is in the +system the commander arrives in. Ordinary traffic stays in the sky. The probe presses +the job's own button. It holds the trigger while the computer aims. It +presses the button again when a fight dropped the course. It presses the +docking computer when the station course hands over. Scoops are fitted for a scoop. +A docking computer is fitted for a leg that ends at the station, because the +probe has no hands for the slot. Each kind is flown eight times, on eight +different days, so the arrival seeds eight different skies. The output is +[`flight-results.txt`](reviews/missions-2026-09-12/flight-results.txt). + +[`escort-probe.ts`](reviews/missions-2026-09-12/escort-probe.ts) traces the +escort runs that stalled, ten seconds at a time. The output is +[`escort-results.txt`](reviews/missions-2026-09-12/escort-results.txt). + +The numbers below are sim seconds from the end of the arrival. They do not +include the jump, the tunnel, or the slot. + +| job | done | failed | dead | stalled | time to done | fights on the way | ship after | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| hunt a Krait | 8 | 0 | 0 | 0 | 4 s | 1.0 | full | +| hunt a Cobra Mk III (Lave arc) | 8 | 0 | 0 | 0 | 4 s | 1.0 | full | +| hunt a Fer-de-Lance (Xeer arc) | 8 | 0 | 0 | 0 | 8 s | 1.0 | fore shield 162 of 255 | +| hunt an Asp (Edle arc) | 8 | 0 | 0 | 0 | 12 s | 1.0 | fore shield 105 of 255 | +| hunt the Constrictor | 8 | 0 | 0 | 0 | 62 s | 1.0 | energy 213, shields 130 and 219 | +| scan an Anaconda for 20 s | 8 | 0 | 0 | 0 | 24 s | 0.1 | full | +| escort a Python to station range | 4 | 0 | 1 | 3 | 497 s | 0.3 | full | +| scoop a canister | 8 | 0 | 0 | 0 | 14 s | 0.3 | full | +| scoop a pod | 8 | 0 | 0 | 0 | 13 s | 0.1 | full | +| clear the lane, then dock | 8 | 0 | 0 | 0 | 130 s | 3.1 | full | +| deliver: fly to the station and dock | 8 | 0 | 0 | 0 | 125 s | 1.4 | full | +| smuggle: slip past the police and dock | 8 | 0 | 0 | 0 | 116 s | 2.6 | full | + +A first batch of the smuggle run, before the days were varied, was scanned in +two runs of eight. The second batch was scanned in none. So the police read +the hold in about one run in eight. + +## What a job costs the player, in time + +A job has six parts, and only one of them is the job. + +1. **The offer.** The MISSIONS screen lists the title, the briefing pages, + the patron and the fee. One button accepts. +2. **The chart.** A diamond marks the world. The readout under the cursor + says how many days away it is. A launch with no destination is refused. +3. **The jump.** The countdown is five seconds, and then the tunnel. +4. **The job.** The arrival line says where the target is, in units and a + bearing. The first course button says what to do about it, in the job's + words. Four to twenty-seven seconds pass, and the console says the leg + moved. +5. **The station.** The station is 62,000 to 82,000 units from the + witchpoint. The station course takes 95 to 145 seconds, and one to three + pirate waves drop it on the way. Fast forward runs eight times faster + while no hostile ship is near. +6. **The slot.** A commander without a docking computer matches the + station's roll and the speed by hand. A docking computer costs 1,500 + credits. + +So a side job is about two minutes of flight with a few seconds of job in +it. The escort is the one exception, at eight and a half minutes. + +| job | pays | the job's own seconds | seconds to the station | +| --- | ---: | ---: | ---: | +| hunt | 800 Cr | 4 | 125 | +| ambush | 600 Cr | 0, and the lane fight on the way | 130 | +| smuggle | 700 Cr | 0 | 116 | +| escort | 600 Cr | 497 | 0 | +| rescue | 500 Cr | 13 | 125 | +| recover | 400 Cr | 14 | 125, and the origin world after | +| deliver | 300 Cr | 0 | 125 | +| scan | 250 Cr | 24 | 125 | + +A Cobra with twenty tonnes clears about 200 credits on a good trade jump. A +hunt pays four of those for one jump. The pull is strong. Part one found +that each side job is offered one time per career, so the pull stops after +eight jobs. + +## 1. High priority: a hunt is over in seconds, and nothing threatens the ship + +**Evidence:** the table above, [`mission-course.ts`](../src/game/mission-course.ts), +[`flight-course.ts`](../src/game/flight-course.ts), +[`autopilot.ts:168`](../src/game/autopilot.ts#L168). + +The target spawns 2,500 to 4,500 units from the witchpoint. It is a pirate, +so it turns to attack the commander. The mission course picks it as the +target, the computer engages by itself, and the aim holds it in the sights. +The probe holds the trigger. A Krait dies in four seconds and a Cobra Mk III +in four. An Asp, the fastest and best-armed hull an arc names, dies in +twelve. The ship ends every hunt with full energy, and only the Asp and the +Fer-de-Lance mark a shield. + +The Constrictor is the same story, one minute long. The gun warning says a +pulse laser needs 115 unbroken hits, and it is right. It does not matter. +The computer's aim gives 115 unbroken hits in a minute, and the ship ends +with energy 213 and shields 130 and 219. + +The warning itself is wrong on every other hunt. `huntWarning` says TARGET +ARMOUR HALVES LASER FIRE for any target where a military laser scores more +than the fitted one. That is every ship, so every side hunt and every arc +hunt says it. Only the Constrictor halves a hit. + +**What this means for the player.** The hunt was the 1984 game's whole +content. Here the decision is one press, and the outcome is never in doubt. +A pilot who holds the trigger cannot lose a hunt with the starting gun. + +**Recommended changes.** The fight has to be the content, or the hunt has to +be a chase. + +- A hunted ship does not come to the commander. It runs, and the commander + runs it down. Part one found that a pirate never leaves the system, so the + escape branches the skeletons already carry are dead. A target that CAN + leave makes the four seconds count. +- A hunted ship flies with company. The lane already spawns three pirates + beside a job, and that job is the one with a fight in it. +- The tier tables that scale ordinary pirates to the commander's standing + can scale a mission target too. +- The warning names the Constrictor's halving on the Constrictor alone. + +## 2. High priority: the escort's charge flees for ever, and the course follows it into the planet + +**Evidence:** [`escort-results.txt`](reviews/missions-2026-09-12/escort-results.txt), +[`npc.ts:793`](../src/game/npc.ts#L793), +[`npc-trader.ts`](../src/game/npc-trader.ts), +[`world-step.ts:590`](../src/game/world-step.ts#L590), +[`course-pilot.ts`](../src/game/course-pilot.ts). + +Three escorts of eight stalled, and one killed the commander. The traces +show one cause. A trader that takes a hit from anybody sets `fleeing`, and +nothing ever clears it. A fleeing trader runs from the point of the hit at +full speed for the rest of its life. It never returns to its working life. So it never comes inside station range, and the leg never ends. + +Seed 1 met a pirate wave at fifty seconds. The charge took a hit in the +crossfire. From sixty seconds to six hundred it flew at 160 units a second. It went +between 34,000 and 39,000 units from the station, and back. The commander +was 370 units behind it. + +Seed 2 met nobody. At twenty seconds the commander's fore shield was 165 of +255 and the charge was fleeing. The escort course rammed it. The course +flies to a standoff of 600 units at the charge's own speed, and the approach +overshoots into the hull. A ram is a hit from the commander, so the charge +fled from the commander, and it fled for 580 seconds more. + +Seed 5 ended with CRASHED INTO THE PLANET at 559 seconds. The ship had full +energy and full shields, 80 units above the surface at 89 units a second. The +fleeing charge flew through the planet, because a ship does not crash. The +escort course aimed 600 units short of it. The course steers round the +planet on the way to a target. It does not refuse a target that is below +the clearance altitude. + +The four escorts that finished took 464 to 559 seconds. A Python flies to +the station at 136 units a second, and the station is 72,000 units away. +That is eight and a half minutes beside a ship that needs nothing from the +commander until something shoots it. Fast forward would cut it to about a +minute, and nothing on the screen says so. + +**Recommended changes.** + +- `fleeing` clears after some seconds with no attacker alive and no shot + fired. A trader that was hit once should go back to work. +- The escort standoff cannot ram. The approach speed near the charge is + capped, as the collect course caps its own. +- A course refuses a target below the planet's clearance altitude, and says + so, rather than fly the commander after it. +- The charge waits for the commander, or moves only while the commander is within some + distance. Plan 208 named this fault and left it. Until then the escort is + a job the commander watches. +- The charge starts nearer the station, or flies at the commander's pace, so + the leg is two minutes rather than nine. + +## 3. Medium priority: the tour opens with a job a new commander cannot fly + +**Evidence:** [`lave.ts:29`](../src/missions/skeletons/arcs/lave.ts#L29), +[`side.ts`](../src/missions/skeletons/side.ts), +[`course-actions.ts:220`](../src/game/course-actions.ts#L220), +[`shop.ts:57`](../src/constants/shop.ts#L57). + +A new commander starts with 100 credits, a pulse laser and no fuel scoops. +Fuel scoops cost 525 credits at a world of tech level five or more. Three of +the eight side jobs are a scoop, and so is the first leg of the Lave arc. The +Lave arc has no gate, so its hail is the first thing a new commander hears at +Lave. The commander accepts it, jumps, and reads NEEDS FUEL SCOOPS on the one button +that matters. The row is honest, and the arc holds a slot for thirty days. + +The arc has a way round. A canister shot before the scoop turns the leg into +a scan, and then into a hunt. Nothing tells the commander that the way round exists, +and shooting the patron's ledger is not a thing a new player tries. + +**Recommended changes.** Gate the arc on the scoops, on a kill count, or on +credits. Or open the tour with a leg that needs nothing: a delivery, a lane, +or a hunt. The recovery can be the second leg. + +## 4. Medium priority: every leg is the same two-minute flight to the station + +**Evidence:** the second table, [`courses.ts`](../src/game/courses.ts), +[`course-actions.ts`](../src/game/course-actions.ts). + +The job is four to twenty-seven seconds. The flight to the station is 95 to +145 seconds, and one to three pirate waves interrupt it. The waves are the +same waves a commander meets with no job. So the play a job adds is a few +seconds at the witchpoint, and the play that fills the time is the ordinary +lane. + +The lane job is the one whose content is on the way. Three pirates wait for +the commander, the fight takes the course, and the station is the end. It +finished eight of eight with no death and full shields, so it is not hard. +It is the right shape. + +**Recommended change.** Put the job where the time is. A target near the +station, or on the line to it, makes the flight the job. A hunt that starts +when the commander is half way in is a hunt that interrupts something. + +## 5. Low priority: the smuggling run is scanned one time in eight + +**Evidence:** [`course-pilot.ts`](../src/game/course-pilot.ts), +[`course-clearance.ts`](../src/game/course-clearance.ts), +[`world-step.ts:870`](../src/game/world-step.ts#L870). + +The slip course aims wide of every policeman within two scanner ranges, at +4,400 units, outside the 2,600 units a Viper reads a hold at. It worked in +fourteen runs of sixteen. A Viper that spawns on the final approach, inside +the hand-over, still reads the hold. That is a fair risk for 700 credits. +The failure says what the police found, and the machine says the job is +lost. + +## 6. Low priority: the scan and the two scoops are short and sure + +The Anaconda waits near the station and never leaves. The hold course keeps +it in view, and the count runs. Twenty-four seconds, eight of eight. A +canister and a pod are scooped in thirteen to fourteen seconds, eight of +eight, with scoops fitted. Without them the row says NEEDS FUEL SCOOPS and +refuses. Mechanically these are sound. As content, a scan is twenty seconds +of a number that counts up. + +## The words the player reads + +The dossiers are good. THE GOVERNOR'S MISSING LEDGER has three briefing +pages in the patron's voice. Each leg has an arrive line, a success line and +a failure line, with the world and the fee in them. KRAIT ON THE LANE +says who asks and what it pays in two sentences. The arrival line says +where the target is, in units and a bearing. The course button says HUNT THE +KRAIT. The console says the count of a scan and the tonnes of a smuggling +load. A player is never lost for what to do next. + +Two lines are wrong. The gun warning is finding 1. The hail THERE ARE N +SIDE JOBS ON THE STATION BOARD is said on every dock where any are open. It +hides the patron's messages, which is part one's finding 5. + +## Is it fun? + +Not yet. The pieces are all present, and each one is sound. Together they +make a loop in which the player decides one thing, and then watches. + +- **The decision is the accept.** After it, the player presses the one row + the situation offers. The commander holds a button while the computer aims. The commander + presses the station row. No job asks a second question. +- **The threat is gone.** The computer's aim and the auto-engage make every + fight a matter of the trigger. A fresh commander with a pulse laser + came through 95 runs of 96 alive, and the one death was a crash. The lane's + three pirates took nobody. +- **Failure costs nothing.** Standing moves and nothing reads it. A deadline + is fourteen or thirty days, and a jump is one to three. A failed job is a + fee not earned. +- **The choice trigger exists and no skeleton uses it.** The machine can + put a question to the player mid-arc. None of the five arcs does. +- **The travel is the game, and fast forward skips it.** The flight to the + station is where the pirates are. The job is over before it starts. + +**What would make it fun**, in the order a plan would take them: + +1. Make the hunt a chase. A target that runs, or that has to be found with + a scan first, turns four seconds into a pursuit with a scanner. +2. Make the escort about position. The charge moves while the commander is + near, and stops when the commander is not. Then the escort is the commander's. +3. Ask a question. The `choice` trigger is built and tested. One choice per + arc, with a consequence the log tells, is a story. +4. Let standing mean something. A patron who remembers a failure offers + less, or nothing. A patron who remembers a success opens the next arc a + jump early. +5. Put the job on the way in, not at the witchpoint. + +The arcs give the game a spine, and the log tells the path a player took. +The words are there. What is missing is a moment in each job where the +player can get it wrong. diff --git a/docs/MISSIONS-REVIEW.md b/docs/MISSIONS-REVIEW.md new file mode 100644 index 00000000..9d9c686a --- /dev/null +++ b/docs/MISSIONS-REVIEW.md @@ -0,0 +1,355 @@ +# The mission machine is sound, and five rules around it do not hold + +Review date: 12 September 2026. Source revision: `cb7785b`. Part one of two. +Part two, [`MISSIONS-FLIGHT-REVIEW.md`](MISSIONS-FLIGHT-REVIEW.md), reviews +how a job is flown, and whether it is fun. + +The machine itself is in good shape. One pure step reads an event and returns +a new record with its effects. The bridge applies the effects one time, before +any save. The lint gate is proved able to fail. The suite passes 5,972 checks. + +The faults sit around the machine, in the data and the wiring. Four are high +priority. An arc is offered in every galaxy at its seed index. A side job is +offered one time per career, and the rule says it comes back. A smuggling run +pays and leaves the goods aboard. The word `fled` reaches no leg that answers +it. No production code changed during this review. + +## Scope and measurement + +The review read every file under [`src/missions/`](../src/missions/). It read +the six game-side modules that feed the machine, the mission constants and the +skeletons. It read the mission tests and the plans 190, 192, 203 and 208. + +Every claim below was checked against the code that runs. Ten claims were +also driven through the real machine with no game world, as +[`test/mission-machine.test.ts`](../test/mission-machine.test.ts) does. The +probe is [`reviews/missions-2026-09-12/probe.ts`](reviews/missions-2026-09-12/probe.ts), +and its output is [`results.txt`](reviews/missions-2026-09-12/results.txt) +beside it. A finding names its probe block in brackets. + +## 1. High priority: an arc is offered in every galaxy at its seed index + +**Evidence:** [`offers.ts:59`](../src/missions/offers.ts#L59), +[`offers.ts:71`](../src/missions/offers.ts#L71), +[`model.ts:183`](../src/missions/model.ts#L183), +[`lave.ts:29`](../src/missions/skeletons/arcs/lave.ts#L29). Probe [1]. + +A world patron is a seed slot, and a seed slot is an index. Every galaxy has +an index 7. `nearHome` compares the commander's index with that slot, and the +five arcs carry no `galaxy` gate. Only the Constrictor does. The comment on +`Gate.galaxy` says that a world patron is already in one galaxy. The code +does not hold that. + +The probe stands the commander at index 7 of galaxy 2, which is Esrilees. The +offer list holds `arc-lave`. The hail says THE GOVERNOR OF LAVE HAS A JOB FOR +YOU. The patron reads THE COMPANY DIRECTOR OF ESRILEES. The dossier's pages +name Lave. The player reads three names for one person. + +The same fault reaches the tour after a galactic jump. The machine moves each +lead to the tour world of the new galaxy, as +[`tour.ts`](../src/missions/tour.ts) intends. The arc that lead opens then +places its handover leg toward a seed index in the new galaxy. Its own lead +points at a seed index too. The probe accepts the relocated Rabedira arc +in galaxy 2. Its convoy leg lands four jumps from galaxy-2 index 100, which is +not a tour world. The tour holds for one arc and then drifts. + +There are two ways out, and the choice is Chris's. The first gates every +world-patron skeleton to galaxy 1 and makes the lint refuse one without a +gate. The relocation on a galactic jump then has no arc to relocate, and the +comment on `tour.ts` about islands goes. The second gives `PatronRef.world` a +galaxy beside its slot. It then derives the hail, the pitch and the dossier +from the world rather than from a fixed name. The second is the larger change and +the one the tour's own comment asks for. + +## 2. High priority: a side job is offered one time per career + +**Evidence:** [`offers.ts:123`](../src/missions/offers.ts#L123), +[`model.ts:203`](../src/missions/model.ts#L203), +[`side.ts:22`](../src/missions/skeletons/side.ts#L22), +[`missions.ts:63`](../src/constants/missions.ts#L63). Probe [2]. + +`Skeleton.cap` is how many times a side job repeats, and absent means one +time. No side job sets it. So each of the eight side jobs can be taken one +time in a career. After that its row is gone from every board in the galaxy. + +Failure rule 5 in plan 190 says a side mission comes back after a delay. The +doc comment on `MISSION_REOFFER_DAYS` says the same. It says that without +the delay one job reads as the only work in the galaxy. The probe +ends `side-recover` on day 10. The job is shut on day 11, on day 18 and on day +400. + +The test that pins rule 5 uses a fixture with `cap: 3` +([`mission-offers.test.ts:102`](../test/mission-offers.test.ts#L102)). So the +mechanism is proved, and the shipped data is never held to the rule. A board +of two or three jobs, once each, empties in a few sessions. + +**Recommended change:** decide what a side job's cap is, and set it in +`side.ts` or make absent mean no cap for a side job. Add one test over +`SIDE_JOBS` that ends each job and asks for it again after the delay. + +## 3. High priority: a smuggling run pays and leaves the goods aboard + +**Evidence:** [`model.ts:375`](../src/missions/model.ts#L375), +[`mission-bridge.ts:109`](../src/game/mission-bridge.ts#L109), +[`smuggle.ts:18`](../src/missions/verbs/smuggle.ts#L18). Probe [3]. + +The leg starts with a `cargo` effect, and the bridge puts three tonnes of +narcotics in the hold. A dock at the target with the tonnes still aboard +succeeds. The success settles a fee of 700 credits. No effect takes the +goods back out. The effect union has no unload, and the settlement asks for +none. + +So the commander keeps the fee and the narcotics, and sells the narcotics at +the market they just landed at. The probe's success effects are a payment, a +deed and two lines. There is no cargo effect among them. The verb test checks +the payment and never reads the hold after it. + +**Recommended change:** an `unload` effect, or a signed `cargo` effect, that +the smuggle leg's success branch emits. The bridge's arithmetic already +tolerates a negative count, but a named effect reads better than a sign. + +## 4. High priority: the word `fled` reaches no leg that answers it + +**Evidence:** [`world-step.ts:564`](../src/game/world-step.ts#L564), +[`npc.ts:793`](../src/game/npc.ts#L793), +[`trader-flight.ts:169`](../src/game/trader-flight.ts#L169), +[`escort.ts:15`](../src/missions/verbs/escort.ts#L15), +[`scan.ts:14`](../src/missions/verbs/scan.ts#L14), +[`hunt.ts:25`](../src/missions/verbs/hunt.ts#L25). Probe [4]. + +Plan 208 M3 says that the `fled` trigger has a sender at last. The world +sends `fled` or `escaped` when a tagged ship despawns. Only +`trader-flight.ts` sets `wantsDespawn`, and only a trader sets `fleeing`. +The guard at `npc.ts:793` names the role. A hunt's target is a pirate +([`spawning.ts:206`](../src/game/spawning.ts#L206)). A pirate never leaves +the system, so a hunt never hears `fled` or `escaped`. The three arcs' +`targetFled` branches and the side hunt's `targetEscaped` branch cannot be +reached in play. + +The two verbs whose targets are traders ignore `fled`. An escort's charge that +a pirate hits runs for the edge of the system and jumps out. The world sends +`fled`. The escort verb answers `escaped`, `escortLost` and `destroyed`, and +returns null on `fled`. The leg stays live, the entity stays alive, and the +charge is spawned again on the next arrival. The console says nothing. The +same holds for a scan's subject that the player shoots. This is the commonest +way an escort is lost, and it is silent. + +The test at [`mission-courses.test.ts:142`](../test/mission-courses.test.ts#L142) +sets `fleeing` on a pirate by hand. It proves the wiring from the flag to the +machine. It does not prove that the world can raise the flag on a hunt's +target, and the world cannot. + +**Recommended change:** the escort and the scan verbs take `fled` as +`targetEscaped`, or as a branch of their own. Decide whether a hunted pirate +can ever leave, and remove the dead branches if it cannot. Add a lint rule: +every trigger a verb module can emit has a branch on the leg, or the skeleton +lists it as ignored. + +## 5. Medium priority: the side-job count silences the patron's messages + +**Evidence:** [`hail.ts:39`](../src/missions/hail.ts#L39), +[`hints.ts:114`](../src/missions/hints.ts#L114). Probe [5]. + +Plan 190 sets a ladder of hints for a far lead. Inside one jump, the patron +sends a message when the commander docks. After four idle docks, the patron +writes one more time. Both are one line, and a dock that makes an offer says +nothing else. + +That rule was written when an offer was rare. Most worlds now carry two or +three side jobs, so most docks make an offer. `hail` passes +`offers.length > 0` as `hailed`, and `dockHint` returns null on it. The probe +docks at Leleer, one jump from Rabedira, with a lead to Rabedira saved. The +dock says THERE ARE 2 SIDE JOBS ON THE STATION BOARD and nothing else. With +the side jobs removed, the same dock says A MESSAGE FROM RABEDIRA. The fourth +idle dock behaves the same way. + +The board rumour and the DATA ON line do not read `hailed`, so a far lead is +not invisible. The two dock messages are. + +**Recommended change:** count only an arc's hail as `hailed`, so a side-job +count and a lead message can share one dock. + +## 6. Medium priority: a save that names a ghost id stops the machine + +**Evidence:** [`machine.ts:76`](../src/missions/machine.ts#L76), +[`lookups.ts:11`](../src/missions/lookups.ts#L11), +[`repair.ts:18`](../src/missions/repair.ts#L18). Probe [6]. + +`repairMissionState` checks that each field has its shape. It does not check +that a live mission names a skeleton the code ships, or a leg that skeleton +has. `skeletonOf` and `legOf` throw on a miss. The comment on each says that +the lint keeps a live mission from naming a ghost. The lint reads skeletons. +It never reads a save. + +The probe loads a record with a live mission on `arc-ghost`. A dock throws. +An abandon throws, so the player cannot free the slot. A record on a real +arc with a leg named `old` throws the same way. + +No shipped id changed since 7 September, so no present save is hit. The +next rename of a leg hits every player who holds that mission. + +**Recommended change:** `repair.ts` drops a live mission or a lead whose +skeleton or leg is unknown, and the loader says one line about it. + +## 7. Medium priority: the journal holds no galaxy + +**Evidence:** [`model.ts:272`](../src/missions/model.ts#L272), +[`story.ts:43`](../src/missions/story.ts#L43), +[`log.ts:45`](../src/game/screens/log.ts#L45), +[`mission-bridge.ts:100`](../src/game/mission-bridge.ts#L100). Probe [7]. + +A journal entry records a world as an index. A lead records a galaxy beside +its index. The LOG screen renders every page with the systems of the galaxy +the commander is in. So after a galactic jump every earlier page names the +wrong worlds. The probe accepts the Lave arc in galaxy 1 and jumps. Rendered +with galaxy 1 the opening line says LAVE. Rendered with galaxy 2 it says +ESRILEES. The route map under the page draws the wrong chart for the same +reason. + +The lead announcement on a galactic jump has the same root. The failed arc +offers its lead at the seed index. The bridge says ASK AT that index in the +new galaxy. The machine then moves the lead to the tour world. The probe +records a lead at world 11 and announces world 6. + +**Recommended change:** a `galaxy` on `JournalEntry`, and a story that reads +it. The relocation in `leaveGalaxy` moves before the announcement, or the +announcement reads the relocated lead. + +## 8. Low priority: a local patron's standing is keyed by the wrong world + +**Evidence:** [`lookups.ts:21`](../src/missions/lookups.ts#L21), +[`machine.ts:323`](../src/missions/machine.ts#L323). Probe [8]. + +`patronId` names a local patron by the world the commander stands at when +the branch settles. A local patron is whoever runs the station the job was +taken at, and [`patrons.ts`](../src/missions/patrons.ts) reads the origin for +the name and the face. A delivery accepted at Tiraor and landed at Edzaon +credits `world-159`, which is Edzaon. + +Nothing reads `standing` yet, so the fault is latent. The key is wrong from +the first write, and the first reader inherits it. + +**Recommended change:** `patronId` reads `acceptedAt` for a local patron. + +## 9. Low priority: a restored mission ship takes the pirate row + +**Evidence:** [`mission-bridge.ts:141`](../src/game/mission-bridge.ts#L141), +[`persistence.ts:242`](../src/game/persistence.ts#L242). Probe [9]. + +`missionShipSpec` looks every tagged ship up under the pirate role. A +restore consults it first. An escort's charge and a scan's subject fly the +trader row. A Python and a Cobra Mk III have both rows, so a reload gives the +charge a pirate's energy and guns. A Boa, an Anaconda and a Transporter have +no pirate row, so they fall through to the trader lookup and come back right. +The spawn and the restore disagree for two of the five trader designs. + +**Recommended change:** `missionShipSpec` reads the role through `verbJob` +on the live leg, as `spawning.ts` does. + +## 10. Low priority: a branch that cannot place its next leg drops the settlement + +**Evidence:** [`machine.ts:244`](../src/missions/machine.ts#L244), +[`lint.ts:25`](../src/missions/lint.ts#L25). Probe [10]. + +`takeBranch` returns before the settlement and the journal when the next +leg's placement finds no world. By then `react` marked the entity dead. +So a kill that cannot place its next leg pays nothing, writes nothing, and +leaves the leg live with no target to spawn. The Constrictor's hunt has no +deadline, so that mission would hold its slot for good. + +The lint measures a `band` from every world and does not measure a +`handover`. The probe measures the four handovers from every world of galaxy +1, and each has a candidate. So the path is unreachable today. It opens when +an arc is accepted in another galaxy, which finding 1 allows. + +**Recommended change:** the lint measures a handover from every world, as it +does a band. `takeBranch` settles and falls back to `anywhere` when a +placement fails, rather than return. + +## 11. Low priority: model surface that nothing ships + +**Evidence:** [`model.ts:25`](../src/missions/model.ts#L25), +[`model.ts:78`](../src/missions/model.ts#L78), +[`model.ts:102`](../src/missions/model.ts#L102), +[`model.ts:190`](../src/missions/model.ts#L190), +[`model.ts:333`](../src/missions/model.ts#L333). + +The machine tests exercise each of these. No skeleton the game ships uses +them, and no game code sends them: + +- the `misjumped` input, which nothing sends; +- the `entity` placement, and the `hull` and `lastWorld` fields on an + entity, which nothing writes after the leg starts; +- the `cargo` field on a deliver verb, which the verb never reads; +- `Skeleton.anchor`, which nothing reads, and the `event` kind; +- the `choice` and `flag` triggers, `setFlags`, `flags`, `notFlags`, + `excludes`, `minRating`, `legalStatus`, `withinJumps` and `legal`; +- a settlement's `override` and `spawn`, and the `changes` record they feed. + +None of this is wrong. Each is a place the next fault can hide, and each +costs a reader time. The model header can say which parts a shipped skeleton +uses and which wait for a plan. + +## 12. Low priority: comments that the code left behind + +- [`missions.ts:4`](../src/constants/missions.ts#L4) names + `game/missions.ts` as the machine that spends the constants. Plan 190 M2 + removed that file. +- [`queries.ts:36`](../src/missions/queries.ts#L36) says the lint does not + see two overrides at one world. `overrideProblems` in `lint.ts` does. +- [`deliver.ts:5`](../src/missions/verbs/deliver.ts#L5) says a hold check + arrives with 190 M4. It did not arrive. +- [`model.ts:25`](../src/missions/model.ts#L25) says an item table arrives + with 190 M4. `ItemId` is a free string. +- [`model.ts:183`](../src/missions/model.ts#L183) says a world patron is + already in one galaxy. Finding 1 shows that it is not. +- The comment on `MissionState.idleDocks` says docks since the journal last + moved. `hail` counts docks since a dock last moved it. A kill in flight + does not reset it. + +## What holds + +- **The machine is pure, and one call runs it.** `stepMissions` clones the + record. The bridge installs the copy and applies the effects before any + save. A reload cannot pay twice. The Constrictor runs end to end through + the probe: bounty on the kill, orders at any station, fee at the courier + world. +- **A settlement is paid one time.** The next leg's verb ignores a second + input for the same kill. That is the whole duplicate-payment rule, and it + is one comparison on a tag. +- **The lint is proved able to fail.** Seventeen fixtures each break one rule, + and the gate names each fault. +- **Every verb is a job.** Eight verb modules, eight side jobs, five arcs + and the Navy mission. The verbs decide and the machine resolves, which is + invariant 15 one level down. +- **The offers, the hints and the words are their own files**, and each + falls back to plain words when a dossier is absent. +- **Days reach the machine.** A jump and a witch-space rescue both send + `dayPassed`, and days move nowhere else. A deadline cannot pass unseen. +- **A rescued passenger rides under the pod's tag.** The survivor screen + opens for one. A deadline after the scoop hands the passenger over as an + ordinary survivor. + +## What the tests do not cover + +- No test holds the shipped side jobs to failure rule 5. The re-offer test + uses a fixture with a cap. +- No test proves that the world can send `fled` for a hunt's target. The one + test sets the flag by hand on a pirate. +- No test offers an arc in a galaxy other than 1. +- No test reads the hold after a smuggling run succeeds. +- No test loads a save with an unknown skeleton or leg id. +- No test renders the log after a galactic jump. +- The lint has no rule that every trigger a verb can emit has a branch. + +## Decisions to record, not defects + +- A hunted ship wrecked by anybody pays the full bounty. `hunt.ts` records + the decision. A pirate that kills the Krait earns the commander 800 + credits. +- An escort's fee needs no presence from the commander. The charge is safe + when it reaches station range with no enemy near it, wherever the commander is. +- Every arrival says YOUR JOB AT X IS N JUMPS AWAY for every held job that is + elsewhere. Three jobs give three lines behind ARRIVED. +- The hail says THERE ARE N SIDE JOBS ON THE STATION BOARD on every dock + where any are open. It says so whether the commander read them or not. diff --git a/docs/PAGES-REVIEW.md b/docs/PAGES-REVIEW.md new file mode 100644 index 00000000..d1fabdbf --- /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. 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 +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/README.md b/docs/TODO/README.md index 58522d56..1dbc06c9 100644 --- a/docs/TODO/README.md +++ b/docs/TODO/README.md @@ -13,7 +13,92 @@ active context: ## Execution queue -The queue is empty. +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, 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 +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. + +**220 CAME FROM CHRIS ON 2026-09-13**, from his phone. A double tap or a +pinch zooms the page. The address bar takes the top of the view, and the +short range chart's names are too small. 220 fixes the three, and it went +first because it is small. **220 landed the same day**, and it is below. + +**219 CAME FROM CHRIS ON 2026-09-13**, after 217 measured the authored play +at four to six hours. He asked for another layer at the higher ratings, a +secret society. He chose the old names, a first whisper at Above Average, +and a reward no shop sells. 219 is the Dark Wheel: a patron, four trials, a +leg in witchspace, and the cloaking device. **219 landed the same day**, +and it is below. + +**218 CAME FROM CHRIS ON 2026-09-13.** The comments, the docs and the +dossier prompt slipped into "she" for the commander, from the novella. +The player names the pilot, and the name can carry any gender. 218 sweeps +the prose to "they" and "the commander", and the dossiers are written +again. **218 landed the same day**, and it is below. + +**217 CAME FROM CHRIS ON 2026-09-13**, after 214 M4 measured the hunt. A +lone Krait dies four seconds after the fight starts, whether it runs or +not. He asked for a gang, a tough one, and for gates on the side jobs by +kills. 217 makes the hunt count a gang, and it gates the hard jobs. **217 +landed the same day**, and it is below. + +**216 CAME FROM CHRIS ON 2026-09-13**, after 215 landed. He asked for a +review of every docked screen, so that each key has a button on a phone. +The review found six places with none. They are the three name screens, +the charts' search and estimate, the test mode's step back, and the +trainer's three panels. 216 adds a key grid, the chart buttons, and arrows on the rows. +**216 landed the same day**, and it is below. + +**215 CAME FROM CHRIS ON 2026-09-13**, from his phone. The three columns of +buttons over the flight view covered the fight. He drew the shape he wants. +Two icons sit over the sky. The guns are a row at the bottom of the console, +with the missile as two buttons and the missile squares gone. He then asked +for the two lists to be closed by default, and to open as one row that +scrolls sideways. **215 landed the same day**, and it is below. 214's last +milestone landed the same day, once he wrote a dossier again. + +**213 AND 214 CAME FROM CHRIS ON 2026-09-13**, after two reviews of the +missions. `docs/MISSIONS-REVIEW.md` read the machine and found five rules +that do not hold. `docs/MISSIONS-FLIGHT-REVIEW.md` flew every kind of job +and found the escort broken and the rest too easy. He said *"let's fix the +broken parts"*, and then *"there should be surprises"*. 213 is the fixes. +214 is the surprises: an ambush on a scoop, pirates at the jump-in, and an +escort that needs the commander. **213 landed the same day**, and it is +below. **214 M1 to M3 landed the same day too.** **M4 landed the same day +too**, once Chris wrote the side hunt's dossier again, and 214 is below. + +**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 +wrote `MOBILE_CONVERSION.md` and set a new direction. The pilot picks a +course, and the ship flies itself. In a fight, the computer aims, and the +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. **205, 206 and 207 landed the same day, and 208 the day after**. +Each 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 +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. **204 landed on 2026-09-11**, and it is below. +**205 M1 retired it the same day**, because a phone still could not fly the +ship. **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 @@ -275,6 +360,76 @@ headphones. **#24** closed on 2026-08-12 with it: *"display is good"*. **#23** closed with 134, as #22 did with 127, #18 with 121, #20 with 122 and #21 with 123. +## What landed on 2026-09-13 + +**215 — the gun row lives in the console, and a list runs sideways.** Chris +flew the game on his phone, and three columns of buttons covered the fight. +He drew the shape. The guns are a row at the bottom of the console, always +four buttons in the same places, with the missile as two. The missile +squares are gone. The two lists over the sky are two headers, closed by +default, and each opens as one row that scrolls sideways. A tap that opens +one folds the other. The desktop has the same layout. 6,072 assertions, +from 6,061. + +**213 — the mission rules hold, and a hit trader goes back to work.** A +trader that took a hit ran for the rest of its life, so an escort's charge +that a pirate grazed never reached the station. It goes back to work after +twenty calm seconds, the escort course cannot ram it, and a course refuses +a target below the planet. The escort finishes eight of eight in the flight +probe, from four. A side job comes back a week after it ends. A smuggling +run leaves its goods at the far end. Every arc is gated to galaxy 1, and a +scoop job waits for the scoops. The gun warning speaks for the Constrictor +alone. The loaders drop a ghost, the journal names its galaxy, and the lint +holds every trigger a verb can emit. 6,029 assertions, from 5,972. + +## What landed on 2026-09-12 + +**208 — every kind of mission has a way to fly it.** A live job is the first +button over the view, in the job's own words, and the ship flies it. The rows +read HUNT, SCAN, ESCORT, RECOVER, PICK UP and SLIP PAST THE POLICE. A delivery +and an ambush end at the station. The work mended three faults. A scoop row +trapped a pilot with no fuel scoops. A hunt stayed live when somebody else +killed its ship. A `fled` branch in three arcs had no sender. A derelict now +tells what the scan finds. 5,845 assertions, from 5,835. + +## What landed on 2026-09-11 + +**207 — the pilot takes the ship into the slot by hand.** Chris played four +docking concepts and picked Match the spin. The course flies the approach and +hands over the last 1,500 units, at the speed the slot takes. The computer +holds the ship on the axis. The pilot matches the station's spin with a roll +strip, and the speed with THRUST and BRAKE. The slot now refuses a ship +that arrives too fast, and a scrape bounces it clear to try again. A docking +computer still flies the slot. 5,820 assertions, from 5,804. + +**206 — the computer flies the fight, and the pilot fires.** When a ship +attacks, the computer lines you up, and the shooting stays yours. The buttons +are FIRE LASER, ARM A MISSILE, E.C.M., and TARGETS to choose what to fight. The target list +says what the law protects before you shoot it. The bought combat computer +does the rest of the fight, but never spends a missile. RUN FOR IT, the bribe +and the cargo dump are buttons now. Mining is a fight with a rock, and +COLLECT EVERYTHING scoops what is adrift. 5,804 assertions, from 5,733. + +**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 +the throttle ramps toward it. Five buttons along the console's top edge +press the flight keys for the missile, the E.C.M., the jump, the torus and +the docking computer. The prompts are buttons. A MENU button opens the +charts, the screens, the views, pause and the escape pod, which asks first. +The first touch asks for the whole screen. Every button carries its key +from the binding table, and the feel on a phone is Chris's to judge. 5,661 +assertions, from 5,619. **205 M1 took it out the same day**, because a phone still could not +fly the ship. The plan is in `retired/` now. + ## What landed on 2026-09-10 **203 — a mission tells the player where to go.** Chris played three side diff --git a/docs/TODO/completed/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 new file mode 100644 index 00000000..c6ff84e9 --- /dev/null +++ b/docs/TODO/completed/205-the-pilot-picks-where-the-ship-goes-and-the-ship-flies-itself.md @@ -0,0 +1,480 @@ +# 205 — The pilot picks where the ship goes, and the ship flies itself + +**Kind:** enhancement · **Severity:** high · **Size:** large · **Depends on:** +204 · **Blocks:** 206, 207, 208 · **GitHub:** none + +## Where we are + +**Chris wrote `MOBILE_CONVERSION.md` 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. His +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. 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 +`PlayerShip.update` flies it: + +- the docking computer flies the ship into the slot (`autopilot.ts`, + `docking.ts`); +- the combat computer points the nose at a threat, in pure pursuit, and it + pulls the trigger (`scripted-co-pilot.ts`); +- the torus drive crosses a system, and a mass lock stops it near a ship or the + station (`flight-instruments.ts`, `world-step.ts`); +- the jump countdown starts on a key, and nothing stops it + (`hyperspace-actions.ts`). + +**No part decides what the ship does next.** Today the pilot decides that with +the stick. After this plan, the pilot picks from a short list, and a computer +flies the pick. + +**The system around the station holds work today.** The review of 2026-09-11 +measured four things: + +- two to four asteroids line the arrival corridor, and a mining laser turns a + rock into ore; +- a rock hermit sits 7,000 to 21,000 units from the station on 30% of worlds; +- a commander with fuel scoops skims the star for fuel, and heat kills a ship + that goes too near; +- a derelict generation ship crosses on 8% of arrivals, with three to six + canisters of goods near it, and today it offers only a message. + +## The words this plan uses + +- **A course** is one thing that the ship does next with no hand on the stick. + "Fly to the station" is a course. "Skim the star" is a course. +- **The course list** is the set of courses that the present situation allows. + It is derived state, as a prompt is (`prompts.ts`). The code saves the course + that the pilot picks. It never saves the list. + +**THE WORD IS "COURSE", AND NOT "ORDER".** `orders.ts` and invariant 16 already +use "order" for a signed contract or a live mission. One word has one meaning. + +## What to do + +Seven milestones. + +### M1 — the touch controls leave + +Remove the code of 204's five milestones. The commits run from `aceb0fc` to +`95b9e92` on this branch. The code is the wanted speed in `Input`, and +`engine/touch.ts`. It is also the command row, the tappable prompts, the +flight menu and the fullscreen request. Remove the touch tests with the code. + +**Keep `Input.press` and `Input.release`, with their test.** They let a button +hold a key. 206 needs them for the laser, which fires while it is held. 207 +needs them for THRUST and BRAKE. + +Remove the paragraph on touch flight from the manual and from the briefing. +Remove `throttleBand` and `TOUCH_STICK_TRAVEL`, then regenerate the catalogue. + +Move the 204 plan from `completed/` to `retired/`. Give it a section in the +retired index with the reason. The reason is that a phone could not fly the ship +by a stick. Keep the work of 197 to 203. That work fits the docked screens to a +phone, and it is on `main` already. + +### M2 — the course list + +A new pure module, `game/courses.ts`, answers one question: what can the ship +do now? It reads the world and the commander. It returns the courses in order, +each with its words and its reason when it is not available. It returns no key. + +The course list for each situation: + +| situation | courses, in order | +| --- | --- | +| 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 | + +Each course has a condition: + +| course | the condition | +| --- | --- | +| jump, and jump on | a hyperspace target, and the fuel for it. `checkJump` already owns both refusals | +| fly to the station | the system has a station | +| the mission objective | a live leg has a target in this system (208 fills this in) | +| investigate the derelict | a generation ship is in the system | +| mine the asteroids | a mining laser, fuel scoops, and a rock in the system (206 flies it) | +| visit the hermit | a hermit is in the system | +| skim the star | fuel scoops, and a tank that is not full | + +On an arrival, the console says what the long-range scan found. That is the +derelict, the hermit and the rocks. Chris's own words for it are *"Long range +scans indicate a derelict - investigate"*. + +`test/courses.test.ts` pins every row of both tables. + +### M3 — a course flies the ship + +A new module, `game/course-pilot.ts`, flies the course that the pilot picked. It +decides and reports a `FlightDemand`, as the two computers do. It draws nothing +from the seeded stream. + +| course | what the pilot module flies | +| --- | --- | +| fly to the station | point at the station, and engage the torus. Stop at a mass lock. Engage the torus again when the lock clears. At the docking range, hand over to the dock | +| investigate the derelict | fly to the ship, and stop 700 units out, beside its canisters | +| visit the hermit | fly to the rock, and arrive within 320 units below 40 u/s. The trade screen then opens, as it does today | +| skim the star | fly toward the star, and hold inside the scoop range and outside the heat that hurts. Stop when the tank is full | +| jump, and jump on | fly straight out while the countdown runs | +| mine the asteroids | fly to the nearest rock, and open the fight on it (206) | + +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 +ship lives, at two sample sizes, before it writes the constant. + +**The course is saved state.** The snapshot carries the picked course. +`snapshot-parse.ts` reads a save with no course as a ship with no course. + +**The flight keys stay as an override.** A flight key suspends the course, as +it suspends the two computers today. The course list then offers the course +again. + +### M4 — the launch sends the ship on its way + +The LAUNCH row on the station menu opens the launch course list. A jump course +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 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 + +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 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. + +### M6 — the manual and the briefing say how the game flies now + +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 near. The skip stops by +itself in these cases: + +1. a hostile ship comes near, by the rule that turns the condition light red; +2. the course ends, or no course is picked; +3. the pilot presses a flight key, or taps the button again. + +A console line keeps its time in the player's seconds, so it can still be +read. M7 found that this is better than a stop for each new line. A trip +says MASS LOCK and TORUS DRIVE ENGAGED at every stop. A stop for each line +would end the skip at the very waits that it exists to shorten. + +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 + +- **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, + 2026-09-11). +- **The rating stays** (Chris, 2026-09-11). The missions and the story arcs grow + in weight, and 208 serves that. +- **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). 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 + 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** (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. + +## Open questions + +None. + +## Watch out for + +- **Invariant 15.** The course pilot decides and reports. The Game starts the + jump, opens the trade screen and applies the dock. +- **`pilotDemand` in `game.ts` chooses the seat.** Today it chooses between the + scripted co-pilot and the trained seat. The course pilot joins that choice. + Two seats must never both fly one frame. +- **A mass lock and the torus.** A course must not engage the torus under a + lock. `massLocked` in `world-step.ts` is the one home of the lock. +- **The hermit opens its trade screen on distance and speed.** The course must + slow below 40 u/s inside 320 units, or it circles the rock. +- **The star kills.** `SUN_KILL_DIST` is 21,000 units. The skim course must + never plan a path inside it. +- **`flight.ts` is 388 lines, under a ceiling of 400.** The course switch goes + in its own file. +- **The prompts line still offers the bribe and the cargo dump.** 206 turns + both into courses. Until then, the prompts line stays. +- **The combat trainer is ordinary flight.** An exercise must not show the + launch course list. `combat-sim-safety.ts` holds what an exercise may touch. + +## Verification + +The gates always run: `npm run check`. `npm run generate:constants` runs first, +because M1 removes constants and M3 adds constants. + +The tier: a rule that changes how the ship flies. `npm run flight-probe` and +`npm run dock-probe` run once. + +Evidence: + +- `test/courses.test.ts` pins both tables of M2, for each condition on both + sides. +- `test/course-pilot.test.ts` flies each course headlessly on the real world + step: + - the station course reaches the docking range from the witchpoint; + - it stops at a mass lock, and it engages the torus again after the lock; + - the hermit course ends below 40 u/s inside 320 units; + - the skim course fills the tank, and the ship lives. +- A test proves that a launch with no course refuses, and that the console + says why. +- A snapshot round trip keeps the course. +- Chris flies it on his phone. The playtest reports, and it does not block. + +## What the milestones found + +### M1 + +- **The code of 204 came out clean.** Every file that 204 touched went back to + its state at `81fcb2b`, the commit before 204's code. No later commit on the + branch changed those files. After the removal, no file in the tree cites + 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. + +### 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. + +### 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. + +### 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. + +### 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. + +### M7 + +- **The skip scales the loop, and nothing else.** The loop banks its frame + time times `SKIP_SPEED`, and it caps the steps at five times that. So a + frame still banks a bounded amount of world time. A test that reads the + loop's source pinned the old clamp, and it now pins the scaled one. +- **A skipped trip reaches the same world as a normal trip.** The test + compares every ship, the commander and the session after 1,200 steps. The + console is left out, because it counts the player's seconds. One step more + is a different world, which proves that the comparison can fail. +- **Both games in that test draw from one seeded stream.** Its first draft + ran them one after the other, and the second trip saw other numbers. The + police ships then differed, and the fault looked like the skip's. The test + now restores the stream before each trip. +- **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. + +## After it landed + +**The chart row is the LOCAL chart** (Chris, 2026-09-12: *"The local chart +should be used for setting a destination, not the galactic chart."*). The +launch list's row, the button in flight, and the jump row's reason all name +it now. The local chart shows what the tank can reach, which is the question +a destination answers. The galactic chart is the wider view, and it keeps its +own key and its own row on the station menu. diff --git a/docs/TODO/completed/206-the-computer-flies-the-fight-and-the-pilot-fires.md b/docs/TODO/completed/206-the-computer-flies-the-fight-and-the-pilot-fires.md new file mode 100644 index 00000000..4cd49c48 --- /dev/null +++ b/docs/TODO/completed/206-the-computer-flies-the-fight-and-the-pilot-fires.md @@ -0,0 +1,318 @@ +# 206 — The computer flies the fight, and the pilot fires + +**Kind:** enhancement · **Severity:** high · **Size:** large · **Depends on:** +205 · **Blocks:** 208 · **GitHub:** none + +## Where we are + +**205 takes the stick away from the pilot for a trip.** A trip meets ships, and +this plan says what happens then. Chris's words in `MOBILE_CONVERSION.md`: +*"Combat mechanism - the computer tries to line you up with the target you have +currently selected. The user can control, lasers, missile arming, misile firing, +ECM system."* + +**The bought combat computer already flies a whole fight.** It points the nose +at a threat, in pure pursuit (`scripted-co-pilot.ts`). It pulls the laser +trigger, and it reaches for the E.C.M. It does not launch a missile. It needs +`equipment.combatComputer`, and it hands the ship back on a touch of a flight +key. + +**It picks its own threat.** `threat-lock.ts` picks the nearest hostile and then +commits to it. Three places share that rule: the combat computer, an armed +trader under attack, and the training episode. No pilot choice reaches it. + +**The ways out of a fight exist as keys.** `prompts.ts` offers two of them +while they are worth something: + +- the bribe, which pays a police patrol (`law.ts`, `BRIBE_SHARE`); +- the cargo dump, which buys off pirates with the best goods first + (`jettison.ts`). + +**The player's ship is the fastest ship in the sky, by a small margin.** +Measured from `SPECS` in `ship-specs.ts` on 2026-09-11: + +| ship | top speed, u/s | +| --- | --- | +| the player's Cobra | 400 | +| the fastest pirate, and the fastest bounty hunter | 381 | +| the Constrictor | 370 | +| a Thargon | 350 | +| a police Viper | 320 | +| a Thargoid | 300 | + +Both sides' lasers reach 3,500 units, and a missile flies at 700 u/s. So a run +from the fastest pirate gains only 19 u/s. A run from a police Viper gains 80. +No ship outruns a missile, and the E.C.M. is the answer to one. + +**Fuel scoops decide what a ship can collect.** Without scoops, a canister +breaks on the hull (`world-step.ts`). A scoop takes a canister inside 45 units. + +## The words this plan uses + +- **The target list** is every ship, rock and derelict inside scanner range, + with the one that the pilot picked marked. It is derived state. +- **The fight** starts when a hostile enters scanner range, or when the pilot + picks a target. It ends when no hostile is in range and no target is picked. + +## What to do + +Six milestones. + +### M1 — the target list + +A new pure module, `game/targets.ts`, lists what the ship can fight. The +hostiles come first, nearest first. The police, the traders, the rocks and the +derelict come after them. Each row carries a name, a range and a standing. +Each row also says what an attack costs where the law protects the ship. The +words come from `harmVerdict` in `law.ts`, the one home of that rule. + +With no pick, the target is the threat that `threat-lock.ts` chose. The pilot's +pick overrides it until that ship dies or leaves. Do not change the rule of +`threat-lock.ts`. The pick sits on top of it, because two other places share +that rule. + +### M2 — the computer flies at the pilot's target, for every pilot + +The scripted co-pilot flies every ship now, and not only a ship with a combat +computer. It takes the target from M1. It flies the nose onto the target, and +it does NOT pull the trigger. The trigger belongs to the pilot. + +A rock does not move, and pure pursuit meets it head-on. A derelict is 340 +units across. The standoff must keep the ship outside +`GENERATION_SHIP_RADIUS`. + +### M3 — the pilot's hands + +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. 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, except the missiles + +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 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 + +During a fight, the course list of 205 carries three more rows: + +- **run**, which turns away from the fight and engages the torus when the lock + clears. The row says the margin, because a run from a fast ship is slow; +- **pay the police**, where `prompts.ts` offers the bribe today; +- **dump cargo**, where `prompts.ts` offers the dump today. + +The prompts line then has nothing left to say about these two. Remove it where +it only repeats a row. + +When the fight ends, the course of 205 resumes. A ship that met pirates on the +way to the station goes on to the station. + +### M6 — collect everything + +When the fight ends with canisters or escape pods in scanner range, the course +list offers "collect everything". It needs fuel scoops, and without them the +row says so. The ship flies to each item, nearest first, and scoops it. A pick +of another course stops the collection. The ship collects nothing on its own +without the pick. + +## Decisions already made + +- **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 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 + items, and the pilot can stop it. +- **A fight can be left** (Chris, 2026-09-11). The run, the bribe and the cargo + dump are all options. +- **Rocks and derelicts use the same fight** (Chris, `MOBILE_CONVERSION.md`). + Mining is a fight with a rock, and the ore is then collected. + +## Open questions + +None. The balance of a fight with a computer at every stick is a measurement, +and the Verification section names it. + +## Watch out for + +- **Invariant 5.** Training flies the real modules. A change to the co-pilot + must not change the NPC side, or the trainer's target ship. The trained + defence seat in `combat-computer.ts` is dormant, and it stays dormant. +- **The trigger is the Game's** (`autopilot.ts`). The pilot's button presses + the fire command through the path that the key uses today. +- **`threat-lock.ts` serves three places.** The pilot's pick is a layer above + it, and never a change to it. +- **The combat trainer is ordinary flight.** It gets the target list and the + buttons for free. `combat-sim-safety.ts` still holds that nothing leaves an + exercise. +- **A mission's hunt target is a row like any other.** 208 marks it. +- **The police are on the list.** An attack on a Viper costs what it costs + today, and the row says so before the pilot fires. + +## Verification + +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 +number runs at two sample sizes before it drives a decision. + +Evidence: + +- `test/targets.test.ts` pins the order of the list and the override of the + 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 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 + pick of another course. +- Chris flies a fight on his phone. His verdict on whether it is FUN is the one + measure that no probe reaches. + +## What the milestones found + +### M1 + +- **A ship has no stable id, so the pick is a flag on its state.** + `NpcState.targeted` is saved with each ship, as every field of that state + is. A pick in the session would hold an index into the ships, and the index + moves when a ship leaves. +- **The name a player sees is the scene object's name**, as the arrival + lines use it. +- **The list says what an attack costs in its own words.** `harmVerdict` in + `law.ts` still decides whom the law protects. Its words are for the moment + of the hit, so the list writes THE LAW PROTECTS THIS SHIP instead. The + hermit is not the law's, and the list says what killing him costs. +- **The suite has 5,746 assertions,** from 5,733. + +### M2, with M4 folded in + +- **M4 is one line of M2, so it landed in the same commit.** The seat's + trigger and E.C.M. requests pass only with `equipment.combatComputer`. So a + ship without one gets the aim, and a ship with one gets the whole fight, + except the missiles. The co-pilot never launched a missile, so M4 had + nothing to take out. +- **One flag says who flies: `SessionState.handFlown`.** A flight key sets + it, in a fight or on a course. A pick of a course clears it, and so does + the aim's key. While it is set, no computer takes the stick back. +- **The automatic start says nothing on the console.** The first draft said + THE COMPUTER IS LINING YOU UP. That line took the console at the moment a + fight starts, which is when the lines that matter arrive. Six tests of the + law's lines caught it. The label over the view says THE COMPUTER IS AIMING, + and a sound marks the moment. +- **The aim's key no longer refuses an unfitted ship.** Every ship has the + aim now. `test/ui.test.ts` pinned the old refusal, and it pins the new one. +- **The four probes cannot see this change.** `survivability`, `aim-probe` + and `defence-probe` build no Game, and `ambush-probe` builds its own + co-pilot with no pick. So they fly exactly as before. The measure of a + fight under the new seat is the headless test in `test/aim.test.ts`, and + Chris's verdict on whether it is fun. +- **The suite has 5,756 assertions,** from 5,746. + +### M3 + +- **A held button needs its own seam.** A click is one moment, and the laser + fires while it is held. `engine/hold-buttons.ts` presses a `data-hold` + code on pointer down and releases it on pointer up, through the + `Input.press` and `Input.release` that 205 M1 kept. Each pointer is tracked + by its id, so a second finger that lifts lets go of its own button only. +- **A target row's code names a ship, not a place in the list.** The list is + in order of range, and the order moves every frame. A code built from a + row's place could pick the ship that moved into that place between the + paint and the tap. `target-actions.ts` gives each ship a number for as long + as it lives, and no save carries it. +- **The target list works in an exercise too.** A training fight is a real + fight, so the trainer has the same buttons. +- **The buttons sit bottom right, and the laser is the lowest.** A right + thumb finds it without a look. The course buttons stay top right. +- **The suite has 5,774 assertions,** from 5,756. + +### M5 + +- **RUN FOR IT is a course, and it says the margin.** It leads the list while + a hostile ship is on the scanner. The ship turns away from the hostile + ships, opens the throttle, and runs the torus once the mass lock lets go. + The aim waits while the ship runs, and the run ends when the scanner is + clear. A run from a police Viper opened the range and got away, in a + headless test. +- **Every offer is a button now.** They sit under the course buttons, each + with its key named beneath the words. They are the bribe, the cargo dump, + the evidence dump, the distress beacon and the docking computer. The + E.C.M.'s offer lights the E.C.M. button instead of showing twice. +- **The line of prompt text is gone with them.** Nothing painted it but the + HUD, and the tests read the offers as data through `keyPrompts`. +- **The cockpit view crossed the size ceiling, so the buttons moved out.** + `cockpit-buttons.ts` holds what each button says, and no world. The + cockpit's own header names the split. +- **Four older constants at 50 and at 8 took their own rule IDs**, as the + constants gate asks of a repeated value. +- **The suite has 5,791 assertions,** from 5,774. + +### M6 + +- **COLLECT EVERYTHING flies onto each canister, nearest first.** It shows + only with a clear scanner, because a ship that stops to scoop in a fight is + a target. Without fuel scoops the row says so, and the course does not fly. +- **MINE THE ASTEROIDS is a fight, so the course steps aside.** It keeps the + nearest rock picked as the target. The computer then lines the ship up, and + the pilot fires. When no rock is left within scanner range, the course ends. +- **The ore has to be collected, and a held trigger destroys it.** The test + found that: it kept firing after the rock broke, and the laser shot the ore + adrift. That is the game's own rule, and a pilot meets it too. +- **A rock has no name, and the browser run showed the blank.** A ship with + no name of its own is called what it is, such as ASTEROID. Its row then + says the range alone, because the name and the standing are the same word. +- **Four older constants at 60 took their own rule IDs**, as the constants + gate asks. +- **The suite has 5,804 assertions,** from 5,791. + +### M7, which the plan did not have: the words for a player + +- **206 changes how a fight reads, so the manual and the briefing say so.** + The briefing's fight page and the manual's Trouble section now teach the + buttons: FIRE LASER, TARGETS, ARM A MISSILE, E.C.M. and RUN FOR IT. They + also say that a missile costs money, and that a steering key takes the ship + back. +- **The briefing's own test pinned the missile keys.** The first journey now + names the buttons instead, as 205 M6 did for the trip. + +## Outcome + +206 landed on 2026-09-11. In a fight the computer lines every pilot's ship up +on the target, and the pilot fires. The target list says what is out there, +what it is, and what the law says about shooting it. A pick sends the computer +after one ship. The bought combat computer fires the laser and the E.C.M. as +well, and never a missile, because a missile costs money. The ways out are +buttons: run, pay the police, dump cargo. Mining is the same fight with a +rock, and COLLECT EVERYTHING takes the cargo aboard. + +The probes ran after the work: `ambush-probe` survived 40 of 40 episodes, +`aim-probe`, `survivability` and `defence-probe` report as before. None of the +four flies the Game's own seat, so none can measure this plan. The measure is +`test/aim.test.ts` and Chris's verdict on whether a fight is fun. The suite +has 5,804 assertions, from 5,733. diff --git a/docs/TODO/completed/207-the-pilot-takes-the-ship-into-the-slot-by-hand.md b/docs/TODO/completed/207-the-pilot-takes-the-ship-into-the-slot-by-hand.md new file mode 100644 index 00000000..bc48f1ec --- /dev/null +++ b/docs/TODO/completed/207-the-pilot-takes-the-ship-into-the-slot-by-hand.md @@ -0,0 +1,227 @@ +# 207 — The pilot takes the ship into the slot by hand + +**Kind:** enhancement · **Severity:** medium · **Size:** medium · **Depends +on:** 205 · **Blocks:** nothing · **GitHub:** none + +## Where we are + +**205 flies the ship to the station, and then the docking computer takes it in +for free.** That is a stopgap. Chris's words in `MOBILE_CONVERSION.md`: +*"Unless you have the docking computer, we still want this to be a challenge. We +should have the computer line you up so that you are facing the docking port, +but then we should have the user try and match the roll of the station and the +speed of docking."* He also wrote: *"Potentially, we could have a completely +different display for this. Let's think outside the box."* + +**The dock today checks the position and the roll, and no speed.** +`dockingOutcome` in `docking.ts` returns `hull` for a ship that meets the +station outside the slot. It returns `slotMiss` for a ship in the slot with its +roll off by more than `ROLL_TOLERANCE`. Either one costs the same +(`world-step.ts`): + +- a scrape of 230 points, which is 90% of one shield face; +- a bounce out to 420 units, at a speed of zero. + +**The numbers that a trial plays against:** + +| quantity | value | +| --- | --- | +| the station's spin | 0.26 rad/s, one turn in 24 s | +| the roll tolerance | 0.65 rad, which is 37° | +| the slot | 124 × 52 units | +| the docking computer's range | 3,500 units | + +**Four concepts are playable.** They are on a private page that Chris can open +on his phone: +. Each +concept runs on the numbers above. Each one starts 1,500 units out, and each +one uses the scrape and the bounce of today. + +| concept | the hands | what the pilot judges | +| --- | --- | --- | +| Match the spin | two thumbs: a roll strip and two held buttons | the roll rate and the speed at once, as in 1984 | +| Stop the sweep | one thumb, four taps | the moment. The computer sweeps the roll, and a tap stops it level. A rough stop adds speed to the final run | +| Stir | one thumb on a dial | the angle of the thumb is the roll, and its distance from the centre is the speed | +| Gate tunnel | one thumb as a stick, over a new display | a line of gates shows how the slot will sit at contact | + +**Chris picked Match the spin on 2026-09-11.** + +## What to do + +Four milestones. + +### M1 — the computer lines the ship up, then hands over + +The station course of 205 flies the ship onto the slot axis, as the docking +computer does today. `dock-path.ts` owns that curve. At the hand-over range, +the course stops, and the trial starts. The page starts the trial 1,500 units +out, and that is the first value to try. A ship with a docking computer never +stops. It flies in, as it does today. + +### M2 — the pilot matches the spin + +During the trial, the computer holds the ship on the slot axis. The pilot owns +two axes only: the roll and the speed. The slot stands still in the view when +the ship's roll rate equals the station's spin. + +The controls on a touch screen are these: + +- **The roll strip** fills the left of the bottom edge. The offset of the thumb + from where it landed sets the roll rate. When the thumb lifts, the roll rate + goes back to zero. +- **THRUST and BRAKE** are two held buttons on the right. With neither held, the + ship keeps its speed. + +On a keyboard, the roll keys and the speed keys of today feed the same two +axes. The trial shows three readouts: the range, the speed against the limit, +and the roll error in degrees. + +The control reports a `FlightDemand` for the roll and the speed. +`PlayerShip.update` flies that demand, as it flies every other seat. + +The page used three values, and they are starting points only: + +| value on the page | meaning | +| --- | --- | +| 0.8 rad/s | the roll rate at full travel of the strip. The spin then sits at a third of the travel | +| 70 u/s² | the thrust while THRUST is held | +| 90 u/s² | the braking while BRAKE is held | + +Run `npm run constants:find` for each one before it becomes a constant. The +player's own flight envelope in `PLAYER_FLIGHT` is the likely owner. + +### M3 — the slot checks the speed + +A ship that meets the slot too fast scrapes and bounces, as a rolled ship does. +The page proposes 120 u/s. `dockingOutcome` gains the check, and the console +names the cause: the roll, or the speed. + +**`LAUNCH_SPEED` is also 120, and the two rules are independent.** A new +constant for the slot takes its own `@rule` ID. Run `npm run constants:find` +first, for "dock speed", "slot speed", "approach speed" and "120". + +### M4 — the manual says how to dock + +Rewrite the manual's docking section, and the briefing's. + +## Decisions already made + +- **A failed dock scrapes and bounces, and the pilot tries again** (Chris, + 2026-09-11). That is the rule of today. +- **The docking computer skips the trial** (Chris, `MOBILE_CONVERSION.md`). +- **Chris wants to see options before a pick** (Chris, 2026-09-11). The page + above holds them. +- **Match the spin is the trial** (Chris, 2026-09-11: *"Match the spin is + good."*). The other three concepts stay on the page as a record. + +## Open questions + +None. + +## Watch out for + +- **The NPC traders share the dock.** `docking.ts` serves both the traders and + the docking computer. The speed check of M3 must not bounce a trader. The + NPC hull margin is already separate, and the speed check follows the same + split. +- **`dock-probe` and `dock-traffic` measure the dock.** Both run before M3 and + after it. +- **The trial must not fight the course pilot.** One seat flies one frame. +- **THRUST and BRAKE are held buttons.** They need `Input.press` and + `Input.release` from 204 M1. 205 M1 keeps that pair for this reason. +- **The roll strip must give fine control near the spin.** At 0.26 rad/s, a + small error drifts the slot 37° in a few seconds. Try a curve on the strip + that is flat near the centre, if a linear strip feels too coarse. + +## Verification + +The gates always run: `npm run check`. `npm run generate:constants` runs first, +because M3 adds a constant. + +The tier: a rule that changes how a dock goes. `npm run dock-probe` and +`npm run dock-traffic` run once after M3. + +Evidence: + +- A test pins the speed check on both sides of the limit, for the player, and + shows that a trader is not bounced. +- A test drives the control of M2 headlessly into a clean dock, and into a + bounce for each cause. +- Chris docks on his phone. + +## What the milestones found + +### M1 and M3 + +- **The hand-over depends on the ship's kit.** With a docking computer fitted, + the course hands over at that computer's own range, 3,500 units, and it + flies the slot as it does today. Without one, the course flies on to 1,500 + units and hands the ship to the pilot. +- **The pilot's stretch is the docking computer's plan with two sticks given + back.** The computer holds the ship on the slot axis, which is the pitch. + The pilot owns the roll and the throttle. So the ship follows the same curve + to the letterbox, and the last of the manoeuvre is the pilot's. +- **The stretch ends where it began.** A ship outside the docking computer's + range is off the approach. The stretch then ends, and the course list offers + the station again. +- **The slot now checks the speed, and it is one answer.** `dockingOutcome` + takes the speed and returns `tooFast`, beside `slotMiss` and `hull`. A + caller cannot hold half of the rule. The console says which of the three it + was. +- **The docking computer keeps 10 units a second of room.** Its approach + settles at 110 against the limit of 120. The dock probe docked 504 of 504 + approaches with no scrape after the change. +- **Two course tests pinned the free dock of the 205 stopgap.** They fit a + docking computer now, and a new block flies the hand-over to the pilot. + +### M2 + +- **The roll strip is a third platform seam**, beside the click and the held + button. `engine/strip-control.ts` reads how far a pointer is from the + strip's middle, and reports a stick between -1 and 1. A roll key beats it, + as a key beats the mouse. +- **The strip moves its own knob.** The painter rebuilds the buttons only when + their words change, and a knob moves every frame a thumb does. +- **The guns wait during the stretch.** The slot asks for two things, so the + pilot's buttons are the strip, THRUST and BRAKE, and nothing else. +- **The speed bar carries the slot's limit as a mark.** It is placed from the + rule itself, so the mark cannot drift from what the slot takes. +- **The label over the view says MATCH THE SLOT — GO IN SLOWLY** while the + stretch runs. + +### M4, and what the browser run found + +- **The manual and the briefing teach the stretch.** The ship flies the + approach, hands you the slot, and you match the spin and the speed. Both + say that a scrape bounces you clear to try again, and that a docking + computer flies the slot for you. +- **The course list showed during the stretch, and it should not.** The ship + is in the station's mouth, and the only things to fly are the roll and the + speed. The cockpit shows no course buttons while the stretch runs. +- **The hand-over at full speed was unfair, and the course now brakes.** The + station course arrives at the hand-over on the arrival plan, at the slot's + own limit. It was measured over 25 systems with a clear sky. The hand-over + comes at 224 to 228 u/s, about 40 s after the arrival, with 1,500 units + left. That is half a second of braking, and the rest is the roll. +- **The hand-over is a range rather than an arrival.** An arrival also waits + for the speed to settle. A ship a few units a second over it sailed past the + station and into the hull. +- **A fight now stops a trip, and that is the design.** A measurement with the + arrival's traffic left 8 of 20 trips short of the station, because the + computer lines the ship up and nobody fires. A pilot fires, runs or pays. + With a clear sky, 40 of 40 trips reached the hand-over. +- **The speed bar's mark needed to be a different colour.** It is green over + the amber fill, so it reads at any speed. + +## Outcome + +207 landed on 2026-09-11. The station course flies the approach and hands the +pilot the last 1,500 units, at the speed the slot will take. The computer holds +the ship on the slot axis. The pilot matches the station's spin with the roll +strip or the roll keys, and the speed with THRUST and BRAKE. The slot refuses a +ship that arrives rolled wrong or above 120 units a second, and the console +says which. A scrape bounces the ship clear, and the pilot tries again. + +A docking computer still flies the slot from 3,500 units out, and it skips the +stretch. The dock probe docked 504 of 504 approaches with no scrape under the +new speed rule. The suite has 5,820 assertions, from 5,804. diff --git a/docs/TODO/completed/208-every-kind-of-mission-has-a-way-to-fly-it.md b/docs/TODO/completed/208-every-kind-of-mission-has-a-way-to-fly-it.md new file mode 100644 index 00000000..722426a7 --- /dev/null +++ b/docs/TODO/completed/208-every-kind-of-mission-has-a-way-to-fly-it.md @@ -0,0 +1,231 @@ +# 208 — Every kind of mission has a way to fly it + +**Kind:** enhancement · **Severity:** high · **Size:** large · **Depends on:** +205, 206 · **Blocks:** nothing · **GitHub:** none + +## Where we are + +**Chris wants the missions to feel part of the game** (2026-09-11: *"Yes, the +missions are becoming much more important - they need to feel part of the +game"*). 205 gives the arrival a course list, and the mission objective is its +first row. This plan says what that row flies for each verb. A verb is one kind +of mission leg, and `src/missions/verbs/` holds the eight of them. + +**Every mission target appears on an arrival.** The world spawns a tagged ship, +pod or canister 2,500 to 4,500 units from the witchpoint, in a random direction +(`spawning.ts`). The tag is `missionTag` on the ship or the canister. The verb +compares that tag with the live mission's own. + +**What each verb needs today**, from the review of 2026-09-11: + +| verb | what completes it | what fails it | +| --- | --- | --- | +| hunt | the player kills the tagged pirate | it jumps out, where the job allows an escape | +| scan | 20 s with the target inside 6,000 units and within 0.35 rad of the nose, or under a missile lock | the target dies or leaves | +| escort | the charge comes within 3,500 units of the station with no enemy within 3,500 units of it | the charge dies or leaves | +| recover | the player scoops the tagged canister | the canister is shot | +| rescue | the player scoops the pod, docks, and chooses to land or sell the survivor | the pod is shot before the scoop | +| ambush | the player arrives, then docks alive. The three lane pirates settle nothing | the verb reacts to nothing else | +| deliver | a dock at the target world | the deadline passes | +| smuggle | a dock at the target with the goods still aboard | a police scan, within 2,600 units of a Viper | + +**The review found four faults that a course makes worse, or can mend:** + +1. **A recover leg without fuel scoops is a trap.** The canister breaks on the + hull, and the mission hears nothing (`world-step.ts`). A course that flies + into it without scoops repeats the trap each visit. +2. **Nothing checks where the player is during an escort.** The charge flies + itself to the station, and the pirates are the only test. +3. **The hunt verb reacts to `fled`, and nothing sends it.** +4. **The hunt target can die to another ship.** The game then sends + `escortLost`, and the hunt verb ignores that input. So the leg stays open + with no target. + +## What to do + +Five milestones. + +### M1 — the mission row on the course list + +`game/courses.ts` from 205 gains the mission row. It reads the live legs through +`missions/queries.ts`, and it names the target in the dossier's words where a +dossier exists. It names the patron as well. The row leads the arrival list. A +leg whose target is in another system gives no row. Its words go on the +MISSIONS screen, as they do today. + +| verb | the row | what the course flies | +| --- | --- | --- | +| hunt | Hunt the named ship | to the target, then the fight of 206 with the target picked | +| scan | Scan the named ship | to a standoff inside 6,000 units, then hold the nose on it for 20 s | +| escort | Escort the named ship | beside the charge, to the station. A pirate in range opens the fight | +| recover | Recover the named goods | to the canister, then the scoop | +| rescue | Pick up the survivor | to the pod, then the scoop, then the station | +| ambush | Fly to the station | the station course. The row warns of the pirates on the lane | +| deliver | Deliver the goods | the station course, in the mission's words | +| smuggle | Slip past the police | the station course, on a path wide of every Viper (M4) | + +### M2 — the scan and the escort hold their station + +A scan course holds a standoff and keeps the nose on the target. The laser +stays the pilot's. The course shows the seconds of the scan, and the console +warns that a shot ends the job. + +An escort course flies in formation with the charge. Fault 2 then stops being a +fault for a pilot on a course. Leave the rule as it is. + +### M3 — the scoop legs cannot trap the pilot + +The recover row and the rescue row need fuel scoops. Without them, the row says +so, and the course does not fly into the canister. That mends fault 1 for a +pilot on a course. A pilot on the flight keys can still break the canister. +Decide in this milestone whether the leg then spawns a new canister. Read +`missions/machine.ts` first. + +Mend fault 4 here as well. A hunt target that dies to another ship completes +the leg with a lower fee, or fails it. The verb decides, and the machine +applies the branch. Remove the `fled` input from the hunt verb, unless a +milestone gives it a sender. + +### M4 — the smuggle path goes wide of the police + +The smuggle course is the one course that needs a new flight rule. It picks a +path to the station that stays outside 2,600 units of every police ship. The +path goes wider when it can, outside the warning band of 4,400 units. When no +such path exists, the row says so. The pilot may then dump the goods and fail +the job, or fly on and take the chance. + +### M5 — the derelict tells a story + +The derelict course of 205 ends 700 units from the generation ship, beside its +canisters. On arrival, the console tells what the scan finds, in words drawn +from the seed, as the system descriptions are. The course list then offers +"collect everything" (206). A later arc may use a derelict as a mission site. + +## Decisions already made + +- **Each mission verb gets a course** (Chris, 2026-09-11). +- **The missions and the arcs grow in weight, and the rating stays** (Chris, + 2026-09-11). +- **Rocks and derelicts use the fight of 206** (Chris, `MOBILE_CONVERSION.md`). + +## Open questions + +None. M3 names one decision that a reading of the machine settles. + +## Watch out for + +- **The verb decides, and the machine applies** (invariant 15, one level down). + A course never pays, never moves a leg, and never reads the purse. +- **The seeded stream.** The derelict's words of M5 draw from the seed. Draw + them on a derived stream, so no world outcome moves. +- **`test/mission-skeletons.test.ts` runs the lint over every skeleton.** A new + rule for a verb goes into `missions/lint.ts` as data. +- **A dossier is words, and the machine never reads one.** The row reads the + dossier for its words only. + +## Verification + +The gates always run: `npm run check`. + +The tier: a rule that changes how a mission and a fight go. `ambush-probe` runs +once after M1. + +Evidence: + +- **Each of the eight side jobs completes with no hand on the stick.** A + headless test flies each job in `skeletons/side.ts` from the arrival to its + end, on the real world step, by the mission row alone. The fight of 206 runs + on the pilot's trigger, which the test holds down. +- A test proves that a recover row without scoops refuses, and that the course + never touches the canister. +- A test proves that the smuggle path keeps outside 2,600 units of each Viper + where such a path exists. +- Chris plays the arc of one patron on his phone. + +## What the milestones found + +### M1 and M2 + +- **Five verbs need a course, and three do not.** A deliver, a smuggle and an + ambush all end at the station, and the station course flies there. The + other five get the first row of the list, in the job's own words. The rows + read HUNT THE KRAIT, SCAN THE ANACONDA, ESCORT THE PYTHON, RECOVER THE + CARGO and PICK UP THE SURVIVOR. +- **Four shapes cover those five.** A hunt is a fight, so the course picks the + ship and the computer's aim flies it, exactly as mining does. A scan holds + 1,200 units off and keeps the nose on the ship. An escort flies 600 units + off its charge, inside the ring the escort is judged by, so the fight comes + to the pilot. A recover and a rescue are a scoop. +- **The run leads the list in a hunt, and that is right.** The hunt's own ship + is hostile, so RUN FOR IT sits above the mission row (docs/TODO/206 M5). +- **The whole side-job flight is tested through the machine.** Each job is + accepted through `runMissions`, moved to the system the ship is in, and + flown by its button alone. A side job is on about a third of the worlds' + boards, so the test walks the galaxy until it finds one that offers it. +- **The suite has 5,831 assertions,** from 5,820. + +### M3 + +- **A scoop row says when the ship cannot fly it.** A recover or a rescue + needs fuel scoops. Without them the canister breaks on the hull, and the + leg hears nothing. The row says NEEDS FUEL SCOOPS, and the pick refuses, + so the course never flies into it. +- **A hunted ship wrecked by somebody else ends the hunt.** The world sends + `escortLost` for a tagged ship wrecked with credit to nobody, and the hunt + verb ignored it. The leg stayed live with no ship left in the galaxy to + kill. It takes it as destroyed now. A branch that paid less for another's + kill would be a third outcome, and no skeleton has one. +- **The `fled` trigger has a sender at last.** Three arcs have a branch for a + hunted ship that runs, and nothing ever sent the word. A tagged ship that + leaves while it flees counts as fled. Any other one jumped out, and it + escaped. So a side hunt no longer fails when its target runs. + +### M4 + +- **The smuggling run is a course of its own: SLIP PAST THE POLICE.** It flies + the station course on a line wide of every policeman in the way, at the + warning band of 4,400 units. That is outside the 2,600 units a policeman + reads a hold at, with a margin. +- **One rule holds both detours.** `sidestep` aims beside a thing in the way, + on the same side as the line, and half as far again. The planet and the + police both use it, and a target that is itself the nearest point needs no + detour. +- **A leg without a tag is still a leg.** The lookup skipped every leg with no + tagged ship, and a smuggling run has none. It asks for a way past the + police, and nothing in the sky is its own. + +### M5 + +- **A derelict says what is there, in the world's own words.** The scan's + report is read off the world's seed rather than drawn. So a derelict tells + the same story on every visit, and two worlds tell different ones. A draw + would move every seeded outcome after it. +- **The cargo beside it is the collect course's, which 206 M6 already flies.** +- **`flight.ts` was one line over its ceiling for an hour.** The instruments + hold the state, so they read the world themselves rather than ask the + flight half for it. + +### The manual + +- **The manual says how a job is flown now.** A job is the first button over + the view, in its own words, and a delivery is the station button. + +## Outcome + +208 landed on 2026-09-12. A live job is the first row of the course list, in +the job's own words, and the ship flies it. A hunt picks its ship, and the +computer's aim lines the ship up while the pilot fires. A scan holds the +ship 1,200 units off and keeps the nose on it. An escort flies 600 units off +its charge. A recover and a rescue scoop. A smuggling run flies the station +course wide of every policeman. A delivery and an ambush end at the station, +which the station course already flies. + +The work mended three faults the review found. A scoop row says NEEDS FUEL +SCOOPS and refuses, so no course flies into a canister that would break on +the hull. A hunted ship wrecked by somebody else ends the hunt rather than +leaving the leg live for ever. A tagged ship that runs sends `fled`, which +three arcs have waited for a branch on. A derelict now tells what the scan +finds, in the world's own words. + +`ambush-probe` survived 40 of 40 episodes after the work. The suite has 5,845 +assertions, from 5,835. diff --git a/docs/TODO/completed/209-the-drive-says-what-stopped-it.md b/docs/TODO/completed/209-the-drive-says-what-stopped-it.md new file mode 100644 index 00000000..13bbb6c2 --- /dev/null +++ b/docs/TODO/completed/209-the-drive-says-what-stopped-it.md @@ -0,0 +1,121 @@ +# 209 — The torus drive says what stopped it + +**Kind:** bug · **Severity:** medium · **Size:** small · **Depends on:** 205 · +**Blocks:** nothing · **GitHub:** none + +## Where we are + +**Chris flew a trip to the station and reported a fault** (2026-09-12: +*"Jumping to the station did not seem to mass lock on neutral traders."*). + +**The rule does fire.** `massLocked()` in `game/world-step.ts` drops the torus +drive within 4,500 units of any live ship that is not a rock. A measurement of +2026-09-12 put a stationary trader on the line to the station. The drive +dropped at 4,457 units. Over 20 trips, a trader was inside the lock radius at +9 of 36 stops. + +**Two things hid it from the pilot.** The first is the message. It said `MASS +LOCK — TORUS DISENGAGED`, which names nothing. A pilot cannot tell a trader +from a rock hermit from the planet. + +The second is the drive itself. The drive is off for about 240 seconds of a +290 second trip. The course brakes towards the station with the drive off, and +that is where the traffic is. So of 26 trader passes inside the lock radius +over 20 trips, only 7 dropped a running drive. The other 19 were silent. + +**Chris wants a neutral ship to be a chance, not an event.** His words in the +design document of 2026-09-11: *"When anything is in range we should show a +list of objects that can be engaged - these can be outright hostiles or neutral +ships that we want to pirate."* The target list of 206 already holds every ship +in scanner range. The pilot had no reason to open it. + +## What to do + +**M1 — the drop names what stopped the drive.** + +1. Add `massLockCause(state)` to `game/world-step.ts`. It returns the player's + own words for the nearest thing that holds the drive down, or null. +2. Make `massLocked(state)` a wrapper over it, so the rule keeps one home. +3. Change the drop message to `TORUS DRIVE OFF — IS TOO CLOSE`. +4. Change the flight key refusal the same way. It said `MASS LOCKED`. +5. Name a ship with `shipName()`, which `game/targets.ts` owns. The list and + the message must not use two words for one ship. + +**M2 — a neutral trader that comes close says so.** + +1. Add `game/close-pass.ts`. It returns one line for each trader that comes + inside `MASS_LOCK_SHIP` and was not announced yet. +2. Hold the flag on the ship, as the target pick is held (`NpcState`). +3. Clear the flag past `CLOSE_PASS_CLEAR`, so a wobble across the radius does + not repeat the line. +4. Push the lines from `stepNpcs`, which is the orchestrator (invariant 15). +5. Say it in the manual. + +## Decisions already made + +- **A neutral ship must not stop the ship.** The pilot chose the course. The + line tells the pilot that the chance is there. Chris's rule of 2026-09-11: + the pilot owns the target choice. +- **The line names a trader, and no other role.** A hostile ship announces + itself, by its guns and by the condition light. The law's ships are a risk + rather than a chance. A line that invites an attack on a Viper beside the + station is bad advice. +- **Nothing speaks inside the station's mass lock.** The pilot is on the + approach there, and the traffic is thick. + +## Open questions + +None. + +## Watch out for + +- **The count of lines.** A line for each pass would be noise. Measure it. +- **`massLocked` has three readers**: the flight keys, the step and the + harnesses through `window.__game`. The wrapper keeps all three. + +## Verification + +1. `test/world-step.test.ts` pins the named cause for the station, the planet, + a trader and a clear sky. +2. `test/close-pass.test.ts` pins one line for each approach, the roles that + get none, and the flag that clears. +3. A probe over 20 trips to the station counts the lines a trip shows. + +## What the milestones found + +**M1 — the lock did fire, and it said nothing useful.** Three measurements of +2026-09-12, before any change: + +| measurement | result | +| --- | --- | +| a stationary trader on the line to the station | the drive dropped at 4,457 units | +| traders inside the lock radius at a stop, over 20 trips | 9 of 36 stops | +| trader passes inside the lock radius, over 20 trips | 26 | +| ...of those, passes that dropped a running drive | 7 | + +So 19 of 26 passes were silent, because the drive was already off. The drive +runs for about 50 seconds of a 290 second trip. + +**The name comes from `targets.ts`.** `shipName()` and `shipArticle()` moved +out of `targetList()`, so one ship reads the same on the list and in the +message. A message said `A OPHIDIAN` before the article rule existed. + +**M2 — the first shape was too loud.** A line for every neutral ship inside +the radius gave a median of 6 lines per trip, and a maximum of 10. The law's +ships were half of them, beside the station. The rule now names a trader +only, and it says nothing inside the station's mass lock. That is a median of +1 line per trip, and a maximum of 3, over the same 20 trips. + +**A ship the pilot already chose gets no line.** `test/law.test.ts` caught it: +the commander shot a trader, and the console answered with an invitation to +attack it. The rule now skips a ship that carries the target pick, and one the +commander already shot at. + +## Outcome + +Landed on 2026-09-12. `npm run check` passes, with 5,871 assertions. + +- The torus drive names what stopped it, in the player's words. +- The flight key refusal names it too. +- A neutral trader that comes close is announced one time. +- The manual says both. diff --git a/docs/TODO/completed/210-the-ship-does-not-roll-all-the-way-there.md b/docs/TODO/completed/210-the-ship-does-not-roll-all-the-way-there.md new file mode 100644 index 00000000..9d6defc2 --- /dev/null +++ b/docs/TODO/completed/210-the-ship-does-not-roll-all-the-way-there.md @@ -0,0 +1,94 @@ +# 210 — The ship does not roll all the way there + +**Kind:** bug · **Severity:** high · **Size:** small · **Depends on:** 205 · +**Blocks:** nothing · **GitHub:** none + +## Where we are + +**Chris flew a course and reported a fault** (2026-09-12: *"There is something +a bit annoying - we seem to be constantly rotating when heading towards +something. I don't remember this happening with the combat computer."*). + +**He is right, and the measurement is large.** A trip to the station cost 41 +full turns of roll on a median run with traffic, and 7.8 with a clear sky. The +roll moved in 92% of the samples. + +**The steering is `bankToTurn`** (`game/pitch-roll-steer.ts`). The ship has no +yaw axis. To point the nose it rolls the target onto the vertical plane, then +pitches up to it. The pitch is gated by the cosine of the roll error, so the +ship pulls once the bank is nearly there. + +**The soft gate leaves a cone.** A trace of 2026-09-12 caught the equilibrium. +The ship rolled at 0.25 radians a second. The pitch that the soft gate still +allowed was 0.0087. The nose then circled the target at a fixed 0.035 radians +off, for ever, because a bearing turns by `pitch / theta`. Near the nose that +ratio is huge, and the two rates cancel exactly. + +**The combat computer never shows it**, which is why Chris does not remember +it. It passes the gun's own hit cone as a null band. That cone is wide up +close, and the computer holds the sticks still inside it. + +## What to do + +**M1 — the course pilot gets a hard roll gate.** + +1. Add a `rollGate` parameter to `bankToTurn`. Above that roll error it asks + for no pitch at all. The default is 0, which keeps every combat caller + exactly as it is. +2. Add `COURSE_ROLL_GATE` (0.05 radians) and `COURSE_AIM_DEADZONE` (0.02 + radians) to `constants/course.ts`. The course pilot passes both. +3. Pin the gate in `test/pitch-roll-steer.test.ts`. +4. Pin the whole trip in `test/course-pilot.test.ts`: less than one full turn + of roll from the witchpoint to the hand-over. + +## Decisions already made + +- **The fight keeps the law it has.** Its targets manoeuvre. A pitch held + still while the roll catches up is time off the gun. The probes that tune + the fight are `aim-probe`, `defence-probe` and `survivability`. + +## Open questions + +- **Should the fight take the gate too?** A measurement of 2026-09-12 shows + that all the roll left on a trip with traffic happens under the combat + computer, in a fight. Chris did not report it, and a dogfight rolls for real + reasons. This is his call. + +## Watch out for + +- **The gate must not cost an arrival.** Measure the trips that reach the + hand-over, the time and the speed. +- **A tighter gate costs time in a big turn**, because the roll must finish + first. + +## Verification + +1. The count of full turns over a trip, before and after. +2. Trips that reach the hand-over, with the time and the speed at hand-over. +3. The new test fails with the gate set to zero. + +## What the milestones found + +**The numbers, over 20 trips with a clear sky:** + +| | before | after | +| --- | --- | --- | +| full turns of roll, p50 | 7.8 | 0.2 | +| trips that reach the hand-over | 20 of 20 | 20 of 20 | +| seconds to the hand-over, p50 | 39.5 | 39.4 | +| speed at the hand-over, p50 | 227.7 | 227.7 | + +**And with traffic, over 10 trips:** the median fell from 41 full turns to 1.3. +The roll that is left is all in a fight, under the combat computer. A split of +the same runs put 39.2 of 39.3 turns there, and 0.1 on the course. + +**The gate is what fixes it, not the deadzone.** A sweep of the deadzone alone +gave 37.7 turns at 0.02 radians, 34.7 at 0.05 and 44.1 at 0.08. The hard gate +gave 5.5 turns at 0.2 radians, 2.9 at 0.1 and 1.3 at 0.05. + +**The test fails without it.** With `COURSE_ROLL_GATE` set to zero, the trip +test reports 7.38 turns against a limit of one. + +## Outcome + +Landed on 2026-09-12. `npm run check` passes. diff --git a/docs/TODO/completed/211-the-ship-stops-short-of-the-rock.md b/docs/TODO/completed/211-the-ship-stops-short-of-the-rock.md new file mode 100644 index 00000000..ff3644d7 --- /dev/null +++ b/docs/TODO/completed/211-the-ship-stops-short-of-the-rock.md @@ -0,0 +1,93 @@ +# 211 — The ship stops short of the rock + +**Kind:** bug · **Severity:** high · **Size:** small · **Depends on:** 206 · +**Blocks:** nothing · **GitHub:** none + +## Where we are + +**Chris flew at a rock and reported a fault** (2026-09-12: *"when targeting +asteroids or the derelict. We fly towards - and just keep flying towards until +we hit it. We should probably stop when we are within a certain range."*). + +**A picked target is flown by the co-pilot** (`game/scripted-co-pilot.ts`). It +holds `PURSUIT_RANGE`, which is 500 units. Beyond it the ship closes. Inside +it the ship drops back. A rock and the derelict are on the target list, as +Chris asked in 206, so the same pilot flies at both. + +**The standoff did not hold.** A trace of 2026-09-12 followed a picked rock +53,000 units out: + +| time | range | speed | +| --- | --- | --- | +| 0 s | 53,074 | 254 | +| 130 s | 1,559 | 397 | +| 140 s | 481 | 4.3 | +| 230 s | 101 | 4.3 | + +**The cause is the speed deadband.** `FlightDemand.throttle` is only a sign, +so a deadband of 6 units stops the throttle from a flip on every frame. Inside +the standoff the wanted speed is zero. A ship that coasts at 4.3 is inside the +band, so the throttle asked for nothing, and the drift went on to the hull. + +**The second fault is the frame of reference.** The range is held to the +target's CENTRE. A rock is 54 units across the radius, and the generation ship +is 340. So the commander stopped 160 units off the derelict's hull. + +## What to do + +**M1 — a stop is a stop.** + +1. In `pursuitThrottle`, brake when the wanted speed is zero. The deadband + then holds a speed, and never a drift. +2. Measure the standoff from the hull. Pass `dist - radius` to the throttle. + The gun keeps the true distance, because a shot travels to the hull by + itself. +3. Pin both in `test/scripted-co-pilot.test.ts`. +4. Fly the whole game in `test/mine-and-collect.test.ts`: pick a rock, wait 90 + seconds, and check the gap. Wait another minute, and check that the gap is + still there. + +## Decisions already made + +- **The stop is the range the co-pilot already holds.** `PURSUIT_RANGE` is + inside the laser's reach and outside the break-off range. A second number + for a rock would be a second rule with no reason. + +## Open questions + +None. + +## Watch out for + +- **The fight must not change.** A fighting target moves, so it asks for a + wanted speed above zero at any useful range. The brake only fires where the + ship is asked to stop. +- **The hull standoff moves a fighter's range too**, by its own radius, which + is 25 to 62 units on the shipped hulls. + +## Verification + +1. The trace, flown again: the range at rest, and the range one minute later. +2. `test/scripted-co-pilot.test.ts` on the throttle rule itself. +3. The whole suite, which holds the fight. + +## What the milestones found + +**The rock, flown again with the fix:** the ship stops at 554 units from the +centre of a 54 unit rock. Its speed is 0.0 units a second. It holds that range +for the rest of the run. + +**The derelict:** the commander holds 838 units from the centre of the 340 +unit hull, which is 500 units of clear space. It matches the hulk's own drift +of 25 units a second, as it did before. + +**The test fails without the fix.** With the brake removed, the whole-game +test reports 2.7 units a second and a loss of 160 units of range in one +minute. + +**The fight is unchanged.** The suite holds it at 5,886 assertions, including +the co-pilot's gun cone, its range and its break-off. + +## Outcome + +Landed on 2026-09-12. `npm run check` passes. diff --git a/docs/TODO/completed/212-the-computer-lines-up-and-the-pilot-plays-the-slot.md b/docs/TODO/completed/212-the-computer-lines-up-and-the-pilot-plays-the-slot.md new file mode 100644 index 00000000..7c422531 --- /dev/null +++ b/docs/TODO/completed/212-the-computer-lines-up-and-the-pilot-plays-the-slot.md @@ -0,0 +1,127 @@ +# 212 — The computer lines up, and the pilot plays the slot + +**Kind:** bug · **Severity:** high · **Size:** medium · **Depends on:** 207 · +**Blocks:** nothing · **GitHub:** none + +## Where we are + +**Chris flew 207's docking on a phone and it failed** (2026-09-12: *"I'm +testing on mobile. The docking does not seem to work at all."*). The touch +input was not the fault. His own diagnosis, the same day: *"lining up is not +actually very accurate until the last few moments. So we aren't actually flying +straight and rolling can send you off away from the slot. We may need a +different solution."* + +**He is right, and the numbers are large.** A measurement of 2026-09-12 flew +the station course headless, then took over with an ideal pilot: + +| at the hand-over | value | +| --- | --- | +| range to the station | 1,494 units | +| speed | 224 to 228 units a second | +| distance off the slot axis | 407 to 886 units | + +The slot channel is 26 units across the half-width. So the pilot took the ship +hundreds of units off the line, at twice the speed the slot accepts. + +**One stick has two jobs, and that is the root of it.** The ship has no yaw +axis, so a roll is how it aims. The slot wants that same roll for something +else: the wings lined up with the letterbox. `docking-sticks.ts` reconciles the +two for the docking computer. 207's split could not, because the pilot held the +roll and the computer held only the pitch. Every roll the pilot made turned the +computer's correction into a new direction. + +**Measured over four seeds, the same approach, two pilots:** + +| pilot | docked | +| --- | --- | +| matches the spin, and brakes | 0 of 4 | +| brakes only, and never rolls | 2 of 4 | + +**Chris asked for a different shape** (2026-09-12): *"I'm wondering if we +actually get lined up by the computer and then hand off to a 'mini' docking +game. Something that is completely on rails."* + +## What to do + +**M1 — the pilot's stretch becomes two stages.** + +1. The computer lines the ship up, with both sticks, exactly as the docking + computer does. The pilot sees LINING UP, and has no controls. +2. The rails then take the ship. They hold it on the slot axis and point it + down the axis. The pilot has the roll and the throttle, and nothing else. +3. The slot's own test is unchanged: the wings inside `ROLL_TOLERANCE`, and the + speed under `SLOT_SPEED_LIMIT`. +4. A scrape lets the rails go, so the computer lines the ship up for another + go. That is the retry Chris asked for on 2026-09-11. + +**M2 — the rails are their own module**, `game/dock-rails.ts`. It is the one +file in the game that moves the commander's ship other than by flying it. +`test/docking.test.ts` scans for that, and it names the file. + +## Decisions already made + +- **The computer does the lining up.** Chris asked for it in those words. +- **The pilot keeps the roll and the speed.** Match the spin was his choice of + 2026-09-11, and the rails are what make it possible. +- **The rails pull, and never snap.** A snap is a teleport. + +## Open questions + +None. + +## Watch out for + +- **The game must not be over before it starts.** If the rails take the ship + 200 units out, the pilot has two seconds and nothing to do. +- **`world-step.ts` must not turn the ship by hand.** That rule is docs/TODO/126 + and `test/docking.test.ts` holds it. + +## Verification + +1. The whole game, flown headless: the course, the lining up, the rails, and a + pilot's hand on the strip. +2. The control: a pilot who never touches the strip. +3. The length of the mini game, in seconds and in units. + +## What the milestones found + +**The rails take the ship 645 units out, at 107 units a second.** That is about +six seconds of mini game. The station turns 1.6 radians in that time, and the +slot is 1.3 radians wide, so the pilot must follow the spin rather than wait. + +**The measurements, over eight seeds:** + +| pilot | docked | +| --- | --- | +| matches the slot | 8 of 8 | +| never touches the strip | 3 of 8 | + +The three are luck rather than skill. The slot fits either way up, so it is +lined up about two turns in five. + +**The retry works.** A pilot who fluffs the slot scrapes, and the computer +lines the ship up again. Two of six seeds docked on the second go. + +**The rails start with 210 to 240 units of error, and they close it.** The +first frame moves the ship 7 units across the line. The error is gone in about +two seconds, well before the mouth. + +**IT WAS TOO EASY AT FIRST.** Chris flew it (2026-09-12): *"That works - but +it's a bit too easy I think. Maybe reducing the speed to nothing so the user +has to thrust forward would be good."* The rails took the ship at 107 units a +second, so the speed was already made, and a hand on the roll was the whole +game. The computer brakes the ship to a halt now. It stops 632 units out, on +the line, and the pilot flies the whole run in. + +**The three pilots, measured again over the same seeds:** + +| pilot | result | +| --- | --- | +| thrusts in and matches the slot | 6 of 6 docked, first go | +| thrusts in and never matches | scrapes, and takes 2 or 3 goes. One of 8 died | +| touches nothing | waits there for ever, and never docks | + +## Outcome + +Landed on 2026-09-12. `npm run check` passes. diff --git a/docs/TODO/completed/213-the-mission-rules-hold-and-a-hit-trader-goes-back-to-work.md b/docs/TODO/completed/213-the-mission-rules-hold-and-a-hit-trader-goes-back-to-work.md new file mode 100644 index 00000000..9ba43fe6 --- /dev/null +++ b/docs/TODO/completed/213-the-mission-rules-hold-and-a-hit-trader-goes-back-to-work.md @@ -0,0 +1,293 @@ +# 213 — The mission rules hold, and a hit trader goes back to work + +**Kind:** defect · **Severity:** high · **Size:** medium · **Depends on:** +nothing · **Blocks:** 214 · **GitHub:** none + +## Where we are + +Two reviews of the missions landed on 2026-09-12 and 2026-09-13. +[`docs/MISSIONS-REVIEW.md`](../MISSIONS-REVIEW.md) reads the machine and its +data. [`docs/MISSIONS-FLIGHT-REVIEW.md`](../MISSIONS-FLIGHT-REVIEW.md) +flies every kind of job in a real headless game. The machine is sound. The +rules around it do not hold. Chris read both on 2026-09-13 and said *"let's +fix the broken parts"*. This plan is the broken parts. 214 is what he asked +for next: surprises. + +The probes under [`docs/reviews/missions-2026-09-12/`](../reviews/missions-2026-09-12/) +measured each fault, and each milestone below says which probe block shows +it. The findings, by the number the reviews gave them: + +| review | finding | what is wrong | +| --- | --- | --- | +| flight 2 | the escort | a trader hit once flees for ever. The escort course rams its charge. It follows a charge into the planet | +| machine 4 | `fled` | the escort and scan verbs ignore it. A scan subject wrecked by nobody is ignored too | +| machine 2 | the cap | no side job sets `cap`, so each is offered one time per career | +| machine 3 | the goods | a smuggling run pays and leaves the narcotics aboard | +| machine 1 | the galaxy | an arc is offered in every galaxy at its seed index | +| flight 3 | the scoops | the Lave arc and two side jobs need scoops a new commander has not got | +| flight 1 | the warning | TARGET ARMOUR HALVES LASER FIRE is said on every hunt | +| machine 5 | the hint | the side-job count silences the patron's dock messages | +| machine 6 | the save | a live mission on a ghost id throws on dock and on abandon | +| machine 7 | the journal | it holds no galaxy, so the log misnames a world after a jump | +| machine 8 | standing | a local patron's key is the world where the branch settled | +| machine 9 | the restore | a restored mission ship takes the pirate row | +| machine 10 | the placement | a branch that cannot place its next leg drops the settlement | +| machine 12 | the comments | six say what the code no longer does | + +## What to do + +Five milestones. Each is one commit. + +### M1 — the flight: a hit trader goes back to work, and the escort ends + +- **`fleeing` clears.** A trader that took no hit for `TRADER_CALM_SECONDS` + and has no live attacker goes back to its working life. A new clock on + the ship counts the seconds since the last hit. `npc-trader.ts` reads it + at the top of the fleeing branch. +- **The escort and scan verbs answer `fled`** as `targetEscaped`. The scan + verb answers `escortLost` as `targetDestroyed`, as the hunt and the escort + already do. +- **The escort course cannot ram its charge.** Inside three standoffs of the + charge the approach speed is capped at the charge's speed plus + `COURSE_ESCORT_CLOSING`. +- **A course refuses a target below the planet's clearance.** The mission + arrival ends with a reason, and the console says THE TARGET IS TOO NEAR + THE PLANET. `CourseStep` gains `why`, and `endCourse` says it in place of + the course's usual word. + +### M2 — the data: a side job comes back, the goods leave, the arcs stay home + +- **A side job with no `cap` repeats without limit**, after + `MISSION_REOFFER_DAYS`. The model comment and `canAccept` change together. +- **A smuggling run unloads.** The smuggle verb's success carries `unload`. + The machine emits an `unload` effect. The bridge takes the tonnes out of + the hold, and no more than are aboard. +- **Every arc is gated to galaxy 1.** The five arcs set `offer.galaxy`. The + lint refuses a world patron whose skeleton has no galaxy gate. The lead + relocation in `leaveGalaxy` goes, with `leadWorldIn` and its tests. A lead + keeps its galaxy, and the LEADS row already says IN ANOTHER GALAXY. +- **A scoop job needs scoops before it is offered.** `Gate.scoops` and + `CommanderFacts.scoops`. The Lave arc, `side-recover` and `side-rescue` + set it. The gate is outside the dossier hash, so no dossier changes. + +### M3 — the words: the warning and the hint + +- **The gun warning speaks for a target that halves a hit**, which is the + Constrictor's `playerLaserMultiplier`, and for no other. +- **A side-job count does not silence a lead.** `hail` passes only an arc's + hail as `hailed`, so the one-jump message and the reminder come through + behind the count. + +### M4 — the record: the save, the journal, the standing, the restore + +- **The loader drops a ghost.** `repairMissionState` drops a live mission + or a lead whose skeleton or leg the code no longer ships, and the loader + says one line about it. +- **The journal holds a galaxy.** `JournalEntry.galaxy`, written by every + push. `storyPages` names a world through the entry's own galaxy, from a + memo of generated galaxies. An entry with no galaxy reads as galaxy 1. +- **The lead announcement names the right world.** The `lead` effect carries + the galaxy, and the bridge names the world through that galaxy's systems. +- **A local patron's standing is keyed by the origin.** `patronId` reads + `acceptedAt` for a local patron. +- **A restored mission ship takes its leg's role.** `missionShipSpec` reads + the role through `verbJob`. +- **A branch that cannot place its next leg still settles.** It falls back + to `anywhere`, and the lint measures a handover from every world, as it + measures a band. + +### M5 — the lint and the comments + +- **Every trigger a verb can emit has a branch, or the leg names it under + `ignores`.** A table in `verbs/registry.ts` says what each verb emits. + The shipped legs that ignore a trigger say so: a side hunt and the + Constrictor ignore `targetFled`, because a pirate cannot leave a system + today. 214 revisits that. +- **The six stale comments go.** They are the machine that spends the + mission constants, and the override conflict the lint does see. They are + the hold check and the item table that never arrived. They are the world + patron in one galaxy, and the idle-dock count. + +## Decisions already made + +- **Fix the broken parts first** (Chris, 2026-09-13). +- **Surprises are 214, not this plan** (Chris, 2026-09-13: *"there should be + surprises"*). +- **The verb decides, and the machine applies** (invariant 15, one level + down). No fix here moves a rule out of its home. +- **A branch change needs a dossier regeneration.** The dossier hash covers + the legs, the verbs, the branches and the pitch. A gate, a spawn and an + `ignores` list are outside it. So this plan adds no branch to a shipped + skeleton. + +## Open questions + +Three questions the review put to Chris, answered here by the review's own +recommendation. Each is one line to change if he decides otherwise. + +- **Does the tour play in other galaxies?** No. The names, the patrons and + the dossiers are galaxy 1's. +- **How many times does a side job repeat?** Without limit, a week apart. + Eight jobs per career is a short game. +- **Does the tour open with a scoop?** It waits for the scoops. A gate is + one line, and a new first leg is a regeneration. + +## Watch out for + +- **A new constant follows `CLAUDE.md`'s rule.** Run `constants:find` for + the name, two synonyms and the value. Then `generate:constants` and + `constants:check`. +- **The seeded stream.** No fix here draws from it. +- **`NpcState` is walked generically into a snapshot.** A new clock on the + ship is saved for free, and a restore from an old save reads it as + absent. Give it a default. +- **The dossier `--check`.** Run it after M2 and M5 to prove the hash did + not move. +- **`test/tour.test.ts` pins the relocation.** M2 removes those checks with + the code, and keeps the checks on `arcStarts`. + +## Verification + +The gates always run: `npm run check`. + +The tier: a rule that changes how a mission and a fight go. The two review +probes run again after M5, and their new output is committed as +`results-after.txt` and `flight-results-after.txt` beside the old. + +Evidence, one per fault: + +- A real-game test puts a commander on the escort, hits the charge, and + sees it go back to its working life after the calm. The escort finishes. +- A real-game test flies the escort approach with the sky empty and sees + the fore shield stay full. +- A course-pilot test hands the mission arrival a target inside the + clearance and reads the refusal. +- The verb tests send `fled` to the escort and the scan, and `escortLost` + to the scan. +- The offer test ends each shipped side job and asks for it again a week + on. +- The verb test reads the hold after a smuggling run succeeds. +- The offer test stands a commander at galaxy 2's index 7 and sees no arc. +- The lint test breaks a fixture one more way per new rule, and the gate + names each fault. +- The bridge test restores a Python escort and reads the trader row. +- The story test renders a galaxy-1 journal against galaxy 2 and reads the + right names. +- The loader test loads a live mission on a ghost id and sees it dropped. + +## What the milestones found + +### M1 + +- **The escort finishes eight of eight.** The flight probe ran again after + the milestone. Before it, four escorts of eight finished, three stalled + and one crashed. After it, all eight finish, in 488 to 525 seconds, and + the ship ends with shields 249 and 255. +- **The calm is one clock on the ship**, `calm`, the seconds since the last + hit. `takeDamage` resets it and `tickClocks` runs it, beside + `underFire`. The snapshot walks `NpcState` generically, so a save carries + it and an old save reads it as 0. `TRADER_CALM_SECONDS` is twenty, and it + lives beside `UNDER_FIRE_SECONDS` under `@rule trader.calmSeconds`. +- **A course that ends for a reason is not done.** `endCourse` used to add + every ended course to `coursesDone`, which takes it off the list for the + visit. A refusal now leaves it on the list, so the pilot can pick the job + again once the charge climbs out of the planet. +- **Ninety seconds beside the charge cost no shield**, and the ship never + came inside a quarter of the standoff. The real-game test holds both. +- **`constants/course.ts` crossed the size ceiling by three lines.** The + four numbers a mission's course spends left it for + `constants/mission-course.ts`: the scan standoff, the escort standoff, + the escort closing speed and the police clearance. Their rule ids stay + under `course.`, and each says `@domain mission-course`. +- The suite gains 13 checks, to 5,984. + +### M2 + +- **The dossier hash did not move.** A gate and a spawn list are outside + it, as the plan said, and `generate:dossiers --check` passes with every + skeleton changed. +- **The lead announcement moved here from M4.** Without the relocation, a + lead an arc offers on a galactic jump was recorded in the galaxy she + arrived in, at a seed index there. `offerLead` reads the galaxy from the + next arc's gate, and the bridge names the world through that galaxy. +- **Two tests pinned the old rules by accident.** `test/arcs.test.ts` said + the first arc opens with no gate. `test/station.test.ts` pinned the dock's + hail lines with the Lave arc among them. The first is rewritten. The second + fits scoops, because the offers are not its subject. +- **The review probe needed the new fact.** `probe.ts` builds its own + commander facts, and it gained `scoops` so the after-run can read the + same blocks. +- The suite has 6,001 checks. + +### M3 + +- **The halving is a field, and the warning reads it.** `playerLaserMultiplier` + on the target's energy policy is the Constrictor's half. The warning + returns nothing for a policy at 1, before it prices a gun. Every test that + read the Constrictor's warning still reads it. +- **A side-job count queues, and the message queues behind it.** The dock + says THERE IS ONE SIDE JOB ON THE STATION BOARD, and then A MESSAGE FROM + the patron next door. Only an arc's hail silences the hint now. + +### M4 + +- **One memo of the eight galaxies**, `galaxy/galaxies.ts`. The bridge kept + a memo of one galaxy at a time, and a story that names two galaxies + thrashes a memo of one. The bridge, the story and the LOG read the one memo. +- **The LOG draws the route of the galaxy she is in.** A page carries its + galaxy, and the route map takes the pages of the commander's galaxy. A + page from another galaxy still reads, with its own names. +- **The loaders have no console**, so a dropped ghost is silent. The header + of `repair.ts` says so. A test drives a ghost skeleton, a ghost leg, a + ghost lead and a ghost passenger through both loaders. +- **The role a job's ship flies under has one home**, `jobRole` in the + verb registry. The spawn and the restore both read it. +- **A test fixture met the ghost rule.** The galaxy-change test saves a lead + to a fixture skeleton, which the loader now drops. The saved lead names a + shipped arc instead. + +### M5 + +- **A hunt that cannot escape cannot run out either.** The hunt verb sent + `targetFled` for any hunt. So the table of what a hunt can emit needed + the Constrictor to ignore a word it can never hear. The verb answers + neither `escaped` nor `fled` for a hunt with `canEscape` false. The + side hunt alone lists `ignores: ['targetFled']`. +- **The table lives in the verb registry**, `verbTriggers`, beside the + module table, so a ninth verb needs both entries. +- **The probes ran again.** `results-after.txt` shows a side job open a + week on, and the goods unloaded. It shows the standing on the origin, the + lead in its own galaxy, and a galaxy-1 journal named in galaxy 1. Block 6 still throws: + the probe hands the machine a ghost directly, and the machine still + refuses one. The loaders drop it before the machine sees it. + `flight-results-after.txt` shows the escort at eight of eight. Every + other row reads as before, because the flight itself did not change. +- **`missions/model.ts` crossed the size ceiling by one line.** The shape of + a patron and a dossier, which the machine never reads, left it for + `missions/words.ts`. Fourteen importers name the new home, and the + generated `dossiers/index.ts` names it through its generator. +- The suite has 6,029 checks. + +## Outcome + +213 landed on 2026-09-13. A hit trader goes back to work after twenty calm +seconds, the escort course closes gently, and a course refuses a target +below the planet. The escort finishes eight of eight in the flight probe, +from four. A side job comes back a week after it ends. A smuggling run +leaves its goods at the far end. Every arc is gated to galaxy 1, and a +lead keeps its galaxy. A scoop job waits for the scoops. The gun warning +speaks for the Constrictor alone. A side-job count no longer silences a +lead. The loaders drop a ghost. The journal names its galaxy, and standing +is keyed by the origin. A restored mission ship takes its leg's role, and +a branch that cannot place its next leg still settles. The lint holds a +galaxy gate, a handover from every world, and every trigger a verb can +emit. Six comments say what the code does. + +Two things the plan did not have. `constants/course.ts` crossed the size +ceiling, and the four numbers a mission's course spends left it for +`constants/mission-course.ts`. The lead announcement moved from M4 into +M2, because the relocation's removal made it wrong at once. + +Three decisions stand as the review recommended, and each is one line to +change. The tour is galaxy 1's. A side job repeats without limit. The first +arc waits for the scoops. The suite has 6,029 assertions, from 5,972. diff --git a/docs/TODO/completed/214-a-job-has-a-surprise-in-it.md b/docs/TODO/completed/214-a-job-has-a-surprise-in-it.md new file mode 100644 index 00000000..353d874a --- /dev/null +++ b/docs/TODO/completed/214-a-job-has-a-surprise-in-it.md @@ -0,0 +1,235 @@ +# 214 — A job has a surprise in it + +**Kind:** enhancement · **Severity:** high · **Size:** large · **Depends on:** +213 · **Blocks:** nothing · **GitHub:** none + +## Where we are + +[`docs/MISSIONS-FLIGHT-REVIEW.md`](../MISSIONS-FLIGHT-REVIEW.md) flew every +kind of job eight times. Every hunt ended in four to thirteen seconds. The +ship ended 95 runs of 96 alive, and the one death was a crash. Five kinds of +job resolve in under thirty seconds with no decision in them. + +Chris set the direction on 2026-09-13: *"I think the missions are too easy - +there should be surprises. Like collecting a canister triggers an ambush. Or +there are pirates waiting for you when you jump in. An escort mission should +be more difficult than just flying to the station."* + +What exists today: + +- `Leg.spawn` lists ships that wait at the leg's world, spawned on every + arrival. Two legs use it, both with `LANE_PIRATES`. +- A verb reports a `VerbReaction` with a trigger, progress and a line. The + machine turns it into effects, and the bridge applies the effects that are + the commander's. Nothing reaches the world from an effect. +- The escort's charge flies to the station on its own. The commander's + position is not measured. 213 M1 makes the charge recover from a hit. +- A hunted ship is a pirate, and a pirate never leaves the system. + +## What to do + +Four milestones. Each is one commit. + +### M1 — pirates wait at the jump-in + +`Leg.spawn` is the mechanism, and this milestone is data with a rule. Each +leg that sends the commander somewhere names who waits there: + +| leg | who waits | +| --- | --- | +| a side hunt | the Krait and one wingman | +| a side deliver | two light pirates, because somebody wants the packet | +| a side recover | one pirate that circles the canister | +| a side scan | nobody; the job is to be quiet | +| an arc hunt | the target and two of its kind | +| an arc deliver | two pirates on the far end | + +A spawn list is outside the dossier hash, so no dossier changes. The words +say nothing of it. That is the surprise. + +### M2 — a scoop springs an ambush + +- `Leg.ambush` names ships that jump in when the leg makes progress. That + is a scoop for a recover or a rescue, and the scan's end for a scan. +- The verb's reaction carries `sprung: true` on that step. The machine + emits a `spawn` effect with the ships. The bridge hands the effect back + to its caller as a spawn order, because the bridge has no world. +- Two callers can hear it: the scoop in `world-step.ts`, and the scan's + end. Each spawns the ships around the commander at `PIRATE_WAVE_RANGE`. + The console says THEY WERE WAITING FOR THE CANISTER. +- The recover, the rescue and the scan side jobs get an ambush. The Lave + arc's ledger and the Vetitice arc's pod get one. + +### M3 — an escort is the commander's to keep + +- **The charge keeps to the commander.** It moves while she is within + `ESCORT_LEASH` of it, and holds where it is when she is not. The console + says THE PYTHON IS HOLDING FOR YOU once. +- **The fee needs her there.** `escortSafe` also needs the commander within + `DOCK_COMPUTER_RANGE` of the charge. +- **Pirates come for the charge.** The escort leg spawns a pack half way to + the station, on the charge's line, with `job: 'hunt'`. A pirate preys on + a trader when the commander is out of reach, so a commander who stays + close draws the fight to herself. + +### M4 — the hunt is a chase + +- **A pirate can leave.** A hunted ship below `HUNT_FLEE_ENERGY` runs for + the edge of the system as a trader does, and jumps out at + `TRADER_JUMP_OUT`. The world already sends `fled` for it. +- **The hunt course pursues.** The fight of 206 already holds the target. + A target that runs is a target the pilot chases at full throttle. +- **The dead branches go live.** `targetFled` on the three arcs, and the + `ignores` on the side hunt from 213 M5 becomes a branch, which needs one + dossier regeneration. + +## Decisions already made + +- **Surprises: an ambush on a scoop, pirates at the jump-in, and a harder + escort** (Chris, 2026-09-13). +- **The words never tell the surprise.** A dossier is words, and the machine + never reads one. A spawn list is rules, and the dossier never reads it. +- **The verb decides, and the machine applies** (invariant 15, one level + down). A verb reports `sprung`; the machine names the effect; the + orchestrator spawns. + +## Open questions + +- **How many pirates is a surprise, and how many is a wall?** M1 starts + small, and the flight probe measures the death rate after each milestone. + A side job should kill a fresh commander in about one run in eight, and + no more. +- **Does M4's regeneration wait for Chris?** Yes. The generator runs through + the `claude` command line on his machine. + +## Watch out for + +- **The seeded stream.** A spawn at a scoop draws positions from the world + stream, as a pirate wave does. Draw once per ship, in a fixed order. +- **A spawn order is not a message.** The bridge returns messages today. A + second return, or a field on the message, must not make a message + carry a world change. +- **The lane pirates are untracked**, and so are these. A kill among them + settles nothing. Say so in the model. + +## Verification + +The gates always run: `npm run check`. + +The tier: a rule that changes how a mission and a fight go. The flight +probe runs after each milestone. The numbers to move: + +- a hunt's time to done, from 4 seconds toward a minute; +- the escort's finish rate, to eight of eight, with fights on the way; +- the death rate on a side job, to about one in eight; +- a scoop that springs an ambush, seen in the probe's fight count. + +Evidence: + +- A real-game test scoops the recover canister and sees the ambush spawn. +- A real-game test parks the commander and sees the charge hold. +- A verb test sends `fled` to a hunt with a branch and reads the branch. +- Chris plays one side job of each kind on his phone. + +## What the milestones found + +### M1 + +- **The company lives in `skeletons/lane.ts`**, beside the lane pirates: + a pair of Sidewinders, a lone Krait, and a wingman of the target's own + design. A side hunt flies with a Krait wingman. A delivery meets the pair + at the far end, and a canister adrift has the Krait circling it. The arc + hunts fly with one of their kind, and the arc deliveries meet the pair. +- **The lint holds a spawned ship to a roster row.** A design with no row + for its job's role was skipped at the arrival in silence. +- **The flight probe moved.** A side hunt takes 7 seconds, from 4, and an + Asp hunt 24, from 12. The Asp pair killed a fresh commander in one run of + eight, which is the rate the open question asked for. A delivery meets + 2.5 fights on the way in, from 1.4. Every other row is as it was. +- **Two tests read the first tagged ship and met the wingman.** Both read + the leg's own tag now. + +### M2 + +- **The spawn of a tagged ship has one home**, `spawnTaggedShips` in + `spawning.ts`. The arrival calls it at the mission target's reach, and + the ambush calls it at a pirate wave's reach. +- **The bridge returns two things now.** `applyMissions` returns the lines + and the spawn orders, and `runMissions` is the lines alone. Two callers + hold the world and hear the order: the scoop, and the scan's end. +- **An ambush line queues behind the settlement's own.** THE CANISTER IS + ABOARD reads first, and THEY WERE WAITING FOR THE CANISTER after it. + PIRATE SIGNATURES DETECTED is said at once. +- **A laser held on a canister breaks it.** The real-game test flies the + scoop with no trigger. The flight probe fires only while the computer + aims, and that is the rule a pilot follows too. +- **The flight probe gained two cases** that go on to the station after + the scoop, so the ambush is met. A recover meets 2.9 fights on the way + home, from 1.4, and finishes eight of eight in 149 seconds. A rescue + meets 1.4 and docks eight of eight. Nobody died. + +### M3 + +- **The charge holds for her.** `holding` on the ship is decided by the + world step each frame, from `ESCORT_LEASH`, and the working life reads it + as a speed of zero. The console says THE PYTHON IS HOLDING FOR YOU once, + through a second latch. The leash derives from the scanner, two thirds of + it, so a charge that holds is still on her scanner. +- **The fee needs her there.** `escortSafe` asks four things now: the + charge alive, inside station range, the commander inside that range of + it, and no enemy inside it. +- **The pirates come with the charge.** A side escort meets a pair of + Sidewinders at the jump-in, and the two arc escorts meet the lane pirates. + A pirate goes for the commander first, and the leash keeps her beside the + charge, so the fight is where the fee is judged. +- **The flight probe moved.** The escort finishes eight of eight in 500 + seconds, with 1.5 fights on the way, from 0.3, and 24 kills over the + eight. The ship ends with energy 239 and shields 225 and 255. +- **`test/mission-verbs.test.ts` crossed the size ceiling.** The company + blocks, and the lane block from 203, went to + `test/mission-company.test.ts`. The four helpers every verb test uses + went to `test/fixtures.ts`, so the two files share one fixture. +- **M4 waited for Chris.** A branch for a hunted ship that runs needs one + dossier regeneration, and the generator runs through the `claude` + command line on his machine. M1 to M3 stand on their own. + +### M4 + +- **The run is one rule in `takeDamage`.** The world step stamps `canFlee` + on the target of a hunt that `canEscape`, each frame, so a restored ship + carries it again at once. A hit that leaves the target under + `HUNT_FLEE_FRACTION` of its energy sets the run. The fighter behaviour + flies it, straight away from the shot. The ship jumps out + `TRADER_JUMP_OUT` short of a waypoint `DEEP_TRADER_RUN` out. Nothing ends + the run. +- **The chase is the co-pilot's.** The mission course picks the target, so + the computer's aim engages at once, and its throttle matches a target + that recedes. The course pilot chases at full speed only while no + co-pilot flies. The row says CHASE, and the console says THE KRAIT IS + RUNNING FOR IT once. +- **The run makes no difference to the numbers.** A Krait has 82 energy + points, and the laser reaches 3,500 units. The target spawns 2,500 out, + so it is under the gun from the first frame. With the trigger held, it + turns to run at three seconds and dies at 3.8, at a quarter or at half. + A target that ran from the first frame died at three seconds, 1,700 + units out. The side hunt still takes 7 seconds in the probe, as after + M1. The results are in + `docs/reviews/missions-2026-09-12/flight-results-after-214-m4.txt`. +- **So a chase needs a target the gun cannot reach at the arrival**, or one + that lives longer than four seconds of fire. Both are balance decisions + outside this plan: the spawn range of a hunt's target, or its hull. The + rule stands, and the branch is live. A pilot who lets go of the trigger, + or who fights the wingman first, loses the bounty. +- **Six constants gained a rule id**, because the new fraction repeats a + quarter five times over. +- **The side hunt's dossier was written again**, because the branch is in + its hash. Chris ran the generator through `claude -p` with Sonnet 5. + +## Outcome + +Landed 2026-09-13, in four milestones. Pirates wait at the jump-in, a +scoop springs an ambush, an escort keeps its charge to the commander, and +a hunt's target runs when it is nearly dead. The run moved no number on +its own, because a lone Krait dies inside the laser's reach either way. +Plan 217 made the hunt a gang, and the side hunt's dossier was written +again on the same run. 6,153 assertions. diff --git a/docs/TODO/completed/215-the-gun-row-lives-in-the-console-and-a-list-runs-sideways.md b/docs/TODO/completed/215-the-gun-row-lives-in-the-console-and-a-list-runs-sideways.md new file mode 100644 index 00000000..d27ff7d6 --- /dev/null +++ b/docs/TODO/completed/215-the-gun-row-lives-in-the-console-and-a-list-runs-sideways.md @@ -0,0 +1,202 @@ +# 215 — The gun row lives in the console, and a list runs sideways + +**Kind:** enhancement · **Severity:** high · **Size:** large · **Depends on:** +nothing · **Blocks:** nothing · **GitHub:** none + +## Where we are + +Chris flew the game on his phone and said, on 2026-09-13: *"the buttons +cover up the area where all the action is happening"*. The flight view +carries three columns of buttons over the sky. The course list sits top +right, the target list bottom left since the same day, and the guns bottom +right. On a phone each column is 46% of the width. What is left for the +fight is a window about 390 by 330 pixels in the middle. A screenshot of the +arrival at Diso, at a phone's size, showed the planet under a button. + +Chris drew the shape he wants, the same day, and this plan builds it: + +- the sky holds two icons only, the course top right and the targets bottom + left, and each opens its list on a tap; +- the guns are a row at the bottom of the console. They are FIRE LASER, + the missile as two buttons, ARM or DISARM and FIRE, and the E.C.M.; +- the missile squares go, because the count is on the arm button; +- the S and E letters sit beside the compass; +- the right gauges sit under the compass, and the left gauges and the + shields share the row below, beside a scanner two rows tall. + +What exists today. `cockpit-buttons.ts` builds three columns from one +source. `hud-buttons.ts` paints a column into an element. The shell listens +for taps on the columns by id, and a held button and the roll strip listen +on the whole document. The console is `#hud`, a fixed row of three panels +on the desktop and a two-column grid on a phone, with `display: contents` +on the panels. The shell measures the console's height at each resize and +writes it to `--console-h`. + +## What to do + +Three milestones. Each is one commit. + +### M1 — the gun row in the console + +- `#guns` is a fourth child of `#hud`, under the three panels, on both + layouts. The three panels gain a wrapper row, `display: contents` on a + phone, so the desktop console can be a column of the panels and the row. +- `gunButtonsFor` replaces `actionButtonsFor`. The row is four buttons in + flight, always in the same places. FIRE LASER is held. ARM A MISSILE or + DISARM reads by the missile's state, with the count as its hint. FIRE THE + MISSILE is lit on a lock. E.C.M. is lit while a missile is inbound. A + button the ship cannot use stays and says why: NONE LEFT, NOT ARMED, NOT + FITTED. A stable row is one a thumb learns. +- During the pilot's stretch into the slot the row holds the stretch's own + buttons, as the column did. They are LINING UP, and then the roll strip, + THRUST, BRAKE and the fitted docking computer. +- The `#actions` column and the missile squares go. The S and E letters keep + their place beside the compass. +- The phone grid follows the mock-up. The scanner spans the rows the four + right gauges take. The compass and the letters sit above them. The left + gauges and the shields share the rows below. +- `--console-h` is measured from `#hud`, so the row is inside it and the + console lines and the two icons stand on it. + +### M2 — the lists fold to headers, and open as one row that scrolls + +Chris refined this the same day, after the gun row landed: *"this could be +an expanding button that is collapsed by default"*, and *"the expanding +target and fly to buttons could actually be horizontal and scroll"*. + +- The course column folds to one header, top right: ACTIONS with a chevron, + closed by default. While a course flies the header says the course, lit, + and FAST FORWARD is a second header beside it, one tap. An offer the + situation raises stays a button under the header, because an offer is + urgent. +- The target column folds to one header, bottom left: TARGETS with the count + and the chevron. +- A tap on a header opens THE ROW. It is one line of buttons beside the + header, the full width, and it scrolls sideways with a snap to each button. + The course row holds the courses, and the target row holds the ships and + LET THE COMPUTER CHOOSE. A pick folds the row, and so does a tap on the + open header, whose chevron points up while it is open. +- One row at a time. A tap that opens one folds the other. +- `course-actions.ts` and `target-actions.ts` each hold the open flag, as + the target list does today. No save carries it. + +### M3 — the desktop keeps the same layout + +- One layout, one set of tests. The desktop gets the console row and the + two icons, with the key names as hints on the buttons. +- The manual's flight page says where the guns are now. + +## Decisions already made + +- **The guns go to the bottom** (Chris, 2026-09-13: *"I think the buttons + should be at the bottom"*). +- **The missile is two buttons, arm or disarm and fire** (Chris, 2026-09-13). +- **The missile squares go** (Chris, 2026-09-13: *"We could actually drop + the squares"*). +- **Two icons over the sky, and each expands into its list** (Chris's + mock-up, 2026-09-13). +- **The headers are closed by default, and a list opens as one row that + scrolls sideways** (Chris, 2026-09-13). + +## Open questions + +- **Does the desktop get the same?** Yes. A desktop screen has room either + way, and one layout is one set of tests. +- **Where does fast forward go?** A second header beside the course + header, shown while a course flies. It stays one tap. +- **What does the row show for an E.C.M. that is not fitted, or a rail with + no missile?** The button, dim, with the reason. A row that changes shape + is a row a thumb cannot learn. + +## Watch out for + +- **`#hud` takes no pointer events** on the desktop, so the row must take + its own. +- **A held button and the roll strip listen on the document**, so the row + needs no wiring for those. A tap does: the shell lists the columns by id. +- **`HudFrame` and `HudState` carry the columns by name**, and two tests + build a frame by hand. +- **The manual is Chris's page.** M3 changes one sentence of fact, and says + so. +- **The phone frame is an iframe.** The browser here cannot shrink to a + phone. So each milestone is checked in an iframe at 390 by 844, driven + through the game's own handle, and then on Chris's phone. + +## Verification + +The gates always run: `npm run check`. + +The tier: prose, comments, markup and a plan doc, and the flight's buttons. +Nothing here changes a rule of flight or a fight. + +Evidence: + +- A test reads the four gun buttons in flight, in order. The arm button's + label follows the missile's state, and the count is its hint. +- A test reads the stretch's buttons in the same row during the slot. +- A real-game test sends the row's codes and sees a missile arm, disarm and + fire, and the laser held. +- A test reads the two headers closed, one row open, and the other folded + by the tap that opened it. +- A screenshot of the arrival at a phone's size, from the iframe, before and + after each milestone. +- Chris flies it on his phone. + +## What the milestones found + +### M1 + +- **The row is four buttons in the same places, always.** A button the ship + cannot use stays and says why: NONE LEFT, NOT ARMED, NOT FITTED. The + fight-button test reads the row in order, and the dock-trial test reads + the stretch's buttons in the same row. +- **The console grows by the row.** On the desktop it is 249 pixels, and on + a phone 267. The shell measures `#hud`, so the console lines and the two + columns over the sky stand on the new height with no change. +- **The phone grid follows the mock-up** with explicit rows. The scanner + spans five. The four right gauges take rows two to five beside it. The + six left gauges split three and three across the two columns below. +- **The missile squares are gone**, with their painter and four style + rules. The S and E letters sit beside the compass. + +### M2 + +- **One fold serves both lists**, `game/list-fold.ts`. It holds which list + is open, or neither, and a tap that opens one folds the other. The target + list's own flag went into it. No save carries it. +- **The run stays under the header.** A list closed by default would hide + RUN FOR IT behind a tap, and a pilot in a fight has no time for one. The + run and the offers sit under the header at all times, and the row holds + the rest. +- **A row scrolls sideways with a snap**, the full width, with the + scrollbar hidden. The course row sits under its header at the top. The + target row sits above its header at the bottom. So cause and effect stay + close. On a phone the second course button is cut at the edge, which is + what says the row scrolls. +- **The chart is a row item.** LOCAL CHART left the header column for the + end of the course row. +- **Three tests read the list through its header now**, and one reads the + run by name rather than by place. + +### M3 + +- **The desktop had the layout from M1 and M2 already**, because both + milestones built one layout. The key names sit under the buttons that + have a key, as they did. Nothing changed in code. +- **The manual's Trouble page says where the guns are**, that the missile + is two buttons, and that TARGETS and ACTIONS each open a row. That is + Chris's page, and the sentence is a fact about the screen. + +## Outcome + +215 landed on 2026-09-13, the day Chris drew it. The guns are a row at the +bottom of the console, always four buttons in the same places, with the +missile as two. The missile squares are gone. The two lists over the sky +are two headers, closed by default, and each opens as one row that scrolls +sideways. A tap that opens one folds the other. The sky is the sky, with +two small marks on it. The desktop has the same layout. The suite has +6,072 assertions, from 6,061. + +What the plan did not have: the run had to stay under the course header. +Without that, a fight would start with RUN FOR IT behind a tap. The plan's title +changed once, because the title gate knows no verb "fold". diff --git a/docs/TODO/completed/216-every-docked-screen-carries-a-button-for-each-key-it-reads.md b/docs/TODO/completed/216-every-docked-screen-carries-a-button-for-each-key-it-reads.md new file mode 100644 index 00000000..877f58a7 --- /dev/null +++ b/docs/TODO/completed/216-every-docked-screen-carries-a-button-for-each-key-it-reads.md @@ -0,0 +1,143 @@ +# 216 — Every docked screen carries a button for each key it reads + +**Kind:** enhancement · **Severity:** high · **Size:** medium · **Depends on:** +nothing · **Blocks:** nothing · **GitHub:** none + +## Where we are + +Chris asked, on 2026-09-13: *"I need you to review all the docked screens - +make sure there are buttons for everything so that it will work on +mobile."* A phone has no keyboard. `ui/screen-host.ts` turns a tap on a +`data-key` button into the keystroke it names, so a button is how a phone +presses a key (invariant 13). A tap on a `data-row` row selects that row. + +The review read every screen's `input()` and every painter's buttons. Most +screens are complete. Fourteen screens carry a button or a row for every +key they read. They are the station menu, the courses, the market, the +equipment, the contracts, the missions and the saves. They are also the +briefing, the survivors, the status, the data, the log, the quit and the +game over. Six places do not: + +- **The three name screens.** A save's name, a rename and a new commander + each read typed letters through `Input.drainPresses`. No button sends a + letter. The rename screen carries no button at all, not even ESC. +- **The charts.** F starts a search by typed letters, and M opens the + market estimate. Neither has a button, and the search has no letters. +- **The test mode.** A row's click steps it forward. Nothing steps it back. +- **The trainer's setup.** Left and right change a value. R rerolls the + seed. HOME and END jump to the ends of a list. None has a button. +- **The trainer's report.** Left and right walk the records. ENTER opens a + comparison. Shift with X exports every record. None has a button. +- **The trainer's comparison.** Left and right walk the other record. No + button. + +## What to do + +Three milestones. Each is one commit. + +### M1 — a key grid types a name + +- `ui/key-grid.ts` paints a grid of buttons. Each carries the key code the + screens already read: `Digit1` to `Digit0`, `KeyA` to `KeyZ`, `Space` and + `Backspace`. Ten columns, so the digits are one row and the alphabet is + three. No screen changes, because a tap is the keystroke. +- The save prompt, the rename and the new commander each paint the grid + above their button row. The rename gains ENTER and ESC buttons. +- The stylesheet gives the grid its face, and more height under a finger. + +### M2 — the charts find and estimate by a button + +- The chart's button row gains FIND A SYSTEM and MARKET ESTIMATE. +- Each chart holds an empty element for the grid. The screen fills it with + the letters when the search starts, and empties it when the search ends. + The search's grid carries DEL and ENTER in place of SPACE, because ENTER + ends the search and keeps the cursor on the match. + +### M3 — the levers and the trainer step by a button + +- A button that carries both `data-row` and `data-key` selects its row + first, and then sends its key. So a row can carry its own arrows. +- The test mode's rows and the trainer's setup rows carry a left and a + right arrow beside the value. +- The trainer's setup gains R for a random seed. The report gains the two + arrows, ENTER for a comparison, and Shift X for every record. The + comparison gains the two arrows. +- The report reads the modifier from the tap, as the market does, so the + Shift X button works. + +## Decisions already made + +- **Buttons, not a text field** (the house rule since docs/TODO/55). A text + field needs focus, and every screen repaints on each keystroke. A grid of + buttons is the same path a key takes. +- **HOME and END get no button.** They are a bigger step of the same act, + and the arrows reach every value. +- **The desktop paints the grid too.** One layout is one set of tests. + +## Open questions + +None. + +## Watch out for + +- **`typeToFind` drains every press in a frame**, ESC included. So a tap on + DONE while a search runs ends the search, and a second tap leaves the + chart. That is how the keyboard behaves today. +- **`redraw()` runs on every cursor move.** The grid is written once when + the search starts, and once when it ends, not on each move. +- **`combat-sim-panel.test.ts` reads the trainer's hints by regex.** The + hints stay as they are. +- **The phone frame is an iframe.** Each screen is checked at 390 by 844, + driven through the game's own handle. + +## Verification + +The gates always run: `npm run check`. + +The tier: markup, a click route and a plan doc. Nothing here changes a rule +of the game. + +Evidence: + +- A test paints each name screen and reads a letter, a digit, SPACE, DEL, + ENTER and ESC as buttons. +- A test paints each chart and reads FIND and MARKET as buttons. It starts + the search and reads the letters in the grid, and ends it and reads none. +- A test paints the test mode and the trainer's setup, and reads a left and + a right arrow on each row, each with its row. A test paints the report + with two records and reads the arrows, ENTER and Shift X. A test paints + the comparison with three records and reads the arrows. +- A test clicks a button with a row and a key, and sees the row selected + and the key sent. + +## What the milestones found + +**M1 (2026-09-13).** The grid is 36 buttons in ten columns, and it fits a +390 pixel frame at 34 pixels a key. A tap on K, I, 7, SPACE, M and DEL +typed `KI7 ` in the frame, and ESC kept the old name. The rename screen +had no button at all before this, so a phone could not leave it. + +**M2 (2026-09-13).** The first shape put the grid under the button row, +and the row is sticky on a phone. It covered the grid. The row also reads +as letters while the search runs, so a tap on DATA typed a D. So the row +hides while the search runs, and the grid sits under the FIND line. A tap +on F, L and E put the cursor on the first name that starts with LE, and +ENTER kept it there. + +**M3 (2026-09-13).** A button with a row and a key selects the row first. +That is one change in `ScreenHost.click`, and both lists share one +painter for the two arrows. HOME and END keep no button. The trainer's +report reads the modifier from the tap, as the cockpit does, so the +shifted X button exports every record. + +**After the gate.** The key grid put `screens/chart.ts` at 414 lines. The +search moved to `screens/chart-search.ts`, and the chart keeps the cursor. +The two alphabets in the grid took lower-case names, because the constants +gate read them as rules. + +## Outcome + +Landed 2026-09-13, in four commits. Every docked screen carries a button +for each key it reads. The three name screens and the chart's search type +on a key grid. The charts find and estimate by a button. The test mode +and the trainer step by arrows on each row. 6,114 assertions. diff --git a/docs/TODO/completed/217-the-side-hunt-is-a-gang-and-the-board-gates-the-hard-jobs.md b/docs/TODO/completed/217-the-side-hunt-is-a-gang-and-the-board-gates-the-hard-jobs.md new file mode 100644 index 00000000..4bf0b22c --- /dev/null +++ b/docs/TODO/completed/217-the-side-hunt-is-a-gang-and-the-board-gates-the-hard-jobs.md @@ -0,0 +1,182 @@ +# 217 — The side hunt is a gang, and the board gates the hard jobs + +**Kind:** enhancement · **Severity:** high · **Size:** medium · **Depends on:** +214 · **Blocks:** nothing · **GitHub:** none + +## Where we are + +Plan 214 M4 made a hunt's target run when it is nearly dead, and it +measured the result. A Krait has 82 energy points and spawns inside the +laser's reach. With the trigger held it dies four seconds after the fight +starts, whether it runs or not. The side hunt is a four second job. + +Chris said, on 2026-09-13: *"rather than a single ship - it should be a +gang of pirates. That will be more of a challenge and also, more +realistic."* Then: *"Let's do it. I think we should make it a tough gang. +I'm also wondering if we should start gating side quests by +equipment/number of kills?"* + +What exists today. A hunt leg names one ship, and the machine mints one +entity for it. The leg's `spawn` list adds untracked company, which comes +back on every arrival. A kill among them settles nothing. The board hides +a job whose gate is shut. A gate can ask for kills, a rating, a clean +record, a galaxy, fuel scoops or a finished skeleton. Every side job but +the two scoop jobs is open to a fresh commander. + +## What to do + +Three milestones. Each is one commit. + +### M1 — the hunt counts a gang + +- The hunt verb gains `gang`, a list of hulls that fly with the target. + The machine mints an entity for each at the leg's start, under the leg's + own tag, so a dead member stays dead across an arrival. The arrival + spawns the members that live, from the record, as it spawns the leader. +- The verb counts the gang. A kill of the leader or of a member is one + ship gone, and the console says how many are left. The leg ends when + every ship is gone. A leader that dies ends it with `targetDestroyed`. + A leader that ran ends it with `targetFled`, once the gang is gone too. + The machine marks a ship that fled or jumped out as gone from the + record, and it says which. So the verb can read the leader's fate. +- The side hunt is a Fer-de-Lance with an Asp, a Krait and a Mamba. Four + hulls, two missiles, two E.C.M. fits, and 372 energy points against the + lone Krait's 82. M1 built it with a Cobra Mk III in the Krait's place, + and M3's probe made the choice. The leader runs at a quarter, as + M4 made it. The whole gang pays 1,500 Cr. A gang whose leader ran pays + half, and the console says the leader got away. +- The course row says HUNT THE GANG with the count left. Its target is + the leader while it lives, and then the nearest member. +- The dossier prompt names the gang, so the prose can too. + +### M2 — the board gates the hard jobs + +- The gang hunt asks for eight kills. The lane job and the escort ask for + four. A fresh commander sees the delivery, the scan, the smuggle and, + with scoops, the two scoop jobs. +- A test walks a fresh commander's board and a blooded one's. + +### M3 — the numbers, and the words + +- The flight probe flies the gang, and the plan records the time to done + and the death rate. The probe's commander is fresh, so the rate is the + worst case. +- Chris writes the side hunt's dossier again. Plan 214 lands on the same + run. + +## Decisions already made + +- **A gang, not a lone ship** (Chris, 2026-09-13). +- **A tough gang** (Chris, 2026-09-13). The hulls are the roster's, at the + top of the pirate list short of the Python. +- **The gang is the Asp, the Krait and the Mamba** (Chris, 2026-09-13), + from three the probe measured. See M3. +- **The job ends when the lane is clear.** A kill of the leader alone does + not end it, because pirates still shoot when the station pays. +- **A leader that ran halves the pay.** It keeps M4's surprise, and it + does not throw away three kills. +- **A shut gate hides the job**, as the Navy's does. A visible row that + says why it is shut is a better board, and a later plan. + +## Open questions + +- **An equipment gate?** The facts a gate reads carry the scoops alone. A + laser or an E.C.M. gate needs a fact each, and a reason on the board. + Chris raised it, and this plan gates by kills first. +- **Does a fresh commander die on the gang?** Likely, and the gate is the + answer. M3 measures it anyway. + +## Watch out for + +- **The dossier hash covers the verb**, so the gang changes it. One run of + the generator at the end covers this plan and 214 M4. +- **`missionSpawns` puts the `spawn` list back on every arrival.** A gang + member must come from the record instead, or a dead one returns. +- **The entity clean-up deletes by the skeleton's prefix**, so a member's + tag must start with the leg's own tag. +- **`test/constants.test.ts` lists the skeleton names** that may hold a + constant outside `src/constants/`. +- **The world step stamps `canFlee` on the leader alone**, by the live + leg's tag. A member fights to the end. + +## Verification + +The gates always run: `npm run check`. + +The tier: a rule of the mission machine, a skeleton, and the board. + +Evidence: + +- A machine test accepts the gang hunt and reads four entities. It kills + the members in any order and reads the count said each time. It kills + the leader last and reads the full pay. It lets the leader run, kills + the rest, and reads half the pay and the leader's fate. +- A real-game test arrives on the gang hunt and reads four tagged pirates + in the sky. It kills one, leaves, comes back, and reads three. +- A test paints the course row with the count, on the leader and then on + a member. +- A test reads a fresh commander's board and a blooded one's. + +## What the milestones found + +### M1 + +- **The gang is on the verb, and the members are records.** `startLeg` + mints one entity per member under the leg's tag with `#gang-N` on the + end. So the end-of-leg clean-up finds them by the skeleton's prefix, and + `missionSpawns` lists the members that live from the record. A dead one + stays dead across an arrival, and a real-game test proves it: four at the + jump-in, one killed, three on the next arrival. +- **The verb reads the record for one thing only**, the leader's fate. The + machine marks a ship that fled or jumped out as gone and as `fled`, once + a leg took the word. The Constrictor's verb takes no such word, so the + Constrictor stays and comes back. The count itself is `progress`. +- **The record holds one mark for a ship that left.** So a leader that + jumped out early ends the leg through `targetFled`, and the words say it + ran. Only a leader that jumps out last says it jumped. Both pay half. +- **The row needs the commander's place.** `missionCourse` takes it now, + so the row can point at the nearest member once the leader is gone. +- **The prompt line changes for a gang alone.** A lone hunt's line is byte + for byte as it was, so no other dossier drifts. +- **The pay repeats the courier's 1,500 Cr**, so the courier's constant + gained a rule id. + +### M2 + +- **The gates are two constants**, derived and not typed. The gang hunt + asks for the kills of the second rung, Mostly Harmless, and the lane job + and the escort ask for half of that. So the numbers hold their reason. +- **What a fresh commander finds.** Every board holds two or three of the + eight side jobs from the seed. Under the gates, a Harmless commander + with no scoops finds 69 of the 256 boards empty. With scoops, 6. At four + kills with no scoops, 11. At eight kills with scoops, none. The three + open jobs are on 90, 73 and 60 boards each. +- **Every test and probe that accepts a gated job now carries sixteen + kills**, and says so. The shared `boardFor` fixture bloods its commander, + and a test about the gate passes its own kills. + +### M3 + +- **The probe flew three gangs**, each led by the Fer-de-Lance, against + a stock Cobra with a pulse laser. With an Asp, a Cobra Mk III and a + Mamba, the commander died six times in eight, and the two that lived + took 35 seconds. With an Asp, a Krait and a Mamba, three died, and the + five took 34 seconds. With a Krait, a Cobra Mk III and a Mamba, nobody + died, the eight took 29 seconds, and the shields ended about half down. +- **Chris chose the middle gang**, the Asp, the Krait and the Mamba. The + bar from plan 214 was about one death in eight. This gang sits over it + for a stock ship at eight kills, and the gate is one constant to move. + The results are in + `docs/reviews/missions-2026-09-12/flight-results-after-217.txt`. +- **The hunt is a fight now.** The lone Krait took 7 seconds. The gang + takes 34, and it costs energy and shields. + +## Outcome + +Landed 2026-09-13, in three milestones. The hunt verb counts a gang, and +its members are records that stay dead across an arrival. The side hunt +is a Fer-de-Lance with an Asp, a Krait and a Mamba, and a gang whose +leader got away pays half. The board gates the gang hunt, the lane job +and the escort by kills. The probe chose the gang: three deaths in eight +for a stock Cobra, and 34 seconds to done. Chris wrote the dossier with +Sonnet 5. 6,153 assertions. diff --git a/docs/TODO/completed/218-the-commander-is-they-not-she.md b/docs/TODO/completed/218-the-commander-is-they-not-she.md new file mode 100644 index 00000000..7ed2550f --- /dev/null +++ b/docs/TODO/completed/218-the-commander-is-they-not-she.md @@ -0,0 +1,95 @@ +# 218 — The commander is they, not she + +**Kind:** fix · **Severity:** medium · **Size:** medium · **Depends on:** +nothing · **Blocks:** nothing · **GitHub:** none + +## Where we are + +Chris, 2026-09-13: *"at some point we seem to have slipped into the +referring to the user as "She" "her" - I think we may have taken the +novella as fact. But the player is the player and they give the pilot a +name that can have any gender."* + +The novella is Chris's own page, and its pilot is a character with a +gender. The comments, the constants' doc comments, the reference docs, +the tests and the training tools took that pronoun for the commander. +So did the dossier prompt: it said "aboard her ship" and "the day she +took the job", and every generated dossier learned it. + +Other characters keep their pronouns. A patron, a rescued pilot, the +surveyor at Vetitice and the administrator at Xeer are people the +skeletons describe, with a gender of their own. A ship called "her" in +the nautical way stays too. + +## What to do + +One milestone, and one run of the generator by Chris. + +- The dossier prompt names the commander as the player, of any gender, + and asks for "they" and "the commander". Its version rises to 4, so + every dossier's hash moves, and one run of the generator writes all + fourteen again. +- Every comment, doc comment, test label and reference doc that calls + the commander "she" or "her" says "they" or "the commander" instead. + The sentence caps hold, so a sentence that grows splits in two. +- The archive stays as it was: the completed plans, the research, the + logs and the novella are records or Chris's own pages. + +## Decisions already made + +- **"They" and "the commander"**, never a guess at a gender (Chris, + 2026-09-13). +- **Other characters keep their pronouns.** The rule is about the player. + +## Open questions + +None. + +## Watch out for + +- **The sentence caps.** "The commander" is two words where "she" was + one, and the gate reads every comment in `src/`. +- **A test that compares a game string with HER** about a rescued pilot + compares it still. +- **The catalogue** is generated from the constants' doc comments, so it + is written again after the sweep. + +## Verification + +The gates always run: `npm run check`. + +The tier: prose alone. No rule of the game changes. + +Evidence: + +- A grep for the four words over `src/`, `test/`, `train/*.ts`, `tools/` + and the reference docs finds only lines about another character or a + ship. +- The drift gate passes on the fourteen dossiers Chris writes again. + +## What the milestones found + +- **The prompt was the root.** Five lines of the dossier prompt said + "she" and "her" for the commander, and every dossier followed them. The + prompt names the commander as the player now, of any gender. +- **About 560 lines over 128 files** said "she" or "her" for the + commander: comments, doc comments, test labels, the training tools and + five reference docs. Three agents swept them in parallel, one per + area, and two lines they kept were the commander after all: "the + station she stands at" is where the commander stands. +- **Other characters keep their pronouns.** The patrons, the rescued + survey pilot, the surveyor at Vetitice, the administrator at Xeer, and + the Python called "her" in the nautical way. The training encoder's + "she" was any defender, and it says "the defender" now. +- **One test pinned the old prose.** The scan test compared the console + line to the old dossier's exact words, so any generation broke it. It + reads the dossier's own line now, filled with the world. +- **The run cost about a dollar at batch rates**: twenty requests for + fourteen dossiers, with retries the validator asked for. + +## Outcome + +Landed 2026-09-13. The commander is "they" or "the commander" in every +comment, doc, test and prompt, and the fourteen dossiers were written +again under prompt version 4, through `claude -p` with Sonnet 5, on +Chris's word. 6,153 assertions. diff --git a/docs/TODO/completed/219-the-dark-wheel-comes-for-a-commander.md b/docs/TODO/completed/219-the-dark-wheel-comes-for-a-commander.md new file mode 100644 index 00000000..5bd048ce --- /dev/null +++ b/docs/TODO/completed/219-the-dark-wheel-comes-for-a-commander.md @@ -0,0 +1,206 @@ +# 219 — The Dark Wheel comes for a commander + +**Kind:** enhancement · **Severity:** high · **Size:** large · **Depends on:** +217 · **Blocks:** nothing · **GitHub:** none + +## Where we are + +The tour is five arcs of seventeen legs, and the fifth ends it. Plan 217 +measured the authored play at four to six hours. After it the game is +Elite's own loop: eight side jobs that repeat, and the climb to Elite. + +Chris said, on 2026-09-13: *"I think there were rumours of some secret +organisation once you started to get to the higher levels - maybe there's +another layer we can add."* He chose the old names, the first whisper at +Above Average, and a reward that no shop sells. + +The 1984 package hinted at a society of pilots that nobody admits exists, +and at a lost world. This plan writes the society: the Dark Wheel. It +finds the commander once the commander is worth finding, it tests before +it trusts, and its door is in witchspace. + +What exists. A gate can read the rating, the kills, the flags, a clean +record and a finished skeleton. A settlement can set a flag, change a +world and leave a standing spawn. A hunt counts a gang. A misjump puts the +ship in witchspace, where the Thargoids wait, and the misjump can be armed +on purpose. The rock hermits trade in flight. The status screen paints the +rating. The rungs run from Harmless to Elite. Above Average is 128 kills. +Competent is 512, and Dangerous is 2,560. + +## What to do + +Five milestones. Each is one commit. + +### M1 — the Wheel is a patron, and the lawless worlds carry its word + +- A patron kind `wheel`. Its name is THE DARK WHEEL. It has no world, no + face and no species. `patronFor` answers for it, and the missions screen + paints a patron with no face. +- A Wheel skeleton is on the board at every Anarchy and every Feudal + world, and nowhere else. The offers filter learns the kind. +- The first skeleton, `wheel-mark`, opens at Above Average. Its hail is + the whisper: A NOTE WAITS FOR YOU. NO SENDER, NO NAME. Its job is a + hunt of a gang with a named leader, at a lawless world two to seven + light years out. The gang is a Fer-de-Lance with two Asps. It pays, and it sets the + flag `wheel.marked`. +- The lint learns that a `wheel` patron needs no galaxy gate. + +### M2 — the blockade + +- A tagged ship can fly as police. The spawn's job gains `police`, and the + role table maps it. +- `wheel-blockade` follows the mark. It asks for a clean record. It is a + smuggle of five tonnes to a Corporate State world two jumps out, and + three Vipers wait at the target. A scan at the target fails it, and the Wheel + says so. It sets `wheel.trusted`. + +### M3 — a leg in witchspace + +- A placement kind `witchspace`. The leg's target is the witchspace + sentinel, not a system. The arrival after a misjump spawns the leg's + ships and items, beside the trap that is already there. The course row + works there, and the missions screen says WITCHSPACE for the world. +- `wheel-pilot` follows the blockade. One of theirs is adrift in a pod in + witchspace. Arm the misjump, find the pod among the Thargoids, scoop it, + and land the pilot alive at any station. It sets `wheel.proven`. + +### M4 — the door + +- A tagged ship can fly as a Thargoid. The spawn's job gains `thargoid`. +- `wheel-door` opens at Competent, after the pilot. The Wheel names the + place: witchspace, one more time. A Thargoid mothership waits with its + drones, tagged as a gang. Clear it, and the door opens. +- The settlement grants a fit: a new effect, `grant`, that the game + applies to the equipment. The reward is the cloaking device. A flag, + `wheel.member`, puts one line under the rating on the status screen: OF + THE DARK WHEEL. The last dossier speaks of Raxxla, and says no more. + +### M5 — the cloaking device + +- `equipment.cloak`, which no shop sells. A key and a gun row button, CLOAK, + lit while it runs. While it runs, no ship is hostile to the commander, + no missile locks on, and the energy bank drains at `CLOAK_ENERGY_PER_SECOND`. + A shot drops it, because a shot is a flare. The console says CLOAKED and + UNCLOAKED. The HUD's label says so while it runs. +- The manual gains one sentence of fact. + +## Decisions already made + +- **The old names** (Chris, 2026-09-13): the Dark Wheel, and Raxxla. +- **The first whisper at Above Average** (Chris, 2026-09-13). +- **A reward no shop sells** (Chris, 2026-09-13). The cloaking device is + the choice here. It is the one fit the 1984 game gave and never sold, + and it changes a fight rather than a number. +- **The door at Competent**, 512 kills. Dangerous is 2,560, which is fifty + hours of hunting, and the layer should be reached. +- **The trials pay credits too.** The Wheel is not poor, and a job that + pays nothing is a job a player skips. +- **The Wheel's chain is not the tour.** The tour stays five arcs, and the + arcs test keeps its count. The Wheel's skeletons live under + `skeletons/wheel/`. + +## Open questions + +- **Where the whisper reaches a commander who avoids lawless worlds.** + About a third of the worlds are Anarchy or Feudal, and a hunter meets + them. If a playtest says otherwise, the rumour can read the rating and + point at the nearest one. +- **Whether the cloak hides the ship from the police scan.** M5 says no: + a scan is a hail, not a targeting. + +## Watch out for + +- **The dossiers.** Four new skeletons need four generated dossiers, at + about seven cents each. Chris says when to run it. +- **`test/patrons.test.ts` expects every world patron in the patron + file**, and a Wheel patron is none. +- **`test/constants.test.ts` lists the skeleton names** that may hold a + constant outside `src/constants/`. +- **A witchspace target must survive a save.** The sentinel is a number + the record can hold, and the repair reads it. +- **The misjump escape jump** uses the target the misjump kept. A leg + that ends in witchspace must leave that path alone. +- **`isHostileToPlayer` is read by the threat light, the co-pilot, the + run course and the pirates.** A cloak that lies to all of them at once + is the point, and the tests must read each. + +## Verification + +The gates always run: `npm run check`. + +The tier: a new patron, four skeletons, a placement, an effect and a fit. +The flight probe flies each trial after it lands. + +Evidence: + +- A test reads the Wheel on the board at an Anarchy and not at a + Democracy, and only at Above Average. +- A machine test walks the four skeletons in order, on the happy path and + through each recovery leg. +- A real-game test arms the misjump, arrives in witchspace on the pilot's + leg, and reads the pod in the sky with the trap. +- A test grants the cloak through the machine and reads it on the ship. +- A test cloaks the ship, reads no hostile ship, reads the drain, and + reads the cloak dropped by a shot. + +## What the milestones found + +### M1 + +- **The Wheel's boards are the lawless worlds by name.** The encounters + file already had a `LAWLESS_GOVERNMENT`, and it reaches a dictatorship, + because pirate waves do. The Wheel's line is narrower, so it is a list of + the two governments' 1984 names, and not a second number. +- **A trial comes back after a failure**, as many times as its `cap` + allows, and never after a pass. The offers filter reads the last outcome + for a Wheel patron, where an arc reads any outcome as the end. +- **The whisper queues behind the Navy.** A commander with the Wheel's + rating has the Navy's kills, so the dock says INCOMING NAVY TRANSMISSION + first and the note behind it. + +### M2 + +- **The blockade's world is any world one jump out**, not a Corporate + State. A placement picks by distance, and it has no government filter. + The three Vipers at the far end are the blockade, wherever it is. +- **The parcel is a smuggle job's load**, three tonnes, and not five. + +### M3 + +- **The sentinel is a number the record holds**, and eight readers of a + target learned it: the line's slot, the deadline, the arrival's line, + the chart's marks, the course row's place, the mis-jump's spawn, the + lint, and the prompt. +- **The rescue verb's line names no gender now.** LAND THE PILOT serves + the survey pilot, the surveyor and the Wheel's own. +- **A real mis-jump on the leg finds the pod among the Thargoids**, and the + trap is untouched. + +### M4 + +- **A Thargoid is hunted under its own roster row.** The hunt verb names + the role its target flies with, and the gun warning prices it there. +- **The grant is an effect**, so the machine stays pure and the bridge puts + the fit on the ship. The cloak is a field of the equipment, and a save + from before the Wheel reads it as off. + +### M5 + +- **The cloak's draw had to beat the recharge.** A bank every thirty + seconds was under it, and the cloak cost nothing. A bank every six + seconds is about four points a second over the recharge, and a full pool + cloaks the ship for about forty five seconds. +- **Two tests used Z as a key bound to nothing.** They use X now. +- **The pools' file owns every write to a pool**, so the draw lives there, + and the five reporters that only read them moved out for the size gate. + The mission effects moved out of the model for the same gate. + +## Outcome + +Landed 2026-09-13, in five milestones. The Dark Wheel is a patron with no +world and no face. Its note reaches a commander at Above Average on the +lawless boards. Four trials follow: a gang with two Asps, a parcel past +three Vipers on a clean record, a pod among the Thargoids in witchspace, +and a Thargoid at the door at Competent. The door grants the cloaking +device, which no shop sells, and the status screen says OF THE DARK +WHEEL. The four dossiers follow on a generator run. 6,159 assertions. diff --git a/docs/TODO/completed/220-the-phone-holds-its-zoom-hides-its-bar-and-reads-the-chart.md b/docs/TODO/completed/220-the-phone-holds-its-zoom-hides-its-bar-and-reads-the-chart.md new file mode 100644 index 00000000..77342e45 --- /dev/null +++ b/docs/TODO/completed/220-the-phone-holds-its-zoom-hides-its-bar-and-reads-the-chart.md @@ -0,0 +1,98 @@ +# 220 — The phone holds its zoom, hides its bar, and reads the chart + +**Kind:** fix · **Severity:** medium · **Size:** small · **Depends on:** +nothing · **Blocks:** nothing · **GitHub:** none + +## Where we are + +Chris, 2026-09-13, from his phone: *"Can we prevent zoom by double +tap/pinch. Hide the address bar. And increase the fonts in the local +chart."* + +Three faults, one page. A double tap on a button zooms the page, and a +pinch on the sky zooms it too, so the console lands off the screen. The +browser's address bar takes the top of the view and comes and goes with a +scroll. The short range chart draws its names at ten pixels on a canvas of +560. The phone shows that canvas at about 370, so a name is under seven +pixels tall. + +## What to do + +One milestone. + +- The viewport meta says no user scale. The root takes `touch-action: + pan-x pan-y`, which refuses the pinch and the double tap where the + browser honours it. Where it does not, the shell refuses the gesture + itself: a `gesturestart` and a scaled `touchmove` are cancelled. The + buttons keep their own `touch-action: none`. +- A web app manifest with `display: standalone`, and the two meta tags a + phone reads to open the page as an app from the home screen. On a phone + that allows it, the first touch in the page asks for fullscreen, so the + bar goes in the browser too. The request is made only on a coarse + pointer, and a refusal is silent. +- The chart's names are drawn in CSS pixels. The painter reads the ratio + of the canvas width to its width on the page. It scales the font, the + dot and the offset by that ratio. A canvas with no page width, as under node, + scales by one. The name size is one constant, twelve pixels. + +## Decisions already made + +- **A constant name size on every screen**, rather than a phone size and + a desktop size. The desktop gains two pixels. +- **Fullscreen on the first touch, on a coarse pointer alone.** A desktop + click never asks for it. + +## Open questions + +None. + +## Watch out for + +- **The iPhone ignores the viewport's no-scale** since iOS 10, and it + honours `touch-action` for a pinch only in part. The gesture listeners + are the fix that holds there. +- **`requestFullscreen` does not exist on the iPhone.** The manifest and + the meta tags are the fix that holds there, once the page is on the home + screen. +- **The reticle canvas and the chart canvases** share the page's touch + rules. Nothing on them pans, so `pan-x pan-y` costs nothing. + +## Verification + +The gates always run: `npm run check`. + +The tier: markup, a listener and a painter's size. No rule changes. + +Evidence: + +- A test reads the viewport meta, the manifest link and the app meta tags + in both pages, and the manifest as JSON with `standalone`. +- A test paints the chart under a canvas shown at half its width and + reads the font at twice the constant. Under no page width it reads the + constant. +- Checked by eye in a 390 by 844 frame. + +## What the milestones found + +- **The names at twelve CSS pixels are nineteen canvas pixels on a phone**, + where the canvas of 560 shows at 350. At that size a name at the right + edge ran off the canvas, so a name that would not fit sits to the left + of its dot. The recording canvas measures nothing, and then every name + sits right, so the test reads the font and not the side. +- **The double tap window is the platform's number**, so it is on the + threshold gate's list with that reason, and not in `src/constants/`. +- **Two other constants at twelve gained a rule id**, because the + constants gate asks for one on every repeated value. +- **The recording canvas keeps the font** beside its two colours now, so + a test can read what size a name was drawn at. +- **The boot line went too.** Chris asked, on the same day: *"Can we get + rid of that 'press ? For controls' message?"* A phone has no ? key, and + the station menu carries the guide and the layout toggle as rows. + +## Outcome + +Landed 2026-09-13, in one milestone. A phone keeps its zoom on a double +tap and a pinch, opens the page from the home screen with no address bar, +asks for the whole screen on the first touch where it can, and reads the +short range chart's names at twelve pixels. Checked in a 390 by 844 +frame, and on Chris's phone next. 6,153 assertions. diff --git a/docs/TODO/completed/221-the-fast-forward-button-is-an-icon.md b/docs/TODO/completed/221-the-fast-forward-button-is-an-icon.md new file mode 100644 index 00000000..ad6d11ee --- /dev/null +++ b/docs/TODO/completed/221-the-fast-forward-button-is-an-icon.md @@ -0,0 +1,61 @@ +# 221 — The fast forward button is an icon + +**Kind:** fix · **Severity:** low · **Size:** small · **Depends on:** +nothing · **Blocks:** nothing · **GitHub:** none + +## Where we are + +Chris, 2026-09-13, from his phone: *"The fast forward button is very big - +let's just have it as an icon."* The button is a second header under the +course header while a course flies. It carries the words FAST FORWARD, a +hint under them, and the reason it cannot start when it cannot. On a phone +that is a bar as wide as the course header. + +## What to do + +One milestone. + +- A button can be an icon: a glyph, small, at the right under the header, + with no hint and no note. The painter gives it a class, and the + stylesheet its size. +- The fast forward button is two arrows. It is lit while it runs, and dim + while it cannot start. The console already says why on the press. +- The briefing and the manual name the icon where they named the words. + +## Decisions already made + +- **An icon, and no words** (Chris, 2026-09-13). + +## Open questions + +None. + +## Watch out for + +- **The briefing test lists the buttons it teaches by their words.** The + icon takes the words' place in that list. + +## Verification + +The gates always run: `npm run check`. + +Evidence: + +- A test reads the icon beside the course header, with no hint under it. +- Checked by eye in a 390 by 844 frame. + +## What the milestones found + +- **An icon is a button with two more flags**, `icon` and `dim`. The note + used to carry the refusal and dim the button at once. An icon has no + note, so `dim` says the button refuses, and the console says why. +- **The glyph is a lower-case name** in the buttons file, because the + constants gate reads an upper-case one as a rule. +- **The briefing test lists the buttons it teaches**, and the glyph took + the words' place in it. + +## Outcome + +Landed 2026-09-13, in one milestone. The fast forward button is two +arrows, small, under the course header, lit while it runs and dim while +it cannot start. 6,159 assertions. 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/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 new file mode 100644 index 00000000..087f6bea --- /dev/null +++ b/docs/TODO/completed/223-the-pages-say-what-the-game-is-and-leave-the-history-to-the-logs.md @@ -0,0 +1,131 @@ +# 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. + +**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. + +**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 + +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 5d6964a9..877903af 100644 --- a/docs/TODO/completed/README.md +++ b/docs/TODO/completed/README.md @@ -214,3 +214,22 @@ 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. +- [x] 206 — [The computer flies the fight, and the pilot fires](206-the-computer-flies-the-fight-and-the-pilot-fires.md) — Chris, 2026-09-11. **M1** is the target list and the pick. **M2 and M4** give every pilot the aim, and the bought computer the trigger. **M3** is the pilot's buttons. **M5** is the run and the offers. **M6** is mining and collecting. 5,804 assertions. +- [x] 207 — [The pilot takes the ship into the slot by hand](207-the-pilot-takes-the-ship-into-the-slot-by-hand.md) — Chris, 2026-09-11. He played four concepts and picked Match the spin. **M1** hands the last 1,500 units to the pilot. **M2** is the roll strip and the throttle buttons. **M3** is the slot's speed limit. **M4** is the guide. 5,820 assertions. +- [x] 208 — [Every kind of mission has a way to fly it](208-every-kind-of-mission-has-a-way-to-fly-it.md) — Chris, 2026-09-12. **M1 and M2** give each verb a course, in the job's own words. **M3** mends the scoop trap and two faults in the hunt. **M4** is the smuggling run wide of the police. **M5** is what a derelict's scan reports. 5,845 assertions. +- [x] 209 — [The torus drive says what stopped it](209-the-drive-says-what-stopped-it.md) — Chris, 2026-09-12. He flew to the station and saw no mass lock on a neutral trader. The rule did fire. **M1** makes the drop name what stopped the drive, because "MASS LOCK" named nothing. **M2** announces a neutral trader that comes close, one time, because the drive is off for most of a trip. 5,871 assertions. +- [x] 210 — [The ship does not roll all the way there](210-the-ship-does-not-roll-all-the-way-there.md) — Chris, 2026-09-12. He flew a course and saw the ship rotate all the way to its target. The steering held a cone: the nose circled the target, and the roll never stopped. **M1** gives the course pilot a hard roll gate, which holds the pitch still until the bank arrives. A median trip fell from 41 full turns of roll to 1.3. The fight keeps the law it has. 5,877 assertions. +- [x] 211 — [The ship stops short of the rock](211-the-ship-stops-short-of-the-rock.md) — Chris, 2026-09-12. He picked a rock and the ship flew on into it. The speed deadband held a drift as well as a speed, and the standoff was measured to the target's centre. **M1** brakes to a stop inside the standoff, and measures that range from the hull. A picked rock now holds 500 units of clear space. 5,886 assertions. +- [x] 212 — [The computer lines up, and the pilot plays the slot](212-the-computer-lines-up-and-the-pilot-plays-the-slot.md) — Chris, 2026-09-12. He flew 207's docking on a phone and it did not work. The pilot took the ship 407 to 886 units off the slot axis, and a roll to match the spin turned the computer's own correction. **M1** makes the stretch two stages: the computer lines the ship up, then the rails hold the line while the pilot matches the slot and the speed. **M2** puts the rails in `game/dock-rails.ts`. The computer stops the ship on the line, so the pilot thrusts the whole run in. An ideal pilot went from 0 of 4 to 6 of 6, and a pilot who touches nothing never docks. 5,893 assertions. +- [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. +- [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. +- [x] 218 — [The commander is they, not she](218-the-commander-is-they-not-she.md) — Chris, 2026-09-13. The comments, the docs and the dossier prompt slipped into "she" for the commander, from the novella. The prompt names the commander as the player of any gender now, about 560 lines over 128 files say "they" or "the commander", other characters keep their pronouns, and the fourteen dossiers were written again under prompt version 4. 6,153 assertions. +- [x] 217 — [The side hunt is a gang, and the board gates the hard jobs](217-the-side-hunt-is-a-gang-and-the-board-gates-the-hard-jobs.md) — Chris, 2026-09-13. The lone Krait died in four seconds. **M1** makes the hunt verb count a gang, whose members are records that stay dead across an arrival, and makes the side hunt a Fer-de-Lance with an Asp, a Krait and a Mamba. **M2** gates the gang hunt, the lane job and the escort by kills. **M3** flies three gangs in the probe, and Chris chose the middle one: three deaths in eight for a stock Cobra. 6,153 assertions. +- [x] 216 — [Every docked screen carries a button for each key it reads](216-every-docked-screen-carries-a-button-for-each-key-it-reads.md) — Chris, 2026-09-13. He asked for a review of every docked screen, so that each key has a button on a phone. Six places had none. **M1** paints a key grid on the three name screens. **M2** gives the charts FIND and MARKET buttons, and the search types on the grid. **M3** puts a left and a right arrow on each row of the test mode and the trainer's setup, and gives the trainer's three panels their keys. The search then moved to its own file, for the size gate. 6,114 assertions. diff --git a/docs/TODO/retired/204-the-ship-flies-by-touch.md b/docs/TODO/retired/204-the-ship-flies-by-touch.md new file mode 100644 index 00000000..964ed9c3 --- /dev/null +++ b/docs/TODO/retired/204-the-ship-flies-by-touch.md @@ -0,0 +1,235 @@ +# 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. + +## Outcome + +All five milestones landed on 2026-09-11. 5,661 assertions, from 5,619. The +flight probe ran once and passed, because the throttle rule touches how a +flight goes. + +### M1 — the input takes a wanted speed and a held button + +`Input.press` and `Input.release` hold a key as a keyboard does, and never +make a tap. `Input.wantedSpeed` is a fraction of top speed or null. +`flightDemand` opens the throttle below it, brakes above it, and coasts +inside `PLAYER_FLIGHT.throttleBand`. A speed key overrides it. + +### M2 — the touch overlay + +`engine/touch.ts` is two halves. `TouchTracker` is pure and runs under node. +`attachTouch` binds the overlay's pointer events, and `Input` calls it when +the page has the overlay. A drag is the mouse stick, with +`TOUCH_STICK_TRAVEL` pixels for full deflection. A held finger holds the +stick through `Input.stickHeld`, which `decayMouse` honours. In the 390 by +844 frame 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. + +### M3 — the command row and the tappable prompts + +`ui/key-help.ts` paints the five buttons at boot from the binding table, and +the HUD swaps MISSILE's key once a missile is armed and lights the buttons +whose state is on. Each prompt carries its code beside its words, through +`keyCodeIfBound`, and a coarse pointer taps it. The browser shell listens on +the row, the menu and the prompt line as it does on a screen. In the frame, +a tap on MISSILE armed one, and a tap on TORUS was refused as MASS LOCKED +beside the station. + +### M4 — the flight menu + +A MENU button opens a list of rows over the view, painted from the binding +table. The escape pod row opens a confirmation inside the list, and only +YES carries its key. In the frame, LOCAL CHART opened the chart and hid the +list and the overlay, and the pod's row opened its confirmation, which NO +closed. + +### M5 — fullscreen, and the manual + +The first touch asks for the whole screen. The manual and the briefing each +gain a paragraph. + +### What the plan did not have + +- **The flight menu is a list, not a screen.** The plan named a screen under + the screen host. A list of rows with the keys on them needs no screen, no + id in the host's union and no input routing. It uses the seam every + button already takes. +- **A synthetic pointer has no capture to give.** `setPointerCapture` throws + for one, and the overlay catches that, so a test can drive it. +- **Node's strip-only mode refuses a parameter property.** The tracker's + constructor assigns its fields by hand. +- **The travel is 125 pixels, not 120.** Four constants already share 120, + and the gate wants each value its own. +- **The feel is not measured here.** Chrome drove the overlay with synthetic + events. Whether the stick's travel and the decay feel right on a phone is + Chris's to judge. + +### After the preview + +Chris flew it on 2026-09-11 and named two faults. The overlay stopped at +the console, so a thumb on the console steered nothing. It covers the whole +screen now, and the buttons keep their places above the console. A drag +down raised the nose, as a stick and the mouse do, and that is upside down +under a thumb. A drag up raises the nose now. The sign flips in the tracker +and nowhere else, so the mouse keeps its own convention. + +### Retired by 205 M1 + +Chris flew the whole-screen version on his phone on 2026-09-11, and he still +could not fly the ship. He wrote `MOBILE_CONVERSION.md` the same day: *"The +current approach does not work - it's not possible to fly the ship using +mobile controls."* He chose a different game. The pilot picks a course, and +the ship flies it (docs/TODO/205). + +205 M1 took the code of this plan out on 2026-09-11. It kept one piece: +`Input.press` and `Input.release`, with their test. A button holds a key with +them, and 206 and 207 need a held button. diff --git a/docs/TODO/retired/README.md b/docs/TODO/retired/README.md index 4761f40e..f52bbb95 100644 --- a/docs/TODO/retired/README.md +++ b/docs/TODO/retired/README.md @@ -21,6 +21,13 @@ investigation records; do not load this directory wholesale. rather than archived. One number carried two plans, and the other one is `81-live-picker-cannot-name-attack-run.md`. +## Superseded by a different way to fly + +- 204 — [The ship flies by touch](204-the-ship-flies-by-touch.md) — it landed + on 2026-09-11, and 205 M1 took it out the same day. Chris could not fly the + ship on a phone by a drag and a FIRE button. 205 to 208 replace the stick + with a course that the pilot picks. `Input.press` and `Input.release` stay. + ## Rejected product direction - 95 — [Save only at stations](95-save-only-at-stations.md) — conflicts with the diff --git a/docs/reviews/combat-computer-2026-09-12/integration-probe.mjs b/docs/reviews/combat-computer-2026-09-12/integration-probe.mjs new file mode 100644 index 00000000..2ca283a3 --- /dev/null +++ b/docs/reviews/combat-computer-2026-09-12/integration-probe.mjs @@ -0,0 +1,160 @@ +// Review reproductions for target commitment, controller state, and automatic fire. +// Every Game operation runs inside withoutSaving(). Production code is unchanged. +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 { pickTarget } from '../../../src/game/targets.ts'; +import { ScriptedCoPilot } from '../../../src/game/scripted-co-pilot.ts'; +import { CombatComputer } from '../../../src/game/combat-computer.ts'; +import { Autopilot } from '../../../src/game/autopilot.ts'; +import { Ordnance } from '../../../src/game/ordnance.ts'; +import { dismissBriefing } from '../../../test/harness.ts'; + +const dt = 1 / 60; +const output = (name, values) => console.log(JSON.stringify({ name, ...values })); +function open() { + seedWorld(20260912); + const g = new Game(() => headlessShell()); + dismissBriefing(g); + g.launch(); + for (let f = 0; f < 400; f++) g.step(dt, f * dt); + g.state.world.clearNpcs(); + g.state.player.position.set(0, 40000, 0); + g.state.player.quaternion.identity(); + g.state.player.speed = 0; + g.state.player.pitchRate = 0; + g.state.player.rollRate = 0; + Object.assign(g.state.session, { + ccEngaged: false, dcEngaged: false, dockTrial: false, dockRails: false, + handFlown: false, torusEngaged: false, course: null, view: 0, + }); + return g; +} +function spawn(g, role, x, z, seed = 1) { + const n = g.state.world.spawn(role, new THREE.Vector3(x, 40000, z), seed); + n.state.speed = 0; + return n; +} +function run(g, frames) { for (let f = 0; f < frames; f++) g.step(dt, 20 + f * dt); } + +withoutSaving(() => { + // The public view-change command while the automatic trigger has a front target. + { + const g = open(); + Object.assign(g.state.commander.equipment, { combatComputer: true, rearLaser: true }); + const pirate = spawn(g, 'pirate', 0, -1000); + const trader = spawn(g, 'trader', 0, 1000, 2); + g.state.session.ccEngaged = true; + g.input.injectPress('Digit2'); + run(g, 1); + output('rear_view_autofire', { + view: g.state.session.view, engaged: g.state.session.ccEngaged, + laserTemp: g.state.sys.laserTemp, legalStatus: g.state.commander.legalStatus, + traderProvoked: trader.state.provokedByPlayer, + pirateProvoked: pirate.state.provokedByPlayer, + }); + } + // A lawful ship obstructs the target, with the correct front mount selected. + { + const g = open(); + g.state.commander.equipment.combatComputer = true; + const pirate = spawn(g, 'pirate', 0, -1500); + const trader = spawn(g, 'trader', 0, -700, 2); + run(g, 1); + output('obstructed_autofire', { + laserTemp: g.state.sys.laserTemp, legalStatus: g.state.commander.legalStatus, + traderProvoked: trader.state.provokedByPlayer, + pirateProvoked: pirate.state.provokedByPlayer, + }); + } + // A saved in-progress turn versus its first step in a fresh game instance. + { + const g = open(); + const rock = spawn(g, 'asteroid', 1600, -1200); + pickTarget(g.state.world.npcs, rock); + run(g, 12); + const snap = g.captureSnapshot(); + run(g, 1); + const continued = g.captureSnapshot().player; + const fresh = open(); + fresh.restoreSnapshot(structuredClone(snap)); + const roundTripSame = JSON.stringify(fresh.captureSnapshot().player) === JSON.stringify(snap.player); + run(fresh, 1); + const restored = fresh.captureSnapshot().player; + output('save_mid_turn', { saved: snap.player, savedComputer: snap.combatComputer, + roundTripSame, continued, restored, + orientationDifference: new THREE.Quaternion(...continued.quat).angleTo(new THREE.Quaternion(...restored.quat)), + }); + } + // An inbound missile survives its launcher, so there is no ship to target. + { + const g = open(); + Object.assign(g.state.commander.equipment, { combatComputer: true, ecm: true }); + g.state.session.ccEngaged = true; + const ordnance = new Ordnance(g.state.world); + ordnance.launchHostile(g.state.player.position.clone().add(new THREE.Vector3(0, 0, -1000))); + const auto = new Autopilot(g.state, new CombatComputer()); + const result = auto.combatSteer(dt, false, ordnance.hostileMissilePos); + output('missile_without_ship', { inbound: ordnance.missileInbound, + ecm: result.ecm, engaged: g.state.session.ccEngaged, events: result.events }); + } + // Manual override and re-engagement retain the old turn rates. + { + const g = open(); + const rock = spawn(g, 'asteroid', 1600, -1200); + const cp = new ScriptedCoPilot(); + let last; + for (let i = 0; i < 30; i++) last = cp.step(dt, g.state.player, [rock], 0, false, null, Infinity, rock); + cp.step(dt, g.state.player, [rock], 0, true, null, Infinity, rock); + rock.object.position.set(0, 40000, -1000); + const resumed = cp.step(dt, g.state.player, [rock], 0, false, null, Infinity, rock); + const fresh = new ScriptedCoPilot().step(dt, g.state.player, [rock], 0, false, null, Infinity, rock); + output('reenage_after_manual_override', { oldDemand: last.demand, resumed: resumed.demand, fresh: fresh.demand }); + } + // reset() also leaves the sticky steering side from the previous engagement. + { + const g = open(); + const rock = spawn(g, 'asteroid', 0, -1000); + rock.object.position.y = 39000; + const cp = new ScriptedCoPilot(); + cp.step(dt, g.state.player, [rock], 0, false, null, Infinity, rock); + cp.reset(); + rock.object.position.set(1000, 40000, -1000); + const reset = cp.step(dt, g.state.player, [rock], 0, false, null, Infinity, rock); + const fresh = new ScriptedCoPilot().step(dt, g.state.player, [rock], 0, false, null, Infinity, rock); + output('reset_retains_steering_side', { reset: reset.demand, fresh: fresh.demand }); + } + // Public Game input: a manual pause lets the real ship stop its roll. + { + const g = open(); + const rock = spawn(g, 'asteroid', 1600, -1200); + pickTarget(g.state.world.npcs, rock); + run(g, 12); + const oldRoll = g.state.player.rollRate; + g.input.press('ArrowUp'); + run(g, 1); + g.input.release('ArrowUp'); + run(g, 180); + rock.object.position.copy(g.state.player.position).addScaledVector(g.state.player.getForward(new THREE.Vector3()), 1000); + g.state.player.speed = 0; + const before = g.state.player.rollRate; + g.state.session.handFlown = false; + run(g, 1); + output('game_reengagement', { oldRoll, before, after: g.state.player.rollRate }); + } + // The automatic lock veto needs no firing solution or recent progress. + { + const g = open(); + const a = spawn(g, 'pirate', 3000 * Math.sin(.4), -3000 * Math.cos(.4)); + const b = spawn(g, 'pirate', 0, -500, 2); + const cp = new ScriptedCoPilot(); + cp.step(dt, g.state.player, [a], 0, false, null); + let result; + for (let f = 0; f < 600; f++) result = cp.step(dt, g.state.player, [a,b], 0, false, null); + const fresh = new ScriptedCoPilot().step(dt, g.state.player, [a,b], 0, false, null); + output('commit_without_gun_solution', { seconds: 10, heldOriginal: cp.lock.held === a, + heldFire: result.demand.fire, freshFire: fresh.demand.fire }); + } +}); diff --git a/docs/reviews/combat-computer-2026-09-12/integration-results.jsonl b/docs/reviews/combat-computer-2026-09-12/integration-results.jsonl new file mode 100644 index 00000000..84a670cb --- /dev/null +++ b/docs/reviews/combat-computer-2026-09-12/integration-results.jsonl @@ -0,0 +1,8 @@ +{"name":"rear_view_autofire","view":1,"engaged":true,"laserTemp":0.06333333333333334,"legalStatus":1,"traderProvoked":true,"pirateProvoked":false} +{"name":"obstructed_autofire","laserTemp":0.06333333333333334,"legalStatus":1,"traderProvoked":true,"pirateProvoked":false} +{"name":"save_mid_turn","saved":{"pos":[0.0006983733735677464,40000.006687334244,-4.766658652631075],"quat":[0.0017085295533655355,-0.00006898670940893003,-0.0856751049233923,0.996321661193796],"speed":43.99999999999999,"pitchRate":0.05471639936873287,"rollRate":-1.4076069681169112},"savedComputer":{"pitch":0,"roll":0,"timer":0,"control":null},"roundTripSame":true,"continued":{"pos":[0.0012139822213510032,40000.010256999616,-5.561094909951504],"quat":[0.0022676317393247798,-0.00010286733648966472,-0.09795979856614058,0.9951877838526191],"speed":47.66666666666666,"pitchRate":0.06732970254606363,"rollRate":-1.4804336411255932},"restored":{"pos":[0.001077508095081397,40000.0095946651,-5.56109768678839],"quat":[0.0018436180576337038,-0.00007841092720803403,-0.08705880104950238,0.9962014656102487],"speed":47.66666666666666,"pitchRate":0.01626108672110719,"rollRate":-0.1666677443079756},"orientationDifference":0.02191263417248067} +{"name":"missile_without_ship","inbound":true,"ecm":false,"engaged":false,"events":[{"kind":"message","text":"AREA CLEAR","seconds":3}]} +{"name":"reenage_after_manual_override","oldDemand":{"pitchRate":7.758100849483098e-17,"rollRate":-2.1844724465368413,"throttle":1,"fire":false},"resumed":{"pitchRate":0,"rollRate":-1.7475783381680894,"throttle":1,"fire":true},"fresh":{"pitchRate":0,"rollRate":0,"throttle":1,"fire":true}} +{"name":"reset_retains_steering_side","reset":{"pitchRate":-5.919164468048283e-18,"rollRate":0.1666677443079756,"throttle":1,"fire":false},"fresh":{"pitchRate":5.919164468048283e-18,"rollRate":-0.1666677443079756,"throttle":1,"fire":false}} +{"name":"game_reengagement","oldRoll":-1.4076069681169112,"before":0,"after":-1.1260858199586763} +{"name":"commit_without_gun_solution","seconds":10,"heldOriginal":true,"heldFire":false,"freshFire":true} diff --git a/docs/reviews/combat-computer-2026-09-12/measurements.jsonl b/docs/reviews/combat-computer-2026-09-12/measurements.jsonl new file mode 100644 index 00000000..372df66b --- /dev/null +++ b/docs/reviews/combat-computer-2026-09-12/measurements.jsonl @@ -0,0 +1,426 @@ +{"grid":"initial","variant":"baseline","name":"horizontal-r400-v150","seconds":30,"radius":15,"firstLock":"2.42","onGun":100,"requestFire":100,"inRange":100,"medianError":1.91,"p95Error":2.05,"longestGap":0,"medianDistance":383,"minHullGap":296.1,"rollsPerMinute":0.1,"rollReversals":0,"pitchReversals":0,"maxRoll":0.073} +{"grid":"initial","variant":"baseline","name":"horizontal-r400-v300","seconds":30,"radius":15,"firstLock":"3.28","onGun":28.5,"requestFire":29.1,"inRange":100,"medianError":6.22,"p95Error":17.97,"longestGap":3.7,"medianDistance":422,"minHullGap":296.1,"rollsPerMinute":8.94,"rollReversals":20,"pitchReversals":0,"maxRoll":2.01} +{"grid":"initial","variant":"baseline","name":"horizontal-r800-v150","seconds":30,"radius":15,"firstLock":"2.23","onGun":100,"requestFire":100,"inRange":100,"medianError":2.37,"p95Error":2.48,"longestGap":0,"medianDistance":516,"minHullGap":493.3,"rollsPerMinute":0.15,"rollReversals":0,"pitchReversals":0,"maxRoll":0.098} +{"grid":"initial","variant":"baseline","name":"horizontal-r800-v300","seconds":30,"radius":15,"firstLock":"2.65","onGun":81,"requestFire":81.4,"inRange":100,"medianError":1.98,"p95Error":7.51,"longestGap":1.38,"medianDistance":512,"minHullGap":492.8,"rollsPerMinute":4.21,"rollReversals":15,"pitchReversals":0,"maxRoll":1.244} +{"grid":"initial","variant":"baseline","name":"horizontal-r1600-v150","seconds":30,"radius":15,"firstLock":"2.05","onGun":100,"requestFire":100,"inRange":100,"medianError":2.87,"p95Error":2.98,"longestGap":0,"medianDistance":515,"minHullGap":492.5,"rollsPerMinute":0.27,"rollReversals":0,"pitchReversals":0,"maxRoll":0.119} +{"grid":"initial","variant":"baseline","name":"horizontal-r1600-v300","seconds":30,"radius":15,"firstLock":"2.22","onGun":72.3,"requestFire":72.5,"inRange":100,"medianError":2.72,"p95Error":10.83,"longestGap":2.52,"medianDistance":512,"minHullGap":492,"rollsPerMinute":4.31,"rollReversals":8,"pitchReversals":1,"maxRoll":1.644} +{"grid":"initial","variant":"baseline","name":"vertical-r400-v150","seconds":30,"radius":15,"firstLock":"1.87","onGun":100,"requestFire":100,"inRange":100,"medianError":1.92,"p95Error":2.05,"longestGap":0,"medianDistance":383,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"baseline","name":"vertical-r400-v300","seconds":30,"radius":15,"firstLock":"2.58","onGun":100,"requestFire":100,"inRange":100,"medianError":1.04,"p95Error":1.29,"longestGap":0,"medianDistance":425,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"baseline","name":"vertical-r800-v150","seconds":30,"radius":15,"firstLock":"1.67","onGun":100,"requestFire":100,"inRange":100,"medianError":2.37,"p95Error":2.49,"longestGap":0,"medianDistance":516,"minHullGap":493.3,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"baseline","name":"vertical-r800-v300","seconds":30,"radius":15,"firstLock":"2.03","onGun":100,"requestFire":100,"inRange":100,"medianError":1.74,"p95Error":1.9,"longestGap":0,"medianDistance":509,"minHullGap":493,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"baseline","name":"vertical-r1600-v150","seconds":30,"radius":15,"firstLock":"1.52","onGun":100,"requestFire":100,"inRange":100,"medianError":2.87,"p95Error":2.98,"longestGap":0,"medianDistance":515,"minHullGap":492.5,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"baseline","name":"vertical-r1600-v300","seconds":30,"radius":15,"firstLock":"1.65","onGun":100,"requestFire":100,"inRange":100,"medianError":2.49,"p95Error":2.58,"longestGap":0,"medianDistance":513,"minHullGap":497.2,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"baseline","name":"tilted-r400-v150","seconds":30,"radius":15,"firstLock":"2.10","onGun":97.2,"requestFire":97.5,"inRange":100,"medianError":1.99,"p95Error":3.51,"longestGap":0.52,"medianDistance":392,"minHullGap":333.2,"rollsPerMinute":2.16,"rollReversals":9,"pitchReversals":0,"maxRoll":1.182} +{"grid":"initial","variant":"baseline","name":"tilted-r400-v300","seconds":30,"radius":15,"firstLock":"2.40","onGun":33.1,"requestFire":31.9,"inRange":100,"medianError":5.39,"p95Error":10.92,"longestGap":1.5,"medianDistance":418,"minHullGap":335.9,"rollsPerMinute":9.12,"rollReversals":20,"pitchReversals":0,"maxRoll":2.232} +{"grid":"initial","variant":"baseline","name":"tilted-r800-v150","seconds":30,"radius":15,"firstLock":"2.07","onGun":19.8,"requestFire":19.8,"inRange":100,"medianError":8.44,"p95Error":11.79,"longestGap":2.45,"medianDistance":517,"minHullGap":496.7,"rollsPerMinute":9.81,"rollReversals":5,"pitchReversals":1,"maxRoll":1.803} +{"grid":"initial","variant":"baseline","name":"tilted-r800-v300","seconds":30,"radius":15,"firstLock":"2.23","onGun":17.6,"requestFire":17.4,"inRange":100,"medianError":8.43,"p95Error":13.04,"longestGap":1.58,"medianDistance":516,"minHullGap":496.7,"rollsPerMinute":9.53,"rollReversals":11,"pitchReversals":0,"maxRoll":2.099} +{"grid":"initial","variant":"baseline","name":"tilted-r1600-v150","seconds":30,"radius":15,"firstLock":"2.32","onGun":33.9,"requestFire":34.1,"inRange":100,"medianError":5.21,"p95Error":10.26,"longestGap":3.57,"medianDistance":517,"minHullGap":498.7,"rollsPerMinute":7.57,"rollReversals":3,"pitchReversals":4,"maxRoll":1.527} +{"grid":"initial","variant":"baseline","name":"tilted-r1600-v300","seconds":30,"radius":15,"firstLock":"2.40","onGun":22.1,"requestFire":22.2,"inRange":100,"medianError":8.01,"p95Error":12.24,"longestGap":2.5,"medianDistance":515,"minHullGap":494.4,"rollsPerMinute":9.13,"rollReversals":1,"pitchReversals":6,"maxRoll":1.991} +{"grid":"initial","variant":"baseline","name":"straight-crossing","seconds":30,"radius":15,"firstLock":"1.98","onGun":34.5,"requestFire":34.1,"inRange":100,"medianError":4.57,"p95Error":10.76,"longestGap":12.88,"medianDistance":516,"minHullGap":292.1,"rollsPerMinute":7.31,"rollReversals":1,"pitchReversals":0,"maxRoll":1.35} +{"grid":"initial","variant":"baseline","name":"weaving-away","seconds":30,"radius":15,"firstLock":"0.00","onGun":7.5,"requestFire":7,"inRange":100,"medianError":9.37,"p95Error":12.26,"longestGap":3.67,"medianDistance":516,"minHullGap":497.9,"rollsPerMinute":11.42,"rollReversals":5,"pitchReversals":0,"maxRoll":1.892} +{"grid":"initial","variant":"baseline","name":"straight-away","seconds":30,"radius":15,"firstLock":"0.00","onGun":100,"requestFire":100,"inRange":100,"medianError":0,"p95Error":0,"longestGap":0,"medianDistance":510,"minHullGap":490,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"baseline","name":"horizontal-r400-v150","seconds":60,"radius":15,"firstLock":"2.42","onGun":100,"requestFire":100,"inRange":100,"medianError":1.92,"p95Error":2.07,"longestGap":0,"medianDistance":411,"minHullGap":296.1,"rollsPerMinute":0.06,"rollReversals":0,"pitchReversals":0,"maxRoll":0.073} +{"grid":"initial","variant":"baseline","name":"horizontal-r400-v300","seconds":60,"radius":15,"firstLock":"3.28","onGun":32.2,"requestFire":31.2,"inRange":100,"medianError":5.55,"p95Error":16.7,"longestGap":3.7,"medianDistance":409,"minHullGap":296.1,"rollsPerMinute":8.62,"rollReversals":50,"pitchReversals":0,"maxRoll":2.061} +{"grid":"initial","variant":"baseline","name":"horizontal-r800-v150","seconds":60,"radius":15,"firstLock":"2.23","onGun":100,"requestFire":100,"inRange":100,"medianError":2.4,"p95Error":2.51,"longestGap":0,"medianDistance":511,"minHullGap":493.2,"rollsPerMinute":0.07,"rollReversals":0,"pitchReversals":0,"maxRoll":0.098} +{"grid":"initial","variant":"baseline","name":"horizontal-r800-v300","seconds":60,"radius":15,"firstLock":"2.65","onGun":42.6,"requestFire":42.8,"inRange":100,"medianError":5.07,"p95Error":12.96,"longestGap":1.6,"medianDistance":514,"minHullGap":492.8,"rollsPerMinute":7.44,"rollReversals":31,"pitchReversals":0,"maxRoll":2.058} +{"grid":"initial","variant":"baseline","name":"horizontal-r1600-v150","seconds":60,"radius":15,"firstLock":"2.05","onGun":100,"requestFire":100,"inRange":100,"medianError":2.92,"p95Error":3,"longestGap":0,"medianDistance":513,"minHullGap":492.3,"rollsPerMinute":0.25,"rollReversals":0,"pitchReversals":0,"maxRoll":0.119} +{"grid":"initial","variant":"baseline","name":"horizontal-r1600-v300","seconds":60,"radius":15,"firstLock":"2.22","onGun":40.8,"requestFire":40.8,"inRange":100,"medianError":5.03,"p95Error":12.03,"longestGap":2.52,"medianDistance":514,"minHullGap":492,"rollsPerMinute":7.51,"rollReversals":8,"pitchReversals":10,"maxRoll":1.729} +{"grid":"initial","variant":"baseline","name":"vertical-r400-v150","seconds":60,"radius":15,"firstLock":"1.87","onGun":100,"requestFire":100,"inRange":100,"medianError":1.92,"p95Error":2.06,"longestGap":0,"medianDistance":410,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"baseline","name":"vertical-r400-v300","seconds":60,"radius":15,"firstLock":"2.58","onGun":100,"requestFire":100,"inRange":100,"medianError":1.03,"p95Error":1.28,"longestGap":0,"medianDistance":409,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"baseline","name":"vertical-r800-v150","seconds":60,"radius":15,"firstLock":"1.67","onGun":100,"requestFire":100,"inRange":100,"medianError":2.4,"p95Error":2.51,"longestGap":0,"medianDistance":511,"minHullGap":493.2,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"baseline","name":"vertical-r800-v300","seconds":60,"radius":15,"firstLock":"2.03","onGun":100,"requestFire":100,"inRange":100,"medianError":1.74,"p95Error":1.91,"longestGap":0,"medianDistance":509,"minHullGap":493,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"baseline","name":"vertical-r1600-v150","seconds":60,"radius":15,"firstLock":"1.52","onGun":100,"requestFire":100,"inRange":100,"medianError":2.9,"p95Error":3,"longestGap":0,"medianDistance":513,"minHullGap":492.3,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"baseline","name":"vertical-r1600-v300","seconds":60,"radius":15,"firstLock":"1.65","onGun":100,"requestFire":100,"inRange":100,"medianError":2.49,"p95Error":2.58,"longestGap":0,"medianDistance":515,"minHullGap":496,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"baseline","name":"tilted-r400-v150","seconds":60,"radius":15,"firstLock":"2.10","onGun":98.9,"requestFire":99,"inRange":100,"medianError":1.95,"p95Error":2.7,"longestGap":0.52,"medianDistance":410,"minHullGap":333.2,"rollsPerMinute":1.21,"rollReversals":9,"pitchReversals":0,"maxRoll":1.182} +{"grid":"initial","variant":"baseline","name":"tilted-r400-v300","seconds":60,"radius":15,"firstLock":"2.40","onGun":28.3,"requestFire":27,"inRange":100,"medianError":5.79,"p95Error":16.52,"longestGap":5.02,"medianDistance":409,"minHullGap":335.9,"rollsPerMinute":9.42,"rollReversals":49,"pitchReversals":0,"maxRoll":2.314} +{"grid":"initial","variant":"baseline","name":"tilted-r800-v150","seconds":60,"radius":15,"firstLock":"2.07","onGun":20.2,"requestFire":20.2,"inRange":100,"medianError":8.38,"p95Error":11.73,"longestGap":2.48,"medianDistance":518,"minHullGap":496.7,"rollsPerMinute":9.7,"rollReversals":8,"pitchReversals":8,"maxRoll":1.803} +{"grid":"initial","variant":"baseline","name":"tilted-r800-v300","seconds":60,"radius":15,"firstLock":"2.23","onGun":17.3,"requestFire":17.2,"inRange":100,"medianError":8.52,"p95Error":13.08,"longestGap":1.6,"medianDistance":515,"minHullGap":496.4,"rollsPerMinute":9.62,"rollReversals":27,"pitchReversals":0,"maxRoll":2.099} +{"grid":"initial","variant":"baseline","name":"tilted-r1600-v150","seconds":60,"radius":15,"firstLock":"2.32","onGun":40.5,"requestFire":40.8,"inRange":100,"medianError":4.24,"p95Error":9.2,"longestGap":3.57,"medianDistance":516,"minHullGap":494.9,"rollsPerMinute":6.87,"rollReversals":11,"pitchReversals":6,"maxRoll":1.527} +{"grid":"initial","variant":"baseline","name":"tilted-r1600-v300","seconds":60,"radius":15,"firstLock":"2.40","onGun":20.3,"requestFire":20.3,"inRange":100,"medianError":8.43,"p95Error":12.09,"longestGap":2.5,"medianDistance":515,"minHullGap":493.9,"rollsPerMinute":9.5,"rollReversals":1,"pitchReversals":15,"maxRoll":1.991} +{"grid":"initial","variant":"baseline","name":"straight-crossing","seconds":60,"radius":15,"firstLock":"1.98","onGun":13.8,"requestFire":13.6,"inRange":100,"medianError":10.9,"p95Error":10.98,"longestGap":42.88,"medianDistance":516,"minHullGap":292.1,"rollsPerMinute":10.7,"rollReversals":1,"pitchReversals":0,"maxRoll":1.364} +{"grid":"initial","variant":"baseline","name":"weaving-away","seconds":60,"radius":15,"firstLock":"0.00","onGun":7.5,"requestFire":7.1,"inRange":100,"medianError":9.39,"p95Error":12.27,"longestGap":3.67,"medianDistance":516,"minHullGap":497.9,"rollsPerMinute":11.38,"rollReversals":13,"pitchReversals":0,"maxRoll":1.892} +{"grid":"initial","variant":"baseline","name":"straight-away","seconds":60,"radius":15,"firstLock":"0.00","onGun":100,"requestFire":100,"inRange":100,"medianError":0,"p95Error":0,"longestGap":0,"medianDistance":510,"minHullGap":490,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"hard-gate","name":"horizontal-r400-v150","seconds":30,"radius":15,"firstLock":"3.48","onGun":100,"requestFire":100,"inRange":100,"medianError":1.91,"p95Error":2.05,"longestGap":0,"medianDistance":383,"minHullGap":295.8,"rollsPerMinute":0.01,"rollReversals":0,"pitchReversals":0,"maxRoll":0.005} +{"grid":"initial","variant":"hard-gate","name":"horizontal-r400-v300","seconds":30,"radius":15,"firstLock":"4.92","onGun":91.9,"requestFire":84.8,"inRange":100,"medianError":1.63,"p95Error":4.34,"longestGap":0.27,"medianDistance":422,"minHullGap":295.8,"rollsPerMinute":1.57,"rollReversals":26,"pitchReversals":0,"maxRoll":0.309} +{"grid":"initial","variant":"hard-gate","name":"horizontal-r800-v150","seconds":30,"radius":15,"firstLock":"3.18","onGun":100,"requestFire":100,"inRange":100,"medianError":2.37,"p95Error":2.48,"longestGap":0,"medianDistance":516,"minHullGap":493.5,"rollsPerMinute":0.02,"rollReversals":0,"pitchReversals":0,"maxRoll":0.013} +{"grid":"initial","variant":"hard-gate","name":"horizontal-r800-v300","seconds":30,"radius":15,"firstLock":"3.72","onGun":100,"requestFire":100,"inRange":100,"medianError":1.73,"p95Error":1.89,"longestGap":0,"medianDistance":511,"minHullGap":492.9,"rollsPerMinute":0.44,"rollReversals":5,"pitchReversals":0,"maxRoll":0.194} +{"grid":"initial","variant":"hard-gate","name":"horizontal-r1600-v150","seconds":30,"radius":15,"firstLock":"2.98","onGun":100,"requestFire":100,"inRange":100,"medianError":2.86,"p95Error":2.98,"longestGap":0,"medianDistance":515,"minHullGap":492.5,"rollsPerMinute":0.04,"rollReversals":0,"pitchReversals":0,"maxRoll":0.022} +{"grid":"initial","variant":"hard-gate","name":"horizontal-r1600-v300","seconds":30,"radius":15,"firstLock":"3.20","onGun":100,"requestFire":100,"inRange":100,"medianError":2.48,"p95Error":2.58,"longestGap":0,"medianDistance":513,"minHullGap":497.2,"rollsPerMinute":0.29,"rollReversals":1,"pitchReversals":0,"maxRoll":0.15} +{"grid":"initial","variant":"hard-gate","name":"vertical-r400-v150","seconds":30,"radius":15,"firstLock":"1.87","onGun":100,"requestFire":100,"inRange":100,"medianError":1.92,"p95Error":2.05,"longestGap":0,"medianDistance":383,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"hard-gate","name":"vertical-r400-v300","seconds":30,"radius":15,"firstLock":"2.58","onGun":100,"requestFire":100,"inRange":100,"medianError":1.04,"p95Error":1.29,"longestGap":0,"medianDistance":425,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"hard-gate","name":"vertical-r800-v150","seconds":30,"radius":15,"firstLock":"1.67","onGun":100,"requestFire":100,"inRange":100,"medianError":2.37,"p95Error":2.49,"longestGap":0,"medianDistance":516,"minHullGap":493.3,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"hard-gate","name":"vertical-r800-v300","seconds":30,"radius":15,"firstLock":"2.03","onGun":100,"requestFire":100,"inRange":100,"medianError":1.74,"p95Error":1.9,"longestGap":0,"medianDistance":509,"minHullGap":493,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"hard-gate","name":"vertical-r1600-v150","seconds":30,"radius":15,"firstLock":"1.52","onGun":100,"requestFire":100,"inRange":100,"medianError":2.87,"p95Error":2.98,"longestGap":0,"medianDistance":515,"minHullGap":492.5,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"hard-gate","name":"vertical-r1600-v300","seconds":30,"radius":15,"firstLock":"1.65","onGun":100,"requestFire":100,"inRange":100,"medianError":2.49,"p95Error":2.58,"longestGap":0,"medianDistance":513,"minHullGap":497.2,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"hard-gate","name":"tilted-r400-v150","seconds":30,"radius":15,"firstLock":"3.20","onGun":100,"requestFire":100,"inRange":100,"medianError":1.91,"p95Error":2.06,"longestGap":0,"medianDistance":391,"minHullGap":327.8,"rollsPerMinute":0.56,"rollReversals":0,"pitchReversals":0,"maxRoll":0.108} +{"grid":"initial","variant":"hard-gate","name":"tilted-r400-v300","seconds":30,"radius":15,"firstLock":"5.13","onGun":87.2,"requestFire":80.8,"inRange":100,"medianError":1.27,"p95Error":5.02,"longestGap":0.53,"medianDistance":418,"minHullGap":327.9,"rollsPerMinute":1.55,"rollReversals":21,"pitchReversals":0,"maxRoll":0.386} +{"grid":"initial","variant":"hard-gate","name":"tilted-r800-v150","seconds":30,"radius":15,"firstLock":"3.02","onGun":100,"requestFire":100,"inRange":100,"medianError":2.36,"p95Error":2.47,"longestGap":0,"medianDistance":516,"minHullGap":493.5,"rollsPerMinute":0.09,"rollReversals":0,"pitchReversals":0,"maxRoll":0.069} +{"grid":"initial","variant":"hard-gate","name":"tilted-r800-v300","seconds":30,"radius":15,"firstLock":"3.83","onGun":100,"requestFire":100,"inRange":100,"medianError":1.76,"p95Error":1.99,"longestGap":0,"medianDistance":510,"minHullGap":493.1,"rollsPerMinute":0.85,"rollReversals":19,"pitchReversals":0,"maxRoll":0.279} +{"grid":"initial","variant":"hard-gate","name":"tilted-r1600-v150","seconds":30,"radius":15,"firstLock":"2.92","onGun":100,"requestFire":100,"inRange":100,"medianError":2.86,"p95Error":2.98,"longestGap":0,"medianDistance":515,"minHullGap":492.5,"rollsPerMinute":0.09,"rollReversals":0,"pitchReversals":0,"maxRoll":0.049} +{"grid":"initial","variant":"hard-gate","name":"tilted-r1600-v300","seconds":30,"radius":15,"firstLock":"3.05","onGun":100,"requestFire":100,"inRange":100,"medianError":2.49,"p95Error":2.59,"longestGap":0,"medianDistance":513,"minHullGap":497.2,"rollsPerMinute":0.37,"rollReversals":4,"pitchReversals":0,"maxRoll":0.195} +{"grid":"initial","variant":"hard-gate","name":"straight-crossing","seconds":30,"radius":15,"firstLock":"2.22","onGun":85.7,"requestFire":86.2,"inRange":100,"medianError":3.35,"p95Error":3.58,"longestGap":1.32,"medianDistance":514,"minHullGap":247.8,"rollsPerMinute":0.02,"rollReversals":0,"pitchReversals":0,"maxRoll":0.158} +{"grid":"initial","variant":"hard-gate","name":"weaving-away","seconds":30,"radius":15,"firstLock":"0.00","onGun":99.6,"requestFire":99.2,"inRange":100,"medianError":1.89,"p95Error":3.22,"longestGap":0.08,"medianDistance":512,"minHullGap":494.5,"rollsPerMinute":0.68,"rollReversals":15,"pitchReversals":5,"maxRoll":0.301} +{"grid":"initial","variant":"hard-gate","name":"straight-away","seconds":30,"radius":15,"firstLock":"0.00","onGun":100,"requestFire":100,"inRange":100,"medianError":0,"p95Error":0,"longestGap":0,"medianDistance":510,"minHullGap":490,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"hard-gate","name":"horizontal-r400-v150","seconds":60,"radius":15,"firstLock":"3.48","onGun":100,"requestFire":100,"inRange":100,"medianError":1.92,"p95Error":2.06,"longestGap":0,"medianDistance":411,"minHullGap":295.8,"rollsPerMinute":0.01,"rollReversals":0,"pitchReversals":0,"maxRoll":0.005} +{"grid":"initial","variant":"hard-gate","name":"horizontal-r400-v300","seconds":60,"radius":15,"firstLock":"4.92","onGun":90,"requestFire":81,"inRange":100,"medianError":1.68,"p95Error":4.58,"longestGap":0.33,"medianDistance":409,"minHullGap":295.8,"rollsPerMinute":1.57,"rollReversals":66,"pitchReversals":0,"maxRoll":0.309} +{"grid":"initial","variant":"hard-gate","name":"horizontal-r800-v150","seconds":60,"radius":15,"firstLock":"3.18","onGun":100,"requestFire":100,"inRange":100,"medianError":2.4,"p95Error":2.51,"longestGap":0,"medianDistance":511,"minHullGap":493.2,"rollsPerMinute":0.02,"rollReversals":0,"pitchReversals":0,"maxRoll":0.013} +{"grid":"initial","variant":"hard-gate","name":"horizontal-r800-v300","seconds":60,"radius":15,"firstLock":"3.72","onGun":100,"requestFire":100,"inRange":100,"medianError":1.75,"p95Error":1.96,"longestGap":0,"medianDistance":509,"minHullGap":492.9,"rollsPerMinute":0.67,"rollReversals":34,"pitchReversals":0,"maxRoll":0.249} +{"grid":"initial","variant":"hard-gate","name":"horizontal-r1600-v150","seconds":60,"radius":15,"firstLock":"2.98","onGun":100,"requestFire":100,"inRange":100,"medianError":2.9,"p95Error":3,"longestGap":0,"medianDistance":513,"minHullGap":492.3,"rollsPerMinute":0.04,"rollReversals":0,"pitchReversals":0,"maxRoll":0.022} +{"grid":"initial","variant":"hard-gate","name":"horizontal-r1600-v300","seconds":60,"radius":15,"firstLock":"3.20","onGun":100,"requestFire":100,"inRange":100,"medianError":2.49,"p95Error":2.59,"longestGap":0,"medianDistance":515,"minHullGap":497.2,"rollsPerMinute":0.36,"rollReversals":15,"pitchReversals":0,"maxRoll":0.183} +{"grid":"initial","variant":"hard-gate","name":"vertical-r400-v150","seconds":60,"radius":15,"firstLock":"1.87","onGun":100,"requestFire":100,"inRange":100,"medianError":1.92,"p95Error":2.06,"longestGap":0,"medianDistance":410,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"hard-gate","name":"vertical-r400-v300","seconds":60,"radius":15,"firstLock":"2.58","onGun":100,"requestFire":100,"inRange":100,"medianError":1.03,"p95Error":1.28,"longestGap":0,"medianDistance":409,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"hard-gate","name":"vertical-r800-v150","seconds":60,"radius":15,"firstLock":"1.67","onGun":100,"requestFire":100,"inRange":100,"medianError":2.4,"p95Error":2.51,"longestGap":0,"medianDistance":511,"minHullGap":493.2,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"hard-gate","name":"vertical-r800-v300","seconds":60,"radius":15,"firstLock":"2.03","onGun":100,"requestFire":100,"inRange":100,"medianError":1.74,"p95Error":1.91,"longestGap":0,"medianDistance":509,"minHullGap":493,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"hard-gate","name":"vertical-r1600-v150","seconds":60,"radius":15,"firstLock":"1.52","onGun":100,"requestFire":100,"inRange":100,"medianError":2.9,"p95Error":3,"longestGap":0,"medianDistance":513,"minHullGap":492.3,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"hard-gate","name":"vertical-r1600-v300","seconds":60,"radius":15,"firstLock":"1.65","onGun":100,"requestFire":100,"inRange":100,"medianError":2.49,"p95Error":2.58,"longestGap":0,"medianDistance":515,"minHullGap":496,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"hard-gate","name":"tilted-r400-v150","seconds":60,"radius":15,"firstLock":"3.20","onGun":100,"requestFire":100,"inRange":100,"medianError":1.92,"p95Error":2.06,"longestGap":0,"medianDistance":410,"minHullGap":327.8,"rollsPerMinute":0.55,"rollReversals":0,"pitchReversals":0,"maxRoll":0.108} +{"grid":"initial","variant":"hard-gate","name":"tilted-r400-v300","seconds":60,"radius":15,"firstLock":"5.13","onGun":88.1,"requestFire":82.2,"inRange":100,"medianError":1.28,"p95Error":5.13,"longestGap":0.53,"medianDistance":409,"minHullGap":327.9,"rollsPerMinute":1.56,"rollReversals":47,"pitchReversals":0,"maxRoll":0.392} +{"grid":"initial","variant":"hard-gate","name":"tilted-r800-v150","seconds":60,"radius":15,"firstLock":"3.02","onGun":100,"requestFire":100,"inRange":100,"medianError":2.4,"p95Error":2.51,"longestGap":0,"medianDistance":511,"minHullGap":493.2,"rollsPerMinute":0.04,"rollReversals":0,"pitchReversals":0,"maxRoll":0.069} +{"grid":"initial","variant":"hard-gate","name":"tilted-r800-v300","seconds":60,"radius":15,"firstLock":"3.83","onGun":100,"requestFire":100,"inRange":100,"medianError":1.77,"p95Error":2.01,"longestGap":0,"medianDistance":510,"minHullGap":493.1,"rollsPerMinute":0.85,"rollReversals":48,"pitchReversals":0,"maxRoll":0.279} +{"grid":"initial","variant":"hard-gate","name":"tilted-r1600-v150","seconds":60,"radius":15,"firstLock":"2.92","onGun":100,"requestFire":100,"inRange":100,"medianError":2.9,"p95Error":3,"longestGap":0,"medianDistance":513,"minHullGap":492.3,"rollsPerMinute":0.07,"rollReversals":0,"pitchReversals":0,"maxRoll":0.049} +{"grid":"initial","variant":"hard-gate","name":"tilted-r1600-v300","seconds":60,"radius":15,"firstLock":"3.05","onGun":100,"requestFire":100,"inRange":100,"medianError":2.49,"p95Error":2.59,"longestGap":0,"medianDistance":515,"minHullGap":497.2,"rollsPerMinute":0.38,"rollReversals":18,"pitchReversals":0,"maxRoll":0.195} +{"grid":"initial","variant":"hard-gate","name":"straight-crossing","seconds":60,"radius":15,"firstLock":"2.22","onGun":74.3,"requestFire":76.2,"inRange":100,"medianError":3.45,"p95Error":3.62,"longestGap":9.97,"medianDistance":513,"minHullGap":247.8,"rollsPerMinute":0.01,"rollReversals":0,"pitchReversals":0,"maxRoll":0.158} +{"grid":"initial","variant":"hard-gate","name":"weaving-away","seconds":60,"radius":15,"firstLock":"0.00","onGun":99.7,"requestFire":99.3,"inRange":100,"medianError":1.8,"p95Error":3.23,"longestGap":0.08,"medianDistance":512,"minHullGap":494.5,"rollsPerMinute":0.6,"rollReversals":35,"pitchReversals":12,"maxRoll":0.301} +{"grid":"initial","variant":"hard-gate","name":"straight-away","seconds":60,"radius":15,"firstLock":"0.00","onGun":100,"requestFire":100,"inRange":100,"medianError":0,"p95Error":0,"longestGap":0,"medianDistance":510,"minHullGap":490,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead","name":"horizontal-r400-v150","seconds":30,"radius":15,"firstLock":"2.43","onGun":100,"requestFire":42.5,"inRange":100,"medianError":3.67,"p95Error":4.12,"longestGap":0,"medianDistance":383,"minHullGap":296.1,"rollsPerMinute":0.13,"rollReversals":0,"pitchReversals":0,"maxRoll":0.082} +{"grid":"initial","variant":"no-lead","name":"horizontal-r400-v300","seconds":30,"radius":15,"firstLock":"3.32","onGun":10.2,"requestFire":1.8,"inRange":100,"medianError":13.99,"p95Error":20.1,"longestGap":16.35,"medianDistance":422,"minHullGap":296.1,"rollsPerMinute":11.6,"rollReversals":18,"pitchReversals":0,"maxRoll":2.303} +{"grid":"initial","variant":"no-lead","name":"horizontal-r800-v150","seconds":30,"radius":15,"firstLock":"2.23","onGun":100,"requestFire":59.5,"inRange":100,"medianError":3.31,"p95Error":3.4,"longestGap":0,"medianDistance":516,"minHullGap":498.5,"rollsPerMinute":0.19,"rollReversals":0,"pitchReversals":0,"maxRoll":0.127} +{"grid":"initial","variant":"no-lead","name":"horizontal-r800-v300","seconds":30,"radius":15,"firstLock":"2.67","onGun":26.9,"requestFire":19.9,"inRange":100,"medianError":5.9,"p95Error":14.07,"longestGap":1.43,"medianDistance":516,"minHullGap":498.8,"rollsPerMinute":8.68,"rollReversals":12,"pitchReversals":0,"maxRoll":1.706} +{"grid":"initial","variant":"no-lead","name":"horizontal-r1600-v150","seconds":30,"radius":15,"firstLock":"2.05","onGun":100,"requestFire":75.1,"inRange":100,"medianError":3.38,"p95Error":3.46,"longestGap":0,"medianDistance":513,"minHullGap":494.6,"rollsPerMinute":0.3,"rollReversals":0,"pitchReversals":0,"maxRoll":0.143} +{"grid":"initial","variant":"no-lead","name":"horizontal-r1600-v300","seconds":30,"radius":15,"firstLock":"2.22","onGun":40.1,"requestFire":28.3,"inRange":100,"medianError":4.24,"p95Error":13.16,"longestGap":2.42,"medianDistance":513,"minHullGap":493.3,"rollsPerMinute":7.51,"rollReversals":5,"pitchReversals":3,"maxRoll":1.637} +{"grid":"initial","variant":"no-lead","name":"vertical-r400-v150","seconds":30,"radius":15,"firstLock":"1.87","onGun":100,"requestFire":42.6,"inRange":100,"medianError":3.67,"p95Error":4.13,"longestGap":0,"medianDistance":383,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead","name":"vertical-r400-v300","seconds":30,"radius":15,"firstLock":"2.58","onGun":99.9,"requestFire":19.3,"inRange":100,"medianError":3.32,"p95Error":3.8,"longestGap":0.02,"medianDistance":425,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead","name":"vertical-r800-v150","seconds":30,"radius":15,"firstLock":"1.67","onGun":100,"requestFire":59.6,"inRange":100,"medianError":3.31,"p95Error":3.4,"longestGap":0,"medianDistance":516,"minHullGap":498.6,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead","name":"vertical-r800-v300","seconds":30,"radius":15,"firstLock":"2.03","onGun":100,"requestFire":38.3,"inRange":100,"medianError":3.19,"p95Error":3.3,"longestGap":0,"medianDistance":513,"minHullGap":497.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead","name":"vertical-r1600-v150","seconds":30,"radius":15,"firstLock":"1.52","onGun":100,"requestFire":75.3,"inRange":100,"medianError":3.38,"p95Error":3.46,"longestGap":0,"medianDistance":512,"minHullGap":494.6,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead","name":"vertical-r1600-v300","seconds":30,"radius":15,"firstLock":"1.65","onGun":100,"requestFire":60.6,"inRange":100,"medianError":3.34,"p95Error":3.43,"longestGap":0,"medianDistance":512,"minHullGap":491.9,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead","name":"tilted-r400-v150","seconds":30,"radius":15,"firstLock":"2.23","onGun":100,"requestFire":41.9,"inRange":100,"medianError":3.63,"p95Error":3.97,"longestGap":0,"medianDistance":392,"minHullGap":331.1,"rollsPerMinute":1.35,"rollReversals":8,"pitchReversals":0,"maxRoll":0.652} +{"grid":"initial","variant":"no-lead","name":"tilted-r400-v300","seconds":30,"radius":15,"firstLock":"2.80","onGun":3.3,"requestFire":1.7,"inRange":100,"medianError":12.22,"p95Error":20.46,"longestGap":10.58,"medianDistance":419,"minHullGap":331.8,"rollsPerMinute":11.06,"rollReversals":18,"pitchReversals":0,"maxRoll":2.303} +{"grid":"initial","variant":"no-lead","name":"tilted-r800-v150","seconds":30,"radius":15,"firstLock":"2.10","onGun":62.1,"requestFire":37,"inRange":100,"medianError":3.4,"p95Error":4.41,"longestGap":0.8,"medianDistance":517,"minHullGap":494.4,"rollsPerMinute":5.25,"rollReversals":10,"pitchReversals":0,"maxRoll":1.229} +{"grid":"initial","variant":"no-lead","name":"tilted-r800-v300","seconds":30,"radius":15,"firstLock":"3.02","onGun":22.1,"requestFire":21.6,"inRange":100,"medianError":8.09,"p95Error":14.61,"longestGap":1.42,"medianDistance":517,"minHullGap":499.1,"rollsPerMinute":9.46,"rollReversals":12,"pitchReversals":0,"maxRoll":1.747} +{"grid":"initial","variant":"no-lead","name":"tilted-r1600-v150","seconds":30,"radius":15,"firstLock":"1.98","onGun":35.9,"requestFire":34.2,"inRange":100,"medianError":4.68,"p95Error":9.31,"longestGap":2.88,"medianDistance":515,"minHullGap":495,"rollsPerMinute":7.3,"rollReversals":3,"pitchReversals":3,"maxRoll":1.313} +{"grid":"initial","variant":"no-lead","name":"tilted-r1600-v300","seconds":30,"radius":15,"firstLock":"2.10","onGun":21.3,"requestFire":21.5,"inRange":100,"medianError":7.27,"p95Error":13.36,"longestGap":2.45,"medianDistance":516,"minHullGap":496.2,"rollsPerMinute":9.55,"rollReversals":1,"pitchReversals":6,"maxRoll":1.852} +{"grid":"initial","variant":"no-lead","name":"straight-crossing","seconds":30,"radius":15,"firstLock":"1.85","onGun":45.3,"requestFire":44.1,"inRange":100,"medianError":3.51,"p95Error":10.73,"longestGap":10.12,"medianDistance":515,"minHullGap":294.7,"rollsPerMinute":6.07,"rollReversals":1,"pitchReversals":0,"maxRoll":1.369} +{"grid":"initial","variant":"no-lead","name":"weaving-away","seconds":30,"radius":15,"firstLock":"0.00","onGun":18.8,"requestFire":16.6,"inRange":100,"medianError":7.56,"p95Error":16.38,"longestGap":5.02,"medianDistance":516,"minHullGap":495.9,"rollsPerMinute":9.84,"rollReversals":7,"pitchReversals":2,"maxRoll":2.431} +{"grid":"initial","variant":"no-lead","name":"straight-away","seconds":30,"radius":15,"firstLock":"0.00","onGun":100,"requestFire":100,"inRange":100,"medianError":0,"p95Error":0,"longestGap":0,"medianDistance":510,"minHullGap":490,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead","name":"horizontal-r400-v150","seconds":60,"radius":15,"firstLock":"2.43","onGun":100,"requestFire":43.2,"inRange":100,"medianError":3.56,"p95Error":4.1,"longestGap":0,"medianDistance":411,"minHullGap":296.1,"rollsPerMinute":0.07,"rollReversals":0,"pitchReversals":0,"maxRoll":0.082} +{"grid":"initial","variant":"no-lead","name":"horizontal-r400-v300","seconds":60,"radius":15,"firstLock":"3.32","onGun":4.1,"requestFire":0.7,"inRange":100,"medianError":15.53,"p95Error":20.21,"longestGap":46.35,"medianDistance":409,"minHullGap":296.1,"rollsPerMinute":12.41,"rollReversals":46,"pitchReversals":0,"maxRoll":2.303} +{"grid":"initial","variant":"no-lead","name":"horizontal-r800-v150","seconds":60,"radius":15,"firstLock":"2.23","onGun":100,"requestFire":60.4,"inRange":100,"medianError":3.31,"p95Error":3.4,"longestGap":0,"medianDistance":515,"minHullGap":494.9,"rollsPerMinute":0.09,"rollReversals":0,"pitchReversals":0,"maxRoll":0.127} +{"grid":"initial","variant":"no-lead","name":"horizontal-r800-v300","seconds":60,"radius":15,"firstLock":"2.67","onGun":24,"requestFire":20.8,"inRange":100,"medianError":7.62,"p95Error":14.63,"longestGap":1.43,"medianDistance":516,"minHullGap":498.7,"rollsPerMinute":9.46,"rollReversals":28,"pitchReversals":0,"maxRoll":1.749} +{"grid":"initial","variant":"no-lead","name":"horizontal-r1600-v150","seconds":60,"radius":15,"firstLock":"2.05","onGun":100,"requestFire":76.4,"inRange":100,"medianError":3.4,"p95Error":3.47,"longestGap":0,"medianDistance":509,"minHullGap":494.4,"rollsPerMinute":0.29,"rollReversals":0,"pitchReversals":0,"maxRoll":0.143} +{"grid":"initial","variant":"no-lead","name":"horizontal-r1600-v300","seconds":60,"radius":15,"firstLock":"2.22","onGun":28.2,"requestFire":23.5,"inRange":100,"medianError":6.17,"p95Error":13.19,"longestGap":2.45,"medianDistance":515,"minHullGap":493.3,"rollsPerMinute":8.8,"rollReversals":5,"pitchReversals":13,"maxRoll":1.637} +{"grid":"initial","variant":"no-lead","name":"vertical-r400-v150","seconds":60,"radius":15,"firstLock":"1.87","onGun":100,"requestFire":43.2,"inRange":100,"medianError":3.56,"p95Error":4.1,"longestGap":0,"medianDistance":410,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead","name":"vertical-r400-v300","seconds":60,"radius":15,"firstLock":"2.58","onGun":99.6,"requestFire":19,"inRange":100,"medianError":3.36,"p95Error":3.93,"longestGap":0.08,"medianDistance":409,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead","name":"vertical-r800-v150","seconds":60,"radius":15,"firstLock":"1.67","onGun":100,"requestFire":60.4,"inRange":100,"medianError":3.32,"p95Error":3.41,"longestGap":0,"medianDistance":515,"minHullGap":494.9,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead","name":"vertical-r800-v300","seconds":60,"radius":15,"firstLock":"2.03","onGun":100,"requestFire":38.4,"inRange":100,"medianError":3.19,"p95Error":3.3,"longestGap":0,"medianDistance":514,"minHullGap":497.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead","name":"vertical-r1600-v150","seconds":60,"radius":15,"firstLock":"1.52","onGun":100,"requestFire":76.8,"inRange":100,"medianError":3.39,"p95Error":3.49,"longestGap":0,"medianDistance":510,"minHullGap":494.5,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead","name":"vertical-r1600-v300","seconds":60,"radius":15,"firstLock":"1.65","onGun":100,"requestFire":60.8,"inRange":100,"medianError":3.34,"p95Error":3.43,"longestGap":0,"medianDistance":512,"minHullGap":491.9,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead","name":"tilted-r400-v150","seconds":60,"radius":15,"firstLock":"2.23","onGun":100,"requestFire":42.7,"inRange":100,"medianError":3.56,"p95Error":3.92,"longestGap":0,"medianDistance":410,"minHullGap":331.1,"rollsPerMinute":0.88,"rollReversals":8,"pitchReversals":0,"maxRoll":0.652} +{"grid":"initial","variant":"no-lead","name":"tilted-r400-v300","seconds":60,"radius":15,"firstLock":"2.80","onGun":1.7,"requestFire":0.7,"inRange":100,"medianError":13.27,"p95Error":21.2,"longestGap":21.6,"medianDistance":409,"minHullGap":331.8,"rollsPerMinute":11.43,"rollReversals":46,"pitchReversals":0,"maxRoll":2.303} +{"grid":"initial","variant":"no-lead","name":"tilted-r800-v150","seconds":60,"radius":15,"firstLock":"2.10","onGun":59.6,"requestFire":36.4,"inRange":100,"medianError":3.41,"p95Error":4.62,"longestGap":0.97,"medianDistance":515,"minHullGap":494,"rollsPerMinute":5.46,"rollReversals":24,"pitchReversals":0,"maxRoll":1.238} +{"grid":"initial","variant":"no-lead","name":"tilted-r800-v300","seconds":60,"radius":15,"firstLock":"3.02","onGun":21.3,"requestFire":20.7,"inRange":100,"medianError":8.74,"p95Error":14.76,"longestGap":1.42,"medianDistance":517,"minHullGap":499.1,"rollsPerMinute":9.82,"rollReversals":28,"pitchReversals":0,"maxRoll":1.758} +{"grid":"initial","variant":"no-lead","name":"tilted-r1600-v150","seconds":60,"radius":15,"firstLock":"1.98","onGun":36.6,"requestFire":34.1,"inRange":100,"medianError":4.58,"p95Error":8.72,"longestGap":3.18,"medianDistance":514,"minHullGap":494.6,"rollsPerMinute":7.28,"rollReversals":6,"pitchReversals":8,"maxRoll":1.313} +{"grid":"initial","variant":"no-lead","name":"tilted-r1600-v300","seconds":60,"radius":15,"firstLock":"2.10","onGun":20.7,"requestFire":20.8,"inRange":100,"medianError":7.28,"p95Error":13.22,"longestGap":2.45,"medianDistance":516,"minHullGap":496.1,"rollsPerMinute":9.62,"rollReversals":1,"pitchReversals":16,"maxRoll":1.852} +{"grid":"initial","variant":"no-lead","name":"straight-crossing","seconds":60,"radius":15,"firstLock":"1.85","onGun":18.1,"requestFire":17.6,"inRange":100,"medianError":11.24,"p95Error":11.33,"longestGap":40.12,"medianDistance":516,"minHullGap":294.7,"rollsPerMinute":10.49,"rollReversals":1,"pitchReversals":0,"maxRoll":1.413} +{"grid":"initial","variant":"no-lead","name":"weaving-away","seconds":60,"radius":15,"firstLock":"0.00","onGun":14.8,"requestFire":13,"inRange":100,"medianError":6.97,"p95Error":15.96,"longestGap":5.02,"medianDistance":515,"minHullGap":495.9,"rollsPerMinute":10.14,"rollReversals":18,"pitchReversals":4,"maxRoll":2.431} +{"grid":"initial","variant":"no-lead","name":"straight-away","seconds":60,"radius":15,"firstLock":"0.00","onGun":100,"requestFire":100,"inRange":100,"medianError":0,"p95Error":0,"longestGap":0,"medianDistance":510,"minHullGap":490,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"low-lead","name":"horizontal-r400-v150","seconds":30,"radius":15,"firstLock":"2.42","onGun":100,"requestFire":100,"inRange":100,"medianError":3.02,"p95Error":3.28,"longestGap":0,"medianDistance":383,"minHullGap":296.1,"rollsPerMinute":0.14,"rollReversals":0,"pitchReversals":0,"maxRoll":0.096} +{"grid":"initial","variant":"low-lead","name":"horizontal-r400-v300","seconds":30,"radius":15,"firstLock":"3.28","onGun":6.9,"requestFire":5.1,"inRange":100,"medianError":10.23,"p95Error":17.54,"longestGap":14.12,"medianDistance":422,"minHullGap":296.1,"rollsPerMinute":11.73,"rollReversals":18,"pitchReversals":0,"maxRoll":2.321} +{"grid":"initial","variant":"low-lead","name":"horizontal-r800-v150","seconds":30,"radius":15,"firstLock":"2.22","onGun":100,"requestFire":100,"inRange":100,"medianError":3.02,"p95Error":3.11,"longestGap":0,"medianDistance":516,"minHullGap":497.4,"rollsPerMinute":0.2,"rollReversals":1,"pitchReversals":0,"maxRoll":0.123} +{"grid":"initial","variant":"low-lead","name":"horizontal-r800-v300","seconds":30,"radius":15,"firstLock":"2.65","onGun":30.4,"requestFire":29.7,"inRange":100,"medianError":5.76,"p95Error":14.5,"longestGap":1.5,"medianDistance":516,"minHullGap":498.1,"rollsPerMinute":9.15,"rollReversals":11,"pitchReversals":0,"maxRoll":1.922} +{"grid":"initial","variant":"low-lead","name":"horizontal-r1600-v150","seconds":30,"radius":15,"firstLock":"2.05","onGun":100,"requestFire":100,"inRange":100,"medianError":3.23,"p95Error":3.33,"longestGap":0,"medianDistance":512,"minHullGap":493.4,"rollsPerMinute":0.25,"rollReversals":0,"pitchReversals":0,"maxRoll":0.108} +{"grid":"initial","variant":"low-lead","name":"horizontal-r1600-v300","seconds":30,"radius":15,"firstLock":"2.22","onGun":42.1,"requestFire":41,"inRange":100,"medianError":4.27,"p95Error":13.23,"longestGap":2.45,"medianDistance":513,"minHullGap":493,"rollsPerMinute":7.63,"rollReversals":5,"pitchReversals":3,"maxRoll":1.827} +{"grid":"initial","variant":"low-lead","name":"vertical-r400-v150","seconds":30,"radius":15,"firstLock":"1.87","onGun":100,"requestFire":100,"inRange":100,"medianError":3.01,"p95Error":3.27,"longestGap":0,"medianDistance":383,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"low-lead","name":"vertical-r400-v300","seconds":30,"radius":15,"firstLock":"2.58","onGun":100,"requestFire":100,"inRange":100,"medianError":2.28,"p95Error":2.66,"longestGap":0,"medianDistance":425,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"low-lead","name":"vertical-r800-v150","seconds":30,"radius":15,"firstLock":"1.67","onGun":100,"requestFire":100,"inRange":100,"medianError":3.02,"p95Error":3.11,"longestGap":0,"medianDistance":515,"minHullGap":497.4,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"low-lead","name":"vertical-r800-v300","seconds":30,"radius":15,"firstLock":"2.03","onGun":100,"requestFire":100,"inRange":100,"medianError":2.67,"p95Error":2.77,"longestGap":0,"medianDistance":509,"minHullGap":491.3,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"low-lead","name":"vertical-r1600-v150","seconds":30,"radius":15,"firstLock":"1.52","onGun":100,"requestFire":100,"inRange":100,"medianError":3.24,"p95Error":3.33,"longestGap":0,"medianDistance":512,"minHullGap":493.5,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"low-lead","name":"vertical-r1600-v300","seconds":30,"radius":15,"firstLock":"1.65","onGun":100,"requestFire":100,"inRange":100,"medianError":3.08,"p95Error":3.17,"longestGap":0,"medianDistance":513,"minHullGap":492.3,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"low-lead","name":"tilted-r400-v150","seconds":30,"radius":15,"firstLock":"2.08","onGun":98.1,"requestFire":97.8,"inRange":100,"medianError":3.04,"p95Error":3.79,"longestGap":0.38,"medianDistance":392,"minHullGap":333.2,"rollsPerMinute":2.51,"rollReversals":11,"pitchReversals":0,"maxRoll":1.188} +{"grid":"initial","variant":"low-lead","name":"tilted-r400-v300","seconds":30,"radius":15,"firstLock":"2.40","onGun":8.7,"requestFire":6.7,"inRange":100,"medianError":10.48,"p95Error":17.99,"longestGap":2.22,"medianDistance":418,"minHullGap":335.9,"rollsPerMinute":11.78,"rollReversals":18,"pitchReversals":0,"maxRoll":2.336} +{"grid":"initial","variant":"low-lead","name":"tilted-r800-v150","seconds":30,"radius":15,"firstLock":"2.05","onGun":27.4,"requestFire":25.4,"inRange":100,"medianError":6.11,"p95Error":11.58,"longestGap":2.28,"medianDistance":517,"minHullGap":498.7,"rollsPerMinute":8.57,"rollReversals":8,"pitchReversals":0,"maxRoll":1.581} +{"grid":"initial","variant":"low-lead","name":"tilted-r800-v300","seconds":30,"radius":15,"firstLock":"2.23","onGun":20.7,"requestFire":21.4,"inRange":100,"medianError":8.64,"p95Error":14.65,"longestGap":1.47,"medianDistance":518,"minHullGap":498,"rollsPerMinute":10.25,"rollReversals":11,"pitchReversals":0,"maxRoll":1.903} +{"grid":"initial","variant":"low-lead","name":"tilted-r1600-v150","seconds":30,"radius":15,"firstLock":"1.97","onGun":36.4,"requestFire":36.4,"inRange":100,"medianError":4.86,"p95Error":9.48,"longestGap":3.07,"medianDistance":517,"minHullGap":495.2,"rollsPerMinute":7.38,"rollReversals":2,"pitchReversals":4,"maxRoll":1.352} +{"grid":"initial","variant":"low-lead","name":"tilted-r1600-v300","seconds":30,"radius":15,"firstLock":"2.03","onGun":21,"requestFire":20.5,"inRange":100,"medianError":7.52,"p95Error":13.04,"longestGap":2.5,"medianDistance":516,"minHullGap":494.9,"rollsPerMinute":9.57,"rollReversals":1,"pitchReversals":6,"maxRoll":1.905} +{"grid":"initial","variant":"low-lead","name":"straight-crossing","seconds":30,"radius":15,"firstLock":"1.87","onGun":34.7,"requestFire":33.7,"inRange":100,"medianError":4.55,"p95Error":11.12,"longestGap":12.87,"medianDistance":516,"minHullGap":293.9,"rollsPerMinute":7.42,"rollReversals":1,"pitchReversals":0,"maxRoll":1.393} +{"grid":"initial","variant":"low-lead","name":"weaving-away","seconds":30,"radius":15,"firstLock":"0.00","onGun":10.5,"requestFire":10.2,"inRange":100,"medianError":8.08,"p95Error":13.68,"longestGap":3.63,"medianDistance":516,"minHullGap":496.2,"rollsPerMinute":11.11,"rollReversals":5,"pitchReversals":0,"maxRoll":1.985} +{"grid":"initial","variant":"low-lead","name":"straight-away","seconds":30,"radius":15,"firstLock":"0.00","onGun":100,"requestFire":100,"inRange":100,"medianError":0,"p95Error":0,"longestGap":0,"medianDistance":510,"minHullGap":490,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"low-lead","name":"horizontal-r400-v150","seconds":60,"radius":15,"firstLock":"2.42","onGun":100,"requestFire":100,"inRange":100,"medianError":2.98,"p95Error":3.26,"longestGap":0,"medianDistance":411,"minHullGap":296.1,"rollsPerMinute":0.07,"rollReversals":0,"pitchReversals":0,"maxRoll":0.096} +{"grid":"initial","variant":"low-lead","name":"horizontal-r400-v300","seconds":60,"radius":15,"firstLock":"3.28","onGun":3.8,"requestFire":2.6,"inRange":100,"medianError":11.94,"p95Error":17.65,"longestGap":14.12,"medianDistance":409,"minHullGap":296.1,"rollsPerMinute":12.41,"rollReversals":46,"pitchReversals":0,"maxRoll":2.321} +{"grid":"initial","variant":"low-lead","name":"horizontal-r800-v150","seconds":60,"radius":15,"firstLock":"2.22","onGun":100,"requestFire":100,"inRange":100,"medianError":3.02,"p95Error":3.12,"longestGap":0,"medianDistance":517,"minHullGap":497.4,"rollsPerMinute":0.09,"rollReversals":1,"pitchReversals":0,"maxRoll":0.123} +{"grid":"initial","variant":"low-lead","name":"horizontal-r800-v300","seconds":60,"radius":15,"firstLock":"2.65","onGun":24.1,"requestFire":24.3,"inRange":100,"medianError":7.39,"p95Error":14.58,"longestGap":1.5,"medianDistance":517,"minHullGap":498.1,"rollsPerMinute":9.86,"rollReversals":28,"pitchReversals":0,"maxRoll":1.922} +{"grid":"initial","variant":"low-lead","name":"horizontal-r1600-v150","seconds":60,"radius":15,"firstLock":"2.05","onGun":100,"requestFire":100,"inRange":100,"medianError":3.26,"p95Error":3.35,"longestGap":0,"medianDistance":510,"minHullGap":492.1,"rollsPerMinute":0.25,"rollReversals":0,"pitchReversals":0,"maxRoll":0.108} +{"grid":"initial","variant":"low-lead","name":"horizontal-r1600-v300","seconds":60,"radius":15,"firstLock":"2.22","onGun":28.6,"requestFire":28,"inRange":100,"medianError":6.31,"p95Error":12.6,"longestGap":2.48,"medianDistance":514,"minHullGap":493,"rollsPerMinute":8.89,"rollReversals":5,"pitchReversals":13,"maxRoll":1.827} +{"grid":"initial","variant":"low-lead","name":"vertical-r400-v150","seconds":60,"radius":15,"firstLock":"1.87","onGun":100,"requestFire":100,"inRange":100,"medianError":2.97,"p95Error":3.27,"longestGap":0,"medianDistance":410,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"low-lead","name":"vertical-r400-v300","seconds":60,"radius":15,"firstLock":"2.58","onGun":100,"requestFire":100,"inRange":100,"medianError":2.3,"p95Error":2.68,"longestGap":0,"medianDistance":409,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"low-lead","name":"vertical-r800-v150","seconds":60,"radius":15,"firstLock":"1.67","onGun":100,"requestFire":100,"inRange":100,"medianError":3.02,"p95Error":3.12,"longestGap":0,"medianDistance":517,"minHullGap":497.4,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"low-lead","name":"vertical-r800-v300","seconds":60,"radius":15,"firstLock":"2.03","onGun":100,"requestFire":100,"inRange":100,"medianError":2.67,"p95Error":2.8,"longestGap":0,"medianDistance":507,"minHullGap":491.3,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"low-lead","name":"vertical-r1600-v150","seconds":60,"radius":15,"firstLock":"1.52","onGun":100,"requestFire":100,"inRange":100,"medianError":3.25,"p95Error":3.36,"longestGap":0,"medianDistance":509,"minHullGap":492.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"low-lead","name":"vertical-r1600-v300","seconds":60,"radius":15,"firstLock":"1.65","onGun":100,"requestFire":100,"inRange":100,"medianError":3.08,"p95Error":3.17,"longestGap":0,"medianDistance":512,"minHullGap":492.3,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"low-lead","name":"tilted-r400-v150","seconds":60,"radius":15,"firstLock":"2.08","onGun":99.2,"requestFire":99.1,"inRange":100,"medianError":2.98,"p95Error":3.39,"longestGap":0.38,"medianDistance":410,"minHullGap":333.2,"rollsPerMinute":1.35,"rollReversals":11,"pitchReversals":0,"maxRoll":1.188} +{"grid":"initial","variant":"low-lead","name":"tilted-r400-v300","seconds":60,"radius":15,"firstLock":"2.40","onGun":11.3,"requestFire":8.9,"inRange":100,"medianError":9.47,"p95Error":17.9,"longestGap":4.07,"medianDistance":409,"minHullGap":335.9,"rollsPerMinute":11.3,"rollReversals":45,"pitchReversals":0,"maxRoll":2.336} +{"grid":"initial","variant":"low-lead","name":"tilted-r800-v150","seconds":60,"radius":15,"firstLock":"2.05","onGun":24.4,"requestFire":23.6,"inRange":100,"medianError":6.82,"p95Error":12.34,"longestGap":2.35,"medianDistance":514,"minHullGap":495,"rollsPerMinute":9.07,"rollReversals":18,"pitchReversals":0,"maxRoll":1.595} +{"grid":"initial","variant":"low-lead","name":"tilted-r800-v300","seconds":60,"radius":15,"firstLock":"2.23","onGun":20,"requestFire":20.7,"inRange":100,"medianError":8.74,"p95Error":14.62,"longestGap":1.47,"medianDistance":518,"minHullGap":498,"rollsPerMinute":10.32,"rollReversals":27,"pitchReversals":0,"maxRoll":1.903} +{"grid":"initial","variant":"low-lead","name":"tilted-r1600-v150","seconds":60,"radius":15,"firstLock":"1.97","onGun":38.3,"requestFire":38,"inRange":100,"medianError":4.48,"p95Error":8.83,"longestGap":3.15,"medianDistance":516,"minHullGap":494.9,"rollsPerMinute":7.16,"rollReversals":7,"pitchReversals":7,"maxRoll":1.352} +{"grid":"initial","variant":"low-lead","name":"tilted-r1600-v300","seconds":60,"radius":15,"firstLock":"2.03","onGun":20.2,"requestFire":19.8,"inRange":100,"medianError":7.53,"p95Error":12.67,"longestGap":2.5,"medianDistance":515,"minHullGap":494.5,"rollsPerMinute":9.62,"rollReversals":1,"pitchReversals":16,"maxRoll":1.905} +{"grid":"initial","variant":"low-lead","name":"straight-crossing","seconds":60,"radius":15,"firstLock":"1.87","onGun":13.9,"requestFire":13.5,"inRange":100,"medianError":11.15,"p95Error":11.23,"longestGap":42.87,"medianDistance":516,"minHullGap":293.9,"rollsPerMinute":10.95,"rollReversals":1,"pitchReversals":0,"maxRoll":1.4} +{"grid":"initial","variant":"low-lead","name":"weaving-away","seconds":60,"radius":15,"firstLock":"0.00","onGun":15.8,"requestFire":15.4,"inRange":100,"medianError":7.92,"p95Error":13.85,"longestGap":3.63,"medianDistance":516,"minHullGap":495.4,"rollsPerMinute":10.47,"rollReversals":17,"pitchReversals":4,"maxRoll":2.262} +{"grid":"initial","variant":"low-lead","name":"straight-away","seconds":60,"radius":15,"firstLock":"0.00","onGun":100,"requestFire":100,"inRange":100,"medianError":0,"p95Error":0,"longestGap":0,"medianDistance":510,"minHullGap":490,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead-hard-gate","name":"horizontal-r400-v150","seconds":30,"radius":15,"firstLock":"3.48","onGun":100,"requestFire":42.5,"inRange":100,"medianError":3.67,"p95Error":4.14,"longestGap":0,"medianDistance":383,"minHullGap":295.8,"rollsPerMinute":0.02,"rollReversals":0,"pitchReversals":0,"maxRoll":0.006} +{"grid":"initial","variant":"no-lead-hard-gate","name":"horizontal-r400-v300","seconds":30,"radius":15,"firstLock":"4.95","onGun":61.1,"requestFire":15.1,"inRange":100,"medianError":3.63,"p95Error":8.85,"longestGap":1.22,"medianDistance":422,"minHullGap":295.8,"rollsPerMinute":1.01,"rollReversals":22,"pitchReversals":0,"maxRoll":0.275} +{"grid":"initial","variant":"no-lead-hard-gate","name":"horizontal-r800-v150","seconds":30,"radius":15,"firstLock":"3.18","onGun":100,"requestFire":59.4,"inRange":100,"medianError":3.31,"p95Error":3.39,"longestGap":0,"medianDistance":516,"minHullGap":498.5,"rollsPerMinute":0.03,"rollReversals":0,"pitchReversals":0,"maxRoll":0.015} +{"grid":"initial","variant":"no-lead-hard-gate","name":"horizontal-r800-v300","seconds":30,"radius":15,"firstLock":"3.72","onGun":93.2,"requestFire":34.6,"inRange":100,"medianError":3.2,"p95Error":3.55,"longestGap":0.25,"medianDistance":514,"minHullGap":497.3,"rollsPerMinute":0.8,"rollReversals":18,"pitchReversals":0,"maxRoll":0.26} +{"grid":"initial","variant":"no-lead-hard-gate","name":"horizontal-r1600-v150","seconds":30,"radius":15,"firstLock":"2.98","onGun":100,"requestFire":74.6,"inRange":100,"medianError":3.39,"p95Error":3.46,"longestGap":0,"medianDistance":513,"minHullGap":494.7,"rollsPerMinute":0.05,"rollReversals":0,"pitchReversals":0,"maxRoll":0.021} +{"grid":"initial","variant":"no-lead-hard-gate","name":"horizontal-r1600-v300","seconds":30,"radius":15,"firstLock":"3.20","onGun":99.5,"requestFire":58.5,"inRange":100,"medianError":3.33,"p95Error":3.44,"longestGap":0.05,"medianDistance":513,"minHullGap":491.9,"rollsPerMinute":0.36,"rollReversals":4,"pitchReversals":0,"maxRoll":0.191} +{"grid":"initial","variant":"no-lead-hard-gate","name":"vertical-r400-v150","seconds":30,"radius":15,"firstLock":"1.87","onGun":100,"requestFire":42.6,"inRange":100,"medianError":3.67,"p95Error":4.13,"longestGap":0,"medianDistance":383,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead-hard-gate","name":"vertical-r400-v300","seconds":30,"radius":15,"firstLock":"2.58","onGun":99.9,"requestFire":19.3,"inRange":100,"medianError":3.32,"p95Error":3.8,"longestGap":0.02,"medianDistance":425,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead-hard-gate","name":"vertical-r800-v150","seconds":30,"radius":15,"firstLock":"1.67","onGun":100,"requestFire":59.6,"inRange":100,"medianError":3.31,"p95Error":3.4,"longestGap":0,"medianDistance":516,"minHullGap":498.6,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead-hard-gate","name":"vertical-r800-v300","seconds":30,"radius":15,"firstLock":"2.03","onGun":100,"requestFire":38.3,"inRange":100,"medianError":3.19,"p95Error":3.3,"longestGap":0,"medianDistance":513,"minHullGap":497.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead-hard-gate","name":"vertical-r1600-v150","seconds":30,"radius":15,"firstLock":"1.52","onGun":100,"requestFire":75.3,"inRange":100,"medianError":3.38,"p95Error":3.46,"longestGap":0,"medianDistance":512,"minHullGap":494.6,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead-hard-gate","name":"vertical-r1600-v300","seconds":30,"radius":15,"firstLock":"1.65","onGun":100,"requestFire":60.6,"inRange":100,"medianError":3.34,"p95Error":3.43,"longestGap":0,"medianDistance":512,"minHullGap":491.9,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead-hard-gate","name":"tilted-r400-v150","seconds":30,"radius":15,"firstLock":"4.13","onGun":100,"requestFire":42.6,"inRange":100,"medianError":3.63,"p95Error":3.94,"longestGap":0,"medianDistance":391,"minHullGap":327.8,"rollsPerMinute":0.57,"rollReversals":0,"pitchReversals":0,"maxRoll":0.12} +{"grid":"initial","variant":"no-lead-hard-gate","name":"tilted-r400-v300","seconds":30,"radius":15,"firstLock":"4.70","onGun":69.1,"requestFire":14.9,"inRange":100,"medianError":3.49,"p95Error":6.68,"longestGap":0.53,"medianDistance":419,"minHullGap":327.8,"rollsPerMinute":1.23,"rollReversals":4,"pitchReversals":0,"maxRoll":0.319} +{"grid":"initial","variant":"no-lead-hard-gate","name":"tilted-r800-v150","seconds":30,"radius":15,"firstLock":"4.15","onGun":100,"requestFire":59.3,"inRange":100,"medianError":3.31,"p95Error":3.39,"longestGap":0,"medianDistance":516,"minHullGap":498.5,"rollsPerMinute":0.07,"rollReversals":0,"pitchReversals":0,"maxRoll":0.051} +{"grid":"initial","variant":"no-lead-hard-gate","name":"tilted-r800-v300","seconds":30,"radius":15,"firstLock":"4.33","onGun":93,"requestFire":34.4,"inRange":100,"medianError":3.21,"p95Error":3.57,"longestGap":0.18,"medianDistance":514,"minHullGap":497.2,"rollsPerMinute":0.82,"rollReversals":19,"pitchReversals":0,"maxRoll":0.274} +{"grid":"initial","variant":"no-lead-hard-gate","name":"tilted-r1600-v150","seconds":30,"radius":15,"firstLock":"4.12","onGun":100,"requestFire":74.5,"inRange":100,"medianError":3.38,"p95Error":3.46,"longestGap":0,"medianDistance":513,"minHullGap":494.7,"rollsPerMinute":0.1,"rollReversals":0,"pitchReversals":0,"maxRoll":0.075} +{"grid":"initial","variant":"no-lead-hard-gate","name":"tilted-r1600-v300","seconds":30,"radius":15,"firstLock":"4.28","onGun":99.6,"requestFire":59.2,"inRange":100,"medianError":3.33,"p95Error":3.43,"longestGap":0.05,"medianDistance":513,"minHullGap":492,"rollsPerMinute":0.32,"rollReversals":3,"pitchReversals":0,"maxRoll":0.175} +{"grid":"initial","variant":"no-lead-hard-gate","name":"straight-crossing","seconds":30,"radius":15,"firstLock":"2.22","onGun":98.3,"requestFire":95.2,"inRange":100,"medianError":3.38,"p95Error":3.49,"longestGap":0.1,"medianDistance":515,"minHullGap":248,"rollsPerMinute":0.01,"rollReversals":0,"pitchReversals":0,"maxRoll":0.02} +{"grid":"initial","variant":"no-lead-hard-gate","name":"weaving-away","seconds":30,"radius":15,"firstLock":"0.00","onGun":86.6,"requestFire":51.7,"inRange":100,"medianError":3.22,"p95Error":4.12,"longestGap":0.28,"medianDistance":512,"minHullGap":494.6,"rollsPerMinute":0.61,"rollReversals":15,"pitchReversals":5,"maxRoll":0.265} +{"grid":"initial","variant":"no-lead-hard-gate","name":"straight-away","seconds":30,"radius":15,"firstLock":"0.00","onGun":100,"requestFire":100,"inRange":100,"medianError":0,"p95Error":0,"longestGap":0,"medianDistance":510,"minHullGap":490,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead-hard-gate","name":"horizontal-r400-v150","seconds":60,"radius":15,"firstLock":"3.48","onGun":100,"requestFire":43.1,"inRange":100,"medianError":3.56,"p95Error":4.1,"longestGap":0,"medianDistance":411,"minHullGap":295.8,"rollsPerMinute":0.01,"rollReversals":0,"pitchReversals":0,"maxRoll":0.006} +{"grid":"initial","variant":"no-lead-hard-gate","name":"horizontal-r400-v300","seconds":60,"radius":15,"firstLock":"4.95","onGun":46.8,"requestFire":12.7,"inRange":100,"medianError":4.04,"p95Error":9.32,"longestGap":1.28,"medianDistance":409,"minHullGap":295.8,"rollsPerMinute":1.2,"rollReversals":64,"pitchReversals":0,"maxRoll":0.282} +{"grid":"initial","variant":"no-lead-hard-gate","name":"horizontal-r800-v150","seconds":60,"radius":15,"firstLock":"3.18","onGun":100,"requestFire":60.3,"inRange":100,"medianError":3.31,"p95Error":3.4,"longestGap":0,"medianDistance":515,"minHullGap":494.9,"rollsPerMinute":0.02,"rollReversals":0,"pitchReversals":0,"maxRoll":0.015} +{"grid":"initial","variant":"no-lead-hard-gate","name":"horizontal-r800-v300","seconds":60,"radius":15,"firstLock":"3.72","onGun":92.8,"requestFire":34.7,"inRange":100,"medianError":3.21,"p95Error":3.58,"longestGap":0.25,"medianDistance":514,"minHullGap":497.3,"rollsPerMinute":0.81,"rollReversals":46,"pitchReversals":0,"maxRoll":0.26} +{"grid":"initial","variant":"no-lead-hard-gate","name":"horizontal-r1600-v150","seconds":60,"radius":15,"firstLock":"2.98","onGun":100,"requestFire":76.4,"inRange":100,"medianError":3.4,"p95Error":3.48,"longestGap":0,"medianDistance":510,"minHullGap":494.6,"rollsPerMinute":0.05,"rollReversals":0,"pitchReversals":0,"maxRoll":0.021} +{"grid":"initial","variant":"no-lead-hard-gate","name":"horizontal-r1600-v300","seconds":60,"radius":15,"firstLock":"3.20","onGun":98.6,"requestFire":58.4,"inRange":100,"medianError":3.34,"p95Error":3.45,"longestGap":0.1,"medianDistance":512,"minHullGap":491.9,"rollsPerMinute":0.4,"rollReversals":19,"pitchReversals":0,"maxRoll":0.191} +{"grid":"initial","variant":"no-lead-hard-gate","name":"vertical-r400-v150","seconds":60,"radius":15,"firstLock":"1.87","onGun":100,"requestFire":43.2,"inRange":100,"medianError":3.56,"p95Error":4.1,"longestGap":0,"medianDistance":410,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead-hard-gate","name":"vertical-r400-v300","seconds":60,"radius":15,"firstLock":"2.58","onGun":99.6,"requestFire":19,"inRange":100,"medianError":3.36,"p95Error":3.93,"longestGap":0.08,"medianDistance":409,"minHullGap":298,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead-hard-gate","name":"vertical-r800-v150","seconds":60,"radius":15,"firstLock":"1.67","onGun":100,"requestFire":60.4,"inRange":100,"medianError":3.32,"p95Error":3.41,"longestGap":0,"medianDistance":515,"minHullGap":494.9,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead-hard-gate","name":"vertical-r800-v300","seconds":60,"radius":15,"firstLock":"2.03","onGun":100,"requestFire":38.4,"inRange":100,"medianError":3.19,"p95Error":3.3,"longestGap":0,"medianDistance":514,"minHullGap":497.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead-hard-gate","name":"vertical-r1600-v150","seconds":60,"radius":15,"firstLock":"1.52","onGun":100,"requestFire":76.8,"inRange":100,"medianError":3.39,"p95Error":3.49,"longestGap":0,"medianDistance":510,"minHullGap":494.5,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead-hard-gate","name":"vertical-r1600-v300","seconds":60,"radius":15,"firstLock":"1.65","onGun":100,"requestFire":60.8,"inRange":100,"medianError":3.34,"p95Error":3.43,"longestGap":0,"medianDistance":512,"minHullGap":491.9,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"initial","variant":"no-lead-hard-gate","name":"tilted-r400-v150","seconds":60,"radius":15,"firstLock":"4.13","onGun":100,"requestFire":43,"inRange":100,"medianError":3.57,"p95Error":3.92,"longestGap":0,"medianDistance":410,"minHullGap":327.8,"rollsPerMinute":0.56,"rollReversals":0,"pitchReversals":0,"maxRoll":0.12} +{"grid":"initial","variant":"no-lead-hard-gate","name":"tilted-r400-v300","seconds":60,"radius":15,"firstLock":"4.70","onGun":71.5,"requestFire":15.2,"inRange":100,"medianError":3.51,"p95Error":6.37,"longestGap":0.67,"medianDistance":409,"minHullGap":327.8,"rollsPerMinute":1.2,"rollReversals":4,"pitchReversals":0,"maxRoll":0.319} +{"grid":"initial","variant":"no-lead-hard-gate","name":"tilted-r800-v150","seconds":60,"radius":15,"firstLock":"4.15","onGun":100,"requestFire":60.3,"inRange":100,"medianError":3.31,"p95Error":3.4,"longestGap":0,"medianDistance":515,"minHullGap":494.9,"rollsPerMinute":0.04,"rollReversals":0,"pitchReversals":0,"maxRoll":0.051} +{"grid":"initial","variant":"no-lead-hard-gate","name":"tilted-r800-v300","seconds":60,"radius":15,"firstLock":"4.33","onGun":92.3,"requestFire":34.6,"inRange":100,"medianError":3.21,"p95Error":3.59,"longestGap":0.18,"medianDistance":514,"minHullGap":497.2,"rollsPerMinute":0.82,"rollReversals":47,"pitchReversals":0,"maxRoll":0.274} +{"grid":"initial","variant":"no-lead-hard-gate","name":"tilted-r1600-v150","seconds":60,"radius":15,"firstLock":"4.12","onGun":100,"requestFire":76.4,"inRange":100,"medianError":3.39,"p95Error":3.48,"longestGap":0,"medianDistance":510,"minHullGap":494.5,"rollsPerMinute":0.08,"rollReversals":0,"pitchReversals":0,"maxRoll":0.075} +{"grid":"initial","variant":"no-lead-hard-gate","name":"tilted-r1600-v300","seconds":60,"radius":15,"firstLock":"4.28","onGun":98.8,"requestFire":58.9,"inRange":100,"medianError":3.34,"p95Error":3.44,"longestGap":0.1,"medianDistance":512,"minHullGap":491.9,"rollsPerMinute":0.37,"rollReversals":17,"pitchReversals":0,"maxRoll":0.184} +{"grid":"initial","variant":"no-lead-hard-gate","name":"straight-crossing","seconds":60,"radius":15,"firstLock":"2.22","onGun":98.7,"requestFire":98.1,"inRange":100,"medianError":3.37,"p95Error":3.5,"longestGap":0.3,"medianDistance":513,"minHullGap":248,"rollsPerMinute":0.01,"rollReversals":0,"pitchReversals":0,"maxRoll":0.02} +{"grid":"initial","variant":"no-lead-hard-gate","name":"weaving-away","seconds":60,"radius":15,"firstLock":"0.00","onGun":87,"requestFire":51.6,"inRange":100,"medianError":3.22,"p95Error":4.03,"longestGap":0.28,"medianDistance":512,"minHullGap":494.6,"rollsPerMinute":0.61,"rollReversals":37,"pitchReversals":12,"maxRoll":0.265} +{"grid":"initial","variant":"no-lead-hard-gate","name":"straight-away","seconds":60,"radius":15,"firstLock":"0.00","onGun":100,"requestFire":100,"inRange":100,"medianError":0,"p95Error":0,"longestGap":0,"medianDistance":510,"minHullGap":490,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"horizontal-r600-v200-phase0.7","seconds":45,"radius":15,"firstLock":"2.97","onGun":100,"requestFire":100,"inRange":100,"medianError":1.81,"p95Error":1.91,"longestGap":0,"medianDistance":515,"minHullGap":495.1,"rollsPerMinute":0.27,"rollReversals":6,"pitchReversals":0,"maxRoll":0.174} +{"grid":"holdout","variant":"baseline","name":"horizontal-r600-v200-phase2.1","seconds":45,"radius":15,"firstLock":"2.13","onGun":100,"requestFire":100,"inRange":100,"medianError":1.79,"p95Error":1.91,"longestGap":0,"medianDistance":517,"minHullGap":456.8,"rollsPerMinute":0.41,"rollReversals":8,"pitchReversals":0,"maxRoll":0.323} +{"grid":"holdout","variant":"baseline","name":"horizontal-r600-v200-phase4.2","seconds":45,"radius":15,"firstLock":"1.48","onGun":24.1,"requestFire":24,"inRange":100,"medianError":7.13,"p95Error":11.54,"longestGap":1.7,"medianDistance":517,"minHullGap":495.9,"rollsPerMinute":8.25,"rollReversals":17,"pitchReversals":0,"maxRoll":1.796} +{"grid":"holdout","variant":"baseline","name":"horizontal-r600-v350-phase0.7","seconds":45,"radius":15,"firstLock":"3.53","onGun":25.6,"requestFire":23.2,"inRange":100,"medianError":6.2,"p95Error":10.17,"longestGap":1.22,"medianDistance":516,"minHullGap":496.4,"rollsPerMinute":6.89,"rollReversals":25,"pitchReversals":0,"maxRoll":1.488} +{"grid":"holdout","variant":"baseline","name":"horizontal-r600-v350-phase2.1","seconds":45,"radius":15,"firstLock":"2.03","onGun":26,"requestFire":22.6,"inRange":100,"medianError":6.27,"p95Error":10.15,"longestGap":1.23,"medianDistance":516,"minHullGap":461.3,"rollsPerMinute":6.94,"rollReversals":25,"pitchReversals":0,"maxRoll":1.487} +{"grid":"holdout","variant":"baseline","name":"horizontal-r600-v350-phase4.2","seconds":45,"radius":15,"firstLock":"0.80","onGun":18.7,"requestFire":19,"inRange":100,"medianError":7.71,"p95Error":12.42,"longestGap":1.13,"medianDistance":517,"minHullGap":495.8,"rollsPerMinute":11.39,"rollReversals":27,"pitchReversals":0,"maxRoll":2.303} +{"grid":"holdout","variant":"baseline","name":"horizontal-r1200-v200-phase0.7","seconds":45,"radius":15,"firstLock":"2.67","onGun":100,"requestFire":100,"inRange":100,"medianError":2.54,"p95Error":2.63,"longestGap":0,"medianDistance":512,"minHullGap":491.7,"rollsPerMinute":0.33,"rollReversals":3,"pitchReversals":0,"maxRoll":0.154} +{"grid":"holdout","variant":"baseline","name":"horizontal-r1200-v200-phase2.1","seconds":45,"radius":15,"firstLock":"2.32","onGun":100,"requestFire":100,"inRange":100,"medianError":2.54,"p95Error":2.63,"longestGap":0,"medianDistance":512,"minHullGap":491.8,"rollsPerMinute":0.46,"rollReversals":6,"pitchReversals":0,"maxRoll":0.271} +{"grid":"holdout","variant":"baseline","name":"horizontal-r1200-v200-phase4.2","seconds":45,"radius":15,"firstLock":"2.57","onGun":23,"requestFire":23,"inRange":100,"medianError":7.11,"p95Error":11.67,"longestGap":2.7,"medianDistance":514,"minHullGap":495.5,"rollsPerMinute":8.92,"rollReversals":2,"pitchReversals":9,"maxRoll":1.694} +{"grid":"holdout","variant":"baseline","name":"horizontal-r1200-v350-phase0.7","seconds":45,"radius":15,"firstLock":"2.93","onGun":24.2,"requestFire":23.7,"inRange":100,"medianError":7.27,"p95Error":17.44,"longestGap":1.92,"medianDistance":515,"minHullGap":495,"rollsPerMinute":9.92,"rollReversals":12,"pitchReversals":5,"maxRoll":2.442} +{"grid":"holdout","variant":"baseline","name":"horizontal-r1200-v350-phase2.1","seconds":45,"radius":15,"firstLock":"2.17","onGun":21.2,"requestFire":20.5,"inRange":100,"medianError":7.96,"p95Error":17.7,"longestGap":1.88,"medianDistance":516,"minHullGap":495.1,"rollsPerMinute":10.11,"rollReversals":11,"pitchReversals":6,"maxRoll":2.452} +{"grid":"holdout","variant":"baseline","name":"horizontal-r1200-v350-phase4.2","seconds":45,"radius":15,"firstLock":"1.92","onGun":22,"requestFire":21.2,"inRange":100,"medianError":7.87,"p95Error":17.68,"longestGap":1.9,"medianDistance":515,"minHullGap":495.1,"rollsPerMinute":10.2,"rollReversals":12,"pitchReversals":5,"maxRoll":2.449} +{"grid":"holdout","variant":"baseline","name":"horizontal-r2400-v200-phase0.7","seconds":45,"radius":15,"firstLock":"2.53","onGun":100,"requestFire":100,"inRange":100,"medianError":3,"p95Error":3.12,"longestGap":0,"medianDistance":515,"minHullGap":493.6,"rollsPerMinute":0.54,"rollReversals":8,"pitchReversals":0,"maxRoll":0.224} +{"grid":"holdout","variant":"baseline","name":"horizontal-r2400-v200-phase2.1","seconds":45,"radius":15,"firstLock":"2.47","onGun":100,"requestFire":100,"inRange":100,"medianError":3.02,"p95Error":3.18,"longestGap":0,"medianDistance":515,"minHullGap":493.7,"rollsPerMinute":0.6,"rollReversals":8,"pitchReversals":0,"maxRoll":0.238} +{"grid":"holdout","variant":"baseline","name":"horizontal-r2400-v200-phase4.2","seconds":45,"radius":15,"firstLock":"3.80","onGun":13.9,"requestFire":13.9,"inRange":100,"medianError":8.57,"p95Error":12.56,"longestGap":24.28,"medianDistance":516,"minHullGap":494.3,"rollsPerMinute":11.13,"rollReversals":3,"pitchReversals":2,"maxRoll":1.776} +{"grid":"holdout","variant":"baseline","name":"horizontal-r2400-v350-phase0.7","seconds":45,"radius":15,"firstLock":"2.63","onGun":30.5,"requestFire":30.6,"inRange":100,"medianError":6.2,"p95Error":12.55,"longestGap":5.18,"medianDistance":513,"minHullGap":492.2,"rollsPerMinute":8.1,"rollReversals":13,"pitchReversals":4,"maxRoll":1.793} +{"grid":"holdout","variant":"baseline","name":"horizontal-r2400-v350-phase2.1","seconds":45,"radius":15,"firstLock":"2.38","onGun":19.9,"requestFire":20.1,"inRange":100,"medianError":6.84,"p95Error":12.99,"longestGap":4.7,"medianDistance":515,"minHullGap":494,"rollsPerMinute":8.95,"rollReversals":12,"pitchReversals":7,"maxRoll":1.806} +{"grid":"holdout","variant":"baseline","name":"horizontal-r2400-v350-phase4.2","seconds":45,"radius":15,"firstLock":"3.42","onGun":18.8,"requestFire":18.9,"inRange":100,"medianError":7.01,"p95Error":12.85,"longestGap":4.82,"medianDistance":515,"minHullGap":492.2,"rollsPerMinute":9.18,"rollReversals":11,"pitchReversals":7,"maxRoll":1.779} +{"grid":"holdout","variant":"baseline","name":"vertical-r600-v200-phase0.7","seconds":45,"radius":15,"firstLock":"2.43","onGun":100,"requestFire":100,"inRange":100,"medianError":1.82,"p95Error":1.91,"longestGap":0,"medianDistance":515,"minHullGap":494.9,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r600-v200-phase2.1","seconds":45,"radius":15,"firstLock":"1.77","onGun":100,"requestFire":100,"inRange":100,"medianError":1.78,"p95Error":1.91,"longestGap":0,"medianDistance":517,"minHullGap":464.7,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r600-v200-phase4.2","seconds":45,"radius":15,"firstLock":"0.55","onGun":100,"requestFire":100,"inRange":100,"medianError":1.82,"p95Error":1.9,"longestGap":0,"medianDistance":517,"minHullGap":496.3,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r600-v350-phase0.7","seconds":45,"radius":15,"firstLock":"3.10","onGun":100,"requestFire":100,"inRange":100,"medianError":1.13,"p95Error":1.46,"longestGap":0,"medianDistance":514,"minHullGap":496.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r600-v350-phase2.1","seconds":45,"radius":15,"firstLock":"1.70","onGun":100,"requestFire":100,"inRange":100,"medianError":1.22,"p95Error":1.48,"longestGap":0,"medianDistance":516,"minHullGap":469,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r600-v350-phase4.2","seconds":45,"radius":15,"firstLock":"0.57","onGun":100,"requestFire":100,"inRange":100,"medianError":1.14,"p95Error":1.46,"longestGap":0,"medianDistance":514,"minHullGap":496.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r1200-v200-phase0.7","seconds":45,"radius":15,"firstLock":"2.17","onGun":100,"requestFire":100,"inRange":100,"medianError":2.54,"p95Error":2.62,"longestGap":0,"medianDistance":512,"minHullGap":491.7,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r1200-v200-phase2.1","seconds":45,"radius":15,"firstLock":"1.92","onGun":100,"requestFire":100,"inRange":100,"medianError":2.53,"p95Error":2.62,"longestGap":0,"medianDistance":512,"minHullGap":491.7,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r1200-v200-phase4.2","seconds":45,"radius":15,"firstLock":"0.48","onGun":100,"requestFire":100,"inRange":100,"medianError":2.54,"p95Error":2.62,"longestGap":0,"medianDistance":512,"minHullGap":491.7,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r1200-v350-phase0.7","seconds":45,"radius":15,"firstLock":"2.42","onGun":100,"requestFire":100,"inRange":100,"medianError":2.06,"p95Error":2.17,"longestGap":0,"medianDistance":514,"minHullGap":492,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r1200-v350-phase2.1","seconds":45,"radius":15,"firstLock":"1.78","onGun":100,"requestFire":100,"inRange":100,"medianError":2.06,"p95Error":2.17,"longestGap":0,"medianDistance":514,"minHullGap":492,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r1200-v350-phase4.2","seconds":45,"radius":15,"firstLock":"0.48","onGun":100,"requestFire":100,"inRange":100,"medianError":2.07,"p95Error":2.17,"longestGap":0,"medianDistance":514,"minHullGap":492,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r2400-v200-phase0.7","seconds":45,"radius":15,"firstLock":"2.03","onGun":100,"requestFire":100,"inRange":100,"medianError":3.02,"p95Error":3.09,"longestGap":0,"medianDistance":514,"minHullGap":493.6,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r2400-v200-phase2.1","seconds":45,"radius":15,"firstLock":"2.03","onGun":100,"requestFire":100,"inRange":100,"medianError":3.02,"p95Error":3.08,"longestGap":0,"medianDistance":515,"minHullGap":493.6,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r2400-v200-phase4.2","seconds":45,"radius":15,"firstLock":"0.53","onGun":100,"requestFire":100,"inRange":100,"medianError":3.02,"p95Error":3.09,"longestGap":0,"medianDistance":514,"minHullGap":493.6,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r2400-v350-phase0.7","seconds":45,"radius":15,"firstLock":"2.13","onGun":100,"requestFire":100,"inRange":100,"medianError":2.75,"p95Error":2.83,"longestGap":0,"medianDistance":510,"minHullGap":490.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r2400-v350-phase2.1","seconds":45,"radius":15,"firstLock":"1.95","onGun":100,"requestFire":100,"inRange":100,"medianError":2.75,"p95Error":2.83,"longestGap":0,"medianDistance":510,"minHullGap":490.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r2400-v350-phase4.2","seconds":45,"radius":15,"firstLock":"0.50","onGun":100,"requestFire":100,"inRange":100,"medianError":2.76,"p95Error":2.83,"longestGap":0,"medianDistance":510,"minHullGap":490,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"tilted-r600-v200-phase0.7","seconds":45,"radius":15,"firstLock":"2.10","onGun":19,"requestFire":18.3,"inRange":100,"medianError":8.57,"p95Error":11.71,"longestGap":1.7,"medianDistance":518,"minHullGap":496.1,"rollsPerMinute":8.85,"rollReversals":16,"pitchReversals":0,"maxRoll":1.779} +{"grid":"holdout","variant":"baseline","name":"tilted-r600-v200-phase2.1","seconds":45,"radius":15,"firstLock":"1.83","onGun":24.8,"requestFire":25,"inRange":100,"medianError":6.95,"p95Error":11.18,"longestGap":1.7,"medianDistance":517,"minHullGap":497.5,"rollsPerMinute":8.02,"rollReversals":18,"pitchReversals":0,"maxRoll":1.765} +{"grid":"holdout","variant":"baseline","name":"tilted-r600-v200-phase4.2","seconds":45,"radius":15,"firstLock":"0.83","onGun":23.7,"requestFire":23.8,"inRange":100,"medianError":7.17,"p95Error":11.47,"longestGap":1.7,"medianDistance":517,"minHullGap":494.9,"rollsPerMinute":8.2,"rollReversals":17,"pitchReversals":0,"maxRoll":1.778} +{"grid":"holdout","variant":"baseline","name":"tilted-r600-v350-phase0.7","seconds":45,"radius":15,"firstLock":"2.32","onGun":24.4,"requestFire":21.2,"inRange":100,"medianError":6.45,"p95Error":10.2,"longestGap":1.22,"medianDistance":517,"minHullGap":497.4,"rollsPerMinute":6.97,"rollReversals":25,"pitchReversals":0,"maxRoll":1.413} +{"grid":"holdout","variant":"baseline","name":"tilted-r600-v350-phase2.1","seconds":45,"radius":15,"firstLock":"1.80","onGun":25.2,"requestFire":21.4,"inRange":100,"medianError":6.28,"p95Error":10.21,"longestGap":1.22,"medianDistance":517,"minHullGap":497.8,"rollsPerMinute":6.89,"rollReversals":25,"pitchReversals":0,"maxRoll":1.374} +{"grid":"holdout","variant":"baseline","name":"tilted-r600-v350-phase4.2","seconds":45,"radius":15,"firstLock":"0.83","onGun":19.9,"requestFire":21.1,"inRange":100,"medianError":7.93,"p95Error":12.61,"longestGap":1.03,"medianDistance":517,"minHullGap":497,"rollsPerMinute":12.32,"rollReversals":28,"pitchReversals":0,"maxRoll":2.303} +{"grid":"holdout","variant":"baseline","name":"tilted-r1200-v200-phase0.7","seconds":45,"radius":15,"firstLock":"2.03","onGun":22.1,"requestFire":21.9,"inRange":100,"medianError":7.89,"p95Error":11.18,"longestGap":2.6,"medianDistance":517,"minHullGap":495.5,"rollsPerMinute":9.16,"rollReversals":0,"pitchReversals":10,"maxRoll":1.934} +{"grid":"holdout","variant":"baseline","name":"tilted-r1200-v200-phase2.1","seconds":45,"radius":15,"firstLock":"1.88","onGun":22.2,"requestFire":22.2,"inRange":100,"medianError":7.72,"p95Error":11.57,"longestGap":2.65,"medianDistance":515,"minHullGap":495.5,"rollsPerMinute":9.1,"rollReversals":1,"pitchReversals":10,"maxRoll":1.897} +{"grid":"holdout","variant":"baseline","name":"tilted-r1200-v200-phase4.2","seconds":45,"radius":15,"firstLock":"0.92","onGun":21.9,"requestFire":21.7,"inRange":100,"medianError":7.97,"p95Error":11.36,"longestGap":2.62,"medianDistance":515,"minHullGap":494.8,"rollsPerMinute":9.16,"rollReversals":0,"pitchReversals":10,"maxRoll":1.858} +{"grid":"holdout","variant":"baseline","name":"tilted-r1200-v350-phase0.7","seconds":45,"radius":15,"firstLock":"2.12","onGun":20.7,"requestFire":20.1,"inRange":100,"medianError":8.03,"p95Error":18.03,"longestGap":1.9,"medianDistance":515,"minHullGap":495.1,"rollsPerMinute":10.44,"rollReversals":11,"pitchReversals":5,"maxRoll":2.453} +{"grid":"holdout","variant":"baseline","name":"tilted-r1200-v350-phase2.1","seconds":45,"radius":15,"firstLock":"1.87","onGun":20.2,"requestFire":19.9,"inRange":100,"medianError":8.23,"p95Error":16.59,"longestGap":2,"medianDistance":516,"minHullGap":495.1,"rollsPerMinute":10.25,"rollReversals":13,"pitchReversals":3,"maxRoll":2.448} +{"grid":"holdout","variant":"baseline","name":"tilted-r1200-v350-phase4.2","seconds":45,"radius":15,"firstLock":"0.87","onGun":19.4,"requestFire":19.7,"inRange":100,"medianError":8.83,"p95Error":16.33,"longestGap":2,"medianDistance":516,"minHullGap":496.2,"rollsPerMinute":10.5,"rollReversals":16,"pitchReversals":0,"maxRoll":2.386} +{"grid":"holdout","variant":"baseline","name":"tilted-r2400-v200-phase0.7","seconds":45,"radius":15,"firstLock":"2.35","onGun":8.7,"requestFire":8.7,"inRange":100,"medianError":9.31,"p95Error":12.58,"longestGap":25.4,"medianDistance":517,"minHullGap":498,"rollsPerMinute":11.59,"rollReversals":3,"pitchReversals":1,"maxRoll":1.763} +{"grid":"holdout","variant":"baseline","name":"tilted-r2400-v200-phase2.1","seconds":45,"radius":15,"firstLock":"1.93","onGun":14.9,"requestFire":14.9,"inRange":100,"medianError":8.08,"p95Error":12.37,"longestGap":19.75,"medianDistance":517,"minHullGap":492.9,"rollsPerMinute":10.56,"rollReversals":5,"pitchReversals":3,"maxRoll":1.762} +{"grid":"holdout","variant":"baseline","name":"tilted-r2400-v200-phase4.2","seconds":45,"radius":15,"firstLock":"3.58","onGun":41.4,"requestFire":41.6,"inRange":100,"medianError":4.22,"p95Error":8.05,"longestGap":3.23,"medianDistance":513,"minHullGap":492.9,"rollsPerMinute":6.82,"rollReversals":6,"pitchReversals":7,"maxRoll":1.765} +{"grid":"holdout","variant":"baseline","name":"tilted-r2400-v350-phase0.7","seconds":45,"radius":15,"firstLock":"4.90","onGun":20.7,"requestFire":20.8,"inRange":100,"medianError":6.99,"p95Error":12.7,"longestGap":4.83,"medianDistance":515,"minHullGap":493,"rollsPerMinute":9.05,"rollReversals":11,"pitchReversals":8,"maxRoll":2.294} +{"grid":"holdout","variant":"baseline","name":"tilted-r2400-v350-phase2.1","seconds":45,"radius":15,"firstLock":"4.58","onGun":21.4,"requestFire":21.4,"inRange":100,"medianError":6.49,"p95Error":12.35,"longestGap":4.7,"medianDistance":514,"minHullGap":493.9,"rollsPerMinute":8.78,"rollReversals":10,"pitchReversals":9,"maxRoll":2.386} +{"grid":"holdout","variant":"baseline","name":"tilted-r2400-v350-phase4.2","seconds":45,"radius":15,"firstLock":"3.42","onGun":21,"requestFire":21.2,"inRange":100,"medianError":6.76,"p95Error":12.43,"longestGap":4.53,"medianDistance":516,"minHullGap":494.2,"rollsPerMinute":8.87,"rollReversals":10,"pitchReversals":9,"maxRoll":1.714} +{"grid":"holdout","variant":"baseline","name":"horizontal-r600-v200-phase0.7","seconds":90,"radius":15,"firstLock":"2.97","onGun":100,"requestFire":100,"inRange":100,"medianError":1.83,"p95Error":1.9,"longestGap":0,"medianDistance":514,"minHullGap":495.1,"rollsPerMinute":0.13,"rollReversals":6,"pitchReversals":0,"maxRoll":0.174} +{"grid":"holdout","variant":"baseline","name":"horizontal-r600-v200-phase2.1","seconds":90,"radius":15,"firstLock":"2.13","onGun":100,"requestFire":100,"inRange":100,"medianError":1.83,"p95Error":1.9,"longestGap":0,"medianDistance":517,"minHullGap":456.8,"rollsPerMinute":0.19,"rollReversals":8,"pitchReversals":0,"maxRoll":0.323} +{"grid":"holdout","variant":"baseline","name":"horizontal-r600-v200-phase4.2","seconds":90,"radius":15,"firstLock":"1.48","onGun":21.3,"requestFire":21,"inRange":100,"medianError":7.67,"p95Error":11.73,"longestGap":1.7,"medianDistance":518,"minHullGap":495.9,"rollsPerMinute":8.64,"rollReversals":39,"pitchReversals":0,"maxRoll":1.796} +{"grid":"holdout","variant":"baseline","name":"horizontal-r600-v350-phase0.7","seconds":90,"radius":15,"firstLock":"3.53","onGun":21.4,"requestFire":20.4,"inRange":100,"medianError":7.05,"p95Error":11.95,"longestGap":1.23,"medianDistance":517,"minHullGap":496.4,"rollsPerMinute":9.17,"rollReversals":59,"pitchReversals":0,"maxRoll":2.297} +{"grid":"holdout","variant":"baseline","name":"horizontal-r600-v350-phase2.1","seconds":90,"radius":15,"firstLock":"2.03","onGun":22.1,"requestFire":20.6,"inRange":100,"medianError":7.03,"p95Error":12.21,"longestGap":1.23,"medianDistance":516,"minHullGap":461.3,"rollsPerMinute":9.21,"rollReversals":60,"pitchReversals":0,"maxRoll":2.302} +{"grid":"holdout","variant":"baseline","name":"horizontal-r600-v350-phase4.2","seconds":90,"radius":15,"firstLock":"0.80","onGun":19.5,"requestFire":20.3,"inRange":100,"medianError":7.86,"p95Error":12.71,"longestGap":1.13,"medianDistance":517,"minHullGap":495.8,"rollsPerMinute":12.01,"rollReversals":63,"pitchReversals":0,"maxRoll":2.303} +{"grid":"holdout","variant":"baseline","name":"horizontal-r1200-v200-phase0.7","seconds":90,"radius":15,"firstLock":"2.67","onGun":100,"requestFire":100,"inRange":100,"medianError":2.55,"p95Error":2.63,"longestGap":0,"medianDistance":511,"minHullGap":491.7,"rollsPerMinute":0.2,"rollReversals":3,"pitchReversals":0,"maxRoll":0.154} +{"grid":"holdout","variant":"baseline","name":"horizontal-r1200-v200-phase2.1","seconds":90,"radius":15,"firstLock":"2.32","onGun":100,"requestFire":100,"inRange":100,"medianError":2.55,"p95Error":2.63,"longestGap":0,"medianDistance":511,"minHullGap":491.7,"rollsPerMinute":0.28,"rollReversals":6,"pitchReversals":0,"maxRoll":0.271} +{"grid":"holdout","variant":"baseline","name":"horizontal-r1200-v200-phase4.2","seconds":90,"radius":15,"firstLock":"2.57","onGun":22.4,"requestFire":22.4,"inRange":100,"medianError":7.49,"p95Error":11.61,"longestGap":2.7,"medianDistance":514,"minHullGap":495.5,"rollsPerMinute":8.99,"rollReversals":2,"pitchReversals":22,"maxRoll":1.694} +{"grid":"holdout","variant":"baseline","name":"horizontal-r1200-v350-phase0.7","seconds":90,"radius":15,"firstLock":"2.93","onGun":22.3,"requestFire":21.8,"inRange":100,"medianError":7.64,"p95Error":17.65,"longestGap":1.92,"medianDistance":515,"minHullGap":495,"rollsPerMinute":10.17,"rollReversals":26,"pitchReversals":12,"maxRoll":2.446} +{"grid":"holdout","variant":"baseline","name":"horizontal-r1200-v350-phase2.1","seconds":90,"radius":15,"firstLock":"2.17","onGun":20.6,"requestFire":20.4,"inRange":100,"medianError":8.29,"p95Error":16.68,"longestGap":2,"medianDistance":516,"minHullGap":495.1,"rollsPerMinute":10.32,"rollReversals":32,"pitchReversals":6,"maxRoll":2.452} +{"grid":"holdout","variant":"baseline","name":"horizontal-r1200-v350-phase4.2","seconds":90,"radius":15,"firstLock":"1.92","onGun":21.4,"requestFire":20.8,"inRange":100,"medianError":7.96,"p95Error":17.68,"longestGap":1.92,"medianDistance":515,"minHullGap":495.1,"rollsPerMinute":10.29,"rollReversals":26,"pitchReversals":12,"maxRoll":2.449} +{"grid":"holdout","variant":"baseline","name":"horizontal-r2400-v200-phase0.7","seconds":90,"radius":15,"firstLock":"2.53","onGun":66.6,"requestFire":66.6,"inRange":100,"medianError":3.08,"p95Error":10.7,"longestGap":12.27,"medianDistance":514,"minHullGap":492.5,"rollsPerMinute":4.31,"rollReversals":14,"pitchReversals":4,"maxRoll":1.76} +{"grid":"holdout","variant":"baseline","name":"horizontal-r2400-v200-phase2.1","seconds":90,"radius":15,"firstLock":"2.47","onGun":55.9,"requestFire":56,"inRange":100,"medianError":3.17,"p95Error":12.22,"longestGap":31.15,"medianDistance":515,"minHullGap":493.7,"rollsPerMinute":5.9,"rollReversals":11,"pitchReversals":1,"maxRoll":1.762} +{"grid":"holdout","variant":"baseline","name":"horizontal-r2400-v200-phase4.2","seconds":90,"radius":15,"firstLock":"3.80","onGun":6.1,"requestFire":6.1,"inRange":100,"medianError":9.95,"p95Error":12.7,"longestGap":69.28,"medianDistance":517,"minHullGap":494.3,"rollsPerMinute":12.36,"rollReversals":3,"pitchReversals":2,"maxRoll":1.776} +{"grid":"holdout","variant":"baseline","name":"horizontal-r2400-v350-phase0.7","seconds":90,"radius":15,"firstLock":"2.63","onGun":26.5,"requestFire":26.6,"inRange":100,"medianError":6.23,"p95Error":12.42,"longestGap":5.18,"medianDistance":514,"minHullGap":492.2,"rollsPerMinute":8.37,"rollReversals":30,"pitchReversals":13,"maxRoll":1.793} +{"grid":"holdout","variant":"baseline","name":"horizontal-r2400-v350-phase2.1","seconds":90,"radius":15,"firstLock":"2.38","onGun":20,"requestFire":20.2,"inRange":100,"medianError":6.73,"p95Error":12.82,"longestGap":4.97,"medianDistance":515,"minHullGap":494,"rollsPerMinute":8.92,"rollReversals":30,"pitchReversals":16,"maxRoll":1.806} +{"grid":"holdout","variant":"baseline","name":"horizontal-r2400-v350-phase4.2","seconds":90,"radius":15,"firstLock":"3.42","onGun":22.3,"requestFire":22.4,"inRange":100,"medianError":6.33,"p95Error":12.42,"longestGap":5.12,"medianDistance":514,"minHullGap":491.9,"rollsPerMinute":8.7,"rollReversals":30,"pitchReversals":14,"maxRoll":1.779} +{"grid":"holdout","variant":"baseline","name":"vertical-r600-v200-phase0.7","seconds":90,"radius":15,"firstLock":"2.43","onGun":100,"requestFire":100,"inRange":100,"medianError":1.83,"p95Error":1.91,"longestGap":0,"medianDistance":514,"minHullGap":494.9,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r600-v200-phase2.1","seconds":90,"radius":15,"firstLock":"1.77","onGun":100,"requestFire":100,"inRange":100,"medianError":1.83,"p95Error":1.9,"longestGap":0,"medianDistance":517,"minHullGap":464.7,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r600-v200-phase4.2","seconds":90,"radius":15,"firstLock":"0.55","onGun":100,"requestFire":100,"inRange":100,"medianError":1.83,"p95Error":1.9,"longestGap":0,"medianDistance":517,"minHullGap":496.3,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r600-v350-phase0.7","seconds":90,"radius":15,"firstLock":"3.10","onGun":100,"requestFire":100,"inRange":100,"medianError":1.2,"p95Error":1.48,"longestGap":0,"medianDistance":514,"minHullGap":496.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r600-v350-phase2.1","seconds":90,"radius":15,"firstLock":"1.70","onGun":100,"requestFire":100,"inRange":100,"medianError":1.23,"p95Error":1.48,"longestGap":0,"medianDistance":516,"minHullGap":469,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r600-v350-phase4.2","seconds":90,"radius":15,"firstLock":"0.57","onGun":100,"requestFire":100,"inRange":100,"medianError":1.23,"p95Error":1.48,"longestGap":0,"medianDistance":514,"minHullGap":496.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r1200-v200-phase0.7","seconds":90,"radius":15,"firstLock":"2.17","onGun":100,"requestFire":100,"inRange":100,"medianError":2.55,"p95Error":2.63,"longestGap":0,"medianDistance":511,"minHullGap":491.7,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r1200-v200-phase2.1","seconds":90,"radius":15,"firstLock":"1.92","onGun":100,"requestFire":100,"inRange":100,"medianError":2.54,"p95Error":2.62,"longestGap":0,"medianDistance":511,"minHullGap":491.7,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r1200-v200-phase4.2","seconds":90,"radius":15,"firstLock":"0.48","onGun":100,"requestFire":100,"inRange":100,"medianError":2.55,"p95Error":2.63,"longestGap":0,"medianDistance":511,"minHullGap":491.7,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r1200-v350-phase0.7","seconds":90,"radius":15,"firstLock":"2.42","onGun":100,"requestFire":100,"inRange":100,"medianError":2.07,"p95Error":2.17,"longestGap":0,"medianDistance":514,"minHullGap":492,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r1200-v350-phase2.1","seconds":90,"radius":15,"firstLock":"1.78","onGun":100,"requestFire":100,"inRange":100,"medianError":2.07,"p95Error":2.17,"longestGap":0,"medianDistance":514,"minHullGap":492,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r1200-v350-phase4.2","seconds":90,"radius":15,"firstLock":"0.48","onGun":100,"requestFire":100,"inRange":100,"medianError":2.07,"p95Error":2.17,"longestGap":0,"medianDistance":514,"minHullGap":492,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r2400-v200-phase0.7","seconds":90,"radius":15,"firstLock":"2.03","onGun":100,"requestFire":100,"inRange":100,"medianError":3.03,"p95Error":3.09,"longestGap":0,"medianDistance":513,"minHullGap":493.6,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r2400-v200-phase2.1","seconds":90,"radius":15,"firstLock":"2.03","onGun":100,"requestFire":100,"inRange":100,"medianError":3.03,"p95Error":3.09,"longestGap":0,"medianDistance":513,"minHullGap":493.6,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r2400-v200-phase4.2","seconds":90,"radius":15,"firstLock":"0.53","onGun":100,"requestFire":100,"inRange":100,"medianError":3.04,"p95Error":3.09,"longestGap":0,"medianDistance":513,"minHullGap":493.6,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r2400-v350-phase0.7","seconds":90,"radius":15,"firstLock":"2.13","onGun":100,"requestFire":100,"inRange":100,"medianError":2.76,"p95Error":2.83,"longestGap":0,"medianDistance":509,"minHullGap":490.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r2400-v350-phase2.1","seconds":90,"radius":15,"firstLock":"1.95","onGun":100,"requestFire":100,"inRange":100,"medianError":2.76,"p95Error":2.83,"longestGap":0,"medianDistance":510,"minHullGap":490.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"vertical-r2400-v350-phase4.2","seconds":90,"radius":15,"firstLock":"0.50","onGun":100,"requestFire":100,"inRange":100,"medianError":2.76,"p95Error":2.83,"longestGap":0,"medianDistance":510,"minHullGap":490,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"baseline","name":"tilted-r600-v200-phase0.7","seconds":90,"radius":15,"firstLock":"2.10","onGun":19.1,"requestFire":18.6,"inRange":100,"medianError":8.57,"p95Error":11.76,"longestGap":1.7,"medianDistance":515,"minHullGap":495.8,"rollsPerMinute":8.89,"rollReversals":38,"pitchReversals":0,"maxRoll":1.795} +{"grid":"holdout","variant":"baseline","name":"tilted-r600-v200-phase2.1","seconds":90,"radius":15,"firstLock":"1.83","onGun":21.5,"requestFire":21.5,"inRange":100,"medianError":7.58,"p95Error":11.7,"longestGap":1.7,"medianDistance":518,"minHullGap":497.5,"rollsPerMinute":8.5,"rollReversals":40,"pitchReversals":0,"maxRoll":1.788} +{"grid":"holdout","variant":"baseline","name":"tilted-r600-v200-phase4.2","seconds":90,"radius":15,"firstLock":"0.83","onGun":21,"requestFire":20.8,"inRange":100,"medianError":7.76,"p95Error":11.74,"longestGap":1.7,"medianDistance":515,"minHullGap":494.9,"rollsPerMinute":8.64,"rollReversals":39,"pitchReversals":0,"maxRoll":1.793} +{"grid":"holdout","variant":"baseline","name":"tilted-r600-v350-phase0.7","seconds":90,"radius":15,"firstLock":"2.32","onGun":21,"requestFire":19.3,"inRange":100,"medianError":7.11,"p95Error":11.89,"longestGap":1.23,"medianDistance":517,"minHullGap":497.4,"rollsPerMinute":8.97,"rollReversals":58,"pitchReversals":0,"maxRoll":2.302} +{"grid":"holdout","variant":"baseline","name":"tilted-r600-v350-phase2.1","seconds":90,"radius":15,"firstLock":"1.80","onGun":21.7,"requestFire":20.1,"inRange":100,"medianError":7.08,"p95Error":12.27,"longestGap":1.22,"medianDistance":516,"minHullGap":497.1,"rollsPerMinute":9.26,"rollReversals":59,"pitchReversals":0,"maxRoll":2.303} +{"grid":"holdout","variant":"baseline","name":"tilted-r600-v350-phase4.2","seconds":90,"radius":15,"firstLock":"0.83","onGun":20.1,"requestFire":21.2,"inRange":100,"medianError":7.98,"p95Error":12.77,"longestGap":1.05,"medianDistance":516,"minHullGap":497,"rollsPerMinute":12.37,"rollReversals":64,"pitchReversals":0,"maxRoll":2.305} +{"grid":"holdout","variant":"baseline","name":"tilted-r1200-v200-phase0.7","seconds":90,"radius":15,"firstLock":"2.03","onGun":21.6,"requestFire":21.4,"inRange":100,"medianError":7.92,"p95Error":11.2,"longestGap":2.63,"medianDistance":515,"minHullGap":495.4,"rollsPerMinute":9.16,"rollReversals":0,"pitchReversals":24,"maxRoll":1.934} +{"grid":"holdout","variant":"baseline","name":"tilted-r1200-v200-phase2.1","seconds":90,"radius":15,"firstLock":"1.88","onGun":22.2,"requestFire":22.2,"inRange":100,"medianError":7.77,"p95Error":11.52,"longestGap":2.67,"medianDistance":514,"minHullGap":495.5,"rollsPerMinute":9.05,"rollReversals":1,"pitchReversals":24,"maxRoll":1.897} +{"grid":"holdout","variant":"baseline","name":"tilted-r1200-v200-phase4.2","seconds":90,"radius":15,"firstLock":"0.92","onGun":22.3,"requestFire":22.1,"inRange":100,"medianError":7.79,"p95Error":11.34,"longestGap":2.65,"medianDistance":515,"minHullGap":494.8,"rollsPerMinute":9.05,"rollReversals":0,"pitchReversals":24,"maxRoll":1.858} +{"grid":"holdout","variant":"baseline","name":"tilted-r1200-v350-phase0.7","seconds":90,"radius":15,"firstLock":"2.12","onGun":21.2,"requestFire":20.6,"inRange":100,"medianError":7.96,"p95Error":17.9,"longestGap":1.9,"medianDistance":515,"minHullGap":495.1,"rollsPerMinute":10.31,"rollReversals":26,"pitchReversals":12,"maxRoll":2.453} +{"grid":"holdout","variant":"baseline","name":"tilted-r1200-v350-phase2.1","seconds":90,"radius":15,"firstLock":"1.87","onGun":19.8,"requestFire":19.7,"inRange":100,"medianError":8.51,"p95Error":16.51,"longestGap":2,"medianDistance":516,"minHullGap":495.1,"rollsPerMinute":10.43,"rollReversals":34,"pitchReversals":3,"maxRoll":2.448} +{"grid":"holdout","variant":"baseline","name":"tilted-r1200-v350-phase4.2","seconds":90,"radius":15,"firstLock":"0.87","onGun":19.2,"requestFire":19.3,"inRange":100,"medianError":8.84,"p95Error":16.36,"longestGap":2,"medianDistance":516,"minHullGap":496.2,"rollsPerMinute":10.48,"rollReversals":37,"pitchReversals":0,"maxRoll":2.386} +{"grid":"holdout","variant":"baseline","name":"tilted-r2400-v200-phase0.7","seconds":90,"radius":15,"firstLock":"2.35","onGun":3.8,"requestFire":3.8,"inRange":100,"medianError":10.08,"p95Error":12.7,"longestGap":70.4,"medianDistance":517,"minHullGap":498,"rollsPerMinute":12.56,"rollReversals":3,"pitchReversals":1,"maxRoll":1.763} +{"grid":"holdout","variant":"baseline","name":"tilted-r2400-v200-phase2.1","seconds":90,"radius":15,"firstLock":"1.93","onGun":6.5,"requestFire":6.5,"inRange":100,"medianError":9.67,"p95Error":12.68,"longestGap":64.75,"medianDistance":517,"minHullGap":492.9,"rollsPerMinute":12.11,"rollReversals":5,"pitchReversals":3,"maxRoll":1.762} +{"grid":"holdout","variant":"baseline","name":"tilted-r2400-v200-phase4.2","seconds":90,"radius":15,"firstLock":"3.58","onGun":18.1,"requestFire":18.2,"inRange":100,"medianError":8.16,"p95Error":12.51,"longestGap":45.03,"medianDistance":515,"minHullGap":492.9,"rollsPerMinute":10.36,"rollReversals":6,"pitchReversals":7,"maxRoll":1.765} +{"grid":"holdout","variant":"baseline","name":"tilted-r2400-v350-phase0.7","seconds":90,"radius":15,"firstLock":"4.90","onGun":21.5,"requestFire":21.6,"inRange":100,"medianError":6.59,"p95Error":12.4,"longestGap":4.88,"medianDistance":515,"minHullGap":491.9,"rollsPerMinute":8.81,"rollReversals":27,"pitchReversals":18,"maxRoll":2.294} +{"grid":"holdout","variant":"baseline","name":"tilted-r2400-v350-phase2.1","seconds":90,"radius":15,"firstLock":"4.58","onGun":21.5,"requestFire":21.6,"inRange":100,"medianError":6.69,"p95Error":12.28,"longestGap":4.7,"medianDistance":515,"minHullGap":492.9,"rollsPerMinute":8.85,"rollReversals":18,"pitchReversals":21,"maxRoll":2.386} +{"grid":"holdout","variant":"baseline","name":"tilted-r2400-v350-phase4.2","seconds":90,"radius":15,"firstLock":"3.42","onGun":21.5,"requestFire":21.7,"inRange":100,"medianError":6.61,"p95Error":12.3,"longestGap":5.08,"medianDistance":515,"minHullGap":492.5,"rollsPerMinute":8.82,"rollReversals":27,"pitchReversals":17,"maxRoll":1.748} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r600-v200-phase0.7","seconds":45,"radius":15,"firstLock":"4.02","onGun":100,"requestFire":100,"inRange":100,"medianError":1.82,"p95Error":1.91,"longestGap":0,"medianDistance":516,"minHullGap":494.9,"rollsPerMinute":0.03,"rollReversals":0,"pitchReversals":0,"maxRoll":0.021} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r600-v200-phase2.1","seconds":45,"radius":15,"firstLock":"2.85","onGun":100,"requestFire":100,"inRange":100,"medianError":1.8,"p95Error":1.91,"longestGap":0,"medianDistance":517,"minHullGap":437,"rollsPerMinute":0.04,"rollReversals":0,"pitchReversals":0,"maxRoll":0.027} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r600-v200-phase4.2","seconds":45,"radius":15,"firstLock":"1.38","onGun":100,"requestFire":100,"inRange":100,"medianError":1.81,"p95Error":1.92,"longestGap":0,"medianDistance":517,"minHullGap":495.8,"rollsPerMinute":0.11,"rollReversals":0,"pitchReversals":0,"maxRoll":0.092} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r600-v350-phase0.7","seconds":45,"radius":15,"firstLock":"4.17","onGun":86,"requestFire":78,"inRange":100,"medianError":1.78,"p95Error":4.49,"longestGap":0.3,"medianDistance":516,"minHullGap":497.8,"rollsPerMinute":1.66,"rollReversals":43,"pitchReversals":0,"maxRoll":0.318} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r600-v350-phase2.1","seconds":45,"radius":15,"firstLock":"2.65","onGun":84.5,"requestFire":75.3,"inRange":100,"medianError":1.81,"p95Error":4.48,"longestGap":0.3,"medianDistance":514,"minHullGap":452.9,"rollsPerMinute":1.65,"rollReversals":43,"pitchReversals":0,"maxRoll":0.311} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r600-v350-phase4.2","seconds":45,"radius":15,"firstLock":"0.80","onGun":83.5,"requestFire":73.8,"inRange":100,"medianError":1.86,"p95Error":4.41,"longestGap":0.3,"medianDistance":515,"minHullGap":495.8,"rollsPerMinute":1.67,"rollReversals":43,"pitchReversals":0,"maxRoll":0.314} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r1200-v200-phase0.7","seconds":45,"radius":15,"firstLock":"3.63","onGun":100,"requestFire":100,"inRange":100,"medianError":2.54,"p95Error":2.62,"longestGap":0,"medianDistance":512,"minHullGap":491.7,"rollsPerMinute":0.03,"rollReversals":0,"pitchReversals":0,"maxRoll":0.01} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r1200-v200-phase2.1","seconds":45,"radius":15,"firstLock":"3.12","onGun":100,"requestFire":100,"inRange":100,"medianError":2.53,"p95Error":2.61,"longestGap":0,"medianDistance":515,"minHullGap":480.4,"rollsPerMinute":0.03,"rollReversals":0,"pitchReversals":0,"maxRoll":0.014} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r1200-v200-phase4.2","seconds":45,"radius":15,"firstLock":"1.80","onGun":100,"requestFire":100,"inRange":100,"medianError":2.54,"p95Error":2.62,"longestGap":0,"medianDistance":512,"minHullGap":491.7,"rollsPerMinute":0.11,"rollReversals":0,"pitchReversals":0,"maxRoll":0.053} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r1200-v350-phase0.7","seconds":45,"radius":15,"firstLock":"3.98","onGun":100,"requestFire":100,"inRange":100,"medianError":2.06,"p95Error":2.2,"longestGap":0,"medianDistance":514,"minHullGap":492,"rollsPerMinute":0.47,"rollReversals":14,"pitchReversals":0,"maxRoll":0.232} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r1200-v350-phase2.1","seconds":45,"radius":15,"firstLock":"2.92","onGun":100,"requestFire":100,"inRange":100,"medianError":2.07,"p95Error":2.25,"longestGap":0,"medianDistance":514,"minHullGap":492,"rollsPerMinute":0.66,"rollReversals":26,"pitchReversals":0,"maxRoll":0.234} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r1200-v350-phase4.2","seconds":45,"radius":15,"firstLock":"1.55","onGun":100,"requestFire":100,"inRange":100,"medianError":2.08,"p95Error":2.28,"longestGap":0,"medianDistance":514,"minHullGap":491.9,"rollsPerMinute":0.67,"rollReversals":26,"pitchReversals":0,"maxRoll":0.245} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r2400-v200-phase0.7","seconds":45,"radius":15,"firstLock":"3.45","onGun":100,"requestFire":100,"inRange":100,"medianError":3,"p95Error":3.08,"longestGap":0,"medianDistance":515,"minHullGap":493.6,"rollsPerMinute":0.02,"rollReversals":0,"pitchReversals":0,"maxRoll":0.014} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r2400-v200-phase2.1","seconds":45,"radius":15,"firstLock":"3.32","onGun":100,"requestFire":100,"inRange":100,"medianError":3.01,"p95Error":3.08,"longestGap":0,"medianDistance":515,"minHullGap":493.6,"rollsPerMinute":0.05,"rollReversals":0,"pitchReversals":0,"maxRoll":0.026} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r2400-v200-phase4.2","seconds":45,"radius":15,"firstLock":"2.32","onGun":100,"requestFire":100,"inRange":100,"medianError":3.02,"p95Error":3.09,"longestGap":0,"medianDistance":514,"minHullGap":493.6,"rollsPerMinute":0.19,"rollReversals":4,"pitchReversals":0,"maxRoll":0.17} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r2400-v350-phase0.7","seconds":45,"radius":15,"firstLock":"3.60","onGun":100,"requestFire":100,"inRange":100,"medianError":2.74,"p95Error":2.85,"longestGap":0,"medianDistance":510,"minHullGap":490.1,"rollsPerMinute":0.28,"rollReversals":8,"pitchReversals":0,"maxRoll":0.227} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r2400-v350-phase2.1","seconds":45,"radius":15,"firstLock":"3.20","onGun":100,"requestFire":100,"inRange":100,"medianError":2.75,"p95Error":2.87,"longestGap":0,"medianDistance":510,"minHullGap":490.1,"rollsPerMinute":0.38,"rollReversals":14,"pitchReversals":0,"maxRoll":0.214} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r2400-v350-phase4.2","seconds":45,"radius":15,"firstLock":"1.78","onGun":100,"requestFire":100,"inRange":100,"medianError":2.75,"p95Error":2.88,"longestGap":0,"medianDistance":510,"minHullGap":490.1,"rollsPerMinute":0.37,"rollReversals":13,"pitchReversals":0,"maxRoll":0.221} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r600-v200-phase0.7","seconds":45,"radius":15,"firstLock":"2.43","onGun":100,"requestFire":100,"inRange":100,"medianError":1.82,"p95Error":1.91,"longestGap":0,"medianDistance":515,"minHullGap":494.9,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r600-v200-phase2.1","seconds":45,"radius":15,"firstLock":"1.77","onGun":100,"requestFire":100,"inRange":100,"medianError":1.78,"p95Error":1.91,"longestGap":0,"medianDistance":517,"minHullGap":464.7,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r600-v200-phase4.2","seconds":45,"radius":15,"firstLock":"0.55","onGun":100,"requestFire":100,"inRange":100,"medianError":1.82,"p95Error":1.9,"longestGap":0,"medianDistance":517,"minHullGap":496.3,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r600-v350-phase0.7","seconds":45,"radius":15,"firstLock":"3.10","onGun":100,"requestFire":100,"inRange":100,"medianError":1.13,"p95Error":1.46,"longestGap":0,"medianDistance":514,"minHullGap":496.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r600-v350-phase2.1","seconds":45,"radius":15,"firstLock":"1.70","onGun":100,"requestFire":100,"inRange":100,"medianError":1.22,"p95Error":1.48,"longestGap":0,"medianDistance":516,"minHullGap":469,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r600-v350-phase4.2","seconds":45,"radius":15,"firstLock":"0.57","onGun":100,"requestFire":100,"inRange":100,"medianError":1.14,"p95Error":1.46,"longestGap":0,"medianDistance":514,"minHullGap":496.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r1200-v200-phase0.7","seconds":45,"radius":15,"firstLock":"2.17","onGun":100,"requestFire":100,"inRange":100,"medianError":2.54,"p95Error":2.62,"longestGap":0,"medianDistance":512,"minHullGap":491.7,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r1200-v200-phase2.1","seconds":45,"radius":15,"firstLock":"1.92","onGun":100,"requestFire":100,"inRange":100,"medianError":2.53,"p95Error":2.62,"longestGap":0,"medianDistance":512,"minHullGap":491.7,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r1200-v200-phase4.2","seconds":45,"radius":15,"firstLock":"0.48","onGun":100,"requestFire":100,"inRange":100,"medianError":2.54,"p95Error":2.62,"longestGap":0,"medianDistance":512,"minHullGap":491.7,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r1200-v350-phase0.7","seconds":45,"radius":15,"firstLock":"2.42","onGun":100,"requestFire":100,"inRange":100,"medianError":2.06,"p95Error":2.17,"longestGap":0,"medianDistance":514,"minHullGap":492,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r1200-v350-phase2.1","seconds":45,"radius":15,"firstLock":"1.78","onGun":100,"requestFire":100,"inRange":100,"medianError":2.06,"p95Error":2.17,"longestGap":0,"medianDistance":514,"minHullGap":492,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r1200-v350-phase4.2","seconds":45,"radius":15,"firstLock":"0.48","onGun":100,"requestFire":100,"inRange":100,"medianError":2.07,"p95Error":2.17,"longestGap":0,"medianDistance":514,"minHullGap":492,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r2400-v200-phase0.7","seconds":45,"radius":15,"firstLock":"2.03","onGun":100,"requestFire":100,"inRange":100,"medianError":3.02,"p95Error":3.09,"longestGap":0,"medianDistance":514,"minHullGap":493.6,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r2400-v200-phase2.1","seconds":45,"radius":15,"firstLock":"2.03","onGun":100,"requestFire":100,"inRange":100,"medianError":3.02,"p95Error":3.08,"longestGap":0,"medianDistance":515,"minHullGap":493.6,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r2400-v200-phase4.2","seconds":45,"radius":15,"firstLock":"0.53","onGun":100,"requestFire":100,"inRange":100,"medianError":3.02,"p95Error":3.09,"longestGap":0,"medianDistance":514,"minHullGap":493.6,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r2400-v350-phase0.7","seconds":45,"radius":15,"firstLock":"2.13","onGun":100,"requestFire":100,"inRange":100,"medianError":2.75,"p95Error":2.83,"longestGap":0,"medianDistance":510,"minHullGap":490.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r2400-v350-phase2.1","seconds":45,"radius":15,"firstLock":"1.95","onGun":100,"requestFire":100,"inRange":100,"medianError":2.75,"p95Error":2.83,"longestGap":0,"medianDistance":510,"minHullGap":490.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r2400-v350-phase4.2","seconds":45,"radius":15,"firstLock":"0.50","onGun":100,"requestFire":100,"inRange":100,"medianError":2.76,"p95Error":2.83,"longestGap":0,"medianDistance":510,"minHullGap":490,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r600-v200-phase0.7","seconds":45,"radius":15,"firstLock":"3.73","onGun":100,"requestFire":100,"inRange":100,"medianError":1.82,"p95Error":1.93,"longestGap":0,"medianDistance":515,"minHullGap":495.1,"rollsPerMinute":0.13,"rollReversals":0,"pitchReversals":0,"maxRoll":0.169} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r600-v200-phase2.1","seconds":45,"radius":15,"firstLock":"4.08","onGun":100,"requestFire":100,"inRange":100,"medianError":1.8,"p95Error":1.91,"longestGap":0,"medianDistance":517,"minHullGap":484.1,"rollsPerMinute":0.15,"rollReversals":0,"pitchReversals":0,"maxRoll":0.212} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r600-v200-phase4.2","seconds":45,"radius":15,"firstLock":"2.62","onGun":100,"requestFire":100,"inRange":100,"medianError":1.81,"p95Error":1.93,"longestGap":0,"medianDistance":515,"minHullGap":495.1,"rollsPerMinute":0.13,"rollReversals":0,"pitchReversals":0,"maxRoll":0.104} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r600-v350-phase0.7","seconds":45,"radius":15,"firstLock":"4.72","onGun":81.7,"requestFire":74.7,"inRange":100,"medianError":1.75,"p95Error":4.91,"longestGap":0.3,"medianDistance":514,"minHullGap":493.6,"rollsPerMinute":1.63,"rollReversals":41,"pitchReversals":0,"maxRoll":0.31} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r600-v350-phase2.1","seconds":45,"radius":15,"firstLock":"4.60","onGun":79.7,"requestFire":71.7,"inRange":100,"medianError":1.87,"p95Error":4.76,"longestGap":0.35,"medianDistance":516,"minHullGap":488.5,"rollsPerMinute":1.72,"rollReversals":42,"pitchReversals":0,"maxRoll":0.311} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r600-v350-phase4.2","seconds":45,"radius":15,"firstLock":"2.72","onGun":80.1,"requestFire":71.3,"inRange":100,"medianError":1.88,"p95Error":4.84,"longestGap":0.3,"medianDistance":513,"minHullGap":494.4,"rollsPerMinute":1.71,"rollReversals":42,"pitchReversals":0,"maxRoll":0.314} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r1200-v200-phase0.7","seconds":45,"radius":15,"firstLock":"3.03","onGun":100,"requestFire":100,"inRange":100,"medianError":2.54,"p95Error":2.63,"longestGap":0,"medianDistance":512,"minHullGap":491.7,"rollsPerMinute":0.09,"rollReversals":0,"pitchReversals":0,"maxRoll":0.068} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r1200-v200-phase2.1","seconds":45,"radius":15,"firstLock":"4.13","onGun":100,"requestFire":100,"inRange":100,"medianError":2.53,"p95Error":2.62,"longestGap":0,"medianDistance":512,"minHullGap":491.7,"rollsPerMinute":0.1,"rollReversals":0,"pitchReversals":0,"maxRoll":0.081} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r1200-v200-phase4.2","seconds":45,"radius":15,"firstLock":"2.87","onGun":100,"requestFire":100,"inRange":100,"medianError":2.54,"p95Error":2.63,"longestGap":0,"medianDistance":512,"minHullGap":491.7,"rollsPerMinute":0.06,"rollReversals":0,"pitchReversals":0,"maxRoll":0.055} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r1200-v350-phase0.7","seconds":45,"radius":15,"firstLock":"3.78","onGun":100,"requestFire":100,"inRange":100,"medianError":2.06,"p95Error":2.17,"longestGap":0,"medianDistance":514,"minHullGap":492,"rollsPerMinute":0.21,"rollReversals":2,"pitchReversals":0,"maxRoll":0.116} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r1200-v350-phase2.1","seconds":45,"radius":15,"firstLock":"4.33","onGun":100,"requestFire":100,"inRange":100,"medianError":2.07,"p95Error":2.23,"longestGap":0,"medianDistance":514,"minHullGap":492,"rollsPerMinute":0.52,"rollReversals":16,"pitchReversals":0,"maxRoll":0.233} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r1200-v350-phase4.2","seconds":45,"radius":15,"firstLock":"3.37","onGun":100,"requestFire":100,"inRange":100,"medianError":2.07,"p95Error":2.24,"longestGap":0,"medianDistance":514,"minHullGap":492,"rollsPerMinute":0.63,"rollReversals":25,"pitchReversals":0,"maxRoll":0.239} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r2400-v200-phase0.7","seconds":45,"radius":15,"firstLock":"2.98","onGun":100,"requestFire":100,"inRange":100,"medianError":3.01,"p95Error":3.09,"longestGap":0,"medianDistance":515,"minHullGap":493.6,"rollsPerMinute":0.15,"rollReversals":2,"pitchReversals":0,"maxRoll":0.16} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r2400-v200-phase2.1","seconds":45,"radius":15,"firstLock":"2.72","onGun":100,"requestFire":100,"inRange":100,"medianError":3.02,"p95Error":3.09,"longestGap":0,"medianDistance":515,"minHullGap":493.5,"rollsPerMinute":0.12,"rollReversals":0,"pitchReversals":0,"maxRoll":0.157} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r2400-v200-phase4.2","seconds":45,"radius":15,"firstLock":"1.85","onGun":100,"requestFire":100,"inRange":100,"medianError":3.02,"p95Error":3.09,"longestGap":0,"medianDistance":514,"minHullGap":493.6,"rollsPerMinute":0.18,"rollReversals":3,"pitchReversals":0,"maxRoll":0.17} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r2400-v350-phase0.7","seconds":45,"radius":15,"firstLock":"5.47","onGun":100,"requestFire":100,"inRange":100,"medianError":2.75,"p95Error":2.86,"longestGap":0,"medianDistance":510,"minHullGap":490.1,"rollsPerMinute":0.37,"rollReversals":10,"pitchReversals":0,"maxRoll":0.263} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r2400-v350-phase2.1","seconds":45,"radius":15,"firstLock":"4.55","onGun":100,"requestFire":100,"inRange":100,"medianError":2.76,"p95Error":2.86,"longestGap":0,"medianDistance":510,"minHullGap":490.1,"rollsPerMinute":0.34,"rollReversals":10,"pitchReversals":0,"maxRoll":0.211} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r2400-v350-phase4.2","seconds":45,"radius":15,"firstLock":"1.87","onGun":100,"requestFire":100,"inRange":100,"medianError":2.75,"p95Error":2.88,"longestGap":0,"medianDistance":510,"minHullGap":490.1,"rollsPerMinute":0.36,"rollReversals":12,"pitchReversals":0,"maxRoll":0.228} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r600-v200-phase0.7","seconds":90,"radius":15,"firstLock":"4.02","onGun":100,"requestFire":100,"inRange":100,"medianError":1.83,"p95Error":1.91,"longestGap":0,"medianDistance":514,"minHullGap":494.9,"rollsPerMinute":0.03,"rollReversals":0,"pitchReversals":0,"maxRoll":0.021} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r600-v200-phase2.1","seconds":90,"radius":15,"firstLock":"2.85","onGun":100,"requestFire":100,"inRange":100,"medianError":1.82,"p95Error":1.9,"longestGap":0,"medianDistance":517,"minHullGap":437,"rollsPerMinute":0.02,"rollReversals":0,"pitchReversals":0,"maxRoll":0.027} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r600-v200-phase4.2","seconds":90,"radius":15,"firstLock":"1.38","onGun":100,"requestFire":100,"inRange":100,"medianError":1.83,"p95Error":1.9,"longestGap":0,"medianDistance":517,"minHullGap":495.8,"rollsPerMinute":0.06,"rollReversals":0,"pitchReversals":0,"maxRoll":0.092} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r600-v350-phase0.7","seconds":90,"radius":15,"firstLock":"4.17","onGun":84.2,"requestFire":75.6,"inRange":100,"medianError":1.8,"p95Error":4.53,"longestGap":0.32,"medianDistance":514,"minHullGap":493.4,"rollsPerMinute":1.65,"rollReversals":98,"pitchReversals":0,"maxRoll":0.318} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r600-v350-phase2.1","seconds":90,"radius":15,"firstLock":"2.65","onGun":82,"requestFire":73.2,"inRange":100,"medianError":1.85,"p95Error":4.68,"longestGap":0.33,"medianDistance":515,"minHullGap":452.9,"rollsPerMinute":1.69,"rollReversals":97,"pitchReversals":0,"maxRoll":0.317} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r600-v350-phase4.2","seconds":90,"radius":15,"firstLock":"0.80","onGun":84.1,"requestFire":75.1,"inRange":100,"medianError":1.83,"p95Error":4.46,"longestGap":0.33,"medianDistance":514,"minHullGap":493.2,"rollsPerMinute":1.67,"rollReversals":99,"pitchReversals":0,"maxRoll":0.322} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r1200-v200-phase0.7","seconds":90,"radius":15,"firstLock":"3.63","onGun":100,"requestFire":100,"inRange":100,"medianError":2.55,"p95Error":2.63,"longestGap":0,"medianDistance":511,"minHullGap":491.7,"rollsPerMinute":0.02,"rollReversals":0,"pitchReversals":0,"maxRoll":0.01} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r1200-v200-phase2.1","seconds":90,"radius":15,"firstLock":"3.12","onGun":100,"requestFire":100,"inRange":100,"medianError":2.54,"p95Error":2.62,"longestGap":0,"medianDistance":514,"minHullGap":480.4,"rollsPerMinute":0.03,"rollReversals":0,"pitchReversals":0,"maxRoll":0.014} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r1200-v200-phase4.2","seconds":90,"radius":15,"firstLock":"1.80","onGun":100,"requestFire":100,"inRange":100,"medianError":2.55,"p95Error":2.63,"longestGap":0,"medianDistance":511,"minHullGap":491.7,"rollsPerMinute":0.07,"rollReversals":0,"pitchReversals":0,"maxRoll":0.053} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r1200-v350-phase0.7","seconds":90,"radius":15,"firstLock":"3.98","onGun":100,"requestFire":100,"inRange":100,"medianError":2.07,"p95Error":2.23,"longestGap":0,"medianDistance":514,"minHullGap":492,"rollsPerMinute":0.57,"rollReversals":48,"pitchReversals":0,"maxRoll":0.242} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r1200-v350-phase2.1","seconds":90,"radius":15,"firstLock":"2.92","onGun":100,"requestFire":100,"inRange":100,"medianError":2.08,"p95Error":2.27,"longestGap":0,"medianDistance":514,"minHullGap":491.9,"rollsPerMinute":0.66,"rollReversals":60,"pitchReversals":0,"maxRoll":0.244} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r1200-v350-phase4.2","seconds":90,"radius":15,"firstLock":"1.55","onGun":100,"requestFire":100,"inRange":100,"medianError":2.08,"p95Error":2.28,"longestGap":0,"medianDistance":513,"minHullGap":491.9,"rollsPerMinute":0.66,"rollReversals":60,"pitchReversals":0,"maxRoll":0.245} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r2400-v200-phase0.7","seconds":90,"radius":15,"firstLock":"3.45","onGun":100,"requestFire":100,"inRange":100,"medianError":3.03,"p95Error":3.09,"longestGap":0,"medianDistance":513,"minHullGap":493.6,"rollsPerMinute":0.09,"rollReversals":3,"pitchReversals":0,"maxRoll":0.159} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r2400-v200-phase2.1","seconds":90,"radius":15,"firstLock":"3.32","onGun":100,"requestFire":100,"inRange":100,"medianError":3.03,"p95Error":3.09,"longestGap":0,"medianDistance":514,"minHullGap":493.6,"rollsPerMinute":0.12,"rollReversals":6,"pitchReversals":0,"maxRoll":0.174} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r2400-v200-phase4.2","seconds":90,"radius":15,"firstLock":"2.32","onGun":100,"requestFire":100,"inRange":100,"medianError":3.03,"p95Error":3.1,"longestGap":0,"medianDistance":513,"minHullGap":493.6,"rollsPerMinute":0.2,"rollReversals":14,"pitchReversals":0,"maxRoll":0.173} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r2400-v350-phase0.7","seconds":90,"radius":15,"firstLock":"3.60","onGun":100,"requestFire":100,"inRange":100,"medianError":2.76,"p95Error":2.87,"longestGap":0,"medianDistance":510,"minHullGap":490,"rollsPerMinute":0.33,"rollReversals":25,"pitchReversals":0,"maxRoll":0.227} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r2400-v350-phase2.1","seconds":90,"radius":15,"firstLock":"3.20","onGun":100,"requestFire":100,"inRange":100,"medianError":2.76,"p95Error":2.88,"longestGap":0,"medianDistance":510,"minHullGap":490.1,"rollsPerMinute":0.37,"rollReversals":32,"pitchReversals":0,"maxRoll":0.223} +{"grid":"holdout","variant":"hard-gate","name":"horizontal-r2400-v350-phase4.2","seconds":90,"radius":15,"firstLock":"1.78","onGun":100,"requestFire":100,"inRange":100,"medianError":2.76,"p95Error":2.88,"longestGap":0,"medianDistance":509,"minHullGap":490.1,"rollsPerMinute":0.37,"rollReversals":31,"pitchReversals":0,"maxRoll":0.242} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r600-v200-phase0.7","seconds":90,"radius":15,"firstLock":"2.43","onGun":100,"requestFire":100,"inRange":100,"medianError":1.83,"p95Error":1.91,"longestGap":0,"medianDistance":514,"minHullGap":494.9,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r600-v200-phase2.1","seconds":90,"radius":15,"firstLock":"1.77","onGun":100,"requestFire":100,"inRange":100,"medianError":1.83,"p95Error":1.9,"longestGap":0,"medianDistance":517,"minHullGap":464.7,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r600-v200-phase4.2","seconds":90,"radius":15,"firstLock":"0.55","onGun":100,"requestFire":100,"inRange":100,"medianError":1.83,"p95Error":1.9,"longestGap":0,"medianDistance":517,"minHullGap":496.3,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r600-v350-phase0.7","seconds":90,"radius":15,"firstLock":"3.10","onGun":100,"requestFire":100,"inRange":100,"medianError":1.2,"p95Error":1.48,"longestGap":0,"medianDistance":514,"minHullGap":496.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r600-v350-phase2.1","seconds":90,"radius":15,"firstLock":"1.70","onGun":100,"requestFire":100,"inRange":100,"medianError":1.23,"p95Error":1.48,"longestGap":0,"medianDistance":516,"minHullGap":469,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r600-v350-phase4.2","seconds":90,"radius":15,"firstLock":"0.57","onGun":100,"requestFire":100,"inRange":100,"medianError":1.23,"p95Error":1.48,"longestGap":0,"medianDistance":514,"minHullGap":496.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r1200-v200-phase0.7","seconds":90,"radius":15,"firstLock":"2.17","onGun":100,"requestFire":100,"inRange":100,"medianError":2.55,"p95Error":2.63,"longestGap":0,"medianDistance":511,"minHullGap":491.7,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r1200-v200-phase2.1","seconds":90,"radius":15,"firstLock":"1.92","onGun":100,"requestFire":100,"inRange":100,"medianError":2.54,"p95Error":2.62,"longestGap":0,"medianDistance":511,"minHullGap":491.7,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r1200-v200-phase4.2","seconds":90,"radius":15,"firstLock":"0.48","onGun":100,"requestFire":100,"inRange":100,"medianError":2.55,"p95Error":2.63,"longestGap":0,"medianDistance":511,"minHullGap":491.7,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r1200-v350-phase0.7","seconds":90,"radius":15,"firstLock":"2.42","onGun":100,"requestFire":100,"inRange":100,"medianError":2.07,"p95Error":2.17,"longestGap":0,"medianDistance":514,"minHullGap":492,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r1200-v350-phase2.1","seconds":90,"radius":15,"firstLock":"1.78","onGun":100,"requestFire":100,"inRange":100,"medianError":2.07,"p95Error":2.17,"longestGap":0,"medianDistance":514,"minHullGap":492,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r1200-v350-phase4.2","seconds":90,"radius":15,"firstLock":"0.48","onGun":100,"requestFire":100,"inRange":100,"medianError":2.07,"p95Error":2.17,"longestGap":0,"medianDistance":514,"minHullGap":492,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r2400-v200-phase0.7","seconds":90,"radius":15,"firstLock":"2.03","onGun":100,"requestFire":100,"inRange":100,"medianError":3.03,"p95Error":3.09,"longestGap":0,"medianDistance":513,"minHullGap":493.6,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r2400-v200-phase2.1","seconds":90,"radius":15,"firstLock":"2.03","onGun":100,"requestFire":100,"inRange":100,"medianError":3.03,"p95Error":3.09,"longestGap":0,"medianDistance":513,"minHullGap":493.6,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r2400-v200-phase4.2","seconds":90,"radius":15,"firstLock":"0.53","onGun":100,"requestFire":100,"inRange":100,"medianError":3.04,"p95Error":3.09,"longestGap":0,"medianDistance":513,"minHullGap":493.6,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r2400-v350-phase0.7","seconds":90,"radius":15,"firstLock":"2.13","onGun":100,"requestFire":100,"inRange":100,"medianError":2.76,"p95Error":2.83,"longestGap":0,"medianDistance":509,"minHullGap":490.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r2400-v350-phase2.1","seconds":90,"radius":15,"firstLock":"1.95","onGun":100,"requestFire":100,"inRange":100,"medianError":2.76,"p95Error":2.83,"longestGap":0,"medianDistance":510,"minHullGap":490.1,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"vertical-r2400-v350-phase4.2","seconds":90,"radius":15,"firstLock":"0.50","onGun":100,"requestFire":100,"inRange":100,"medianError":2.76,"p95Error":2.83,"longestGap":0,"medianDistance":510,"minHullGap":490,"rollsPerMinute":0,"rollReversals":0,"pitchReversals":0,"maxRoll":0} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r600-v200-phase0.7","seconds":90,"radius":15,"firstLock":"3.73","onGun":100,"requestFire":100,"inRange":100,"medianError":1.83,"p95Error":1.9,"longestGap":0,"medianDistance":514,"minHullGap":495.1,"rollsPerMinute":0.06,"rollReversals":0,"pitchReversals":0,"maxRoll":0.169} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r600-v200-phase2.1","seconds":90,"radius":15,"firstLock":"4.08","onGun":100,"requestFire":100,"inRange":100,"medianError":1.82,"p95Error":1.9,"longestGap":0,"medianDistance":517,"minHullGap":484.1,"rollsPerMinute":0.07,"rollReversals":0,"pitchReversals":0,"maxRoll":0.212} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r600-v200-phase4.2","seconds":90,"radius":15,"firstLock":"2.62","onGun":100,"requestFire":100,"inRange":100,"medianError":1.83,"p95Error":1.9,"longestGap":0,"medianDistance":514,"minHullGap":495.1,"rollsPerMinute":0.06,"rollReversals":0,"pitchReversals":0,"maxRoll":0.104} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r600-v350-phase0.7","seconds":90,"radius":15,"firstLock":"4.72","onGun":85,"requestFire":76.6,"inRange":100,"medianError":1.77,"p95Error":4.56,"longestGap":0.3,"medianDistance":512,"minHullGap":493.6,"rollsPerMinute":1.64,"rollReversals":97,"pitchReversals":0,"maxRoll":0.315} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r600-v350-phase2.1","seconds":90,"radius":15,"firstLock":"4.60","onGun":81.3,"requestFire":73.3,"inRange":100,"medianError":1.85,"p95Error":4.72,"longestGap":0.35,"medianDistance":515,"minHullGap":488.5,"rollsPerMinute":1.7,"rollReversals":98,"pitchReversals":0,"maxRoll":0.316} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r600-v350-phase4.2","seconds":90,"radius":15,"firstLock":"2.72","onGun":81.3,"requestFire":72.9,"inRange":100,"medianError":1.89,"p95Error":4.79,"longestGap":0.33,"medianDistance":513,"minHullGap":494.4,"rollsPerMinute":1.7,"rollReversals":97,"pitchReversals":0,"maxRoll":0.32} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r1200-v200-phase0.7","seconds":90,"radius":15,"firstLock":"3.03","onGun":100,"requestFire":100,"inRange":100,"medianError":2.55,"p95Error":2.63,"longestGap":0,"medianDistance":511,"minHullGap":491.7,"rollsPerMinute":0.06,"rollReversals":0,"pitchReversals":0,"maxRoll":0.068} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r1200-v200-phase2.1","seconds":90,"radius":15,"firstLock":"4.13","onGun":100,"requestFire":100,"inRange":100,"medianError":2.55,"p95Error":2.63,"longestGap":0,"medianDistance":511,"minHullGap":491.7,"rollsPerMinute":0.06,"rollReversals":0,"pitchReversals":0,"maxRoll":0.081} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r1200-v200-phase4.2","seconds":90,"radius":15,"firstLock":"2.87","onGun":100,"requestFire":100,"inRange":100,"medianError":2.55,"p95Error":2.63,"longestGap":0,"medianDistance":511,"minHullGap":491.7,"rollsPerMinute":0.04,"rollReversals":0,"pitchReversals":0,"maxRoll":0.055} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r1200-v350-phase0.7","seconds":90,"radius":15,"firstLock":"3.78","onGun":100,"requestFire":100,"inRange":100,"medianError":2.07,"p95Error":2.21,"longestGap":0,"medianDistance":514,"minHullGap":491.9,"rollsPerMinute":0.46,"rollReversals":36,"pitchReversals":0,"maxRoll":0.238} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r1200-v350-phase2.1","seconds":90,"radius":15,"firstLock":"4.33","onGun":100,"requestFire":100,"inRange":100,"medianError":2.08,"p95Error":2.27,"longestGap":0,"medianDistance":513,"minHullGap":491.9,"rollsPerMinute":0.6,"rollReversals":50,"pitchReversals":0,"maxRoll":0.246} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r1200-v350-phase4.2","seconds":90,"radius":15,"firstLock":"3.37","onGun":100,"requestFire":100,"inRange":100,"medianError":2.08,"p95Error":2.25,"longestGap":0,"medianDistance":513,"minHullGap":492,"rollsPerMinute":0.64,"rollReversals":59,"pitchReversals":0,"maxRoll":0.239} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r2400-v200-phase0.7","seconds":90,"radius":15,"firstLock":"2.98","onGun":100,"requestFire":100,"inRange":100,"medianError":3.03,"p95Error":3.1,"longestGap":0,"medianDistance":513,"minHullGap":493.6,"rollsPerMinute":0.18,"rollReversals":12,"pitchReversals":0,"maxRoll":0.174} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r2400-v200-phase2.1","seconds":90,"radius":15,"firstLock":"2.72","onGun":100,"requestFire":100,"inRange":100,"medianError":3.03,"p95Error":3.09,"longestGap":0,"medianDistance":513,"minHullGap":493.5,"rollsPerMinute":0.17,"rollReversals":10,"pitchReversals":0,"maxRoll":0.16} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r2400-v200-phase4.2","seconds":90,"radius":15,"firstLock":"1.85","onGun":100,"requestFire":100,"inRange":100,"medianError":3.03,"p95Error":3.1,"longestGap":0,"medianDistance":513,"minHullGap":493.6,"rollsPerMinute":0.19,"rollReversals":13,"pitchReversals":0,"maxRoll":0.182} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r2400-v350-phase0.7","seconds":90,"radius":15,"firstLock":"5.47","onGun":100,"requestFire":100,"inRange":100,"medianError":2.76,"p95Error":2.87,"longestGap":0,"medianDistance":509,"minHullGap":490.1,"rollsPerMinute":0.37,"rollReversals":28,"pitchReversals":0,"maxRoll":0.263} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r2400-v350-phase2.1","seconds":90,"radius":15,"firstLock":"4.55","onGun":100,"requestFire":100,"inRange":100,"medianError":2.76,"p95Error":2.87,"longestGap":0,"medianDistance":510,"minHullGap":490.1,"rollsPerMinute":0.36,"rollReversals":28,"pitchReversals":0,"maxRoll":0.239} +{"grid":"holdout","variant":"hard-gate","name":"tilted-r2400-v350-phase4.2","seconds":90,"radius":15,"firstLock":"1.87","onGun":100,"requestFire":100,"inRange":100,"medianError":2.76,"p95Error":2.89,"longestGap":0,"medianDistance":510,"minHullGap":490.1,"rollsPerMinute":0.37,"rollReversals":30,"pitchReversals":0,"maxRoll":0.228} diff --git a/docs/reviews/combat-computer-2026-09-12/probe.mjs b/docs/reviews/combat-computer-2026-09-12/probe.mjs new file mode 100644 index 00000000..78dfb23d --- /dev/null +++ b/docs/reviews/combat-computer-2026-09-12/probe.mjs @@ -0,0 +1,142 @@ +// Review experiment: move one pirate along a known path and fly the real player. +// Usage from the repository root: +// node --experimental-strip-types --no-warnings docs/reviews/combat-computer-2026-09-12/probe.mjs +// Add --holdout for the independent orbit grid; add --hard-gate for the candidate. +// It prints JSON lines. It runs no career, shots, damage, saves or browser code. +// The target stays selected to isolate tracking from target-switch decisions. +// Orbit radius describes the path around the origin, not the player's distance. +// The first ten seconds are excluded from sustained-tracking measurements. +// rollsPerMinute counts absolute roll travel, including changes of direction. +// firstLock is the first valid firing angle and range; it is not a sustained lock. +import * as THREE from 'three'; +import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { PlayerShip } from '../../../src/player.ts'; +import { NpcShip } from '../../../src/game/npc.ts'; +// The variant changes only the co-pilot call's optional roll gate. The shared +// steering rule, flight model, lead, throttle and gun cone stay as shipped. +const sourceUrl = new URL('../../../src/game/scripted-co-pilot.ts', import.meta.url); +const hardGate = process.argv.includes('--hard-gate'); +let moduleUrl = sourceUrl.href; +let temporary; +if (hardGate) { + let source = readFileSync(sourceUrl, 'utf8'); + const before = 'this.aim.sub(player.position), this.steerMem, cone)'; + if (source.split(before).length !== 2) throw Error('The controller changed; review the experiment before reuse.'); + source = source.replace(before, 'this.aim.sub(player.position), this.steerMem, cone, 0.1)'); + source = source.replace(/from '([^']+)'/g, (whole, name) => { + if (name === 'three') return `from '${import.meta.resolve('three')}'`; + return name.startsWith('.') ? `from '${new URL(name, sourceUrl).href}'` : whole; + }); + temporary = mkdtempSync(join(tmpdir(), 'harmless-combat-review-')); + const path = join(temporary, 'scripted-co-pilot.ts'); + writeFileSync(path, source); + moduleUrl = pathToFileURL(path).href; +} +const { ScriptedCoPilot } = await import(moduleUrl); +if (temporary) rmSync(temporary, { recursive: true }); +import { hitCone } from '../../../src/game/gunnery.ts'; +import { LASER_RANGE } from '../../../src/constants/player-gun.ts'; +import { seedWorld } from '../../../src/game/rng.ts'; + +const dt = 1 / 60; +const up = new THREE.Vector3(0, 1, 0); +function run(name, path, seconds, pilotClass = ScriptedCoPilot, initialSpeed = 200) { + seedWorld(20260912); + const player = new PlayerShip(new THREE.Vector3(), new THREE.Vector3(0, 0, -1)); + player.speed = initialSpeed; + const target = new NpcShip('pirate', new THREE.Vector3(0, 0, -1000), 1); + const cp = new pilotClass(); + const velocity = new THREE.Vector3(); + const forward = new THREE.Vector3(); + const to = new THREE.Vector3(); + const ahead = new THREE.Vector3(); + const look = new THREE.Matrix4(); + const errors = []; + const distances = []; + let firstLock = null, onGun = 0, askFire = 0, inRange = 0, n = 0; + let gap = 0, longestGap = 0, rollTravel = 0, rollReversals = 0, pitchReversals = 0; + let lastRoll = 0, lastPitch = 0, maxRoll = 0; + let minGap = Infinity; + for (let i = 0; i < seconds * 60; i++) { + const t = i * dt; + path(t, target.object.position, velocity); + target.state.speed = velocity.length(); + look.lookAt(target.object.position, ahead.copy(target.object.position).add(velocity), up); + target.object.quaternion.setFromRotationMatrix(look); + const step = cp.step(dt, player, [target], 0, false, null, Infinity, target); + if (step.kind !== 'fly') throw Error('lost target'); + player.update(dt, step.demand); + to.copy(target.object.position).sub(player.position); + forward.set(0, 0, -1).applyQuaternion(player.quaternion); + const distance = to.length(); + const error = forward.angleTo(to); + const hit = error < hitCone(target.radius, distance) && distance <= LASER_RANGE; + if (hit && firstLock === null) firstLock = t; + minGap = Math.min(minGap, distance - target.radius); + if (t <= 10) continue; + n++; + if (hit) { onGun++; gap = 0; } else { gap += dt; longestGap = Math.max(longestGap, gap); } + if (distance <= LASER_RANGE) inRange++; + if (step.demand.fire) askFire++; + errors.push(error * 180 / Math.PI); + distances.push(distance); + rollTravel += Math.abs(step.demand.rollRate) * dt; + maxRoll = Math.max(maxRoll, Math.abs(step.demand.rollRate)); + const r = Math.abs(step.demand.rollRate) > 0.1 ? Math.sign(step.demand.rollRate) : 0; + const p = Math.abs(step.demand.pitchRate) > 0.1 ? Math.sign(step.demand.pitchRate) : 0; + if (r && lastRoll && r !== lastRoll) rollReversals++; + if (p && lastPitch && p !== lastPitch) pitchReversals++; + if (r) lastRoll = r; + if (p) lastPitch = p; + } + errors.sort((a,b) => a-b); + distances.sort((a,b) => a-b); + const pct = (v) => +(100 * v / n).toFixed(1); + const result = { name, seconds, radius: target.radius, firstLock: firstLock?.toFixed(2), + onGun: pct(onGun), requestFire: pct(askFire), inRange: pct(inRange), + medianError: +errors[Math.floor(n / 2)].toFixed(2), p95Error: +errors[Math.floor(n * .95)].toFixed(2), + longestGap: +longestGap.toFixed(2), medianDistance: +distances[Math.floor(n / 2)].toFixed(0), + minHullGap: +minGap.toFixed(1), rollsPerMinute: +(rollTravel / (2 * Math.PI) / (n * dt) * 60).toFixed(2), + rollReversals, pitchReversals, maxRoll: +maxRoll.toFixed(3), + }; + console.log(JSON.stringify(result)); + return result; +} +const orbit = (radius, speed, plane, phase = 0) => (t, position, velocity) => { + const a = t * speed / radius + phase; + const x = Math.cos(a) * radius, y = Math.sin(a) * radius; + const vx = -Math.sin(a) * speed, vy = Math.cos(a) * speed; + if (plane === 'horizontal') { position.set(x, 0, y); velocity.set(vx, 0, vy); } + if (plane === 'vertical') { position.set(0, x, y); velocity.set(0, vx, vy); } + if (plane === 'tilted') { position.set(x, y / Math.SQRT2, y / Math.SQRT2); velocity.set(vx, vy / Math.SQRT2, vy / Math.SQRT2); } +}; + +for (const seconds of process.argv.includes('--holdout') ? [] : [30, 60]) { + for (const plane of ['horizontal', 'vertical', 'tilted']) { + for (const radius of [400, 800, 1600]) { + for (const speed of [150, 300]) run(`${plane}-r${radius}-v${speed}`, orbit(radius, speed, plane), seconds); + } + } + run('straight-crossing', (t, p, v) => { p.set(-900 + 150 * t, 0, -700); v.set(150, 0, 0); }, seconds); + run('weaving-away', (t, p, v) => { p.set(350 * Math.sin(.8 * t), 0, -1200 - 180 * t); v.set(280 * Math.cos(.8 * t), 0, -180); }, seconds); + run('straight-away', (t, p, v) => { p.set(0, 0, -1800 - 280 * t); v.set(0, 0, -280); }, seconds); +} + +if (process.argv.includes('--holdout')) { + for (const seconds of [45, 90]) { + for (const plane of ['horizontal', 'vertical', 'tilted']) { + for (const radius of [600, 1200, 2400]) { + for (const speed of [200, 350]) { + for (const phase of [.7, 2.1, 4.2]) { + run(`${plane}-r${radius}-v${speed}-phase${phase}`, orbit(radius, speed, plane, phase), seconds); + } + } + } + } + } +} + +export { run, orbit }; diff --git a/docs/reviews/missions-2026-09-12/escort-probe.ts b/docs/reviews/missions-2026-09-12/escort-probe.ts new file mode 100644 index 00000000..76bae0f3 --- /dev/null +++ b/docs/reviews/missions-2026-09-12/escort-probe.ts @@ -0,0 +1,80 @@ +// Why an escort stalls: a trace of the charge and the commander, ten seconds +// at a time, on the seeds flight-probe.ts saw time out or die. +// +// node --experimental-strip-types --no-warnings docs/reviews/missions-2026-09-12/escort-probe.ts + +import { Game } from '../../../src/game/game.ts'; +import { headlessShell } from '../../../src/engine/shell.ts'; +import { withoutSaving } from '../../../src/game/storage.ts'; +import { seedWorld } from '../../../src/game/rng.ts'; +import { runMissions } from '../../../src/game/mission-bridge.ts'; +import { COURSE_KEYS } from '../../../src/game/bindings.ts'; +import { keymap } from '../../../src/engine/keymap.ts'; +import { hostilesNear } from '../../../src/game/hostility.ts'; + +const DT = 1 / 60; +const FIRE = keymap().fire[0]; + +function escortGame(k: number): Game { + const g = withoutSaving(() => { + seedWorld(20_260_950 + k); + const game = new Game(() => headlessShell()); + if (game.mode === 'briefing') { game.input.injectPress('Escape'); game.step(DT, DT); } + return game; + }).value; + const c = g.state.commander; + for (let world = 0; world < 256 && c.missions.live.length === 0; world++) { + c.systemIndex = world; + c.kills = 16; // the board gates the escort by kills since docs/TODO/217 M2 + withoutSaving(() => runMissions(c, { kind: 'accept', skeleton: 'side-escort' }, g.state.systems, () => 0.5)); + } + c.missions.live[0].target = c.systemIndex; + c.day += k * 3; + withoutSaving(() => { + g.launch(); + for (let f = 0, at = 0; f < 400; f++) g.step(DT, at += DT); + g.arriveInSystem(); + for (let f = 0, at = 0; f < 400; f++) g.step(DT, at += DT); + }); + return g; +} + +for (const k of [1, 2, 5, 7, 0]) { + const g = escortGame(k); + const s = g.state; + const charge = s.world.npcs.find((n) => n.state.missionTag !== null)!; + const st = s.world.station.position; + console.log(`\nseed ${k}: the charge starts ${Math.round(charge.object.position.distanceTo(st)).toLocaleString()} from the station, ` + + `${Math.round(charge.object.position.distanceTo(s.player.position)).toLocaleString()} from the commander`); + console.log(' t | charge: phase flee speed to-station | commander: to-charge energy shields course | hostile'); + let minGap = Infinity; + let rams = 0; + let lastEnergy = s.sys.energy; + withoutSaving(() => { + g.input.injectPress(COURSE_KEYS.mission); + for (let f = 0; f < 600 * 60; f++) { + const hostile = hostilesNear(s.world.npcs, s.player.position, s.commander.legalStatus, s.player.position.distanceTo(st)); + if (s.session.ccEngaged) g.input.press(FIRE); else g.input.release(FIRE); + if (!hostile && s.session.course === null && !s.session.dockTrial && g.mode === 'flight') g.input.injectPress(COURSE_KEYS.mission); + g.step(DT, 100 + f * DT); + const gap = s.player.position.distanceTo(charge.object.position); + minGap = Math.min(minGap, gap); + if (s.sys.energy < lastEnergy - 5 && gap < 200) rams += 1; + lastEnergy = s.sys.energy; + if (f % 600 === 0 || g.mode === 'dead' || s.commander.missions.done['side-escort']) { + console.log(`${String(Math.round(f * DT)).padStart(4)} | ${charge.state.traderPhase.padEnd(9)} ${String(charge.state.fleeing).padEnd(5)} ${String(Math.round(charge.state.speed)).padStart(5)} ${Math.round(charge.object.position.distanceTo(st)).toLocaleString().padStart(9)} | ` + + `${Math.round(gap).toLocaleString().padStart(10)} ${String(s.sys.energy).padStart(6)} ${String(s.sys.foreShield).padStart(3)}/${String(s.sys.aftShield).padEnd(3)} ${String(s.session.course).padEnd(9)} | ${hostile}`); + } + if (g.mode === 'dead') { + const w = s.world; + console.log(` THE COMMANDER DIED at ${Math.round(f * DT)} s; ${rams} energy drops within 200 units of the charge; nearest ${Math.round(minGap)}`); + console.log(` at death: ${Math.round(s.player.position.distanceTo(w.planetPos) - w.planetRadius).toLocaleString()} above the planet, ${Math.round(s.player.position.distanceTo(st)).toLocaleString()} from the station, ${Math.round(s.player.position.distanceTo(w.sunPos)).toLocaleString()} from the sun, speed ${Math.round(s.player.speed)}`); + break; + } + if (s.commander.missions.done['side-escort']) { console.log(` ESCORT ${s.commander.missions.done['side-escort'].toUpperCase()} at ${Math.round(f * DT)} s; nearest ${Math.round(minGap)}`); break; } + if (!charge.state.alive) { console.log(` the charge died at ${Math.round(f * DT)} s`); break; } + } + }); + g.input.release(FIRE); + if (!s.commander.missions.done['side-escort'] && g.mode !== 'dead') console.log(` still going at 600 s; nearest ${Math.round(minGap)}; ${rams} energy drops within 200 units of the charge`); +} diff --git a/docs/reviews/missions-2026-09-12/escort-results.txt b/docs/reviews/missions-2026-09-12/escort-results.txt new file mode 100644 index 00000000..6323be2a --- /dev/null +++ b/docs/reviews/missions-2026-09-12/escort-results.txt @@ -0,0 +1,311 @@ + +seed 1: the charge starts 69,886 from the station, 4,181 from the commander + t | charge: phase flee speed to-station | commander: to-charge energy shields course | hostile + 0 | arriving false 136 69,883 | 4,180 255 255/255 mission | false + 10 | arriving false 136 68,523 | 1,102 255 255/255 mission | false + 20 | arriving false 136 67,163 | 710 255 255/255 mission | false + 30 | arriving false 136 65,803 | 616 255 255/255 mission | false + 40 | arriving false 136 64,443 | 626 255 255/255 mission | false + 50 | arriving false 136 63,083 | 716 255 255/255 mission | true + 60 | arriving true 145 61,833 | 1,079 254 255/255 null | true + 70 | arriving true 160 60,343 | 105 255 255/255 mission | false + 80 | arriving true 160 58,766 | 122 255 255/255 mission | false + 90 | arriving true 160 57,190 | 138 255 255/255 mission | false + 100 | arriving true 160 55,615 | 155 255 255/255 mission | false + 110 | arriving true 160 54,042 | 172 255 255/255 mission | false + 120 | arriving true 160 52,471 | 188 255 255/255 mission | false + 130 | arriving true 160 50,902 | 205 255 255/255 mission | false + 140 | arriving true 160 49,334 | 222 255 255/255 mission | false + 150 | arriving true 160 47,755 | 239 255 255/255 mission | false + 160 | arriving true 160 46,161 | 256 255 255/255 mission | false + 170 | arriving true 160 44,570 | 273 255 255/255 mission | false + 180 | arriving true 160 42,995 | 289 255 255/255 mission | false + 190 | arriving true 160 41,448 | 306 255 255/255 mission | false + 200 | arriving true 160 39,937 | 323 255 255/255 mission | false + 210 | arriving true 160 38,469 | 340 255 255/255 mission | false + 220 | arriving true 160 37,017 | 356 255 255/255 mission | false + 230 | arriving true 160 35,585 | 367 255 255/255 mission | false + 240 | arriving true 160 34,641 | 367 255 255/255 mission | false + 250 | arriving true 160 34,733 | 367 255 255/255 mission | false + 260 | arriving true 160 35,758 | 367 255 255/255 mission | false + 270 | arriving true 160 37,201 | 366 255 255/255 mission | false + 280 | arriving true 160 38,426 | 373 255 255/255 mission | false + 290 | arriving true 160 38,829 | 367 255 255/255 mission | false + 300 | arriving true 160 38,291 | 371 255 255/255 mission | false + 310 | arriving true 160 37,086 | 369 255 255/255 mission | false + 320 | arriving true 160 35,671 | 369 255 255/255 mission | false + 330 | arriving true 160 34,689 | 373 255 255/255 mission | false + 340 | arriving true 160 34,732 | 370 255 255/255 mission | false + 350 | arriving true 160 35,794 | 366 255 255/255 mission | false + 360 | arriving true 160 37,245 | 371 255 255/255 mission | false + 370 | arriving true 160 38,451 | 371 255 255/255 mission | false + 380 | arriving true 160 38,855 | 371 255 255/255 mission | false + 390 | arriving true 160 38,411 | 374 255 255/255 mission | false + 400 | arriving true 160 37,139 | 367 255 255/255 mission | false + 410 | arriving true 160 35,713 | 372 255 255/255 mission | false + 420 | arriving true 160 34,817 | 368 255 255/255 mission | false + 430 | arriving true 160 34,960 | 370 255 255/255 mission | false + 440 | arriving true 160 35,937 | 372 255 255/255 mission | false + 450 | arriving true 160 37,370 | 370 255 255/255 mission | false + 460 | arriving true 160 38,585 | 375 255 255/255 mission | false + 470 | arriving true 160 39,003 | 371 255 255/255 mission | false + 480 | arriving true 160 38,482 | 374 255 255/255 mission | false + 490 | arriving true 160 37,273 | 372 255 255/255 mission | false + 500 | arriving true 160 35,861 | 372 255 255/255 mission | false + 510 | arriving true 160 34,903 | 375 255 255/255 mission | false + 520 | arriving true 160 34,996 | 370 255 255/255 mission | false + 530 | arriving true 160 36,090 | 367 255 255/255 mission | false + 540 | arriving true 160 37,536 | 371 255 255/255 mission | false + 550 | arriving true 160 38,705 | 371 255 255/255 mission | false + 560 | arriving true 160 39,024 | 368 255 255/255 mission | false + 570 | arriving true 160 38,325 | 366 255 255/255 mission | false + 580 | arriving true 160 37,050 | 373 255 255/255 mission | false + 590 | arriving true 160 35,677 | 367 255 255/255 mission | false + still going at 600 s; nearest 70; 0 energy drops within 200 units of the charge + +seed 2: the charge starts 74,822 from the station, 4,317 from the commander + t | charge: phase flee speed to-station | commander: to-charge energy shields course | hostile + 0 | arriving false 136 74,819 | 4,319 255 255/255 mission | false + 10 | arriving false 136 73,463 | 531 255 255/255 mission | false + 20 | arriving true 160 72,237 | 174 255 165/255 mission | false + 30 | arriving true 160 71,142 | 192 255 196/255 mission | false + 40 | arriving true 160 70,066 | 209 255 226/255 mission | false + 50 | arriving true 160 69,010 | 227 255 255/255 mission | false + 60 | arriving true 160 67,975 | 245 255 255/255 mission | false + 70 | arriving true 160 66,963 | 262 255 255/255 mission | false + 80 | arriving true 160 65,974 | 280 255 255/255 mission | false + 90 | arriving true 160 65,010 | 298 255 255/255 mission | false + 100 | arriving true 160 64,071 | 315 255 255/255 mission | false + 110 | arriving true 160 63,158 | 333 255 255/255 mission | false + 120 | arriving true 160 62,273 | 351 255 255/255 mission | false + 130 | arriving true 160 61,417 | 368 255 255/255 mission | false + 140 | arriving true 160 60,592 | 386 255 255/255 mission | false + 150 | arriving true 160 59,798 | 404 255 255/255 mission | false + 160 | arriving true 160 59,036 | 421 255 255/255 mission | false + 170 | arriving true 160 58,308 | 439 255 255/255 mission | false + 180 | arriving true 160 57,616 | 457 255 255/255 mission | false + 190 | arriving true 160 56,960 | 474 255 255/255 mission | false + 200 | arriving true 160 56,342 | 492 255 255/255 mission | false + 210 | arriving true 160 55,763 | 510 255 255/255 mission | false + 220 | arriving true 160 55,225 | 677 255 255/255 mission | false + 230 | arriving true 160 54,727 | 715 255 255/255 mission | false + 240 | arriving true 160 54,273 | 732 255 255/255 mission | false + 250 | arriving true 160 53,862 | 730 255 255/255 mission | false + 260 | arriving true 160 53,496 | 708 255 255/255 mission | false + 270 | arriving true 160 53,176 | 667 255 255/255 mission | false + 280 | arriving true 160 52,902 | 630 255 255/255 mission | false + 290 | arriving true 160 52,675 | 608 255 255/255 mission | false + 300 | arriving true 160 52,496 | 600 255 255/255 mission | false + 310 | arriving true 160 52,365 | 611 255 255/255 mission | false + 320 | arriving true 160 52,283 | 642 255 255/255 mission | false + 330 | arriving true 160 52,250 | 688 255 255/255 mission | false + 340 | arriving true 160 52,266 | 721 255 255/255 mission | false + 350 | arriving true 160 52,331 | 734 255 255/255 mission | false + 360 | arriving true 160 52,444 | 727 255 255/255 mission | false + 370 | arriving true 160 52,606 | 701 255 255/255 mission | false + 380 | arriving true 160 52,816 | 658 255 255/255 mission | false + 390 | arriving true 160 53,074 | 624 255 255/255 mission | false + 400 | arriving true 160 53,378 | 604 255 255/255 mission | false + 410 | arriving true 160 53,728 | 601 255 255/255 mission | false + 420 | arriving true 160 54,123 | 616 255 255/255 mission | false + 430 | arriving true 160 54,562 | 651 255 255/255 mission | false + 440 | arriving true 160 55,045 | 697 255 255/255 mission | false + 450 | arriving true 160 55,569 | 725 255 255/255 mission | false + 460 | arriving true 160 56,134 | 734 255 255/255 mission | false + 470 | arriving true 160 56,738 | 723 255 255/255 mission | false + 480 | arriving true 160 57,381 | 692 255 255/255 mission | false + 490 | arriving true 160 58,060 | 649 255 255/255 mission | false + 500 | arriving true 160 58,776 | 618 255 255/255 mission | false + 510 | arriving true 160 59,526 | 602 255 255/255 mission | false + 520 | arriving true 160 60,308 | 602 255 255/255 mission | false + 530 | arriving true 160 61,123 | 622 255 255/255 mission | false + 540 | arriving true 160 61,969 | 662 255 255/255 mission | false + 550 | arriving true 160 62,843 | 705 255 255/255 mission | false + 560 | arriving true 160 63,746 | 729 255 255/255 mission | false + 570 | arriving true 160 64,676 | 733 255 255/255 mission | false + 580 | arriving true 160 65,632 | 718 255 255/255 mission | false + 590 | arriving true 160 66,612 | 683 255 255/255 mission | false + still going at 600 s; nearest 49; 0 energy drops within 200 units of the charge + +seed 5: the charge starts 74,302 from the station, 4,553 from the commander + t | charge: phase flee speed to-station | commander: to-charge energy shields course | hostile + 0 | arriving false 136 74,300 | 4,554 255 255/255 mission | false + 10 | arriving false 136 72,940 | 708 255 255/255 mission | false + 20 | arriving false 136 71,580 | 130 255 255/255 mission | false + 30 | arriving false 136 70,220 | 127 255 255/255 mission | false + 40 | arriving false 136 68,860 | 123 255 255/255 mission | false + 50 | arriving false 136 67,500 | 120 255 255/255 mission | false + 60 | arriving false 136 66,140 | 117 255 255/255 mission | false + 70 | arriving false 136 64,780 | 113 255 255/255 mission | false + 80 | arriving false 136 63,420 | 110 255 255/255 mission | false + 90 | arriving false 136 62,060 | 107 255 255/255 mission | false + 100 | arriving false 136 60,700 | 103 255 255/255 mission | false + 110 | arriving false 136 59,340 | 100 255 255/255 mission | false + 120 | arriving false 136 57,980 | 97 255 255/255 mission | false + 130 | arriving false 136 56,620 | 93 255 255/255 mission | false + 140 | arriving false 136 55,260 | 90 255 255/255 mission | false + 150 | arriving false 136 53,900 | 87 255 255/255 mission | false + 160 | arriving false 136 52,540 | 83 255 255/255 mission | false + 170 | arriving false 136 51,180 | 80 255 255/255 mission | false + 180 | arriving false 136 49,820 | 77 255 255/255 mission | false + 190 | arriving false 136 48,460 | 73 255 255/255 mission | false + 200 | arriving false 136 47,100 | 70 255 255/255 mission | false + 210 | arriving false 136 45,740 | 67 255 255/255 mission | false + 220 | arriving false 136 44,380 | 63 255 255/255 mission | false + 230 | arriving false 136 43,020 | 60 255 255/255 mission | false + 240 | arriving false 136 41,660 | 57 255 255/255 mission | false + 250 | arriving false 136 40,300 | 53 255 255/255 mission | false + 260 | arriving false 136 38,940 | 50 255 255/255 mission | false + 270 | arriving false 136 37,580 | 47 255 255/255 mission | false + 280 | arriving true 160 36,105 | 177 255 155/255 mission | false + 290 | arriving true 160 34,505 | 195 255 185/255 mission | false + 300 | arriving true 160 32,905 | 213 255 216/255 mission | false + 310 | arriving true 160 31,305 | 230 255 246/255 mission | false + 320 | arriving true 160 29,706 | 248 255 255/255 mission | false + 330 | arriving true 160 28,106 | 266 255 255/255 mission | false + 340 | arriving true 160 26,506 | 283 255 255/255 mission | false + 350 | arriving true 160 24,907 | 301 255 255/255 mission | false + 360 | arriving true 160 23,307 | 319 255 255/255 mission | false + 370 | arriving true 160 21,708 | 336 255 255/255 mission | false + 380 | arriving true 160 20,108 | 354 255 255/255 mission | false + 390 | arriving true 160 18,509 | 372 255 255/255 mission | false + 400 | arriving true 160 16,910 | 389 255 255/255 mission | false + 410 | arriving true 160 15,311 | 407 255 255/255 mission | false + 420 | arriving true 160 13,712 | 425 255 255/255 mission | false + 430 | arriving true 160 12,114 | 442 255 255/255 mission | false + 440 | arriving true 160 10,516 | 460 255 255/255 mission | false + 450 | arriving true 160 8,919 | 478 255 255/255 mission | false + 460 | arriving true 160 7,323 | 495 255 255/255 mission | false + 470 | arriving true 160 5,729 | 513 255 255/255 mission | false + 480 | arriving true 160 4,140 | 625 255 255/255 mission | false + 490 | arriving true 160 2,565 | 605 255 255/255 mission | false + 500 | arriving true 160 1,068 | 600 255 255/255 mission | false + 510 | arriving true 160 905 | 615 255 255/255 mission | false + 520 | arriving true 160 2,370 | 649 255 255/255 mission | false + 530 | arriving true 160 3,942 | 694 255 255/255 mission | false + 540 | arriving true 160 5,530 | 724 255 255/255 mission | false + 550 | arriving true 160 7,123 | 734 255 255/255 mission | false + 559 | arriving true 160 8,620 | 723 255 255/255 mission | false + THE COMMANDER DIED at 559 s; 0 energy drops within 200 units of the charge; nearest 45 + at death: 80 above the planet, 7,900 from the station, 316,954 from the sun, speed 89 + +seed 7: the charge starts 71,610 from the station, 2,768 from the commander + t | charge: phase flee speed to-station | commander: to-charge energy shields course | hostile + 0 | arriving false 136 71,608 | 2,768 255 255/255 mission | false + 10 | arriving false 136 70,248 | 697 255 255/255 mission | false + 20 | arriving false 136 68,888 | 603 255 255/255 mission | false + 30 | arriving false 136 67,528 | 658 255 255/255 mission | false + 40 | arriving false 136 66,168 | 714 255 255/255 mission | false + 50 | arriving false 136 64,808 | 622 255 255/255 mission | false + 60 | arriving false 136 63,448 | 618 255 255/255 mission | false + 70 | arriving false 136 62,088 | 714 255 255/255 mission | false + 80 | arriving false 136 60,728 | 660 255 255/255 mission | false + 90 | arriving false 136 59,368 | 601 255 255/255 mission | false + 100 | arriving false 136 58,008 | 689 255 255/255 mission | false + 110 | arriving false 136 56,648 | 700 255 255/255 mission | false + 120 | arriving false 136 55,288 | 607 255 255/255 mission | false + 130 | arriving false 136 53,928 | 645 255 255/255 mission | false + 140 | arriving false 136 52,568 | 717 255 255/255 mission | false + 150 | arriving false 136 51,208 | 632 255 255/255 mission | false + 160 | arriving false 136 49,848 | 610 255 255/255 mission | false + 170 | arriving false 136 48,488 | 709 255 255/255 mission | false + 180 | arriving false 136 47,128 | 674 255 255/255 mission | false + 190 | arriving false 136 45,768 | 600 255 255/255 mission | false + 200 | arriving false 136 44,408 | 676 255 255/255 mission | false + 210 | arriving false 136 43,048 | 708 255 255/255 mission | false + 220 | arriving false 136 41,688 | 612 255 255/255 mission | true + 230 | arriving true 160 40,618 | 1,703 255 238/245 mission | false + 240 | arriving true 160 39,146 | 166 255 243/255 mission | false + 250 | arriving true 160 37,727 | 182 255 255/255 mission | false + 260 | arriving true 160 36,322 | 199 255 255/255 mission | false + 270 | arriving true 160 34,935 | 216 255 255/255 mission | false + 280 | arriving true 160 33,572 | 232 255 255/255 mission | false + 290 | arriving true 160 32,307 | 249 255 255/255 mission | false + 300 | arriving true 160 31,203 | 266 255 255/255 mission | false + 310 | arriving true 160 30,268 | 283 255 255/255 mission | false + 320 | arriving true 160 29,499 | 300 255 255/255 mission | false + 330 | arriving true 160 28,881 | 317 255 255/255 mission | false + 340 | arriving true 160 28,393 | 334 255 255/255 mission | false + 350 | arriving true 160 28,009 | 351 255 255/255 mission | false + 360 | arriving true 160 27,762 | 365 255 255/255 mission | false + 370 | arriving true 160 27,761 | 372 255 255/255 mission | false + 380 | arriving true 160 28,046 | 372 255 255/255 mission | false + 390 | arriving true 160 28,478 | 370 255 255/255 mission | false + 400 | arriving true 160 28,882 | 377 255 255/255 mission | false + 410 | arriving true 160 29,079 | 376 255 255/255 mission | false + 420 | arriving true 160 28,982 | 378 255 255/255 mission | false + 430 | arriving true 160 28,660 | 378 255 255/255 mission | false + 440 | arriving true 160 28,220 | 377 255 255/255 mission | false + 450 | arriving true 160 27,876 | 381 255 255/255 mission | false + 460 | arriving true 160 27,799 | 376 255 255/255 mission | false + 470 | arriving true 160 28,031 | 372 255 255/255 mission | false + 480 | arriving true 160 28,437 | 372 255 255/255 mission | false + 490 | arriving true 160 28,827 | 376 255 255/255 mission | false + 500 | arriving true 160 29,063 | 378 255 255/255 mission | false + 510 | arriving true 160 28,970 | 371 255 255/255 mission | false + 520 | arriving true 160 28,636 | 373 255 255/255 mission | false + 530 | arriving true 160 28,219 | 378 255 255/255 mission | false + 540 | arriving true 160 27,891 | 374 255 255/255 mission | false + 550 | arriving true 160 27,823 | 372 255 255/255 mission | false + 560 | arriving true 160 28,035 | 374 255 255/255 mission | false + 570 | arriving true 160 28,425 | 380 255 255/255 mission | false + 580 | arriving true 160 28,824 | 376 255 255/255 mission | false + 590 | arriving true 160 29,015 | 371 255 255/255 mission | false + still going at 600 s; nearest 67; 0 energy drops within 200 units of the charge + +seed 0: the charge starts 73,667 from the station, 5,329 from the commander + t | charge: phase flee speed to-station | commander: to-charge energy shields course | hostile + 0 | arriving false 136 73,665 | 5,330 255 255/255 mission | false + 10 | arriving false 136 72,306 | 1,798 255 255/255 mission | false + 20 | arriving false 136 70,946 | 624 255 255/255 mission | false + 30 | arriving false 136 69,586 | 621 255 255/255 mission | false + 40 | arriving false 136 68,226 | 715 255 255/255 mission | false + 50 | arriving false 136 66,866 | 655 255 255/255 mission | false + 60 | arriving false 136 65,506 | 601 255 255/255 mission | false + 70 | arriving false 136 64,146 | 692 255 255/255 mission | false + 80 | arriving false 136 62,786 | 697 255 255/255 mission | false + 90 | arriving false 136 61,426 | 605 255 255/255 mission | false + 100 | arriving false 136 60,066 | 649 255 255/255 mission | false + 110 | arriving false 136 58,706 | 716 255 255/255 mission | false + 120 | arriving false 136 57,346 | 628 255 255/255 mission | false + 130 | arriving false 136 55,986 | 613 255 255/255 mission | false + 140 | arriving false 136 54,626 | 711 255 255/255 mission | false + 150 | arriving false 136 53,266 | 669 255 255/255 mission | false + 160 | arriving false 136 51,906 | 600 255 255/255 mission | false + 170 | arriving false 136 50,546 | 681 255 255/255 mission | false + 180 | arriving false 136 49,186 | 705 255 255/255 mission | false + 190 | arriving false 136 47,826 | 610 255 255/255 mission | false + 200 | arriving false 136 46,466 | 636 255 255/255 mission | false + 210 | arriving false 136 45,106 | 717 255 255/255 mission | false + 220 | arriving false 136 43,746 | 639 255 255/255 mission | false + 230 | arriving false 136 42,386 | 606 255 255/255 mission | false + 240 | arriving false 136 41,026 | 704 255 255/255 mission | false + 250 | arriving false 136 39,666 | 683 255 255/255 mission | false + 260 | arriving false 136 38,306 | 601 255 255/255 mission | false + 270 | arriving false 136 36,946 | 667 255 255/255 mission | false + 280 | arriving false 136 35,586 | 711 255 255/255 mission | false + 290 | arriving false 136 34,226 | 617 255 255/255 mission | false + 300 | arriving false 136 32,866 | 625 255 255/255 mission | false + 310 | arriving false 136 31,506 | 716 255 255/255 mission | false + 320 | arriving false 136 30,146 | 651 255 255/255 mission | false + 330 | arriving false 136 28,786 | 602 255 255/255 mission | false + 340 | arriving false 136 27,426 | 696 255 255/255 mission | false + 350 | arriving false 136 26,066 | 694 255 255/255 mission | false + 360 | arriving false 136 24,706 | 604 255 255/255 mission | false + 370 | arriving false 136 23,346 | 654 255 255/255 mission | false + 380 | arriving false 136 21,986 | 715 255 255/255 mission | false + 390 | arriving false 136 20,626 | 625 255 255/255 mission | false + 400 | arriving false 136 19,266 | 615 255 255/255 mission | false + 410 | arriving false 136 17,906 | 712 255 255/255 mission | false + 420 | arriving false 136 16,546 | 664 255 255/255 mission | false + 430 | arriving false 136 15,186 | 600 255 255/255 mission | false + 440 | arriving false 136 13,826 | 685 255 255/255 mission | false + 450 | arriving false 136 12,466 | 703 255 255/255 mission | false + 460 | arriving false 136 11,106 | 608 255 255/255 mission | false + 470 | arriving false 136 9,746 | 640 255 255/255 mission | false + 480 | arriving false 136 8,386 | 717 255 255/255 mission | false + 490 | arriving false 136 7,026 | 635 255 255/255 mission | false + 500 | arriving false 136 5,666 | 608 255 255/255 mission | false + 510 | arriving false 136 4,306 | 707 255 255/255 mission | false + 516 | arriving false 136 3,499 | 696 255 255/255 mission | false + ESCORT COMPLETE at 516 s; nearest 540 diff --git a/docs/reviews/missions-2026-09-12/flight-probe.ts b/docs/reviews/missions-2026-09-12/flight-probe.ts new file mode 100644 index 00000000..7545463a --- /dev/null +++ b/docs/reviews/missions-2026-09-12/flight-probe.ts @@ -0,0 +1,253 @@ +// The flight probe behind docs/MISSIONS-REVIEW.md, part two: can the ship's +// own automation finish each kind of job, and how long does it take? +// +// node --experimental-strip-types --no-warnings docs/reviews/missions-2026-09-12/flight-probe.ts [seeds] +// +// Each run is a real headless Game. A fresh commander (pulse laser, three +// missiles, 100 Cr) is put on one live leg whose work is in the system she +// arrives in. Ordinary traffic stays in the sky. The probe presses the job's +// own button, holds the trigger in a fight, presses the button again when a +// fight drops the course, and presses the docking computer when the station +// course hands over. Scoops are fitted for a scoop, and a docking computer +// for a leg that ends at the station, because the probe has no hands for the +// slot. The output is committed beside it as flight-results.txt. + +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 { runMissions } from '../../../src/game/mission-bridge.ts'; +import { COURSE_KEYS } from '../../../src/game/bindings.ts'; +import { keymap } from '../../../src/engine/keymap.ts'; +import { SOURCE_DESIGN, CONSTRICTOR_SPEC } from '../../../src/game/ship-specs.ts'; +import { shipDesignIdOf } from '../../../src/game/ship-identity.ts'; +import { hostilesNear } from '../../../src/game/hostility.ts'; +import type { MissionState } from '../../../src/missions/model.ts'; + +const SEEDS = Number(process.argv[2] ?? 8); +const DT = 1 / 60; +const FIRE = keymap().fire[0]; +const DOCK_KEY = 'KeyC'; + +interface Kit { scoops?: boolean; dc?: boolean } + +function newGame(seed: number, kit: Kit): Game { + const g = withoutSaving(() => { + seedWorld(seed); + const game = new Game(() => headlessShell()); + if (game.mode === 'briefing') { game.input.injectPress('Escape'); game.step(DT, DT); } + return game; + }).value; + const c = g.state.commander; + c.equipment.scoops = kit.scoops ?? false; + c.equipment.dockingComputer = kit.dc ?? false; + return g; +} + +/** Accept a side job where a board offers it, and move its work here. */ +function onSideJob(g: Game, skeleton: string): void { + const c = g.state.commander; + for (let world = 0; world < 256 && c.missions.live.length === 0; world++) { + c.systemIndex = world; + // Sixteen kills, so the jobs the board gates are on it (docs/TODO/217 M2). + c.kills = 16; + withoutSaving(() => runMissions(c, { kind: 'accept', skeleton }, g.state.systems, () => 0.5)); + } + if (!c.missions.live[0]) throw new Error(`no world offers ${skeleton}`); + c.missions.live[0].target = c.systemIndex; +} + +/** Put a hand-built live leg on the record: an arc's hunt, with its ship here. */ +function onArcHunt(g: Game, skeleton: string, leg: string, ship: string): void { + const c = g.state.commander; + const here = c.systemIndex; + const tag = `${skeleton}#1#${leg}`; + const st: MissionState = c.missions; + st.live.push({ skeleton, leg, target: here, tag, progress: 0, deadlineDay: null }); + st.journal.push({ skeleton, leg, outcome: 'accepted', day: c.day, world: here }); + st.entities[tag] = { kind: 'ship', ship, hull: 1, lastWorld: here, alive: true }; +} + +/** + * Launch, jump, and arrive with the job's target in the sky and the traffic + * too. The arrival reseeds the world from the system and the day, so each + * seed lands on a different day to meet a different sky. + */ +function arrive(g: Game, k = 0): void { + g.state.commander.day += k * 3; + withoutSaving(() => { + g.launch(); + for (let f = 0, at = 0; f < 400; f++) g.step(DT, at += DT); + g.arriveInSystem(); + for (let f = 0, at = 0; f < 400; f++) g.step(DT, at += DT); + }); +} + +interface Run { + outcome: 'done' | 'failed' | 'dead' | 'timeout'; + seconds: number; + fights: number; + scanned: boolean; + kills: number; + /** energy and the two shields when the run ended, as the HUD reads them */ + energy: number; + fore: number; + aft: number; + /** how far the station was from the witchpoint at the arrival */ + stationAway: number; +} + +/** + * Fly a course button until `until` says the work is done, the leg failed, + * the ship died, or the seconds ran out. `trigger` holds the laser in a + * fight. The button is pressed again whenever a fight dropped the course. + */ +function fly( + g: Game, key: string, seconds: number, until: () => boolean, failed: () => boolean, trigger: boolean, + then: Phase[] = [], +): Run { + const s = g.state; + const kills0 = s.commander.kills; + let fights = 0; + let inFight = false; + let flown = 0; + let outcome: Run['outcome'] = 'timeout'; + const stationAway = s.player.position.distanceTo(s.world.station.position); + // The phases still to fly: the first is the case's own, and the rest follow. + const phases: Phase[] = [{ key, until: () => until(), trigger }, ...then.map((p) => ({ ...p, until: () => p.until(g) }))]; + let phase = 0; + withoutSaving(() => { + g.input.injectPress(key); + for (let f = 0; f < seconds * 60; f++) { + key = phases[phase].key; + trigger = phases[phase].trigger; + const hostile = hostilesNear(s.world.npcs, s.player.position, s.commander.legalStatus, + s.player.position.distanceTo(s.world.station.position)); + if (hostile && !inFight) fights += 1; + inFight = hostile; + if (trigger && s.session.ccEngaged) g.input.press(FIRE); else g.input.release(FIRE); + // The course dropped for a fight and the fight is over: press it again. + if (!hostile && s.session.course === null && !s.session.dockTrial && g.mode === 'flight') { + g.input.injectPress(key); + } + // The station course handed over: the docking computer flies the slot. + if (s.session.dockTrial && !s.session.dcEngaged && s.commander.equipment.dockingComputer) { + g.input.injectPress(DOCK_KEY); + } + g.step(DT, 100 + f * DT); + flown = f * DT; + if (g.mode === 'dead') { outcome = 'dead'; break; } + if (failed()) { outcome = 'failed'; break; } + if (phases[phase].until()) { + if (phase === phases.length - 1) { outcome = 'done'; break; } + phase += 1; + g.input.injectPress(phases[phase].key); + } + } + }); + g.input.release(FIRE); + return { + outcome, seconds: flown, fights, scanned: s.session.policeScanned, kills: s.commander.kills - kills0, + energy: s.sys.energy, fore: s.sys.foreShield, aft: s.sys.aftShield, stationAway, + }; +} + +const done = (g: Game, id: string) => g.state.commander.missions.done[id] === 'complete'; +const failed = (g: Game, id: string) => g.state.commander.missions.done[id] === 'fail'; +const leg = (g: Game) => g.state.commander.missions.live[0]?.leg; +const isDocked = (g: Game) => g.mode !== 'flight' && g.mode !== 'dead'; + +interface Phase { key: string; until: (g: Game) => boolean; trigger: boolean } + +interface Case { + name: string; + kit: Kit; + put: (g: Game) => void; + key: string; + seconds: number; + until: (g: Game) => boolean; + failed: (g: Game) => boolean; + trigger: boolean; + /** further phases after `until`: a second key, and what ends it (docs/TODO/214 M2) */ + then?: Phase[]; +} + +const cobra = shipDesignIdOf(SOURCE_DESIGN.cobraMk3); +const asp = shipDesignIdOf(SOURCE_DESIGN.asp); +const fdl = shipDesignIdOf(SOURCE_DESIGN.ferDeLance); + +const CASES: Case[] = [ + { name: 'side-hunt: a gang of four, pulse laser', kit: {}, put: (g) => onSideJob(g, 'side-hunt'), + key: COURSE_KEYS.mission, seconds: 300, until: (g) => done(g, 'side-hunt'), failed: (g) => failed(g, 'side-hunt'), trigger: true }, + { name: 'arc-lave runner: a Cobra Mk III, pulse laser', kit: {}, put: (g) => onArcHunt(g, 'arc-lave', 'runner', cobra), + key: COURSE_KEYS.mission, seconds: 300, until: (g) => leg(g) !== 'runner', failed: (g) => failed(g, 'arc-lave'), trigger: true }, + { name: 'arc-xeer chase: a Fer-de-Lance, pulse laser', kit: {}, put: (g) => onArcHunt(g, 'arc-xeer', 'chase', fdl), + key: COURSE_KEYS.mission, seconds: 300, until: (g) => leg(g) !== 'chase', failed: (g) => failed(g, 'arc-xeer'), trigger: true }, + { name: 'arc-edle purge: an Asp, pulse laser', kit: {}, put: (g) => onArcHunt(g, 'arc-edle', 'purge', asp), + key: COURSE_KEYS.mission, seconds: 300, until: (g) => leg(g) !== 'purge', failed: (g) => failed(g, 'arc-edle'), trigger: true }, + { name: 'constrictor hunt, pulse laser', kit: {}, put: (g) => onArcHunt(g, 'constrictor', 'hunt', CONSTRICTOR_SPEC.designId), + key: COURSE_KEYS.mission, seconds: 300, until: (g) => leg(g) !== 'hunt', failed: (g) => failed(g, 'constrictor'), trigger: true }, + { name: 'side-scan: an Anaconda for 20 s', kit: {}, put: (g) => onSideJob(g, 'side-scan'), + key: COURSE_KEYS.mission, seconds: 300, until: (g) => done(g, 'side-scan'), failed: (g) => failed(g, 'side-scan'), trigger: true }, + { name: 'side-escort: a Python to station range', kit: {}, put: (g) => onSideJob(g, 'side-escort'), + key: COURSE_KEYS.mission, seconds: 900, until: (g) => done(g, 'side-escort'), failed: (g) => failed(g, 'side-escort'), trigger: true }, + { name: 'side-recover: scoop a canister (scoops fitted)', kit: { scoops: true }, put: (g) => onSideJob(g, 'side-recover'), + key: COURSE_KEYS.mission, seconds: 240, until: (g) => leg(g) === 'home', failed: (g) => failed(g, 'side-recover'), trigger: true }, + { name: 'side-rescue: scoop a pod (scoops fitted)', kit: { scoops: true }, put: (g) => onSideJob(g, 'side-rescue'), + key: COURSE_KEYS.mission, seconds: 240, until: (g) => (g.state.commander.missions.live[0]?.progress ?? 0) > 0, failed: (g) => failed(g, 'side-rescue'), trigger: true }, + { name: 'side-recover: scoop, then fly home and dock (docking computer)', kit: { scoops: true, dc: true }, put: (g) => onSideJob(g, 'side-recover'), + key: COURSE_KEYS.mission, seconds: 480, until: (g) => leg(g) === 'home', failed: (g) => failed(g, 'side-recover'), trigger: true, + then: [{ key: COURSE_KEYS.station, until: (g) => done(g, 'side-recover'), trigger: true }] }, + { name: 'side-rescue: scoop, then dock (docking computer)', kit: { scoops: true, dc: true }, put: (g) => onSideJob(g, 'side-rescue'), + key: COURSE_KEYS.mission, seconds: 480, until: (g) => (g.state.commander.missions.live[0]?.progress ?? 0) > 0, failed: (g) => failed(g, 'side-rescue'), trigger: true, + then: [{ key: COURSE_KEYS.station, until: (g) => isDocked(g), trigger: true }] }, + { name: 'side-ambush: the lane, then dock (docking computer)', kit: { dc: true }, put: (g) => onSideJob(g, 'side-ambush'), + key: COURSE_KEYS.station, seconds: 480, until: (g) => done(g, 'side-ambush'), failed: (g) => failed(g, 'side-ambush'), trigger: true }, + { name: 'side-deliver: fly to the station and dock (docking computer)', kit: { dc: true }, put: (g) => onSideJob(g, 'side-deliver'), + key: COURSE_KEYS.station, seconds: 480, until: (g) => done(g, 'side-deliver'), failed: (g) => failed(g, 'side-deliver'), trigger: true }, + { name: 'side-smuggle: slip past the police and dock (docking computer)', kit: { dc: true }, put: (g) => onSideJob(g, 'side-smuggle'), + key: COURSE_KEYS.mission, seconds: 480, until: (g) => done(g, 'side-smuggle'), failed: (g) => failed(g, 'side-smuggle'), trigger: true }, +]; + +console.log(`flight probe: ${SEEDS} seeds per case, ordinary traffic in the sky\n`); +for (const c of CASES) { + const runs: Run[] = []; + const t0 = Date.now(); + for (let k = 0; k < SEEDS; k++) { + const g = newGame(20_260_950 + k, c.kit); + c.put(g); + arrive(g, k); + runs.push(fly(g, c.key, c.seconds, () => c.until(g), () => c.failed(g), c.trigger, c.then)); + if (c.name.startsWith('side-escort') && runs[k].outcome !== 'done') { + const charge = g.state.world.npcs.find((n) => n.state.missionTag !== null); + const st = g.state.world.station.position; + console.log(` escort seed ${k}: ${runs[k].outcome}; the charge is ${charge ? `${charge.state.alive ? 'alive' : 'dead'}, phase ${charge.state.traderPhase}, ${Math.round(charge.object.position.distanceTo(st)).toLocaleString()} units from the station, docked ${charge.state.docked}` : 'gone from the sky'}`); + } + } + const n = (o: Run['outcome']) => runs.filter((r) => r.outcome === o).length; + const doneRuns = runs.filter((r) => r.outcome === 'done'); + const mean = doneRuns.length ? doneRuns.reduce((a, r) => a + r.seconds, 0) / doneRuns.length : NaN; + const max = doneRuns.length ? Math.max(...doneRuns.map((r) => r.seconds)) : NaN; + const fights = runs.reduce((a, r) => a + r.fights, 0) / runs.length; + console.log(c.name); + console.log(` done ${n('done')} failed ${n('failed')} dead ${n('dead')} timeout ${n('timeout')} of ${runs.length}` + + ` | time to done: mean ${mean.toFixed(0)} s, worst ${max.toFixed(0)} s` + + ` | fights per run ${fights.toFixed(1)} | kills ${runs.reduce((a, r) => a + r.kills, 0)}` + + (c.name.startsWith('side-smuggle') ? ` | scanned ${runs.filter((r) => r.scanned).length}` : '') + + ` | wall ${((Date.now() - t0) / 1000).toFixed(0)} s`); + console.log(' per seed: ' + runs.map((r) => `${r.outcome[0]}${r.seconds.toFixed(0)}`).join(' ') + + ` | ship after: energy ${(runs.reduce((a, r) => a + r.energy, 0) / runs.length).toFixed(0)}, shields ${(runs.reduce((a, r) => a + r.fore, 0) / runs.length).toFixed(0)}/${(runs.reduce((a, r) => a + r.aft, 0) / runs.length).toFixed(0)}` + + ` | station ${Math.round(runs[0].stationAway).toLocaleString()} units from the witchpoint`); +} + +console.log('\nwithout fuel scoops, the scoop rows refuse:'); +{ + // The offer waits for the scoops since docs/TODO/213 M2, so the job is + // taken with them and flown without, as a sale of the scoops would leave it. + const g = newGame(20_260_950, { scoops: true }); + onSideJob(g, 'side-recover'); + g.state.commander.equipment.scoops = false; + arrive(g); + const row = g.coursePanel()?.rows?.find((r) => r.kind === 'mission'); + console.log(` side-recover row: "${row?.what}" why: "${row?.why}"`); +} diff --git a/docs/reviews/missions-2026-09-12/flight-results-after-214-m4.txt b/docs/reviews/missions-2026-09-12/flight-results-after-214-m4.txt new file mode 100644 index 00000000..a5d8efed --- /dev/null +++ b/docs/reviews/missions-2026-09-12/flight-results-after-214-m4.txt @@ -0,0 +1,47 @@ +flight probe: 8 seeds per case, ordinary traffic in the sky + +side-hunt: a Krait, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 7 s, worst 14 s | fights per run 1.0 | kills 11 | wall 0 s + per seed: d4 d6 d4 d14 d4 d4 d13 d8 | ship after: energy 254, shields 252/250 | station 72,591 units from the witchpoint +arc-lave runner: a Cobra Mk III, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 10 s, worst 14 s | fights per run 1.0 | kills 15 | wall 0 s + per seed: d11 d12 d11 d14 d11 d10 d5 d11 | ship after: energy 254, shields 250/164 | station 63,792 units from the witchpoint +arc-xeer chase: a Fer-de-Lance, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 16 s, worst 22 s | fights per run 1.0 | kills 14 | wall 0 s + per seed: d14 d21 d19 d16 d22 d15 d8 d16 | ship after: energy 227, shields 157/195 | station 63,949 units from the witchpoint +arc-edle purge: an Asp, pulse laser + done 7 failed 0 dead 1 timeout 0 of 8 | time to done: mean 24 s, worst 28 s | fights per run 1.0 | kills 14 | wall 0 s + per seed: d28 d26 d23 d27 d23 d28 d12 d16 | ship after: energy 89, shields 2/230 | station 64,041 units from the witchpoint +constrictor hunt, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 62 s, worst 66 s | fights per run 1.0 | kills 8 | wall 0 s + per seed: d64 d63 d60 d59 d64 d61 d66 d57 | ship after: energy 213, shields 130/219 | station 64,405 units from the witchpoint +side-scan: an Anaconda for 20 s + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 24 s, worst 27 s | fights per run 0.1 | kills 1 | wall 0 s + per seed: d25 d23 d23 d23 d27 d22 d27 d21 | ship after: energy 255, shields 255/255 | station 69,173 units from the witchpoint +side-escort: a Python to station range + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 500 s, worst 535 s | fights per run 1.5 | kills 24 | wall 2 s + per seed: d535 d489 d491 d518 d484 d489 d492 d506 | ship after: energy 239, shields 225/255 | station 74,065 units from the witchpoint +side-recover: scoop a canister (scoops fitted) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 21 s, worst 37 s | fights per run 1.0 | kills 11 | wall 0 s + per seed: d37 d18 d18 d16 d25 d12 d29 d15 | ship after: energy 255, shields 255/255 | station 63,823 units from the witchpoint +side-rescue: scoop a pod (scoops fitted) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 13 s, worst 26 s | fights per run 0.1 | kills 1 | wall 0 s + per seed: d8 d18 d13 d10 d13 d6 d26 d8 | ship after: energy 255, shields 255/255 | station 81,801 units from the witchpoint +side-recover: scoop, then fly home and dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 149 s, worst 185 s | fights per run 2.9 | kills 27 | wall 1 s + per seed: d179 d140 d141 d185 d156 d134 d116 d141 | ship after: energy 255, shields 255/255 | station 63,823 units from the witchpoint +side-rescue: scoop, then dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 144 s, worst 163 s | fights per run 1.4 | kills 11 | wall 1 s + per seed: d133 d142 d152 d122 d144 d162 d163 d134 | ship after: energy 255, shields 255/255 | station 81,801 units from the witchpoint +side-ambush: the lane, then dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 133 s, worst 150 s | fights per run 3.1 | kills 42 | wall 1 s + per seed: d122 d138 d131 d141 d143 d135 d106 d150 | ship after: energy 255, shields 255/255 | station 70,808 units from the witchpoint +side-deliver: fly to the station and dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 136 s, worst 151 s | fights per run 2.5 | kills 28 | wall 1 s + per seed: d123 d149 d150 d135 d132 d141 d151 d107 | ship after: energy 255, shields 255/255 | station 74,065 units from the witchpoint +side-smuggle: slip past the police and dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 116 s, worst 134 s | fights per run 2.6 | kills 27 | scanned 0 | wall 1 s + per seed: d117 d111 d98 d134 d123 d124 d104 d114 | ship after: energy 255, shields 255/255 | station 69,173 units from the witchpoint + +without fuel scoops, the scoop rows refuse: + side-recover row: "RECOVER THE CARGO" why: "NEEDS FUEL SCOOPS" diff --git a/docs/reviews/missions-2026-09-12/flight-results-after-214.txt b/docs/reviews/missions-2026-09-12/flight-results-after-214.txt new file mode 100644 index 00000000..b50430a5 --- /dev/null +++ b/docs/reviews/missions-2026-09-12/flight-results-after-214.txt @@ -0,0 +1,47 @@ +flight probe: 8 seeds per case, ordinary traffic in the sky + +side-hunt: a Krait, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 7 s, worst 14 s | fights per run 1.0 | kills 11 | wall 0 s + per seed: d4 d5 d4 d14 d4 d4 d13 d8 | ship after: energy 254, shields 253/250 | station 72,591 units from the witchpoint +arc-lave runner: a Cobra Mk III, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 10 s, worst 14 s | fights per run 1.0 | kills 15 | wall 0 s + per seed: d11 d12 d11 d14 d11 d10 d5 d11 | ship after: energy 254, shields 250/164 | station 63,792 units from the witchpoint +arc-xeer chase: a Fer-de-Lance, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 16 s, worst 22 s | fights per run 1.0 | kills 13 | wall 0 s + per seed: d14 d21 d19 d16 d22 d15 d8 d16 | ship after: energy 225, shields 156/195 | station 63,949 units from the witchpoint +arc-edle purge: an Asp, pulse laser + done 7 failed 0 dead 1 timeout 0 of 8 | time to done: mean 24 s, worst 28 s | fights per run 1.0 | kills 14 | wall 0 s + per seed: d28 d28 d24 d27 d23 d28 d12 d16 | ship after: energy 88, shields 34/235 | station 64,041 units from the witchpoint +constrictor hunt, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 62 s, worst 66 s | fights per run 1.0 | kills 8 | wall 0 s + per seed: d64 d63 d60 d59 d64 d61 d66 d57 | ship after: energy 213, shields 130/219 | station 64,405 units from the witchpoint +side-scan: an Anaconda for 20 s + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 24 s, worst 27 s | fights per run 0.1 | kills 1 | wall 0 s + per seed: d25 d23 d23 d23 d27 d22 d27 d21 | ship after: energy 255, shields 255/255 | station 69,173 units from the witchpoint +side-escort: a Python to station range + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 500 s, worst 535 s | fights per run 1.5 | kills 24 | wall 2 s + per seed: d535 d489 d491 d518 d484 d489 d492 d506 | ship after: energy 239, shields 225/255 | station 74,065 units from the witchpoint +side-recover: scoop a canister (scoops fitted) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 21 s, worst 37 s | fights per run 1.0 | kills 11 | wall 0 s + per seed: d37 d18 d18 d16 d25 d12 d29 d15 | ship after: energy 255, shields 255/255 | station 63,823 units from the witchpoint +side-rescue: scoop a pod (scoops fitted) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 13 s, worst 26 s | fights per run 0.1 | kills 1 | wall 0 s + per seed: d8 d18 d13 d10 d13 d6 d26 d8 | ship after: energy 255, shields 255/255 | station 81,801 units from the witchpoint +side-recover: scoop, then fly home and dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 149 s, worst 185 s | fights per run 2.9 | kills 27 | wall 1 s + per seed: d179 d140 d141 d185 d156 d134 d116 d141 | ship after: energy 255, shields 255/255 | station 63,823 units from the witchpoint +side-rescue: scoop, then dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 144 s, worst 163 s | fights per run 1.4 | kills 11 | wall 1 s + per seed: d133 d142 d152 d122 d144 d162 d163 d134 | ship after: energy 255, shields 255/255 | station 81,801 units from the witchpoint +side-ambush: the lane, then dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 133 s, worst 150 s | fights per run 3.1 | kills 42 | wall 1 s + per seed: d122 d138 d131 d141 d143 d135 d106 d150 | ship after: energy 255, shields 255/255 | station 70,808 units from the witchpoint +side-deliver: fly to the station and dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 136 s, worst 151 s | fights per run 2.5 | kills 28 | wall 1 s + per seed: d123 d149 d150 d135 d132 d141 d151 d107 | ship after: energy 255, shields 255/255 | station 74,065 units from the witchpoint +side-smuggle: slip past the police and dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 116 s, worst 134 s | fights per run 2.6 | kills 27 | scanned 0 | wall 1 s + per seed: d117 d111 d98 d134 d123 d124 d104 d114 | ship after: energy 255, shields 255/255 | station 69,173 units from the witchpoint + +without fuel scoops, the scoop rows refuse: + side-recover row: "RECOVER THE CARGO" why: "NEEDS FUEL SCOOPS" diff --git a/docs/reviews/missions-2026-09-12/flight-results-after-217.txt b/docs/reviews/missions-2026-09-12/flight-results-after-217.txt new file mode 100644 index 00000000..35dafce0 --- /dev/null +++ b/docs/reviews/missions-2026-09-12/flight-results-after-217.txt @@ -0,0 +1,47 @@ +flight probe: 8 seeds per case, ordinary traffic in the sky + +side-hunt: a gang of four, pulse laser + done 5 failed 0 dead 3 timeout 0 of 8 | time to done: mean 34 s, worst 38 s | fights per run 1.0 | kills 24 | wall 0 s + per seed: d38 d37 d19 d30 d19 d31 d23 d35 | ship after: energy 122, shields 77/116 | station 72,530 units from the witchpoint +arc-lave runner: a Cobra Mk III, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 10 s, worst 14 s | fights per run 1.0 | kills 15 | wall 0 s + per seed: d11 d12 d11 d14 d11 d10 d5 d11 | ship after: energy 254, shields 250/164 | station 63,792 units from the witchpoint +arc-xeer chase: a Fer-de-Lance, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 16 s, worst 22 s | fights per run 1.0 | kills 14 | wall 0 s + per seed: d14 d21 d19 d16 d22 d15 d8 d16 | ship after: energy 227, shields 157/195 | station 63,949 units from the witchpoint +arc-edle purge: an Asp, pulse laser + done 7 failed 0 dead 1 timeout 0 of 8 | time to done: mean 24 s, worst 28 s | fights per run 1.0 | kills 14 | wall 0 s + per seed: d28 d26 d23 d27 d23 d28 d12 d16 | ship after: energy 89, shields 2/230 | station 64,041 units from the witchpoint +constrictor hunt, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 62 s, worst 66 s | fights per run 1.0 | kills 8 | wall 0 s + per seed: d64 d63 d60 d59 d64 d61 d66 d57 | ship after: energy 213, shields 130/219 | station 64,405 units from the witchpoint +side-scan: an Anaconda for 20 s + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 24 s, worst 27 s | fights per run 0.1 | kills 1 | wall 0 s + per seed: d25 d23 d23 d23 d27 d22 d27 d21 | ship after: energy 255, shields 255/255 | station 69,173 units from the witchpoint +side-escort: a Python to station range + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 500 s, worst 535 s | fights per run 1.5 | kills 24 | wall 2 s + per seed: d535 d489 d491 d518 d484 d489 d492 d506 | ship after: energy 239, shields 225/255 | station 74,065 units from the witchpoint +side-recover: scoop a canister (scoops fitted) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 21 s, worst 37 s | fights per run 1.0 | kills 11 | wall 0 s + per seed: d37 d18 d18 d16 d25 d12 d29 d15 | ship after: energy 255, shields 255/255 | station 63,823 units from the witchpoint +side-rescue: scoop a pod (scoops fitted) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 13 s, worst 26 s | fights per run 0.1 | kills 1 | wall 0 s + per seed: d8 d18 d13 d10 d13 d6 d26 d8 | ship after: energy 255, shields 255/255 | station 81,801 units from the witchpoint +side-recover: scoop, then fly home and dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 149 s, worst 185 s | fights per run 2.9 | kills 27 | wall 1 s + per seed: d179 d140 d141 d185 d156 d134 d116 d141 | ship after: energy 255, shields 255/255 | station 63,823 units from the witchpoint +side-rescue: scoop, then dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 144 s, worst 163 s | fights per run 1.4 | kills 11 | wall 1 s + per seed: d133 d142 d152 d122 d144 d162 d163 d134 | ship after: energy 255, shields 255/255 | station 81,801 units from the witchpoint +side-ambush: the lane, then dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 133 s, worst 150 s | fights per run 3.1 | kills 42 | wall 1 s + per seed: d122 d138 d131 d141 d143 d135 d106 d150 | ship after: energy 255, shields 255/255 | station 70,808 units from the witchpoint +side-deliver: fly to the station and dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 136 s, worst 151 s | fights per run 2.5 | kills 28 | wall 1 s + per seed: d123 d149 d150 d135 d132 d141 d151 d107 | ship after: energy 255, shields 255/255 | station 74,065 units from the witchpoint +side-smuggle: slip past the police and dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 116 s, worst 134 s | fights per run 2.6 | kills 27 | scanned 0 | wall 1 s + per seed: d117 d111 d98 d134 d123 d124 d104 d114 | ship after: energy 255, shields 255/255 | station 69,173 units from the witchpoint + +without fuel scoops, the scoop rows refuse: + side-recover row: "RECOVER THE CARGO" why: "NEEDS FUEL SCOOPS" diff --git a/docs/reviews/missions-2026-09-12/flight-results-after.txt b/docs/reviews/missions-2026-09-12/flight-results-after.txt new file mode 100644 index 00000000..f676bed2 --- /dev/null +++ b/docs/reviews/missions-2026-09-12/flight-results-after.txt @@ -0,0 +1,41 @@ +flight probe: 8 seeds per case, ordinary traffic in the sky + +side-hunt: a Krait, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 4 s, worst 5 s | fights per run 1.0 | kills 8 | wall 0 s + per seed: d5 d4 d4 d5 d4 d4 d5 d4 | ship after: energy 254, shields 255/255 | station 71,626 units from the witchpoint +arc-lave runner: a Cobra Mk III, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 4 s, worst 5 s | fights per run 1.0 | kills 8 | wall 0 s + per seed: d4 d5 d5 d5 d5 d4 d5 d4 | ship after: energy 254, shields 255/255 | station 63,792 units from the witchpoint +arc-xeer chase: a Fer-de-Lance, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 8 s, worst 9 s | fights per run 1.0 | kills 7 | wall 0 s + per seed: d6 d9 d8 d9 d9 d7 d9 d6 | ship after: energy 254, shields 162/255 | station 63,958 units from the witchpoint +arc-edle purge: an Asp, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 12 s, worst 13 s | fights per run 1.0 | kills 8 | wall 0 s + per seed: d11 d13 d11 d12 d13 d13 d11 d11 | ship after: energy 254, shields 105/255 | station 64,040 units from the witchpoint +constrictor hunt, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 62 s, worst 66 s | fights per run 1.0 | kills 8 | wall 0 s + per seed: d64 d63 d60 d59 d64 d61 d66 d57 | ship after: energy 213, shields 130/219 | station 64,405 units from the witchpoint +side-scan: an Anaconda for 20 s + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 24 s, worst 27 s | fights per run 0.1 | kills 1 | wall 0 s + per seed: d25 d23 d23 d23 d27 d22 d27 d21 | ship after: energy 255, shields 255/255 | station 69,173 units from the witchpoint +side-escort: a Python to station range + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 508 s, worst 525 s | fights per run 0.3 | kills 2 | wall 2 s + per seed: d516 d488 d525 d519 d490 d521 d511 d497 | ship after: energy 255, shields 249/255 | station 72,080 units from the witchpoint +side-recover: scoop a canister (scoops fitted) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 14 s, worst 23 s | fights per run 0.3 | kills 2 | wall 0 s + per seed: d23 d14 d11 d11 d9 d13 d19 d12 | ship after: energy 255, shields 255/255 | station 62,707 units from the witchpoint +side-rescue: scoop a pod (scoops fitted) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 13 s, worst 26 s | fights per run 0.1 | kills 1 | wall 0 s + per seed: d8 d18 d13 d10 d13 d6 d26 d8 | ship after: energy 255, shields 255/255 | station 81,801 units from the witchpoint +side-ambush: the lane, then dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 133 s, worst 150 s | fights per run 3.1 | kills 42 | wall 1 s + per seed: d122 d138 d131 d141 d143 d135 d106 d150 | ship after: energy 255, shields 255/255 | station 70,808 units from the witchpoint +side-deliver: fly to the station and dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 128 s, worst 144 s | fights per run 1.4 | kills 10 | wall 1 s + per seed: d106 d144 d141 d144 d113 d137 d144 d98 | ship after: energy 255, shields 255/255 | station 72,080 units from the witchpoint +side-smuggle: slip past the police and dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 116 s, worst 134 s | fights per run 2.6 | kills 27 | scanned 0 | wall 1 s + per seed: d117 d111 d98 d134 d123 d124 d104 d114 | ship after: energy 255, shields 255/255 | station 69,173 units from the witchpoint + +without fuel scoops, the scoop rows refuse: + side-recover row: "RECOVER THE CARGO" why: "NEEDS FUEL SCOOPS" diff --git a/docs/reviews/missions-2026-09-12/flight-results.txt b/docs/reviews/missions-2026-09-12/flight-results.txt new file mode 100644 index 00000000..54bb5ba0 --- /dev/null +++ b/docs/reviews/missions-2026-09-12/flight-results.txt @@ -0,0 +1,45 @@ +flight probe: 8 seeds per case, ordinary traffic in the sky + +side-hunt: a Krait, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 4 s, worst 5 s | fights per run 1.0 | kills 8 | wall 0 s + per seed: d5 d4 d4 d5 d4 d4 d5 d4 | ship after: energy 254, shields 255/255 | station 71,626 units from the witchpoint +arc-lave runner: a Cobra Mk III, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 4 s, worst 5 s | fights per run 1.0 | kills 8 | wall 0 s + per seed: d4 d5 d5 d5 d5 d4 d5 d4 | ship after: energy 254, shields 255/255 | station 63,792 units from the witchpoint +arc-xeer chase: a Fer-de-Lance, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 8 s, worst 9 s | fights per run 1.0 | kills 7 | wall 0 s + per seed: d6 d9 d8 d9 d9 d7 d9 d6 | ship after: energy 254, shields 162/255 | station 63,958 units from the witchpoint +arc-edle purge: an Asp, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 12 s, worst 13 s | fights per run 1.0 | kills 8 | wall 0 s + per seed: d11 d13 d11 d12 d13 d13 d11 d11 | ship after: energy 254, shields 105/255 | station 64,040 units from the witchpoint +constrictor hunt, pulse laser + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 62 s, worst 66 s | fights per run 1.0 | kills 8 | wall 0 s + per seed: d64 d63 d60 d59 d64 d61 d66 d57 | ship after: energy 213, shields 130/219 | station 64,405 units from the witchpoint +side-scan: an Anaconda for 20 s + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 24 s, worst 27 s | fights per run 0.1 | kills 1 | wall 0 s + per seed: d25 d23 d23 d23 d27 d22 d27 d21 | ship after: energy 255, shields 255/255 | station 69,173 units from the witchpoint + escort seed 1: timeout; the charge is alive, phase arriving, 37,845 units from the station, docked false + escort seed 2: timeout; the charge is alive, phase arriving, 104,860 units from the station, docked false + escort seed 5: dead; the charge is alive, phase arriving, 8,620 units from the station, docked false + escort seed 7: timeout; the charge is alive, phase arriving, 28,391 units from the station, docked false +side-escort: a Python to station range + done 4 failed 0 dead 1 timeout 3 of 8 | time to done: mean 497 s, worst 519 s | fights per run 0.3 | kills 2 | wall 2 s + per seed: d516 t900 t900 d519 d490 d559 d464 t900 | ship after: energy 255, shields 255/255 | station 72,080 units from the witchpoint +side-recover: scoop a canister (scoops fitted) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 14 s, worst 23 s | fights per run 0.3 | kills 2 | wall 0 s + per seed: d23 d14 d11 d11 d9 d13 d19 d12 | ship after: energy 255, shields 255/255 | station 62,707 units from the witchpoint +side-rescue: scoop a pod (scoops fitted) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 13 s, worst 26 s | fights per run 0.1 | kills 1 | wall 0 s + per seed: d8 d18 d13 d10 d13 d6 d26 d8 | ship after: energy 255, shields 255/255 | station 81,801 units from the witchpoint +side-ambush: the lane, then dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 130 s, worst 141 s | fights per run 3.1 | kills 43 | wall 1 s + per seed: d122 d138 d131 d141 d132 d135 d106 d139 | ship after: energy 255, shields 255/255 | station 70,808 units from the witchpoint +side-deliver: fly to the station and dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 125 s, worst 144 s | fights per run 1.4 | kills 10 | wall 0 s + per seed: d106 d144 d141 d121 d113 d137 d144 d98 | ship after: energy 255, shields 255/255 | station 72,080 units from the witchpoint +side-smuggle: slip past the police and dock (docking computer) + done 8 failed 0 dead 0 timeout 0 of 8 | time to done: mean 116 s, worst 134 s | fights per run 2.6 | kills 27 | scanned 0 | wall 1 s + per seed: d117 d111 d98 d134 d123 d124 d104 d111 | ship after: energy 255, shields 255/255 | station 69,173 units from the witchpoint + +without fuel scoops, the scoop rows refuse: + side-recover row: "RECOVER THE CARGO" why: "NEEDS FUEL SCOOPS" diff --git a/docs/reviews/missions-2026-09-12/probe.ts b/docs/reviews/missions-2026-09-12/probe.ts new file mode 100644 index 00000000..e9bb585d --- /dev/null +++ b/docs/reviews/missions-2026-09-12/probe.ts @@ -0,0 +1,148 @@ +// The probes behind docs/MISSIONS-REVIEW.md. Each block drives the real +// mission machine with no game world, as test/mission-machine.test.ts does. +// +// node --experimental-strip-types --no-warnings docs/reviews/missions-2026-09-12/probe.ts +// +// The output is committed beside it as results.txt. + +import { generateGalaxy } from '../../../src/galaxy/galaxy.ts'; +import { routeTable } from '../../../src/galaxy/route.ts'; +import { stepMissions } from '../../../src/missions/machine.ts'; +import { canAccept, offersFor, sideJobsAt } from '../../../src/missions/offers.ts'; +import { emptyMissionState } from '../../../src/missions/state.ts'; +import { SKELETONS } from '../../../src/missions/skeletons/index.ts'; +import { SIDE_JOBS } from '../../../src/missions/skeletons/side.ts'; +import { pickByJumps } from '../../../src/missions/placement.ts'; +import { patronFor } from '../../../src/missions/patrons.ts'; +import { storyPages } from '../../../src/missions/story.ts'; +import { NARCOTICS } from '../../../src/constants/commodities.ts'; +import { specForDesign, SOURCE_DESIGN } from '../../../src/game/ship-specs.ts'; +import { shipDesignIdOf } from '../../../src/game/ship-identity.ts'; +import type { CommanderFacts, MissionState } from '../../../src/missions/model.ts'; + +const g1 = generateGalaxy(1); +const g2 = generateGalaxy(2); +const facts = (o: Partial = {}): CommanderFacts => ({ + galaxy: 1, systemIndex: 7, kills: 16, combatScore: 0, legalStatus: 0, day: 0, cargo: new Array(17).fill(0), scoops: true, ...o, +}); +const ctx = (c: Partial = {}, systems = g1, skeletons?: readonly typeof SKELETONS[number][]) => + ({ commander: facts(c), systems, rng: () => 0.5, ...(skeletons ? { skeletons } : {}) }); +const boardWith = (id: string): number => + g1.find((s) => sideJobsAt(s, SKELETONS).some((j) => j.id === id))!.index; +const start = (id: string) => { + const here = boardWith(id); + const r = stepMissions(emptyMissionState(), { kind: 'accept', skeleton: id }, ctx({ systemIndex: here })); + if (!r.state.live.length) throw new Error(`${id} not accepted at ${here}`); + return { r, here }; +}; +const kinds = (effects: { kind: string; text?: string }[]): string[] => + effects.map((e) => e.kind + (e.text ? ':' + e.text : '')); + +console.log('[1] an arc is offered in every galaxy at its seed index'); +{ + const c2 = { commander: facts({ galaxy: 2, systemIndex: 7 }), systems: g2 }; + console.log(' canAccept arc-lave at galaxy 2 index 7:', canAccept(emptyMissionState(), 'arc-lave', c2)); + console.log(' galaxy-2 world at index 7:', g2[7].name, '| patron:', patronFor({ kind: 'world', seedSlot: 7 }, facts({ galaxy: 2 }), g2).name); + console.log(' offers there:', offersFor(emptyMissionState(), c2).map((s) => s.id).join(' ')); + // After a galactic jump the lead moves to the tour world, and the arc it opens + // then hands over toward a seed index, not the tour. + let r = stepMissions(emptyMissionState(), { kind: 'accept', skeleton: 'arc-lave' }, ctx()); + r = stepMissions(r.state, { kind: 'galaxyChanged', from: 1, to: 2 }, ctx({ galaxy: 2, systemIndex: 7 }, g2)); + const lead = r.state.leads[0]; + console.log(' lead after the jump:', JSON.stringify(lead), '| announced world:', (r.effects.find((e) => e.kind === 'lead') as { world: number } | undefined)?.world); + r = stepMissions(r.state, { kind: 'accept', skeleton: 'arc-rabedira' }, ctx({ galaxy: 2, systemIndex: lead.world }, g2)); + console.log(' arc-rabedira accepted at the relocated lead:', r.state.live.length === 1); + const paper = r.state.live[0]; + if (paper) { + r = stepMissions(r.state, { kind: 'docked' }, ctx({ galaxy: 2, systemIndex: paper.target! }, g2)); + const toGoal = routeTable(g2, 100).jumps; + console.log(` convoy leg placed ${toGoal[r.state.live[0].target!]} jumps from galaxy-2 index 100 (${g2[100].name}), which is not the tour`); + } +} + +console.log('\n[2] a side job with no cap is offered once per career'); +{ + console.log(' caps:', SIDE_JOBS.map((s) => `${s.id}=${s.cap}`).join(' ')); + const { r, here } = start('side-recover'); + const ended = stepMissions(r.state, { kind: 'abandon', skeleton: 'side-recover' }, ctx({ systemIndex: here, day: 10 })); + for (const day of [11, 17, 18, 400]) { + console.log(` canAccept side-recover at its board on day ${day}:`, canAccept(ended.state, 'side-recover', { commander: facts({ systemIndex: here, day }), systems: g1 })); + } +} + +console.log('\n[3] a smuggle run pays and leaves the goods aboard'); +{ + const { r } = start('side-smuggle'); + const cargo = new Array(17).fill(0); cargo[NARCOTICS] = 3; + const done = stepMissions(r.state, { kind: 'docked' }, ctx({ systemIndex: r.state.live[0].target!, cargo })); + console.log(' effects on success:', done.effects.map((e) => e.kind + (e.kind === 'pay' ? `(${e.tenths})` : e.kind === 'cargo' ? `(${e.tonnes})` : '')).join(' ')); +} + +console.log('\n[4] what each ship verb makes of the four ways a target can go'); +for (const id of ['side-hunt', 'side-scan', 'side-escort']) { + const { r } = start(id); + const tag = r.state.live[0].tag!; + for (const kind of ['fled', 'escaped', 'escortLost', 'destroyed'] as const) { + const s = stepMissions(r.state, { kind, tag }, ctx()); + const pay = s.effects.filter((e) => e.kind === 'pay').map((e) => (e as { tenths: number }).tenths).join(','); + console.log(` ${id.padEnd(11)} ${kind.padEnd(10)} live=${s.state.live.length} done=${s.state.done[id] ?? '-'} entityAlive=${s.state.entities[tag]?.alive} pay=${pay || '-'} says=${s.effects.filter((e) => e.kind === 'say').length}`); + } +} + +console.log('\n[5] the dock hint is shadowed by the side-job count'); +{ + const jumps = routeTable(g1, 6).jumps; + const oneOut = g1.find((s) => jumps[s.index] === 1)!; + const st: MissionState = { ...emptyMissionState(), done: { 'arc-lave': 'complete' }, leads: [{ skeleton: 'arc-rabedira', galaxy: 1, world: 6, sinceDay: 0 }] }; + const r = stepMissions(st, { kind: 'docked' }, ctx({ systemIndex: oneOut.index })); + console.log(` dock at ${oneOut.name}, one jump from Rabedira:`, kinds(r.effects).join(' | ')); + const r2 = stepMissions(st, { kind: 'docked' }, ctx({ systemIndex: oneOut.index }, g1, SKELETONS.filter((s) => s.kind !== 'side'))); + console.log(' the same dock with the side jobs removed:', kinds(r2.effects).join(' | ')); + const far = g1.find((s) => jumps[s.index] === 3)!; + const r3 = stepMissions({ ...st, idleDocks: 3 }, { kind: 'docked' }, ctx({ systemIndex: far.index })); + console.log(` fourth idle dock at ${far.name}, three jumps out:`, kinds(r3.effects).join(' | ')); +} + +console.log('\n[6] a saved record that names a ghost skeleton or leg'); +{ + const ghost: MissionState = { ...emptyMissionState(), live: [{ skeleton: 'arc-ghost', leg: 'x', target: null, tag: null, progress: 0, deadlineDay: null }] }; + const oldLeg: MissionState = { ...emptyMissionState(), live: [{ skeleton: 'arc-lave', leg: 'old', target: null, tag: null, progress: 0, deadlineDay: null }] }; + for (const [name, st, input] of [ + ['ghost skeleton, docked', ghost, { kind: 'docked' }], + ['ghost skeleton, abandon', ghost, { kind: 'abandon', skeleton: 'arc-ghost' }], + ['ghost leg, docked', oldLeg, { kind: 'docked' }], + ] as const) { + try { stepMissions(st, input, ctx()); console.log(` ${name}: ok`); } + catch (e) { console.log(` ${name}: THROWS "${(e as Error).message}"`); } + } +} + +console.log('\n[7] the journal holds no galaxy, so the log misnames a world after a jump'); +{ + let r = stepMissions(emptyMissionState(), { kind: 'accept', skeleton: 'arc-lave' }, ctx()); + r = stepMissions(r.state, { kind: 'galaxyChanged', from: 1, to: 2 }, ctx({ galaxy: 2, systemIndex: 7 }, g2)); + const inG1 = storyPages(r.state, g1)[0].lines[0]; + const inG2 = storyPages(r.state, g2)[0].lines[0]; + console.log(' the acceptance line, rendered with galaxy 1:', inG1); + console.log(' the same line, rendered with galaxy 2: ', inG2); +} + +console.log('\n[8] a local patron\'s standing is keyed by where the branch settles'); +{ + const { r, here } = start('side-deliver'); + const target = r.state.live[0].target!; + const done = stepMissions(r.state, { kind: 'docked' }, ctx({ systemIndex: target })); + console.log(` accepted at ${g1[here].name} (${here}), delivered at ${g1[target].name} (${target}), standing:`, JSON.stringify(done.state.standing)); +} + +console.log('\n[9] the pirate row a restored mission ship is looked up under'); +for (const k of ['python', 'cobraMk3', 'anaconda', 'boa', 'transporter'] as const) { + const id = shipDesignIdOf(SOURCE_DESIGN[k]); + console.log(` ${k.padEnd(12)} pirate row: ${String(!!specForDesign('pirate', id)).padEnd(5)} trader row: ${!!specForDesign('trader', id)}`); +} + +console.log('\n[10] handover placement has a candidate from every world of galaxy 1'); +for (const [g, name] of [[6, 'Rabedira'], [100, 'Vetitice'], [150, 'Xeer'], [162, 'Edle']] as const) { + const dry = g1.filter((s) => pickByJumps(g1, s.index, g, { min: 2, max: 4 }, () => 0.5) === null); + console.log(` toward ${name.padEnd(9)} worlds with no candidate: ${dry.length}`); +} diff --git a/docs/reviews/missions-2026-09-12/results-after.txt b/docs/reviews/missions-2026-09-12/results-after.txt new file mode 100644 index 00000000..a9a2e040 --- /dev/null +++ b/docs/reviews/missions-2026-09-12/results-after.txt @@ -0,0 +1,60 @@ +[1] an arc is offered in every galaxy at its seed index + canAccept arc-lave at galaxy 2 index 7: false + galaxy-2 world at index 7: Esrilees | patron: THE COMPANY DIRECTOR OF ESRILEES + offers there: side-deliver side-smuggle side-escort + lead after the jump: {"skeleton":"arc-rabedira","galaxy":1,"world":6,"sinceDay":0} | announced world: 6 + arc-rabedira accepted at the relocated lead: false + +[2] a side job with no cap is offered once per career + caps: side-hunt=undefined side-deliver=undefined side-recover=undefined side-rescue=undefined side-ambush=undefined side-smuggle=undefined side-escort=undefined side-scan=undefined + canAccept side-recover at its board on day 11: false + canAccept side-recover at its board on day 17: true + canAccept side-recover at its board on day 18: true + canAccept side-recover at its board on day 400: true + +[3] a smuggle run pays and leaves the goods aboard + effects on success: unload pay(7000) say deed say + +[4] what each ship verb makes of the four ways a target can go + side-hunt fled live=1 done=- entityAlive=true pay=- says=0 + side-hunt escaped live=0 done=fail entityAlive=undefined pay=- says=1 + side-hunt escortLost live=0 done=complete entityAlive=undefined pay=8000 says=1 + side-hunt destroyed live=0 done=complete entityAlive=undefined pay=8000 says=1 + side-scan fled live=0 done=fail entityAlive=undefined pay=- says=1 + side-scan escaped live=0 done=fail entityAlive=undefined pay=- says=1 + side-scan escortLost live=0 done=fail entityAlive=undefined pay=- says=1 + side-scan destroyed live=0 done=fail entityAlive=undefined pay=- says=1 + side-escort fled live=0 done=fail entityAlive=undefined pay=- says=1 + side-escort escaped live=0 done=fail entityAlive=undefined pay=- says=1 + side-escort escortLost live=0 done=fail entityAlive=undefined pay=- says=1 + side-escort destroyed live=0 done=fail entityAlive=undefined pay=- says=1 + +[5] the dock hint is shadowed by the side-job count + dock at Leleer, one jump from Rabedira: say:THERE ARE 2 SIDE JOBS ON THE STATION BOARD. | later:A MESSAGE FROM RABEDIRA: COME WHEN YOU CAN. THERE IS WORK. + the same dock with the side jobs removed: later:A MESSAGE FROM RABEDIRA: COME WHEN YOU CAN. THERE IS WORK. + fourth idle dock at Ontimaxe, three jumps out: say:THERE ARE 3 SIDE JOBS ON THE STATION BOARD. | later:A SECOND MESSAGE FROM RABEDIRA: THE OFFER STANDS. + +[6] a saved record that names a ghost skeleton or leg + ghost skeleton, docked: THROWS "mission arc-ghost: no such skeleton" + ghost skeleton, abandon: THROWS "mission arc-ghost: no such skeleton" + ghost leg, docked: THROWS "mission arc-lave: no leg old" + +[7] the journal holds no galaxy, so the log misnames a world after a jump + the acceptance line, rendered with galaxy 1: On day 0 the commander took the governor's contract at LAVE, to chase down a stolen ledger. + the same line, rendered with galaxy 2: On day 0 the commander took the governor's contract at ESRILEES, to chase down a stolen ledger. + +[8] a local patron's standing is keyed by where the branch settles + accepted at Tiraor (5), delivered at Edzaon (159), standing: {"world-5":1} + +[9] the pirate row a restored mission ship is looked up under + python pirate row: true trader row: true + cobraMk3 pirate row: true trader row: true + anaconda pirate row: false trader row: true + boa pirate row: false trader row: true + transporter pirate row: false trader row: true + +[10] handover placement has a candidate from every world of galaxy 1 + toward Rabedira worlds with no candidate: 0 + toward Vetitice worlds with no candidate: 0 + toward Xeer worlds with no candidate: 0 + toward Edle worlds with no candidate: 0 diff --git a/docs/reviews/missions-2026-09-12/results.txt b/docs/reviews/missions-2026-09-12/results.txt new file mode 100644 index 00000000..ac94d2b7 --- /dev/null +++ b/docs/reviews/missions-2026-09-12/results.txt @@ -0,0 +1,61 @@ +[1] an arc is offered in every galaxy at its seed index + canAccept arc-lave at galaxy 2 index 7: true + galaxy-2 world at index 7: Esrilees | patron: THE COMPANY DIRECTOR OF ESRILEES + offers there: arc-lave side-deliver side-smuggle side-escort + lead after the jump: {"skeleton":"arc-rabedira","galaxy":2,"world":11,"sinceDay":0} | announced world: 6 + arc-rabedira accepted at the relocated lead: true + convoy leg placed 4 jumps from galaxy-2 index 100 (Isusle), which is not the tour + +[2] a side job with no cap is offered once per career + caps: side-hunt=undefined side-deliver=undefined side-recover=undefined side-rescue=undefined side-ambush=undefined side-smuggle=undefined side-escort=undefined side-scan=undefined + canAccept side-recover at its board on day 11: false + canAccept side-recover at its board on day 17: false + canAccept side-recover at its board on day 18: false + canAccept side-recover at its board on day 400: false + +[3] a smuggle run pays and leaves the goods aboard + effects on success: pay(7000) say deed say + +[4] what each ship verb makes of the four ways a target can go + side-hunt fled live=1 done=- entityAlive=true pay=- says=0 + side-hunt escaped live=0 done=fail entityAlive=undefined pay=- says=1 + side-hunt escortLost live=0 done=complete entityAlive=undefined pay=8000 says=1 + side-hunt destroyed live=0 done=complete entityAlive=undefined pay=8000 says=1 + side-scan fled live=1 done=- entityAlive=true pay=- says=0 + side-scan escaped live=0 done=fail entityAlive=undefined pay=- says=1 + side-scan escortLost live=1 done=- entityAlive=true pay=- says=0 + side-scan destroyed live=0 done=fail entityAlive=undefined pay=- says=1 + side-escort fled live=1 done=- entityAlive=true pay=- says=0 + side-escort escaped live=0 done=fail entityAlive=undefined pay=- says=1 + side-escort escortLost live=0 done=fail entityAlive=undefined pay=- says=1 + side-escort destroyed live=0 done=fail entityAlive=undefined pay=- says=1 + +[5] the dock hint is shadowed by the side-job count + dock at Leleer, one jump from Rabedira: say:THERE ARE 2 SIDE JOBS ON THE STATION BOARD. + the same dock with the side jobs removed: later:A MESSAGE FROM RABEDIRA: COME WHEN YOU CAN. THERE IS WORK. + fourth idle dock at Ontimaxe, three jumps out: say:THERE ARE 3 SIDE JOBS ON THE STATION BOARD. + +[6] a saved record that names a ghost skeleton or leg + ghost skeleton, docked: THROWS "mission arc-ghost: no such skeleton" + ghost skeleton, abandon: THROWS "mission arc-ghost: no such skeleton" + ghost leg, docked: THROWS "mission arc-lave: no leg old" + +[7] the journal holds no galaxy, so the log misnames a world after a jump + the acceptance line, rendered with galaxy 1: On day 0 the commander took the governor's contract at LAVE, to chase down a stolen ledger. + the same line, rendered with galaxy 2: On day 0 the commander took the governor's contract at ESRILEES, to chase down a stolen ledger. + +[8] a local patron's standing is keyed by where the branch settles + accepted at Tiraor (5), delivered at Edzaon (159), standing: {"world-159":1} + +[9] the pirate row a restored mission ship is looked up under + python pirate row: true trader row: true + cobraMk3 pirate row: true trader row: true + anaconda pirate row: false trader row: true + boa pirate row: false trader row: true + transporter pirate row: false trader row: true + +[10] handover placement has a candidate from every world of galaxy 1 + toward Rabedira worlds with no candidate: 0 + toward Vetitice worlds with no candidate: 0 + toward Xeer worlds with no candidate: 0 + toward Edle worlds with no candidate: 0 diff --git a/index.html b/index.html index 4a619e0c..93b5a2c1 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,15 @@ - + + + + + + + HARMLESS: a browser tribute to Elite (1984) - + @@ -51,7 +57,7 @@

HARMLESS

LAUNCH ► - Free · in your browser · no account · keyboard and mouse + Free · in your browser · no account · keyboard, mouse or touch
@@ -74,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 @@ -111,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/manual.html b/manual.html index bcaebf69..69394fad 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. The ▶▶ button + 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. The ▶▶ button 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,18 @@

    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. The console tells you which one it was. + When the space around you is clear again, the ship starts the drive + again by itself. +

    +

    + You will pass other traders on the way in. The console names one when + it comes close. They are carrying cargo, and nothing stops you taking + it: choose TARGETS, pick the ship, and open fire. The law takes + a dim view, so make sure nobody is watching.

    @@ -248,19 +270,32 @@

    Docking

    with a slot that will not hold still.

      -
    1. Approach the face with the port on it. An amber marker shows where - the port is, with an arrow at the screen edge when it is behind you.
    2. -
    3. Get on the axis, straight out from the port, not off to one side.
    4. +
    5. Choose FLY TO THE STATION. The ship flies the approach and + gets on the axis, straight out from the port. An amber marker shows + where the port is, with an arrow at the screen edge when it is + behind you.
    6. +
    7. The console reads LINING UP while the computer puts the + ship on the axis. There is nothing to do yet.
    8. +
    9. The ship comes to a stop on the line, and the slot is yours. It + stays on the line by itself from there, so you cannot miss the + hole. Two things are left to you, and they are the whole of it: the + roll, and the speed.
    10. +
    11. Nothing happens until you thrust. You can sit there as + long as you like and watch the port turn.
    12. Roll to match the port's rotation. This is the part people miss. The opening is a letterbox, and your ship has to be the same - way up as it is.
    13. -
    14. Slow down. Then go in. The marker turns green and reads - DOCKING PORT — LINED UP when you have it.
    15. + way up as it is. Drag the strip at the bottom right, or use your + roll keys. +
    16. Then the speed. Hold THRUST or BRAKE until + you are under the mark on the speed bar, and go in. The marker turns + green and reads DOCKING PORT — LINED UP when + you have the roll.

    - Get it wrong and you bounce off the hull with a bang and most of a - shield gone. Get it wrong on a shield that is already down and it - reaches you. + Arrive rolled wrong, or too fast, and you bounce off the hull with a + bang and most of a shield gone. The station puts you back outside, and + you can try again. Get it wrong on a shield that is already down and + it reaches you.

    When you have the money, buy a docking computer. It is the @@ -271,6 +306,30 @@

    Docking

    Trouble

    +

    + When somebody opens fire, the computer takes the stick and lines the + ship up on them. The shooting stays yours. The guns are the row at + the bottom of the console. FIRE LASER fires while you hold + it, ARM MISSILE arms one and FIRE MISSILE sends + it, and E.C.M. answers a missile coming at you. + The button, bottom left, opens a row of every ship, rock and + derelict on the scanner, and a row sends the computer after that + one. It also says when the law protects a ship, before you shoot + it. ACTIONS, top right, opens the row of where the ship can + go next. +

    +

    + A missile costs money, so the computer never fires one for you. Buy + a combat computer and it will do the rest of the fight while + you watch. Press a steering key at any time and you have the ship + back. +

    +

    + If the fight is going badly, the buttons on the left offer the ways + out: RUN FOR IT, which says whether you are faster, and the + offers that fit the moment, such as paying a patrol off or throwing + cargo to a pirate. +

    Pirates
    They want your cargo, and they size you up before committing. A fat @@ -281,6 +340,9 @@

    Trouble

    ECM (E)
    Destroys incoming missiles. Costs energy. A missile you do not answer will flatten the shield it hits, almost exactly.
    +
    Cloaking device (Z)
    +
    No shop sells one. While it runs, nobody out there sees you; it + drains the bank, and a shot gives you away.
    Police
    Vipers, and they are good. They care about contraband and about who shot first. Shoot a clean ship near a station and you will find @@ -355,7 +417,15 @@

    Missions

    the route drawn.

    - How a job plays. The MISSIONS screen shows the job you hold, + How a job plays. When you jump into the world a job names, the + job is the first button in the list over the view, in its own words: + HUNT THE KRAIT, SCAN THE ANACONDA, ESCORT THE PYTHON, RECOVER THE + CARGO, PICK UP THE SURVIVOR, or SLIP PAST THE POLICE. Choose it and + the ship flies the job. A delivery ends at the station, so + FLY TO THE STATION is the button for it. +

    +

    + The MISSIONS screen shows the job you hold, the briefing, and the order you are on now. The world you must go to has an amber diamond on both charts. When you jump in, the console tells you what you are there for and where the target is: how far, diff --git a/package.json b/package.json index 6ba24c50..a09b1982 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "elite-a": "npm run generate:elite-a -- --check && node --experimental-strip-types --no-warnings test/elite-a.ts", "campaign": "node --experimental-strip-types --no-warnings test/campaign.ts", "survivability": "node --experimental-strip-types --no-warnings train/survivability.ts", + "combat-aim": "node --experimental-strip-types --no-warnings train/combat-aim.ts", "flight-probe": "node --experimental-strip-types --no-warnings train/flight-probe.ts", "aim-probe": "node --experimental-strip-types --no-warnings train/aim-probe.ts", "ram-probe": "node --experimental-strip-types --no-warnings train/ram-probe.ts", diff --git a/play.html b/play.html index b7224a57..4a6c884f 100644 --- a/play.html +++ b/play.html @@ -2,7 +2,13 @@ - + + + + + + + HARMLESS — play @@ -29,13 +35,26 @@

    -
    +
    + +
    + +
    + +
    +
    -
    + +
    @@ -77,10 +106,19 @@ S E
    -
    + + +
    -

    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

    -423 exported constants. Regenerate with `npm run generate:constants`; +474 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 | @@ -15,8 +15,10 @@ search names, meanings and values with `npm run constants:find -- ""`. | attack-run | EXTEND_RANGE_MAX | 850 | | | [attack-run.ts:28](./attack-run.ts#L28) | | attack-run | EXTEND_RANGE | (EXTEND_RANGE_MIN + EXTEND_RANGE_MAX) / 2 | Default turn-back range for a caller that rolled none — mid-band. | | [attack-run.ts:31](./attack-run.ts#L31) | | attack-run | UNDER_FIRE_SECONDS | 1.2 | How long a ship keeps to evasive flight after the last hit. | | [attack-run.ts:37](./attack-run.ts#L37) | -| attack-run | CLOSING_THROTTLE_MIN | 0.45 | The slowest that an attacking ship throttles back to in order to turn. | | [attack-run.ts:45](./attack-run.ts#L45) | -| attack-run | MIN_CRUISE_FRACTION | 0.43 | A hostile cannot throttle below this fraction of its top speed. | | [attack-run.ts:52](./attack-run.ts#L52) | +| attack-run | TRADER_CALM_SECONDS | 20 | How long a trader keeps to its run after the last hit it took: twenty seconds. | trader.calmSeconds | [attack-run.ts:53](./attack-run.ts#L53) | +| attack-run | HUNT_FLEE_FRACTION | 0.25 | The fraction of its energy under which a hunt's target runs for the edge of the system: a quarter (docs/TODO/214 M4). | hunt.fleeFraction | [attack-run.ts:70](./attack-run.ts#L70) | +| attack-run | CLOSING_THROTTLE_MIN | 0.45 | The slowest that an attacking ship throttles back to in order to turn. | | [attack-run.ts:78](./attack-run.ts#L78) | +| attack-run | MIN_CRUISE_FRACTION | 0.43 | A hostile cannot throttle below this fraction of its top speed. | | [attack-run.ts:85](./attack-run.ts#L85) | | audio | AUDIBLE_RANGE | SCANNER_RANGE | How far a bang carries, in world units. | audio.audibleRange | [audio.ts:35](./audio.ts#L35) | | audio | STEREO_WIDTH | 0.7 | How far across the stereo field a sound may sit: 0 is mono, 1 is one ear only. | audio.stereoWidth | [audio.ts:58](./audio.ts#L58) | | blame | PROVOKES | { laser: true, missile: true, bomb: true, ram: false, } | Which of the commander's damage sources provoke the ship they hit. | | [blame.ts:22](./blame.ts#L22) | @@ -28,11 +30,11 @@ search names, meanings and values with `npm run constants:find -- ""`. | brain-flight | BRAIN_RATE_DECAY | 5.2207 | | | [brain-flight.ts:25](./brain-flight.ts#L25) | | brain-flight | DECISION_INTERVAL | 0.1 | How long a brain holds a decision before it takes another: 10 Hz. | flight.brain.decisionInterval | [brain-flight.ts:38](./brain-flight.ts#L38) | | brain-flight | OBS_SPEED_SCALE | 400 | The speed scale that normalizes every observation, in world units a second. | flight.brain.observationSpeed | [brain-flight.ts:52](./brain-flight.ts#L52) | -| camera | CAMERA_FOV | 60 | Vertical field of view, in degrees. | | [camera.ts:10](./camera.ts#L10) | -| camera | CAMERA_NEAR | 1 | Near plane — 1 unit, about a wingtip. | camera.near | [camera.ts:20](./camera.ts#L20) | -| camera | CAMERA_FAR | 1_000_000 | Far plane — a million units. | | [camera.ts:26](./camera.ts#L26) | -| camera | HEADLESS_WIDTH | 1280 | The viewport that a run with no window pretends to have. | | [camera.ts:34](./camera.ts#L34) | -| camera | HEADLESS_HEIGHT | 720 | | | [camera.ts:35](./camera.ts#L35) | +| camera | CAMERA_FOV | 60 | Vertical field of view, in degrees. | camera.fov | [camera.ts:12](./camera.ts#L12) | +| camera | CAMERA_NEAR | 1 | Near plane — 1 unit, about a wingtip. | camera.near | [camera.ts:22](./camera.ts#L22) | +| camera | CAMERA_FAR | 1_000_000 | Far plane — a million units. | | [camera.ts:28](./camera.ts#L28) | +| camera | HEADLESS_WIDTH | 1280 | The viewport that a run with no window pretends to have. | | [camera.ts:36](./camera.ts#L36) | +| camera | HEADLESS_HEIGHT | 720 | | | [camera.ts:37](./camera.ts#L37) | | character | CHARACTER | [ [0, 'Honest'], [10, 'Dubious'], [25, 'Dodgy'], [50, 'Shady'], [80, 'Notorious'], [120, 'Cutthroat'], ] | The character ladder: the disrepute score, lowest first, and the rung that it earns. | | [character.ts:19](./character.ts#L19) | | character | DISREPUTE_HERMIT_KILL | 40 | What each deed adds to disrepute, tuned in play. | character.hermitKill | [character.ts:35](./character.ts#L35) | | character | HERMIT_HIT_LINE | 'ROCK HERMIT HIT — SOMEBODY LIVES IN THAT ROCK' | What the console says on the first hit that lands on a rock hermit. | | [character.ts:52](./character.ts#L52) | @@ -52,11 +54,14 @@ search names, meanings and values with `npm run constants:find -- ""`. | chart-metric | CHART_SPAN_Y | CHART_SPAN_X / CHART_Y_SQUASH | The height — half the width. | | [chart-metric.ts:43](./chart-metric.ts#L43) | | chart-metric | LOCAL_SCALE | 15 | The console's short-range chart: canvas px per chart unit. | | [chart-metric.ts:51](./chart-metric.ts#L51) | | chart-metric | LOCAL_CANVAS | 560 | Square, so a light year is the same number of pixels whichever way you go. | | [chart-metric.ts:54](./chart-metric.ts#L54) | -| chart-metric | CHART_CANVAS_W | 780 | The galactic chart's canvas, in px. | | [chart-metric.ts:66](./chart-metric.ts#L66) | -| chart-metric | CHART_CANVAS_H | 400 | Its height. | | [chart-metric.ts:74](./chart-metric.ts#L74) | -| chart-metric | LANE_PICK_PX | 8 | How near the pointer must come to a trade lane to be pointing AT it, in canvas pixels. | | [chart-metric.ts:85](./chart-metric.ts#L85) | +| chart-metric | CHART_LABEL_PX | 12 | A system's name on the short range chart, in CSS pixels: twelve (docs/TODO/220). | chart.labelPx | [chart-metric.ts:64](./chart-metric.ts#L64) | +| chart-metric | CHART_CANVAS_W | 780 | The galactic chart's canvas, in px. | | [chart-metric.ts:76](./chart-metric.ts#L76) | +| chart-metric | CHART_CANVAS_H | 400 | Its height. | | [chart-metric.ts:84](./chart-metric.ts#L84) | +| chart-metric | LANE_PICK_PX | 8 | How near the pointer must come to a trade lane to be pointing AT it, in canvas pixels. | chart.lanePickPx | [chart-metric.ts:97](./chart-metric.ts#L97) | | chart-overlay | LANE_FADE_FLOOR | 0.35 | The alpha that the quietest drawn trade lane keeps, with the busiest at 1. | chart.laneFadeFloor | [chart-overlay.ts:25](./chart-overlay.ts#L25) | | chart-overlay | LANE_CARGO_NAMED | 3 | How many of a lane's commodities the detail line names before it counts the rest ("+2"). | chart.laneCargoNamed | [chart-overlay.ts:37](./chart-overlay.ts#L37) | +| cloak | CLOAK_ENERGY_PER_SECOND | ENERGY_BANK_POINTS / 6 | What the cloak draws from the energy bank each second: a bank every six seconds, which is about eleven points. | cloak.energyPerSecond | [cloak.ts:17](./cloak.ts#L17) | +| cloak | CLOAK_MIN_ENERGY | LOW_ENERGY | The cloak drops when the bank is down to its last one, so it never takes the energy that keeps the ship alive. | cloak.minEnergy | [cloak.ts:25](./cloak.ts#L25) | | collision | PLAYER_SPEED_KEPT | 0.3 | Speed kept after a collision. | | [collision.ts:10](./collision.ts#L10) | | collision | NPC_SPEED_KEPT | 0.3 | | | [collision.ts:11](./collision.ts#L11) | | collision | STATION_SPEED_KEPT | 0.4 | A hit on the station is the gentlest of the three. | collision.stationSpeedKept | [collision.ts:22](./collision.ts#L22) | @@ -80,21 +85,24 @@ search names, meanings and values with `npm run constants:find -- ""`. | combat-computer | PURSUIT_SLASH_CONE | 1.3 | A pursuit PIRATE switches flight models on where it sits in the commander's arc. | | [combat-computer.ts:178](./combat-computer.ts#L178) | | combat-computer | PURSUIT_HOLD_CONE | 1.85 | | | [combat-computer.ts:179](./combat-computer.ts#L179) | | combat-computer | ENGAGED_CONE | 0.6 | The nose-to-target angle, in radians, within which the co-pilot counts itself ENGAGED and will not switch targets. | | [combat-computer.ts:188](./combat-computer.ts#L188) | -| combat-computer | TARGET_DIST_WEIGHT | 800 | How many world units of range weigh as much as one radian of off-nose turn. | copilot.targetDistWeight | [combat-computer.ts:206](./combat-computer.ts#L206) | +| combat-computer | ENGAGED_PATIENCE | 3 | How long the co-pilot stays committed to a target it cannot shoot, in seconds. | copilot.engagedPatience | [combat-computer.ts:221](./combat-computer.ts#L221) | +| combat-computer | COMBAT_ROLL_GATE | 0.27 | How near its bank the co-pilot must be before it pulls the nose, in radians. | copilot.rollGate | [combat-computer.ts:273](./combat-computer.ts#L273) | +| combat-computer | TARGET_DIST_WEIGHT | 800 | How many world units of range weigh as much as one radian of off-nose turn. | copilot.targetDistWeight | [combat-computer.ts:291](./combat-computer.ts#L291) | +| combat-computer | PURSUIT_LEAD_GAIN | 2.0 | How many SECONDS the co-pilot aims ahead of a target, per radian that its nose still has to swing. | copilot.leadGain | [combat-computer.ts:358](./combat-computer.ts#L358) | | combat-record | SAMPLE_HZ | 10 | How often the code samples the geometry, in Hz. | | [combat-record.ts:12](./combat-record.ts#L12) | | combat-record | SIX_CONE | Math.PI / 3 | The rear cone that counts as somebody's six, as a half-angle from directly astern. | | [combat-record.ts:20](./combat-record.ts#L20) | | combat-record | PASS_CLOSE | 400 | What an attack run is, in ranges. | | [combat-record.ts:42](./combat-record.ts#L42) | -| combat-record | PASS_FAR | 600 | | | [combat-record.ts:43](./combat-record.ts#L43) | -| combat-record | SIM_LOG_LIMIT | 20 | How many exercise records the in-memory ring keeps. | combatrecord.simLogLimit | [combat-record.ts:50](./combat-record.ts#L50) | -| combat-record | MAX_SAMPLES | 12_000 | Samples kept before the buffer closes. | record.maxSamples | [combat-record.ts:63](./combat-record.ts#L63) | +| combat-record | PASS_FAR | 600 | ...and the far end of that pair: the range a ship must break back out to for the pass to count. | combatrecord.passFar | [combat-record.ts:49](./combat-record.ts#L49) | +| combat-record | SIM_LOG_LIMIT | 20 | How many exercise records the in-memory ring keeps. | combatrecord.simLogLimit | [combat-record.ts:56](./combat-record.ts#L56) | +| combat-record | MAX_SAMPLES | 12_000 | Samples kept before the buffer closes. | record.maxSamples | [combat-record.ts:69](./combat-record.ts#L69) | | commander | DEFAULT_NAME | 'JAMESON' | The original's own commander, and the default here. | | [commander.ts:10](./commander.ts#L10) | -| commander | STARTING_CREDITS | 1000 | The grubstake, in tenths of a credit — the classic 100.0 Cr. | | [commander.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,43 +122,70 @@ 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:31](./course.ts#L31) | +| course | COURSE_AIM_DEADZONE | 0.02 | How near the nose a course counts its target as straight ahead, in radians. | | [course.ts:55](./course.ts#L55) | +| course | COURSE_ROLL_GATE | 0.05 | How near its bank a course pilot must be before it pulls the nose, in radians. | | [course.ts:82](./course.ts#L82) | +| course | COURSE_TORUS_DROP | TORUS_MULTIPLIER * PLAYER_FLIGHT.maxSpeed * 2.5 | How far from its target the course pilot drops the torus drive, in world units, before an arrival. | | [course.ts:95](./course.ts#L95) | +| course | COURSE_ARRIVE_BRAKE | 0.7 | The share of the ship's thrust that an arrival plans to brake with. | course.arriveBrake | [course.ts:107](./course.ts#L107) | +| course | COURSE_ARRIVE_TOLERANCE | 75 | How near its standoff the ship must be to count as arrived, in world units. | course.arriveTolerance | [course.ts:117](./course.ts#L117) | +| course | COURSE_DERELICT_STANDOFF | GENERATION_CARGO_SCATTER + 400 | Where the derelict course stops, as a distance from the generation ship's centre, in world units. | | [course.ts:130](./course.ts#L130) | +| course | COURSE_HERMIT_STANDOFF | 240 | Where the hermit course stops, as a distance from the rock's centre, in world units. | course.hermitStandoff | [course.ts:143](./course.ts#L143) | +| course | COURSE_SKIM_DISTANCE | 65_000 | The hold distance of the skim course, from the centre of the star, in world units. | course.skimDistance | [course.ts:158](./course.ts#L158) | +| course | COURSE_PLANET_CLEARANCE | MASS_LOCK_PLANET_ALTITUDE * 1.25 | How high above the planet's surface a course keeps its line, in world units. | | [course.ts:170](./course.ts#L170) | +| course | COURSE_HERMIT_SPEED | HERMIT_DOCK_SPEED / 2 | The speed at which the hermit course arrives, in world units a second. | | [course.ts:180](./course.ts#L180) | +| course | SKIP_SPEED | 8 | How much faster the world runs under the fast forward button, as a count of fixed steps in one screen frame (docs/TODO/205 M7). | course.skipSpeed | [course.ts:196](./course.ts#L196) | +| course | RUN_CLOSE_MARGIN | 50 | Below this lead in top speed, in world units a second, the run row says the ship is only a little faster (docs/TODO/206 M5). | course.runCloseMargin | [course.ts:210](./course.ts#L210) | +| course | COURSE_RUN_REACH | 50_000 | How far ahead the run course aims, in world units, on the line away from the hostile ships (docs/TODO/206 M5). | course.runReach | [course.ts:220](./course.ts#L220) | +| course | COURSE_COLLECT_SPEED | 60 | How fast the collect course flies onto a canister, in world units a second (docs/TODO/206 M6). | course.collectSpeed | [course.ts:234](./course.ts#L234) | +| course | COURSE_COLLECT_LEAD | 5 | The furthest ahead of a drifting canister the collect course will aim, in seconds. | course.collectLead | [course.ts:269](./course.ts#L269) | +| course | COURSE_COLLECT_CAP | COURSE_COLLECT_SPEED * 2 | The fastest the collect course flies on its way to a canister, in world units a second. | course.collectCap | [course.ts:306](./course.ts#L306) | +| course | COURSE_DOCK_HANDOVER | 1500 | How far from the station the station course hands the ship to the pilot, in world units (docs/TODO/207 M1). | course.dockHandover | [course.ts:320](./course.ts#L320) | +| course | COURSE_OBSTACLE_CLEARANCE | PLAYER_FLIGHT.maxSpeed / PLAYER_FLIGHT.maxPitch | How far clear of a solid thing's HULL a course flies, in world units. | | [course.ts:346](./course.ts#L346) | | docking | GATE_HALF_WIDTHS | 5 | How far out the approach gate sits, in multiples of the station half-width. | docking.gateHalfWidths | [docking.ts:25](./docking.ts#L25) | | docking | TURN_IN | Math.PI / 4 | How far round from the slot axis an approach turns in, in radians. | | [docking.ts:49](./docking.ts#L49) | | docking | RUN_IN_WIDTHS | GATE_HALF_WIDTHS * 0.6 | Where the approach stops the curve and flies straight, in station half-widths. | | [docking.ts:87](./docking.ts#L87) | | docking | LINED_UP_LATERAL | 45 | The off-axis error we insist on before we commit to the run in, in world units. | | [docking.ts:100](./docking.ts#L100) | -| docking | HULL_BOX_MARGIN | 50 | The bounding cube around the station: a margin over the half-width, in world units, a little larger than the hull. | | [docking.ts:112](./docking.ts#L112) | -| docking | NPC_HULL_BOX_MARGIN | HULL_BOX_MARGIN | The same cube for every NPC. | | [docking.ts:117](./docking.ts#L117) | -| docking | SLOT_HALF_ACROSS | 26 | The slot channel, as half-extents ACROSS the slot and ALONG it, in station-local world units. | | [docking.ts:125](./docking.ts#L125) | -| docking | SLOT_HALF_ALONG | 62 | | | [docking.ts:126](./docking.ts#L126) | -| docking | SLOT_DEPTH | 60 | How far into the -Z face puts a ship in the channel, in world units. | | [docking.ts:129](./docking.ts#L129) | -| docking | ROLL_TOLERANCE | 0.65 | The wings against the slot's long axis, in radians: how badly you may be rolled and still fit through the letterbox. | | [docking.ts:140](./docking.ts#L140) | +| docking | HULL_BOX_MARGIN | 50 | The bounding cube around the station: a margin over the half-width, in world units, a little larger than the hull. | docking.hullBoxMargin | [docking.ts:114](./docking.ts#L114) | +| docking | NPC_HULL_BOX_MARGIN | HULL_BOX_MARGIN | The same cube for every NPC. | | [docking.ts:119](./docking.ts#L119) | +| docking | SLOT_HALF_ACROSS | 26 | The slot channel, as half-extents ACROSS the slot and ALONG it, in station-local world units. | | [docking.ts:127](./docking.ts#L127) | +| docking | SLOT_HALF_ALONG | 62 | | | [docking.ts:128](./docking.ts#L128) | +| docking | SLOT_DEPTH | 60 | How far into the -Z face puts a ship in the channel, in world units. * | docking.slotDepth | [docking.ts:133](./docking.ts#L133) | +| docking | ROLL_TOLERANCE | 0.24 | The wings against the slot's long axis, in radians, FOR A HAND ON THE STICK. | docking.rollTolerance | [docking.ts:153](./docking.ts#L153) | +| docking | COMPUTER_ROLL_TOLERANCE | 0.65 | ...and the same angle for a BOUGHT DOCKING COMPUTER, which is wider. | docking.computerRollTolerance | [docking.ts:179](./docking.ts#L179) | +| docking | SLOT_SPEED_LIMIT | 120 | How fast a ship may be going when it reaches the slot, in world units a second (docs/TODO/207 M3). | docking.slotSpeedLimit | [docking.ts:200](./docking.ts#L200) | +| docking | RAILS_LATERAL | 250 | How far off the slot axis the ship may be when the rails take it, in world units (docs/TODO/212). | docking.railsLateral | [docking.ts:222](./docking.ts#L222) | +| docking | RAILS_RANGE | 900 | How far out the rails take the ship, in world units (docs/TODO/212). | docking.railsRange | [docking.ts:234](./docking.ts#L234) | +| docking | RAILS_PULL | 2 | How hard the rails pull the ship onto the axis, per second (docs/TODO/212). | docking.railsPull | [docking.ts:246](./docking.ts#L246) | +| docking | RAILS_CONE | 0.03 | How far off the slot axis the ship's NOSE may point when the PILOT is given the slot, in radians. | docking.railsCone | [docking.ts:280](./docking.ts#L280) | +| docking | RAILS_TURN | 3 | How hard the rails turn the nose onto the axis, per second. | docking.railsTurn | [docking.ts:297](./docking.ts#L297) | +| docking | RAILS_STOPPED | 2 | The speed under which the ship counts as stopped, in world units a second (docs/TODO/212). | docking.railsStopped | [docking.ts:311](./docking.ts#L311) | | docking-computer | DOCK_COMPUTER_RANGE | 3500 | How close to the station the docking computer will take the job, in world units. | | [docking-computer.ts:46](./docking-computer.ts#L46) | | docking-computer | DC_SLOT_MARGIN | 0.30 | How much of the slot's roll tolerance the TURN may spend, as a fraction. | docking.slotMargin | [docking-computer.ts:97](./docking-computer.ts#L97) | | docking-computer | DC_TURN_FADE_ANGLE | 0.10 | The off-nose angle, in radians, over which the TURN's claim on the roll axis ramps in. | docking.turnFadeAngle | [docking-computer.ts:151](./docking-computer.ts#L151) | | docking-computer | DC_ROLL_LEAD | 0.10 | How far ahead IN TIME the roll ask reads the rate it already rolls at, in seconds. | docking.rollLead | [docking-computer.ts:211](./docking-computer.ts#L211) | | docking-computer | DC_PATH_LOOKAHEAD | 1.5 | How far ahead ALONG THE PATH the follower aims, in station half-widths. | docking.pathLookahead | [docking-computer.ts:272](./docking-computer.ts#L272) | | encounters | TRADER_GAP | 100 | The gap between trader arrivals in a system with no economy to speak of. | | [encounters.ts:20](./encounters.ts#L20) | -| encounters | TRADER_GAP_JITTER | 60 | ...and the jitter on top, drawn flat, so the lane never runs to a metronome. | | [encounters.ts:23](./encounters.ts#L23) | -| encounters | TRADER_GAP_BUSY_MAX | 50 | The most that a busy economy can discount off `TRADER_GAP`. | | [encounters.ts:36](./encounters.ts#L36) | -| encounters | PRODUCTIVITY_PER_SECOND | 1200 | How much 1984 productivity buys one second off that gap. | | [encounters.ts:47](./encounters.ts#L47) | -| encounters | TRADER_GAP_FIRST | 20 | How long after a system's clocks start the first trader may appear. | encounters.traderGapFirst | [encounters.ts:60](./encounters.ts#L60) | -| encounters | TRADER_GAP_FIRST_JITTER | 40 | ...and its jitter, so two arrivals in one system are not the same arrival. * | encounters.traderGapFirstJitter | [encounters.ts:65](./encounters.ts#L65) | -| encounters | PIRATE_WAVE_GAP | 60 | The gap between pirate waves in the most organised system that still breeds them. | | [encounters.ts:73](./encounters.ts#L73) | -| encounters | PIRATE_WAVE_GAP_PER_GOVERNMENT | 40 | ...and how much longer the wait grows for every step up the government ladder. | encounters.pirateWaveGapPerGovernment | [encounters.ts:86](./encounters.ts#L86) | -| encounters | PIRATE_WAVE_GAP_JITTER | 90 | ...and the jitter. | | [encounters.ts:90](./encounters.ts#L90) | -| encounters | LAWLESS_GOVERNMENT | 3 | A government at or below this breeds pirate waves at all. 3 is a dictatorship on the 1984 ladder, so waves stop at communist (4) and above. | encounters.lawlessGovernment | [encounters.ts:102](./encounters.ts#L102) | -| encounters | ANARCHY_GOVERNMENT | 1 | ...and a government at or below THIS sends them two at a time: anarchy (0) and feudal (1). | encounters.anarchyGovernment | [encounters.ts:118](./encounters.ts#L118) | -| encounters | MAX_THARGONS | 2 | How many drones the Thargoids keep in the sky at once, across every mothership. | encounters.maxThargons | [encounters.ts:138](./encounters.ts#L138) | -| encounters | THARGON_REDEPLOY | 5 | Seconds between one drone and the next, and the wait for the first. | encounters.thargonRedeploy | [encounters.ts:153](./encounters.ts#L153) | +| encounters | TRADER_GAP_JITTER | 60 | ...and the jitter on top, drawn flat, so the lane never runs to a metronome. * | encounters.traderGapJitter | [encounters.ts:25](./encounters.ts#L25) | +| encounters | TRADER_GAP_BUSY_MAX | 50 | The most that a busy economy can discount off `TRADER_GAP`. | encounters.traderGapBusyMax | [encounters.ts:40](./encounters.ts#L40) | +| encounters | PRODUCTIVITY_PER_SECOND | 1200 | How much 1984 productivity buys one second off that gap. | encounters.productivityPerSecond | [encounters.ts:53](./encounters.ts#L53) | +| encounters | TRADER_GAP_FIRST | 20 | How long after a system's clocks start the first trader may appear. | encounters.traderGapFirst | [encounters.ts:66](./encounters.ts#L66) | +| encounters | TRADER_GAP_FIRST_JITTER | 40 | ...and its jitter, so two arrivals in one system are not the same arrival. * | encounters.traderGapFirstJitter | [encounters.ts:71](./encounters.ts#L71) | +| encounters | PIRATE_WAVE_GAP | 60 | The gap between pirate waves in the most organised system that still breeds them. | encounters.pirateWaveGap | [encounters.ts:81](./encounters.ts#L81) | +| encounters | PIRATE_WAVE_GAP_PER_GOVERNMENT | 40 | ...and how much longer the wait grows for every step up the government ladder. | encounters.pirateWaveGapPerGovernment | [encounters.ts:94](./encounters.ts#L94) | +| encounters | PIRATE_WAVE_GAP_JITTER | 90 | ...and the jitter. | | [encounters.ts:98](./encounters.ts#L98) | +| encounters | LAWLESS_GOVERNMENT | 3 | A government at or below this breeds pirate waves at all. 3 is a dictatorship on the 1984 ladder, so waves stop at communist (4) and above. | encounters.lawlessGovernment | [encounters.ts:110](./encounters.ts#L110) | +| encounters | ANARCHY_GOVERNMENT | 1 | ...and a government at or below THIS sends them two at a time: anarchy (0) and feudal (1). | encounters.anarchyGovernment | [encounters.ts:126](./encounters.ts#L126) | +| encounters | MAX_THARGONS | 2 | How many drones the Thargoids keep in the sky at once, across every mothership. | encounters.maxThargons | [encounters.ts:147](./encounters.ts#L147) | +| encounters | THARGON_REDEPLOY | 5 | Seconds between one drone and the next, and the wait for the first. | encounters.thargonRedeploy | [encounters.ts:162](./encounters.ts#L162) | | exercise | OPENING_RANGE | 4500 | The opening range for a fight that you are meant to see coming. | exercise.openingRange | [exercise.ts:33](./exercise.ts#L33) | | exercise | AMBUSH_RANGE | 2400 | An ambush opens INSIDE their gun, because that is what an ambush is. | | [exercise.ts:40](./exercise.ts#L40) | | exercise | MIN_OPENING_RANGE | 2 * PASS_FAR | No opening may be closer than this. | | [exercise.ts:49](./exercise.ts#L49) | -| 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.entryThrottle | [exercise.ts:89](./exercise.ts#L89) | +| exercise | SCENARIO_TIMEOUT | 120 | Seconds a scenario exercise may run before it times out. * | exercise.scenarioTimeout | [exercise.ts:94](./exercise.ts#L94) | +| exercise | NO_AMBIENT_TRAFFIC | 1e9 | How far out the encounter timers are pushed while an exercise runs. | | [exercise.ts:104](./exercise.ts#L104) | | extend-arc | EXTEND_ARC_ANGLE | (60 * Math.PI) / 180 | The angle that the run-out holds off the OUTWARD radial, at its tightest. | | [extend-arc.ts:13](./extend-arc.ts#L13) | | extend-arc | CLEAR_RANGE | 340 | How far out the ship gets before it starts to curve at all. | | [extend-arc.ts:22](./extend-arc.ts#L22) | | hermit-market | HERMIT_ORE | new Set(['Minerals', 'Gold', 'Platinum', 'Gem-Stones']) | What a hermit sits on: whatever they dug up. | | [hermit-market.ts:16](./hermit-market.ts#L16) | @@ -160,12 +195,12 @@ search names, meanings and values with `npm run constants:find -- ""`. | hermit-market | HERMIT_SUPPLY_PRICE | 1.3 | Supplies cost a third more: nobody else delivers out here. | | [hermit-market.ts:32](./hermit-market.ts#L32) | | hermit-market | HERMIT_REFUSES_AT | CHARACTER.find(([, rung]) => rung === 'Dodgy')![0] | The character a hermit will not deal with: the ladder's Dodgy rung. | | [hermit-market.ts:48](./hermit-market.ts#L48) | | hermit-market | HERMIT_FAVOUR | 0.2 | Mates' rates, at the widest. | hermit.favour | [hermit-market.ts:67](./hermit-market.ts#L67) | -| hermit-market | HERMIT_HAIL_RANGE | 900 | How near a rock hermit the commander must be to hear its hail. | hermit.hail | [hermit-market.ts:82](./hermit-market.ts#L82) | -| hermit-market | HERMIT_DOCK_RANGE | 320 | How near the commander must be to actually trade with a hermit. | hermit.dockRange | [hermit-market.ts:93](./hermit-market.ts#L93) | -| hermit-market | HERMIT_DOCK_SPEED | 40 | How slow the commander must be flying to trade with a hermit. | hermit.dockSpeed | [hermit-market.ts:105](./hermit-market.ts#L105) | -| hull-breach | EQUIPMENT_DAMAGE_CHANCE | 0.25 | The chance that a hit which reaches the hull wrecks cargo or a fitting. | | [hull-breach.ts:19](./hull-breach.ts#L19) | -| hull-breach | CARGO_LOSS_CHANCE | 0.7 | Cargo is lost this often when there is any aboard. | breach.cargoLossChance | [hull-breach.ts:35](./hull-breach.ts#L35) | -| hull-breach | BREAKABLE | [ ['ecm', 'E.C.M. SYSTEM'], ['scoops', 'FUEL SCOOPS'], ['rearLaser', 'REAR LASER'], ['leftLaser', 'LEFT LASER'], ['rightLaser', 'RIGHT LASER'], ['dockingComputer', 'DOCKING COMPUTER'], ['combatComputer', 'COMBAT COMPUTER'], ] as const | The fittings that a hull breach can knock out, in the order they are offered. | | [hull-breach.ts:46](./hull-breach.ts#L46) | +| hermit-market | HERMIT_HAIL_RANGE | 900 | How near a rock hermit the commander must be to hear its hail. | hermit.hail | [hermit-market.ts:83](./hermit-market.ts#L83) | +| hermit-market | HERMIT_DOCK_RANGE | 320 | How near the commander must be to actually trade with a hermit. | hermit.dockRange | [hermit-market.ts:94](./hermit-market.ts#L94) | +| hermit-market | HERMIT_DOCK_SPEED | 40 | How slow the commander must be flying to trade with a hermit. | hermit.dockSpeed | [hermit-market.ts:106](./hermit-market.ts#L106) | +| hull-breach | EQUIPMENT_DAMAGE_CHANCE | 0.25 | The chance that a hit which reaches the hull wrecks cargo or a fitting. | hullbreach.equipmentDamageChance | [hull-breach.ts:21](./hull-breach.ts#L21) | +| hull-breach | CARGO_LOSS_CHANCE | 0.7 | Cargo is lost this often when there is any aboard. | breach.cargoLossChance | [hull-breach.ts:37](./hull-breach.ts#L37) | +| hull-breach | BREAKABLE | [ ['ecm', 'E.C.M. SYSTEM'], ['scoops', 'FUEL SCOOPS'], ['rearLaser', 'REAR LASER'], ['leftLaser', 'LEFT LASER'], ['rightLaser', 'RIGHT LASER'], ['dockingComputer', 'DOCKING COMPUTER'], ['combatComputer', 'COMBAT COMPUTER'], ] as const | The fittings that a hull breach can knock out, in the order they are offered. | | [hull-breach.ts:48](./hull-breach.ts#L48) | | hull-motion | TURN | { pitch: 1.4, roll: 2.4 } as const | A hull's `turnRate` is one number. | | [hull-motion.ts:23](./hull-motion.ts#L23) | | hull-motion | ACCEL_FRACTION | 0.46 | How hard a hull accelerates, as a fraction of its top speed. | | [hull-motion.ts:37](./hull-motion.ts#L37) | | hunt-ranges | PIRATE_HUNT_RANGE | 6000 | How far a pirate will look for a trader to rob. | hunt.pirate | [hunt-ranges.ts:14](./hunt-ranges.ts#L14) | @@ -193,18 +228,18 @@ search names, meanings and values with `npm run constants:find -- ""`. | law | FUGITIVE | 2 | The top rung: every police ship in the galaxy hunts a Fugitive. | law.fugitive | [law.ts:66](./law.ts#L66) | | law | STATION_TRUCE | 7000 | How close to the station the commander must be for the truce to hold. | law.stationTruce | [law.ts:94](./law.ts#L94) | | law | CONTRABAND | [3, 6, 10] | The commodity indices that the Galactic Government defines as illegal: slaves, narcotics and firearms. | | [law.ts:102](./law.ts#L102) | -| law | OFFENDER_FINE | 250 | The fine for a dock with a record, capped at what you can actually pay. | | [law.ts:108](./law.ts#L108) | -| law | FUGITIVE_FINE | 750 | | | [law.ts:109](./law.ts#L109) | -| law | KILLS_PER_RUNG | 5 | Pirate kills that take a legal record down one rung. | law.killsPerRung | [law.ts:140](./law.ts#L140) | -| law | DEFENCE_RANGE | 9000 | Misbehave within this range of the station slot, and the Vipers launch. | | [law.ts:148](./law.ts#L148) | -| law | SCAN_RANGE | 2600 | How close a police ship must be to scan your hold. | | [law.ts:151](./law.ts#L151) | -| law | SCAN_WARN_RANGE | 4400 | A police ship this close is about to be able to read your hold, and the console says so while it stays there. | | [law.ts:171](./law.ts#L171) | -| law | SCAN_WARN_REPEAT | 2 | Seconds between repeats of that warning, while a patrol stays in the band. | law.scanWarnRepeat | [law.ts:188](./law.ts#L188) | -| law | BRIBE_SHARE | 0.5 | What a policeman charges to not read your hold: this share of what the contraband aboard is worth at market. | law.bribeShare | [law.ts:217](./law.ts#L217) | -| law | BRIBE_FLOOR | 500 | ...but never less than this, so a light run is not a free pass. 50 Cr, in tenths of a credit (invariant 8). | law.bribeFloor | [law.ts:237](./law.ts#L237) | -| law | PATROL_BRIBE_FINES | 4 | What a police ship that already shoots at you charges to break off, as a multiple of the fine for the rung you are on. | law.patrolBribeFines | [law.ts:261](./law.ts#L261) | -| law | BRIBE_REFUSED | 0.35 | How often an HONEST commander's offer is refused and reported. | law.bribeRefused | [law.ts:293](./law.ts#L293) | -| law | SCAN_LINE_SECONDS | 4 | How long the scan's own line holds the console, and therefore how long the verdict that explains it waits behind it. | law.scanLineSeconds | [law.ts:308](./law.ts#L308) | +| law | OFFENDER_FINE | 250 | The fine for a dock with a record, capped at what you can actually pay. | law.offenderFine | [law.ts:110](./law.ts#L110) | +| law | FUGITIVE_FINE | 750 | ...and the fine for a fugitive, which is three times as much. | law.fugitiveFine | [law.ts:117](./law.ts#L117) | +| law | KILLS_PER_RUNG | 5 | Pirate kills that take a legal record down one rung. | law.killsPerRung | [law.ts:148](./law.ts#L148) | +| law | DEFENCE_RANGE | 9000 | Misbehave within this range of the station slot, and the Vipers launch. | | [law.ts:156](./law.ts#L156) | +| law | SCAN_RANGE | 2600 | How close a police ship must be to scan your hold. | | [law.ts:159](./law.ts#L159) | +| law | SCAN_WARN_RANGE | 4400 | A police ship this close is about to be able to read your hold, and the console says so while it stays there. | | [law.ts:179](./law.ts#L179) | +| law | SCAN_WARN_REPEAT | 2 | Seconds between repeats of that warning, while a patrol stays in the band. | law.scanWarnRepeat | [law.ts:196](./law.ts#L196) | +| law | BRIBE_SHARE | 0.5 | What a policeman charges to not read your hold: this share of what the contraband aboard is worth at market. | law.bribeShare | [law.ts:225](./law.ts#L225) | +| law | BRIBE_FLOOR | 500 | ...but never less than this, so a light run is not a free pass. 50 Cr, in tenths of a credit (invariant 8). | law.bribeFloor | [law.ts:245](./law.ts#L245) | +| law | PATROL_BRIBE_FINES | 4 | What a police ship that already shoots at you charges to break off, as a multiple of the fine for the rung you are on. | law.patrolBribeFines | [law.ts:269](./law.ts#L269) | +| law | BRIBE_REFUSED | 0.35 | How often an HONEST commander's offer is refused and reported. | law.bribeRefused | [law.ts:301](./law.ts#L301) | +| law | SCAN_LINE_SECONDS | 4 | How long the scan's own line holds the console, and therefore how long the verdict that explains it waits behind it. | law.scanLineSeconds | [law.ts:316](./law.ts#L316) | | living-galaxy | PRESSURE_DECAY | 0.12 | How fast price pressure decays back toward the 1984 baseline, per day. | | [living-galaxy.ts:9](./living-galaxy.ts#L9) | | living-galaxy | HEAT_DECAY | 0.06 | How fast talk about the player dies down, per day. | living.heatDecay | [living-galaxy.ts:24](./living-galaxy.ts#L24) | | living-galaxy | DANGER_DECAY | 0.015 | How fast a system's reputation for piracy fades, per day. | | [living-galaxy.ts:32](./living-galaxy.ts#L32) | @@ -216,40 +251,54 @@ search names, meanings and values with `npm run constants:find -- ""`. | market | SALE_NOTORIETY_REVENUE | 40_000 | What a sale does to your reputation. | | [market.ts:30](./market.ts#L30) | | market | SALE_NOTORIETY_CONTRABAND | 0.04 | Extra talk per tonne of CONTRABAND sold, on top of the takings. | | [market.ts:45](./market.ts#L45) | | market | SALE_NOTORIETY_MAX | 0.5 | The most heat that one sale can raise, out of the 0..1 bar that `LivingGalaxy` keeps. | market.saleNotorietyMax | [market.ts:61](./market.ts#L61) | -| missions | MISSION_KILL_THRESHOLD | 16 | Kills before the Navy considers you worth a word: 16, as the original demanded. | missions.killThreshold | [missions.ts:14](./missions.ts#L14) | -| missions | MISSION_HUNT_RANGE | { min: 30, max: 80 } as const | The Constrictor hides this far from where you are briefed, in tenths of a light year. | | [missions.ts:22](./missions.ts#L22) | -| missions | MISSION_COURIER_RANGE | { min: 50, max: 90 } as const | The courier run is longer: the plans matter more than your convenience. | | [missions.ts:25](./missions.ts#L25) | -| missions | CONSTRICTOR_BOUNTY | 25_000 | What a kill of the Constrictor pays — 2,500 Cr, in tenths of a credit. | | [missions.ts:28](./missions.ts#L28) | -| missions | COURIER_PAYMENT | 15_000 | ...and what a delivery of the plans pays: 1,500 Cr. | | [missions.ts:31](./missions.ts#L31) | -| missions | MISSION_LIVE_CAP | 3 | How many missions a commander can hold open at one time: three. | missions.liveCap | [missions.ts:47](./missions.ts#L47) | -| missions | MISSION_REOFFER_DAYS | 7 | Days before a finished side job is offered again: a week. | missions.reofferDays | [missions.ts:63](./missions.ts#L63) | -| missions | LEAD_RUMOUR_JUMPS | 5 | Inside this many jumps of a lead's world, the station talks about it. | missions.leadRumourJumps | [missions.ts:76](./missions.ts#L76) | -| missions | LEAD_NAG_DOCKS | 4 | Docks with no mission progress before a patron with a lead writes a second time. | missions.leadNagDocks | [missions.ts:88](./missions.ts#L88) | -| missions | SIDE_JOB_PAY | { hunt: 8_000, deliver: 3_000, recover: 4_000, rescue: 5_000, ambush: 6_000, smuggle: 7_000, escort: 6_000, scan: 2_500, } as const | What a side job pays, per verb, in tenths of a credit. | missions.sideJobPay | [missions.ts:102](./missions.ts#L102) | -| missions | RESCUE_SALVAGE_PAY | 1_500 | The lower fee a rescue pays when the pod is lost and the data still arrives, in tenths of a credit. | missions.rescueSalvagePay | [missions.ts:118](./missions.ts#L118) | -| missions | SIDE_JOB_RANGE | { min: 20, max: 70 } as const | How far a side job sends the commander, in tenths of a light year: two to seven. | missions.sideJobRange | [missions.ts:126](./missions.ts#L126) | -| missions | SIDE_JOB_DAYS | 14 | Days a side job allows before its deadline passes: two weeks. | missions.sideJobDays | [missions.ts:133](./missions.ts#L133) | -| missions | DEADLINE_WARNING_DAYS | 3 | How many days before a deadline the console starts to say how many are left. | missions.deadlineWarningDays | [missions.ts:144](./missions.ts#L144) | -| missions | SCAN_SECONDS | 20 | Seconds a scan target must stay under the scanner lock: twenty, which is a pass and a turn at a trader's speed. | missions.scanSeconds | [missions.ts:152](./missions.ts#L152) | -| missions | WATCH_CONE | 0.35 | How far off the centre of the view a scan's subject may sit and still count as watched, in radians: 0.35, which is twenty degrees. | missions.watchCone | [missions.ts:166](./missions.ts#L166) | -| missions | SMUGGLE_TONNES | 3 | Tonnes of the patron's goods on a smuggle job: three, which fits a Cobra's hold beside its own stock. | missions.smuggleTonnes | [missions.ts:178](./missions.ts#L178) | -| missions | ESCORT_ENEMY_ROLES | ['pirate', 'hunter', 'thargoid', 'thargon'] | The roles that count as an enemy near an escorted ship: the ones that prey. | | [missions.ts:187](./missions.ts#L187) | -| missions | TOUR_ARCS | 5 | How many arcs the tour holds: five, Chris's number (docs/TODO/190, item 192). | missions.tourArcs | [missions.ts:197](./missions.ts#L197) | -| missions | TOUR_STEP_JUMPS | { min: 4, max: 6 } as const | How far each arc's start world is from the one before it, in JUMPS on the full-tank graph: four to six. | missions.tourStepJumps | [missions.ts:210](./missions.ts#L210) | -| missions | ARC_HANDOVER_JUMPS | { min: 2, max: 4 } as const | How far an arc's final leg lies from the next arc's start world, in JUMPS: two to four. | missions.arcHandoverJumps | [missions.ts:219](./missions.ts#L219) | -| missions | ARC_PAY | { hunt: 12_000, deliver: 5_000, recover: 6_000, rescue: 8_000, ambush: 9_000, smuggle: 10_000, escort: 9_000, scan: 4_000, } as const | What an arc leg pays, per verb, in tenths of a credit. | missions.arcPay | [missions.ts:232](./missions.ts#L232) | -| missions | ARC_LEG_DAYS | 30 | Days an arc leg allows before its deadline passes: a month, twice a side job's fortnight, because an arc leg may be four jumps out. | missions.arcLegDays | [missions.ts:245](./missions.ts#L245) | +| mission-course | COURSE_WATCH_STANDOFF | 1200 | How far from a ship the scan course holds, in world units (docs/TODO/208 M2). | course.watchStandoff | [mission-course.ts:24](./mission-course.ts#L24) | +| mission-course | COURSE_ESCORT_STANDOFF | 600 | How far from its charge the escort course flies, in world units (docs/TODO/208 M2). | course.escortStandoff | [mission-course.ts:37](./mission-course.ts#L37) | +| mission-course | COURSE_ESCORT_CLOSING | 60 | How much faster than its charge the escort course may close, in world units a second, once it is inside three standoffs of it: sixty. | course.escortClosing | [mission-course.ts:52](./mission-course.ts#L52) | +| mission-course | ESCORT_LEASH | SCANNER_RANGE * 2 / 3 | How far the commander may fall behind their charge before it holds for them, in world units (docs/TODO/214 M3). | course.escortLeash | [mission-course.ts:68](./mission-course.ts#L68) | +| mission-course | COURSE_POLICE_CLEARANCE | SCAN_WARN_RANGE | How wide of a police ship the smuggling course flies, in world units (docs/TODO/208 M4). | | [mission-course.ts:81](./mission-course.ts#L81) | +| missions | MISSION_KILL_THRESHOLD | 16 | Kills before the Navy considers you worth a word: 16, as the original demanded. | missions.killThreshold | [missions.ts:17](./missions.ts#L17) | +| missions | MISSION_HUNT_RANGE | { min: 30, max: 80 } as const | The Constrictor hides this far from where you are briefed, in tenths of a light year. | | [missions.ts:25](./missions.ts#L25) | +| missions | MISSION_COURIER_RANGE | { min: 50, max: 90 } as const | The courier run is longer: the plans matter more than your convenience. | | [missions.ts:28](./missions.ts#L28) | +| missions | CONSTRICTOR_BOUNTY | 25_000 | What a kill of the Constrictor pays — 2,500 Cr, in tenths of a credit. | | [missions.ts:31](./missions.ts#L31) | +| missions | COURIER_PAYMENT | 15_000 | ...and what a delivery of the plans pays: 1,500 Cr. | missions.courierPayment | [missions.ts:38](./missions.ts#L38) | +| missions | MISSION_LIVE_CAP | 3 | How many missions a commander can hold open at one time: three. | missions.liveCap | [missions.ts:54](./missions.ts#L54) | +| missions | MISSION_REOFFER_DAYS | 7 | Days before a finished side job is offered again: a week. | missions.reofferDays | [missions.ts:70](./missions.ts#L70) | +| missions | LEAD_RUMOUR_JUMPS | 5 | Inside this many jumps of a lead's world, the station talks about it. | missions.leadRumourJumps | [missions.ts:83](./missions.ts#L83) | +| missions | LEAD_NAG_DOCKS | 4 | Docks with no mission progress before a patron with a lead writes a second time. | missions.leadNagDocks | [missions.ts:95](./missions.ts#L95) | +| missions | SIDE_JOB_PAY | { hunt: 8_000, deliver: 3_000, recover: 4_000, rescue: 5_000, ambush: 6_000, smuggle: 7_000, escort: 6_000, scan: 2_500, } as const | What a side job pays, per verb, in tenths of a credit. | missions.sideJobPay | [missions.ts:109](./missions.ts#L109) | +| missions | GANG_BOUNTY | 15_000 | What the station pays for the whole gang on the side hunt, in tenths of a credit: 1,500 Cr (docs/TODO/217 M1). | missions.gangBounty | [missions.ts:123](./missions.ts#L123) | +| missions | GANG_BROKEN_BOUNTY | GANG_BOUNTY / 2 | ...and half of it when the gang is gone but its leader ran (docs/TODO/217 M1). | missions.gangBrokenBounty | [missions.ts:131](./missions.ts#L131) | +| missions | GANG_HUNT_KILLS | RATINGS[1][0] | Kills before the board offers the gang hunt (docs/TODO/217 M2). | missions.gangHuntKills | [missions.ts:142](./missions.ts#L142) | +| missions | LANE_JOB_KILLS | GANG_HUNT_KILLS / 2 | Kills before the board offers a lane to clear or a trader to cover: half the gang hunt's (docs/TODO/217 M2). | missions.laneJobKills | [missions.ts:153](./missions.ts#L153) | +| missions | WHEEL_WHISPER_RUNG | RATINGS.findIndex(([, name]) => name === 'Above Average') | The rung the Dark Wheel's first whisper waits for (docs/TODO/219 M1): Above Average, 128 kills. | missions.wheelWhisperRung | [missions.ts:162](./missions.ts#L162) | +| missions | WHEEL_DOOR_RUNG | RATINGS.findIndex(([, name]) => name === 'Competent') | The rung the Wheel's door waits for (docs/TODO/219 M4): Competent, 512 kills. | missions.wheelDoorRung | [missions.ts:171](./missions.ts#L171) | +| missions | LAWLESS_GOVERNMENTS | ['Anarchy', 'Feudal'] | The governments whose boards carry the Wheel's word (docs/TODO/219 M1), by their 1984 names. | missions.lawlessGovernments | [missions.ts:184](./missions.ts#L184) | +| missions | WHEEL_PAY | { mark: 20_000, blockade: 20_000, pilot: 25_000 } as const | What a Wheel trial pays, in tenths of a credit (docs/TODO/219 M1). | missions.wheelPay | [missions.ts:193](./missions.ts#L193) | +| missions | WITCHSPACE_TARGET | -1 | The target a leg placed in witchspace carries (docs/TODO/219 M3). | missions.witchspaceTarget | [missions.ts:204](./missions.ts#L204) | +| missions | RESCUE_SALVAGE_PAY | 1_500 | The lower fee a rescue pays when the pod is lost and the data still arrives, in tenths of a credit. | missions.rescueSalvagePay | [missions.ts:217](./missions.ts#L217) | +| missions | SIDE_JOB_RANGE | { min: 20, max: 70 } as const | How far a side job sends the commander, in tenths of a light year: two to seven. | missions.sideJobRange | [missions.ts:225](./missions.ts#L225) | +| missions | SIDE_JOB_DAYS | 14 | Days a side job allows before its deadline passes: two weeks. | missions.sideJobDays | [missions.ts:232](./missions.ts#L232) | +| missions | DEADLINE_WARNING_DAYS | 3 | How many days before a deadline the console starts to say how many are left. | missions.deadlineWarningDays | [missions.ts:243](./missions.ts#L243) | +| missions | SCAN_SECONDS | 20 | Seconds a scan target must stay under the scanner lock: twenty, which is a pass and a turn at a trader's speed. | missions.scanSeconds | [missions.ts:251](./missions.ts#L251) | +| missions | WATCH_CONE | 0.35 | How far off the centre of the view a scan's subject may sit and still count as watched, in radians: 0.35, which is twenty degrees. | missions.watchCone | [missions.ts:265](./missions.ts#L265) | +| missions | SMUGGLE_TONNES | 3 | Tonnes of the patron's goods on a smuggle job: three, which fits a Cobra's hold beside its own stock. | missions.smuggleTonnes | [missions.ts:277](./missions.ts#L277) | +| missions | ESCORT_ENEMY_ROLES | ['pirate', 'hunter', 'thargoid', 'thargon'] | The roles that count as an enemy near an escorted ship: the ones that prey. | | [missions.ts:286](./missions.ts#L286) | +| missions | TOUR_ARCS | 5 | How many arcs the tour holds: five, Chris's number (docs/TODO/190, item 192). | missions.tourArcs | [missions.ts:296](./missions.ts#L296) | +| missions | TOUR_STEP_JUMPS | { min: 4, max: 6 } as const | How far each arc's start world is from the one before it, in JUMPS on the full-tank graph: four to six. | missions.tourStepJumps | [missions.ts:309](./missions.ts#L309) | +| missions | ARC_HANDOVER_JUMPS | { min: 2, max: 4 } as const | How far an arc's final leg lies from the next arc's start world, in JUMPS: two to four. | missions.arcHandoverJumps | [missions.ts:318](./missions.ts#L318) | +| missions | ARC_PAY | { hunt: 12_000, deliver: 5_000, recover: 6_000, rescue: 8_000, ambush: 9_000, smuggle: 10_000, escort: 9_000, scan: 4_000, } as const | What an arc leg pays, per verb, in tenths of a credit. | missions.arcPay | [missions.ts:331](./missions.ts#L331) | +| missions | ARC_LEG_DAYS | 30 | Days an arc leg allows before its deadline passes: a month, twice a side job's fortnight, because an arc leg may be four jumps out. | missions.arcLegDays | [missions.ts:344](./missions.ts#L344) | | npc-gun | NPC_LASER_RANGE | LASER_RANGE | How far an NPC can shoot: the player's reach. | | [npc-gun.ts:17](./npc-gun.ts#L17) | | npc-gun | NPC_COOLDOWN_LO | 0.9 | Time between an NPC's shots. | | [npc-gun.ts:31](./npc-gun.ts#L31) | | npc-gun | NPC_COOLDOWN_SPREAD | 0.8 | | | [npc-gun.ts:32](./npc-gun.ts#L32) | | npc-gun | NPC_MEAN_COOLDOWN | NPC_COOLDOWN_LO + NPC_COOLDOWN_SPREAD / 2 | What a shot costs a gun that never waits to be aimed: the LO plus half the spread, because `npcTriggerPull` draws uniformly across it. | | [npc-gun.ts:44](./npc-gun.ts#L44) | -| npc-gun | NPC_FIRE_GATE | 0.25 | How near the nose a target must be before an NPC pulls the trigger. | | [npc-gun.ts:61](./npc-gun.ts#L61) | -| npc-gun | THARGOID_FIRE_RATE | 0.7 | Thargoids reload faster than anything else in the galaxy. | npc.thargoidFireRate | [npc-gun.ts:73](./npc-gun.ts#L73) | -| npc-gun | NPC_HIT_BASE | 0.9 | Hit chance falls off with range, clamped at both ends. | | [npc-gun.ts:76](./npc-gun.ts#L76) | -| npc-gun | NPC_HIT_FALLOFF | NPC_LASER_RANGE | The slope of the falloff. | | [npc-gun.ts:84](./npc-gun.ts#L84) | -| npc-gun | NPC_HIT_CAP | 0.85 | | | [npc-gun.ts:85](./npc-gun.ts#L85) | -| npc-gun | NPC_HIT_FLOOR | 0.15 | The far end of that curve. | npc.hitFloor | [npc-gun.ts:95](./npc-gun.ts#L95) | -| npc-gun | NPC_VS_NPC_HIT | 0.5 | Whether one ship's shot at another connects: a coin flip. | | [npc-gun.ts:102](./npc-gun.ts#L102) | +| npc-gun | NPC_FIRE_GATE | 0.25 | How near the nose a target must be before an NPC pulls the trigger. | npcgun.fireGate | [npc-gun.ts:64](./npc-gun.ts#L64) | +| npc-gun | THARGOID_FIRE_RATE | 0.7 | Thargoids reload faster than anything else in the galaxy. | npc.thargoidFireRate | [npc-gun.ts:76](./npc-gun.ts#L76) | +| npc-gun | NPC_HIT_BASE | 0.9 | Hit chance falls off with range, clamped at both ends. | | [npc-gun.ts:79](./npc-gun.ts#L79) | +| npc-gun | NPC_HIT_FALLOFF | NPC_LASER_RANGE | The slope of the falloff. | | [npc-gun.ts:87](./npc-gun.ts#L87) | +| npc-gun | NPC_HIT_CAP | 0.85 | | | [npc-gun.ts:88](./npc-gun.ts#L88) | +| npc-gun | NPC_HIT_FLOOR | 0.15 | The far end of that curve. | npc.hitFloor | [npc-gun.ts:98](./npc-gun.ts#L98) | +| npc-gun | NPC_VS_NPC_HIT | 0.5 | Whether one ship's shot at another connects: a coin flip. | | [npc-gun.ts:105](./npc-gun.ts#L105) | | opposition-ring | OPPOSITION_RANGE | 3200 | The default ring radius, in units. | | [opposition-ring.ts:19](./opposition-ring.ts#L19) | | opposition-ring | OPPOSITION_RANGE_MAX | 20_000 | A ceiling on the ring radius. | | [opposition-ring.ts:26](./opposition-ring.ts#L26) | | opposition-ring | OPPOSITION_CONE | 0.5 | Half-angle of the cone, in radians, when a facing is known and the caller says no more. | | [opposition-ring.ts:35](./opposition-ring.ts#L35) | @@ -263,22 +312,22 @@ search names, meanings and values with `npm run constants:find -- ""`. | ordnance | MISSILE_LIFE | 25 | How long a missile lives before it gives up and detonates. | | [ordnance.ts:16](./ordnance.ts#L16) | | ordnance | HOSTILE_MISSILE_LIFE | 30 | A hostile missile lives longer, because it has further to come. | ordnance.hostileMissileLife | [ordnance.ts:26](./ordnance.ts#L26) | | ordnance | MISSILE_TURN | 2.5 | Turn rate while it homes, radians per second. | | [ordnance.ts:28](./ordnance.ts#L28) | -| ordnance | MISSILE_HIT_RANGE | 50 | Close enough to detonate. | | [ordnance.ts:30](./ordnance.ts#L30) | -| ordnance | LOCK_CONE | 0.09 | Lock cone: how near the crosshair a ship must be to be locked. | | [ordnance.ts:33](./ordnance.ts#L33) | -| ordnance | LOCK_RANGE | 5500 | ...and how far away it may be. | | [ordnance.ts:35](./ordnance.ts#L35) | -| ordnance | MISSILE_MAX_RANGE | 3200 | The far edge of the seeker's envelope. | | [ordnance.ts:40](./ordnance.ts#L40) | -| ordnance | MISSILE_LAST_STAND_HULL | 0.4 | The hull fraction below which a ship stops saving its missiles for later. | ordnance.missileLastStandHull | [ordnance.ts:51](./ordnance.ts#L51) | -| ordnance | MISSILE_LAST_STAND_GATE | Math.PI / 2 | ...and it launches on a bearing rather than on a firing line. | | [ordnance.ts:56](./ordnance.ts#L56) | -| ordnance | MISSILE_LAST_STAND_MIN_RANGE | 250 | Desperation widens the envelope INWARD, but not all the way. | | [ordnance.ts:62](./ordnance.ts#L62) | -| ordnance | MISSILE_RELOAD | 2 | Gap between launches, so a Python does not empty both rails in one frame. | ordnance.missileReload | [ordnance.ts:71](./ordnance.ts#L71) | -| ordnance | MISSILE_COMMIT_PASSES | 2 | How many passes a ship makes before it accepts that this is not going its way. | ordnance.missileCommitPasses | [ordnance.ts:83](./ordnance.ts#L83) | -| ordnance | ECM_RANGE | 2800 | A target with an E.C.M. fries an incoming missile inside this. | | [ordnance.ts:88](./ordnance.ts#L88) | -| ordnance | ECM_RATE | 0.45 | ...at this chance per second. | | [ordnance.ts:90](./ordnance.ts#L90) | -| ordnance | ECM_ENERGY_COST | ENERGY_BANK_POINTS | A shot of the E.C.M. costs one bank of energy. | | [ordnance.ts:96](./ordnance.ts#L96) | -| ordnance | ENERGY_BOMB_RANGE | 8000 | The energy bomb reaches this far. | | [ordnance.ts:99](./ordnance.ts#L99) | -| pass-aim | PASS_MISS_DISTANCE | 110 | How far to the SIDE of its target a ship aims its attack run. 110 clears the largest pirate hull, plus the commander's radius, twice over. | | [pass-aim.ts:13](./pass-aim.ts#L13) | -| pass-aim | MAX_LEAD_SECONDS | 0.5 | The furthest ahead of a target that a ship will aim, in seconds. | | [pass-aim.ts:21](./pass-aim.ts#L21) | -| pass-aim | MAX_MISS_STRETCH | 3 | The most that geometry may stretch the aim, in multiples of the intended pass. | passaim.maxMissStretch | [pass-aim.ts:31](./pass-aim.ts#L31) | +| ordnance | MISSILE_HIT_RANGE | 50 | Close enough to detonate. * | ordnance.missileHitRange | [ordnance.ts:32](./ordnance.ts#L32) | +| ordnance | LOCK_CONE | 0.09 | Lock cone: how near the crosshair a ship must be to be locked. | | [ordnance.ts:35](./ordnance.ts#L35) | +| ordnance | LOCK_RANGE | 5500 | ...and how far away it may be. | | [ordnance.ts:37](./ordnance.ts#L37) | +| ordnance | MISSILE_MAX_RANGE | 3200 | The far edge of the seeker's envelope. | | [ordnance.ts:42](./ordnance.ts#L42) | +| ordnance | MISSILE_LAST_STAND_HULL | 0.4 | The hull fraction below which a ship stops saving its missiles for later. | ordnance.missileLastStandHull | [ordnance.ts:53](./ordnance.ts#L53) | +| ordnance | MISSILE_LAST_STAND_GATE | Math.PI / 2 | ...and it launches on a bearing rather than on a firing line. | | [ordnance.ts:58](./ordnance.ts#L58) | +| ordnance | MISSILE_LAST_STAND_MIN_RANGE | 250 | Desperation widens the envelope INWARD, but not all the way. | ordnance.missileLastStandMinRange | [ordnance.ts:66](./ordnance.ts#L66) | +| ordnance | MISSILE_RELOAD | 2 | Gap between launches, so a Python does not empty both rails in one frame. | ordnance.missileReload | [ordnance.ts:75](./ordnance.ts#L75) | +| ordnance | MISSILE_COMMIT_PASSES | 2 | How many passes a ship makes before it accepts that this is not going its way. | ordnance.missileCommitPasses | [ordnance.ts:87](./ordnance.ts#L87) | +| ordnance | ECM_RANGE | 2800 | A target with an E.C.M. fries an incoming missile inside this. | | [ordnance.ts:92](./ordnance.ts#L92) | +| ordnance | ECM_RATE | 0.45 | ...at this chance per second. | | [ordnance.ts:94](./ordnance.ts#L94) | +| ordnance | ECM_ENERGY_COST | ENERGY_BANK_POINTS | A shot of the E.C.M. costs one bank of energy. | | [ordnance.ts:100](./ordnance.ts#L100) | +| ordnance | ENERGY_BOMB_RANGE | 8000 | The energy bomb reaches this far. | | [ordnance.ts:103](./ordnance.ts#L103) | +| pass-aim | PASS_MISS_DISTANCE | 110 | How far to the SIDE of its target a ship aims its attack run. 110 clears the largest pirate hull, plus the commander's radius, twice over. | | [pass-aim.ts:18](./pass-aim.ts#L18) | +| pass-aim | MAX_LEAD_SECONDS | 0.5 | The furthest ahead of a target that a ship will aim, in seconds. | | [pass-aim.ts:32](./pass-aim.ts#L32) | +| pass-aim | MAX_MISS_STRETCH | 3 | The most that geometry may stretch the aim, in multiples of the intended pass. | passaim.maxMissStretch | [pass-aim.ts:42](./pass-aim.ts#L42) | | planet | WITCHPOINT_RADII | 16 | How far out of the planet you drop from witch-space, in planet radii. | planet.witchpointRadii | [planet.ts:15](./planet.ts#L15) | | planet | PLANET_CRASH_ALTITUDE | 80 | The altitude above the surface at which the ship is destroyed. | | [planet.ts:24](./planet.ts#L24) | | player-flight | PLAYER_FLIGHT | { maxSpeed: 400, accel: 220, maxRoll: 2.5, maxPitch: 1.45, rateRamp: 4.1396, rateDecay: 13.3886, } as const | The player's flight envelope, in one place that a harness can read. | flight.player.maxSpeed
    flight.player.rateRamp | [player-flight.ts:21](./player-flight.ts#L21) | @@ -295,7 +344,7 @@ search names, meanings and values with `npm run constants:find -- ""`. | player-gun | ASSIST_FADE_START | 900 | Where the aim assist begins to fade out, in world units. | gun.assistFadeStart | [player-gun.ts:157](./player-gun.ts#L157) | | player-gun | ASSIST_FADE_END | 2400 | | | [player-gun.ts:158](./player-gun.ts#L158) | | player-interest | PLAYER_INTEREST_RANGE | 9000 | A hostile closer than this is engaged with you. | | [player-interest.ts:21](./player-interest.ts#L21) | -| player-interest | TURN_AND_FIGHT_RANGE | 6000 | How close the commander must be before an armed trader on the run turns and fights her, rather than whoever else is shooting at it. | interest.turnAndFight | [player-interest.ts:39](./player-interest.ts#L39) | +| player-interest | TURN_AND_FIGHT_RANGE | 6000 | How close the commander must be before an armed trader on the run turns and fights them, rather than whoever else is shooting at it. | interest.turnAndFight | [player-interest.ts:39](./player-interest.ts#L39) | | pools | MAX_ENERGY | 255 | Released capacity of every flyable hull's energy bank and each shield. | | [pools.ts:6](./pools.ts#L6) | | pools | MAX_SHIELD | 255 | | | [pools.ts:7](./pools.ts#L7) | | pools | ENERGY_BANKS | 4 | How many banks the console reads the energy pool as. | pools.energyBanks | [pools.ts:14](./pools.ts#L14) | @@ -309,7 +358,7 @@ search names, meanings and values with `npm run constants:find -- ""`. | population | GENERATION_SHIP_CHANCE | 0.08 | The chance that a generation ship crosses, on arrival only. | | [population.ts:69](./population.ts#L69) | | population | ASTEROIDS_MIN | 2 | The fewest rocks a system holds. | population.asteroidsMin | [population.ts:81](./population.ts#L81) | | population | ASTEROIDS_VARIATION | 3 | ...and how many more it holds, drawn flat: `ASTEROIDS_MIN` plus 0, 1 or 2. | population.asteroidsVariation | [population.ts:90](./population.ts#L90) | -| population | GENERATION_SIGHT_RANGE | 6000 | How near a derelict generation ship the commander must be to notice it. | population.generationSight | [population.ts:106](./population.ts#L106) | +| population | GENERATION_SIGHT_RANGE | 6000 | How near a derelict generation ship the commander must be to notice it. | population.generationSight | [population.ts:107](./population.ts#L107) | | rating | RATINGS | [ [0, 'Harmless'], [8, 'Mostly Harmless'], [16, 'Poor'], [32, 'Below Average'], [64, 'Average'], [128, 'Above Average'], [512, 'Competent'], [2560, 'Dangerous'], [6400, 'Deadly'], [25600, 'E L I T E'], ] | Score thresholds and the name that each one earns, lowest first. | | [rating.ts:23](./rating.ts#L23) | | recharge | ENERGY_REGEN_FRACTION | 0.025 | The fraction of a full pool that a Cobra Mk III recovers each second. | | [recharge.ts:41](./recharge.ts#L41) | | recharge | SHIELD_REGEN_FRACTION | 0.012 | The shield's half of the pair above, and the one docs/TODO/139 moved. | | [recharge.ts:44](./recharge.ts#L44) | @@ -323,46 +372,47 @@ search names, meanings and values with `npm run constants:find -- ""`. | saves | MAX_SAVE_NAME | 16 | The longest name a player may type. 16, because that is what the list column holds without a wrap, and what keeps an id short. | saves.maxSaveName | [saves.ts:68](./saves.ts#L68) | | scoop | SCOOP_RANGE | 45 | How close the commander must fly to scoop a drifting object, in world units. | | [scoop.ts:17](./scoop.ts#L17) | | separation | SEPARATION_RANGE | 200 | How near a wingman has to be before a ship cares, in world units. | separation.range | [separation.ts:11](./separation.ts#L11) | -| separation | SEPARATION_PUSH | 120 | How hard a ship that closes bends its aim to avoid a mate, in units of offset. | | [separation.ts:17](./separation.ts#L17) | +| separation | SEPARATION_PUSH | 120 | How hard a ship that closes bends its aim to avoid a mate, in units of offset. | separation.push | [separation.ts:19](./separation.ts#L19) | | shop | FUEL_PRICE | 0.4 | What a refuel costs, in tenths of a credit per tenth of a LY. | shop.fuelPrice | [shop.ts:19](./shop.ts#L19) | | shop | PULSE_LASER_PRICE | 4000 | What a pulse laser costs, wherever it is mounted. | | [shop.ts:27](./shop.ts#L27) | | shop | BEAM_LASER_PRICE | 10000 | The beam laser's price. | | [shop.ts:34](./shop.ts#L34) | | shop | EQUIPMENT_CATALOGUE | [ { id: 'missile', name: 'Missile', price: 300, minTL: 1 }, { id: 'largeBay', name: `Large Cargo Bay (${LARGE_BAY_TONNES}t)`, price: 4000, minTL: 1 }, { id: 'ecm', name: 'E.C.M. System', price: 6000, minTL: 2 }, { id: 'rearLaser', name: 'Rear Pulse Laser', price: PULSE_LASER_PRICE, minTL: 3 }, { id: 'leftLaser', name: 'Left Pulse Laser', price: PULSE_LASER_PRICE, minTL: 3 }, { id: 'rightLaser', name: 'Right Pulse Laser', price: PULSE_LASER_PRICE, minTL: 3 }, { id: 'beam', name: 'Beam Laser', price: BEAM_LASER_PRICE, minTL: 4 }, { id: 'scoops', name: 'Fuel Scoops', price: 5250, minTL: 5 }, { id: 'escapePod', name: 'Escape Pod', price: 10000, minTL: 6 }, { id: 'energyBomb', name: 'Energy Bomb', price: 9000, minTL: 7 }, { id: 'energyUnit', name: 'Extra Energy Unit', price: 15000, minTL: 8 }, { id: 'dockingComputer', name: 'Docking Computer', price: 15000, minTL: 9 }, { id: 'miningLaser', name: 'Mining Laser', price: 8000, minTL: 10 }, { id: 'combatComputer', name: 'Combat Computer', price: 20000, minTL: 9 }, { id: 'trumble', name: 'Trumble (adorable, harmless*)', price: 20, minTL: 1 }, { id: 'military', name: 'Military Laser', price: 60000, minTL: 10 }, { id: 'galacticDrive', name: 'Galactic Hyperdrive', price: 50000, minTL: 10 }, ] | The outfitter's shelf, in the order the screen lists it. | | [shop.ts:49](./shop.ts#L49) | | spawn-placement | TRADER_SCATTER | 1800 | How far from the station a trader on its run loiters. | | [spawn-placement.ts:25](./spawn-placement.ts#L25) | -| spawn-placement | POLICE_SCATTER | 1200 | How far off the arrival corridor the police scatter. | | [spawn-placement.ts:33](./spawn-placement.ts#L33) | -| spawn-placement | POLICE_PATROL_RANGE | 18_000 | How far a launch scatters the patrol across the system. | | [spawn-placement.ts:41](./spawn-placement.ts#L41) | -| spawn-placement | ASTEROID_SCATTER | 5000 | How far the rocks scatter round the station. | | [spawn-placement.ts:55](./spawn-placement.ts#L55) | -| spawn-placement | ASTEROID_LANE_SCATTER | SCANNER_RANGE / 1.5 | How far off the arrival corridor each rock sits. | | [spawn-placement.ts:73](./spawn-placement.ts#L73) | -| spawn-placement | HUNTER_SCATTER | 6000 | How far out a bounty hunter starts, at work on the whole system. | spawn.hunterScatter | [spawn-placement.ts:83](./spawn-placement.ts#L83) | -| spawn-placement | HERMIT_SCATTER | 14_000 | How far out the rock hermit hides. | | [spawn-placement.ts:97](./spawn-placement.ts#L97) | -| spawn-placement | CORRIDOR_START | 0.1 | Where along the route from you to the station the nearest pirate can be, as a fraction of that route. | spawn.corridorStart | [spawn-placement.ts:111](./spawn-placement.ts#L111) | -| spawn-placement | CORRIDOR_SPAN | 0.75 | How much of the route the rest are spread across. 0.1 + 0.75 leaves the last 15% clear, which is the approach to the station. | | [spawn-placement.ts:117](./spawn-placement.ts#L117) | -| spawn-placement | PIRATE_SCATTER | 2500 | How far off the corridor's line each pirate sits. | spawn.pirateScatter | [spawn-placement.ts:130](./spawn-placement.ts#L130) | -| spawn-placement | TRADER_ARRIVAL_RANGE | 22_000 | How far out a fresh trader warps in. | | [spawn-placement.ts:137](./spawn-placement.ts#L137) | -| spawn-placement | DEEP_TRADER_RANGE | 12_000 | How far AHEAD OF THE COMMANDER a trader warps in, out in deep space. | spawn.deepTraderRange | [spawn-placement.ts:150](./spawn-placement.ts#L150) | -| spawn-placement | DEEP_TRADER_CONE | Math.asin(MASS_LOCK_SHIP / DEEP_TRADER_RANGE) | The half-angle of the cone it warps into, about the commander's own heading, in RADIANS. 0.5 is about 29 degrees. | spawn.deepTraderCone | [spawn-placement.ts:177](./spawn-placement.ts#L177) | -| spawn-placement | DEEP_TRADER_RUN | 30_000 | How far it runs before it jumps out. | | [spawn-placement.ts:196](./spawn-placement.ts#L196) | -| spawn-placement | PIRATE_WAVE_RANGE | 9000 | How far from the commander a pirate wave warps in. | | [spawn-placement.ts:204](./spawn-placement.ts#L204) | -| spawn-placement | PIRATE_WAVE_RANGE_SPAN | 4000 | ...and how much further out than that they may be. | | [spawn-placement.ts:207](./spawn-placement.ts#L207) | -| spawn-placement | GENERATION_SHIP_RANGE | 14_000 | How far from the commander a generation ship crosses. | | [spawn-placement.ts:213](./spawn-placement.ts#L213) | -| spawn-placement | GENERATION_SHIP_RANGE_SPAN | 8000 | ...and the width of that band. | | [spawn-placement.ts:216](./spawn-placement.ts#L216) | -| spawn-placement | GENERATION_CARGO_SCATTER | 700 | How far from a generation ship its shed cargo drifts. | | [spawn-placement.ts:223](./spawn-placement.ts#L223) | -| spawn-placement | MISSION_TARGET_RANGE | 2500 | How far from the commander a mission's target waits on arrival. | spawn.missionTargetRange | [spawn-placement.ts:238](./spawn-placement.ts#L238) | -| spawn-placement | MISSION_TARGET_RANGE_SPAN | 2000 | ...and the width of that band, so a target sits between 2,500 and 4,500 out. | | [spawn-placement.ts:241](./spawn-placement.ts#L241) | -| spawn-placement | THARGON_DEPLOY_RANGE | 150 | How far from its mother a Thargon drone appears. | | [spawn-placement.ts:247](./spawn-placement.ts#L247) | -| spawn-placement | STATION_DEFENCE_MIN | 1 | The fewest Vipers the station launches after you shoot at something you should not. | spawn.stationDefenceMin | [spawn-placement.ts:260](./spawn-placement.ts#L260) | -| spawn-placement | STATION_DEFENCE_SPAN | 2 | ...and the width of that draw: one or two of them. | spawn.stationDefenceSpan | [spawn-placement.ts:271](./spawn-placement.ts#L271) | -| spawn-placement | STATION_DEFENCE_STANDOFF | 500 | How far out of the slot the first one launches. | | [spawn-placement.ts:277](./spawn-placement.ts#L277) | -| spawn-placement | STATION_DEFENCE_STACK | 120 | ...and how much further out each one after it starts, so a pair does not arrive inside each other. | | [spawn-placement.ts:283](./spawn-placement.ts#L283) | -| spawn-placement | STATION_DEFENCE_JITTER | 80 | ...and the random nudge on each, so a second launch does not look like the first. | | [spawn-placement.ts:293](./spawn-placement.ts#L293) | -| spawn-placement | TRADER_ARRIVED | 900 | How near the station an arriving trader has to be to start trading. | spawn.traderArrived | [spawn-placement.ts:308](./spawn-placement.ts#L308) | -| spawn-placement | TRADER_JUMP_OUT | 2500 | How near its waypoint a departing trader has to be to jump out. | spawn.traderJumpOut | [spawn-placement.ts:324](./spawn-placement.ts#L324) | +| spawn-placement | POLICE_SCATTER | 1200 | How far off the arrival corridor the police scatter. | spawn.policeScatter | [spawn-placement.ts:35](./spawn-placement.ts#L35) | +| spawn-placement | POLICE_PATROL_RANGE | 18_000 | How far a launch scatters the patrol across the system. | | [spawn-placement.ts:43](./spawn-placement.ts#L43) | +| spawn-placement | ASTEROID_SCATTER | 5000 | How far the rocks scatter round the station. | | [spawn-placement.ts:57](./spawn-placement.ts#L57) | +| spawn-placement | ASTEROID_LANE_SCATTER | SCANNER_RANGE / 1.5 | How far off the arrival corridor each rock sits. | | [spawn-placement.ts:75](./spawn-placement.ts#L75) | +| spawn-placement | HUNTER_SCATTER | 6000 | How far out a bounty hunter starts, at work on the whole system. | spawn.hunterScatter | [spawn-placement.ts:85](./spawn-placement.ts#L85) | +| spawn-placement | HERMIT_SCATTER | 14_000 | How far out the rock hermit hides. | | [spawn-placement.ts:99](./spawn-placement.ts#L99) | +| spawn-placement | CORRIDOR_START | 0.1 | Where along the route from you to the station the nearest pirate can be, as a fraction of that route. | spawn.corridorStart | [spawn-placement.ts:113](./spawn-placement.ts#L113) | +| spawn-placement | CORRIDOR_SPAN | 0.75 | How much of the route the rest are spread across. 0.1 + 0.75 leaves the last 15% clear, which is the approach to the station. | | [spawn-placement.ts:119](./spawn-placement.ts#L119) | +| spawn-placement | PIRATE_SCATTER | 2500 | How far off the corridor's line each pirate sits. | spawn.pirateScatter | [spawn-placement.ts:132](./spawn-placement.ts#L132) | +| spawn-placement | TRADER_ARRIVAL_RANGE | 22_000 | How far out a fresh trader warps in. | | [spawn-placement.ts:139](./spawn-placement.ts#L139) | +| spawn-placement | DEEP_TRADER_RANGE | 12_000 | How far AHEAD OF THE COMMANDER a trader warps in, out in deep space. | spawn.deepTraderRange | [spawn-placement.ts:152](./spawn-placement.ts#L152) | +| spawn-placement | DEEP_TRADER_CONE | Math.asin(MASS_LOCK_SHIP / DEEP_TRADER_RANGE) | The half-angle of the cone it warps into, about the commander's own heading, in RADIANS. 0.5 is about 29 degrees. | spawn.deepTraderCone | [spawn-placement.ts:179](./spawn-placement.ts#L179) | +| spawn-placement | DEEP_TRADER_RUN | 30_000 | How far it runs before it jumps out. | | [spawn-placement.ts:198](./spawn-placement.ts#L198) | +| spawn-placement | PIRATE_WAVE_RANGE | 9000 | How far from the commander a pirate wave warps in. | | [spawn-placement.ts:206](./spawn-placement.ts#L206) | +| spawn-placement | PIRATE_WAVE_RANGE_SPAN | 4000 | ...and how much further out than that they may be. | | [spawn-placement.ts:209](./spawn-placement.ts#L209) | +| spawn-placement | GENERATION_SHIP_RANGE | 14_000 | How far from the commander a generation ship crosses. | | [spawn-placement.ts:215](./spawn-placement.ts#L215) | +| spawn-placement | GENERATION_SHIP_RANGE_SPAN | 8000 | ...and the width of that band. | | [spawn-placement.ts:218](./spawn-placement.ts#L218) | +| spawn-placement | GENERATION_CARGO_SCATTER | 700 | How far from a generation ship its shed cargo drifts. | | [spawn-placement.ts:225](./spawn-placement.ts#L225) | +| spawn-placement | MISSION_TARGET_RANGE | 2500 | How far from the commander a mission's target waits on arrival. | spawn.missionTargetRange | [spawn-placement.ts:240](./spawn-placement.ts#L240) | +| spawn-placement | MISSION_TARGET_RANGE_SPAN | 2000 | ...and the width of that band, so a target sits between 2,500 and 4,500 out. | | [spawn-placement.ts:243](./spawn-placement.ts#L243) | +| spawn-placement | THARGON_DEPLOY_RANGE | 150 | How far from its mother a Thargon drone appears. | | [spawn-placement.ts:249](./spawn-placement.ts#L249) | +| spawn-placement | STATION_DEFENCE_MIN | 1 | The fewest Vipers the station launches after you shoot at something you should not. | spawn.stationDefenceMin | [spawn-placement.ts:262](./spawn-placement.ts#L262) | +| spawn-placement | STATION_DEFENCE_SPAN | 2 | ...and the width of that draw: one or two of them. | spawn.stationDefenceSpan | [spawn-placement.ts:273](./spawn-placement.ts#L273) | +| spawn-placement | STATION_DEFENCE_STANDOFF | 500 | How far out of the slot the first one launches. | | [spawn-placement.ts:279](./spawn-placement.ts#L279) | +| spawn-placement | STATION_DEFENCE_STACK | 120 | ...and how much further out each one after it starts, so a pair does not arrive inside each other. | spawn.stationDefenceStack | [spawn-placement.ts:287](./spawn-placement.ts#L287) | +| spawn-placement | STATION_DEFENCE_JITTER | 80 | ...and the random nudge on each, so a second launch does not look like the first. | | [spawn-placement.ts:297](./spawn-placement.ts#L297) | +| spawn-placement | TRADER_ARRIVED | 900 | How near the station an arriving trader has to be to start trading. | spawn.traderArrived | [spawn-placement.ts:312](./spawn-placement.ts#L312) | +| spawn-placement | TRADER_JUMP_OUT | 2500 | How near its waypoint a departing trader has to be to jump out. | spawn.traderJumpOut | [spawn-placement.ts:328](./spawn-placement.ts#L328) | +| spawn-placement | SPAWN_PLANET_ALTITUDE | 1000 | The lowest a ship may appear above the planet's surface, measured to the ship's centre, in world units. | spawn.planetAltitude | [spawn-placement.ts:347](./spawn-placement.ts#L347) | | station | STATION_SPIN | 0.26 | How fast the station spins about its slot axis, in radians a second. | | [station.ts:19](./station.ts#L19) | | station | DODO_TECH_LEVEL | 10 | The tech level at which a system's station is the dodecahedral Dodo rather than the Coriolis, in SHOWN one-based units. | | [station.ts:32](./station.ts#L32) | | station | BOUNCE_STANDOFF | 420 | Where a fluffed docking bounces you to. | | [station.ts:48](./station.ts#L48) | | station | LAUNCH_STANDOFF | 450 | How far off the slot you sit when the bay spits you out, in world units. | | [station.ts:51](./station.ts#L51) | -| station | LAUNCH_SPEED | 120 | ...and how fast, in world units a second — a firm push, not a cruise. | | [station.ts:54](./station.ts#L54) | -| station | DOCKED_BACKDROP_DISTANCE | 900 | Where the docked menu parks your ship, along the slot normal, in world units. | station.backdropDistance | [station.ts:63](./station.ts#L63) | +| station | LAUNCH_SPEED | 120 | ...and how fast, in world units a second — a firm push, not a cruise. | station.launchSpeed | [station.ts:58](./station.ts#L58) | +| station | DOCKED_BACKDROP_DISTANCE | 900 | Where the docked menu parks your ship, along the slot normal, in world units. | station.backdropDistance | [station.ts:67](./station.ts#L67) | | sun | SUN_HEAT_START | 110_000 | Closer than this, and the cabin starts to warm. | | [sun.ts:22](./sun.ts#L22) | | sun | SUN_SCOOP_RANGE | 80_000 | Close enough to scoop fuel, if you have the scoops. | | [sun.ts:30](./sun.ts#L30) | | sun | SUN_HEAT_MAX | 26_000 | The bottom of the temperature ramp. | | [sun.ts:38](./sun.ts#L38) | @@ -375,10 +425,10 @@ search names, meanings and values with `npm run constants:find -- ""`. | tactic-choice | PASS_CLEARANCE | 1.6 | How much wider than contact a pass has to be AIMED to actually clear, as a multiple of the two hulls' radii. | | [tactic-choice.ts:21](./tactic-choice.ts#L21) | | tactic-choice | RAM_MIN_SPEED | PLAYER_FLIGHT.maxSpeed * 0.7 | The slowest hull that may be offered a ram, as a fraction of the commander's top speed. | | [tactic-choice.ts:30](./tactic-choice.ts#L30) | | tactic-choice | TACTIC_HURT_HEALTH | 0.6 | How hurt a ship has to be before a hit makes it rethink. | | [tactic-choice.ts:37](./tactic-choice.ts#L37) | -| tactic-choice | TACTIC_LAST_STAND_HEALTH | 0.25 | ...and how hurt before a ram is on the table, and nothing else new is. | | [tactic-choice.ts:40](./tactic-choice.ts#L40) | -| tactic-choice | TACTIC_WEIGHTS | { spawn: { run: 50, slash: 25, knife: 25, ram: 0 }, sleeper: { run: 40, slash: 30, knife: 30, ram: 0 }, hurt: { run: 20, slash: 40, knife: 40, ram: 0 }, lastStand: { run: 15, slash: 40, knife: 0, ram: 45 }, } as const | How likely each tactic is, per reason. | | [tactic-choice.ts:54](./tactic-choice.ts#L54) | -| tactic-choice | TACTIC_MIN_DWELL | 5 | The least time a ship keeps a tactic before it may take another. | tactic.minDwell | [tactic-choice.ts:72](./tactic-choice.ts#L72) | -| tactic-choice | TACTIC_SLEEPER_SECONDS | 12 | How long a ship goes without a shot away before it concludes that whatever it does is not working. | | [tactic-choice.ts:80](./tactic-choice.ts#L80) | +| tactic-choice | TACTIC_LAST_STAND_HEALTH | 0.25 | ...and how hurt before a ram is on the table, and nothing else new is. | tactic.lastStandHealth | [tactic-choice.ts:44](./tactic-choice.ts#L44) | +| tactic-choice | TACTIC_WEIGHTS | { spawn: { run: 50, slash: 25, knife: 25, ram: 0 }, sleeper: { run: 40, slash: 30, knife: 30, ram: 0 }, hurt: { run: 20, slash: 40, knife: 40, ram: 0 }, lastStand: { run: 15, slash: 40, knife: 0, ram: 45 }, } as const | How likely each tactic is, per reason. | | [tactic-choice.ts:58](./tactic-choice.ts#L58) | +| tactic-choice | TACTIC_MIN_DWELL | 5 | The least time a ship keeps a tactic before it may take another. | tactic.minDwell | [tactic-choice.ts:76](./tactic-choice.ts#L76) | +| tactic-choice | TACTIC_SLEEPER_SECONDS | 12 | How long a ship goes without a shot away before it concludes that whatever it does is not working. | tactic.sleeperSeconds | [tactic-choice.ts:86](./tactic-choice.ts#L86) | | tactics | TACTIC_IDS | ['slash', 'run', 'knife', 'ram'] | Every tactic, least to most committed — the order a readout should list. | | [tactics.ts:23](./tactics.ts#L23) | | tactics | TACTICS | { run: { id: 'run', missDistance: PASS_MISS_DISTANCE, arcAngle: EXTEND_ARC_ANGLE, throttleFloor: CLOSING_THROTTLE_MIN, aimsToHit: false, }, slash: { id: 'slash', missDistance: 175, arcAngle: (45 * Math.PI) / 180, throttleFloor: 0.72, aimsToHit: false, }, knife: { id: 'knife', missDistance: 100, arcAngle: (70 * Math.PI) / 180, throttleFloor: CLOSING_THROTTLE_MIN, aimsToHit: false, }, ram: { id: 'ram', missDistance: 0, arcAngle: EXTEND_ARC_ANGLE, throttleFloor: 1, aimsToHit: true, }, } | The four tactics, as the three numbers each one overrides. | | [tactics.ts:52](./tactics.ts#L52) | | tech-level | TECH_MIN | 1 | The lowest tech level any system shows. | tech.levelMin | [tech-level.ts:16](./tech-level.ts#L16) | @@ -390,24 +440,25 @@ search names, meanings and values with `npm run constants:find -- ""`. | threat | DISREPUTE_DRAW | 0.5 | How much a criminal reputation draws challengers, against combat fame's 1. | threat.disreputeDraw | [threat.ts:92](./threat.ts#L92) | | threat | COURTESY_RATE | 0.15 | Professional courtesy: the share of receptions that never form at all, because somebody recognised a commander they would rather not cross. | | [threat.ts:110](./threat.ts#L110) | | threat | PRIZE_SATURATION | 25000 | The cargo value at which the prize term saturates, in tenths of a credit: 25,000, which is 2,500 Cr. | | [threat.ts:118](./threat.ts#L118) | -| threat | DEFENCE_WEIGHT | 12 | The weights on the three fields of `sourceThreatScore`: 1. how much fire a hull survives (`maxEnergy`, weight 1, the base); 2. how much of each hit it shrugs off (`perHitDefence`, a subtraction); 3. how hard it hits back (`laserPower`). | | [threat.ts:131](./threat.ts#L131) | -| threat | LASER_WEIGHT | 8 | | | [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 | DEFENCE_WEIGHT | 12 | The weights on the three fields of `sourceThreatScore`: 1. how much fire a hull survives (`maxEnergy`, weight 1, the base); 2. how much of each hit it shrugs off (`perHitDefence`, a subtraction); 3. how hard it hits back (`laserPower`). | threat.defenceWeight | [threat.ts:133](./threat.ts#L133) | +| threat | LASER_WEIGHT | 8 | The weight on how hard a hull hits back (`laserPower`), the third field of `sourceThreatScore`. | threat.laserWeight | [threat.ts:141](./threat.ts#L141) | +| threat | PROFESSIONAL_SCORE | 110 | The tier ladder over `sourceThreatScore`. | | [threat.ts:147](./threat.ts#L147) | +| threat | GANG_SCORE | 160 | | | [threat.ts:148](./threat.ts#L148) | +| threat | MAX_TIER | 2 | The ladder's top rung. | threat.maxTier | [threat.ts:159](./threat.ts#L159) | +| threat | CURATED_TIER | { 'elite-a:design:17': 0, } | Hulls held at a tier that the score alone would not give them. | | [threat.ts:169](./threat.ts#L169) | | threat-lock | THREAT_SWITCH_MARGIN | 2.0 | A rival threat must be this much NEARER than the threat under fire before the defender may switch to it. | threat.switchMargin | [threat-lock.ts:14](./threat-lock.ts#L14) | | threat-lock | THREAT_MIN_HOLD | 5 | Seconds that the defender fights a threat before it considers a rival. | threat.minHold | [threat-lock.ts:25](./threat-lock.ts#L25) | -| torus | TORUS_MULTIPLIER | 8 | How much faster the torus drive travels than ordinary flight. | | [torus.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) | +| torus | CLOSE_PASS_CLEAR | 6750 | How far a ship must open back out before it can be announced a second time (game/close-pass.ts). | torus.closePassClear | [torus.ts:65](./torus.ts#L65) | | trumbles | TRUMBLE_PURGE_TEMP | 0.55 | The cabin heat that drives them out. | | [trumbles.ts:12](./trumbles.ts#L12) | | trumbles | BREED_INTERVAL | 20 | Seconds between broods. | trumbles.breedInterval | [trumbles.ts:20](./trumbles.ts#L20) | | trumbles | BREED_RATE | 1.6 | They multiply by this, plus one, every brood. | | [trumbles.ts:23](./trumbles.ts#L23) | | 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) | @@ -420,11 +471,11 @@ search names, meanings and values with `npm run constants:find -- ""`. | witchspace | THARGOID_AMBUSH_RANGE | 3500 | How far out they wait. | | [witchspace.ts:48](./witchspace.ts#L48) | | witchspace | THARGOID_AMBUSH_RANGE_SPAN | 2500 | ...and the width of that band, so they do not all arrive at one distance. | witchspace.thargoidAmbushRangeSpan | [witchspace.ts:59](./witchspace.ts#L59) | | world-clock | FIXED_DT | 1 / 60 | The world advances in slices of exactly this. 60Hz. | | [world-clock.ts:16](./world-clock.ts#L16) | -| world-clock | MAX_FRAME_TIME | 0.25 | The longest real interval the loop will simulate before it drops the backlog. | | [world-clock.ts:23](./world-clock.ts#L23) | -| world-clock | MAX_STEPS_PER_FRAME | 5 | ...and the most steps one frame may run, so a stall cannot spiral. | clock.maxStepsPerFrame | [world-clock.ts:35](./world-clock.ts#L35) | -| world-clock | CARRY_LIMIT | 3 | Unread taps of one key that the input carries across busy frames. | clock.carryLimit | [world-clock.ts:45](./world-clock.ts#L45) | +| world-clock | MAX_FRAME_TIME | 0.25 | The longest real interval the loop will simulate before it drops the backlog. | clock.maxFrameTime | [world-clock.ts:25](./world-clock.ts#L25) | +| world-clock | MAX_STEPS_PER_FRAME | 5 | ...and the most steps one frame may run, so a stall cannot spiral. | clock.maxStepsPerFrame | [world-clock.ts:37](./world-clock.ts#L37) | +| world-clock | CARRY_LIMIT | 3 | Unread taps of one key that the input carries across busy frames. | clock.carryLimit | [world-clock.ts:47](./world-clock.ts#L47) | | wreck | ESCAPE_CHANCE | { trader: 0.45, other: 0.2 } as const | How often the pilot punches out before the hull goes. | | [wreck.ts:22](./wreck.ts#L22) | -| wreck | WRECK_BURST_GRACE | 1.0 | Seconds the commander's beam registers nothing on a bystander, counted from the moment her own shot destroys a ship (GitHub #35). | wreck.wreckBurstGrace | [wreck.ts:67](./wreck.ts#L67) | +| wreck | WRECK_BURST_GRACE | 1.0 | Seconds the commander's beam registers nothing on a bystander, counted from the moment their own shot destroys a ship (GitHub #35). | wreck.wreckBurstGrace | [wreck.ts:67](./wreck.ts#L67) | | wreck | POD_LAUNCH_GRACE | 1.5 | Seconds a fresh capsule cannot be shot, counted from the moment it launches (GitHub #28). | wreck.podLaunchGrace | [wreck.ts:91](./wreck.ts#L91) | | wreck | MINING_YIELD_MIN | 1 | Canisters of ore that a mined asteroid yields: at least the first one, plus a flat draw over the span. | wreck.miningYieldMin | [wreck.ts:110](./wreck.ts#L110) | | wreck | MINING_YIELD_SPAN | 3 | ...and the span above the minimum: one to four canisters in all. | wreck.miningYieldSpan | [wreck.ts:116](./wreck.ts#L116) | diff --git a/src/constants/attack-run.ts b/src/constants/attack-run.ts index e67fbec2..f81bff4b 100644 --- a/src/constants/attack-run.ts +++ b/src/constants/attack-run.ts @@ -36,6 +36,39 @@ export const EXTEND_RANGE = (EXTEND_RANGE_MIN + EXTEND_RANGE_MAX) / 2; */ export const UNDER_FIRE_SECONDS = 1.2; +/** + * How long a trader keeps to its run after the last hit it took: twenty + * seconds. After that, with no live attacker, it goes back to work. + * + * A trader that took a hit ran for the rest of its life until docs/TODO/213 + * M1. An escort's charge that a pirate grazed then flew from the station + * for ever. So did one that the commander's own course bumped. The escort + * could not end. The clock is the same shape as `UNDER_FIRE_SECONDS`: a decay from + * the last hit, not a latch. It is much longer, because a trader that turns + * back into a fight it just fled is a trader that dies. Twenty seconds is a + * pirate wave's approach, so a wave that is still there keeps it running. + * + * @rule trader.calmSeconds + */ +export const TRADER_CALM_SECONDS = 20; + +/** + * The fraction of its energy under which a hunt's target runs for the edge + * of the system: a quarter (docs/TODO/214 M4). A ship that ran at half would + * leave most fights. A ship that ran at a tenth would die in the turn. So + * the commander sees a fight, and then a chase, and the chase is short. + * + * It is the target's rule alone. A trader runs on the first hit, and it + * comes back to work once calm (`TRADER_CALM_SECONDS`). A wingman fights to + * the end. The world step stamps `canFlee` on the target of a hunt that + * `canEscape`, and `NpcShip.takeDamage` reads the fraction. The Constrictor + * cannot escape, so it never runs. + * + * @domain attack-run + * @rule hunt.fleeFraction + */ +export const HUNT_FLEE_FRACTION = 0.25; + /** * The slowest that an attacking ship throttles back to in order to turn. There * are two literals on purpose. This one sits just above `MIN_CRUISE_FRACTION`, so diff --git a/src/constants/blame.ts b/src/constants/blame.ts index ddc306c7..dbd50973 100644 --- a/src/constants/blame.ts +++ b/src/constants/blame.ts @@ -1,4 +1,4 @@ -// Whether a hit is the commander's own deed, so the ship holds it against her. +// Whether a hit is the commander's own deed, so the ship holds it against them. // // A ship's grudge is `provokedByPlayer` (game/npc-state.ts). It is a private // quarrel, apart from the law's record, and `game/hostility.ts` reads it for a @@ -11,9 +11,9 @@ * A warhead and the bomb are aimed, and so is the laser. A ram is CONTACT. The * geometry reads overlap only (`playerVsNpcs` in game/collisions.ts), so it * cannot say who moved. A guess against the commander was GitHub #42: a Viper - * flew into her, and it was hostile for the rest of the flight. So contact + * flew into them, and it was hostile for the rest of the flight. So contact * does not provoke (docs/TODO/194). The ram still costs the ship its points, - * and the ledger still credits it to her line. Blame is the one thing it + * and the ledger still credits it to their line. Blame is the one thing it * withholds. * * The keys are `DealtSource` in game/damage-dealt.ts, and that file indexes diff --git a/src/constants/camera.ts b/src/constants/camera.ts index 10e4236d..faed934f 100644 --- a/src/constants/camera.ts +++ b/src/constants/camera.ts @@ -6,7 +6,9 @@ * Vertical field of view, in degrees. It is load-bearing beyond the looks. The * trainer's `IN_VIEW_DEG` arc is argued from this, so a change moves what "the * pilot can see it" means. - */ + * + * @rule camera.fov +*/ export const CAMERA_FOV = 60; /** diff --git a/src/constants/chart-metric.ts b/src/constants/chart-metric.ts index abb8c4eb..df2eb6c5 100644 --- a/src/constants/chart-metric.ts +++ b/src/constants/chart-metric.ts @@ -53,6 +53,16 @@ export const LOCAL_SCALE = 15; /** Square, so a light year is the same number of pixels whichever way you go. */ export const LOCAL_CANVAS = 560; +/** + * A system's name on the short range chart, in CSS pixels: twelve + * (docs/TODO/220). The canvas is 560 wide and a phone shows it at about + * 370, so a name drawn in canvas pixels came out under seven tall. The + * painter scales the font by the canvas width over its width on the page. + * + * @rule chart.labelPx + */ +export const CHART_LABEL_PX = 12; + /** * The galactic chart's canvas, in px. It has its own home because two things * now read it. The first is the markup that sizes the canvas. The second is the @@ -81,5 +91,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/cloak.ts b/src/constants/cloak.ts new file mode 100644 index 00000000..a416e9f7 --- /dev/null +++ b/src/constants/cloak.ts @@ -0,0 +1,25 @@ +// The cloaking device, which no shop sells (docs/TODO/219 M5). The Dark +// Wheel's door grants it. While it runs, no ship out there is hostile to the +// commander, and the bank pays for it. + +import { ENERGY_BANK_POINTS, LOW_ENERGY } from './pools.ts'; + +/** + * What the cloak draws from the energy bank each second: a bank every six + * seconds, which is about eleven points. The recharge gives back about six + * a second (`ENERGY_REGEN_FRACTION`), so the pool falls by about four a + * second under the cloak. A full pool then cloaks the ship for about forty + * five seconds before the last bank drops it. A draw under the recharge + * would be a cloak that costs nothing, and the fight's answer to everything. + * + * @rule cloak.energyPerSecond + */ +export const CLOAK_ENERGY_PER_SECOND = ENERGY_BANK_POINTS / 6; + +/** + * The cloak drops when the bank is down to its last one, so it never takes + * the energy that keeps the ship alive. It is `LOW_ENERGY`'s own line. + * + * @rule cloak.minEnergy + */ +export const CLOAK_MIN_ENERGY = LOW_ENERGY; diff --git a/src/constants/combat-computer.ts b/src/constants/combat-computer.ts index ebd105c9..47cc2b77 100644 --- a/src/constants/combat-computer.ts +++ b/src/constants/combat-computer.ts @@ -187,6 +187,91 @@ export const PURSUIT_HOLD_CONE = 1.85; */ export const ENGAGED_CONE = 0.6; +/** + * How long the co-pilot stays committed to a target it cannot shoot, in + * seconds. Past it, `ENGAGED_CONE` no longer vetoes a switch. + * + * ENGAGED USED TO MEAN ROUGHLY ON THE NOSE, AND NOTHING MORE. The review of + * 2026-09-12 held a target 3,000 units away and 23 degrees off, with a second + * hostile 500 units dead ahead. Ten seconds later the lock still refused the + * easy shot, and a fresh controller took it. A cone says where a ship is. It + * does not say the attack is going anywhere. + * + * So the veto now asks for PROGRESS as well. The target must fall inside the + * gun cone within this many seconds. The distance rule still governs the rest, and + * `THREAT_MIN_HOLD` and `THREAT_SWITCH_MARGIN` are untouched. A switch still + * needs a target much nearer, held for long enough. + * + * THE VALUE IS NOT PINNED BY MEASUREMENT, and that is worth saying plainly. A + * sweep from 1 second to 999 moved nothing. It moved neither the review's 21 + * prescribed paths, which carry one target and cannot exercise a switch at all, + * nor 72 real fights through `npm run combat-aim`. The rule is dormant in + * everything this repository can measure. It fires in the case the review + * built, where a near hostile is ignored for a far one. That case is a fight a + * player can meet. + * + * So 3 seconds is reasoned rather than fitted. It is about two swings of the + * nose at `MAX_LEAD_SECONDS`. It is long enough that a target crossing the + * sight is not dropped between passes, and short enough that a chase going + * nowhere ends. Measure it on a harness that produces the geometry before you + * move it. + * + * @rule copilot.engagedPatience + */ +export const ENGAGED_PATIENCE = 3; + +/** + * How near its bank the co-pilot must be before it pulls the nose, in radians. + * Above it, `bankToTurn` asks for NO pitch at all. + * + * IT WAS ZERO, AND THE COMMENT SAID IT HAD TO BE. `pitch-roll-steer.ts` held + * that the combat computer must not take the hard gate. A pitch held still + * while the roll catches up is time off the gun, it said, and the wide gun cone + * hid the fault anyway. A review of 2026-09-12 measured both claims false + * (`docs/COMBAT-COMPUTER-REVIEW.md`). + * + * WHAT THE SOFT GATE COSTS. Pitch scaled by the cosine of the bank error never + * reaches zero. That pitch moves the target's bearing while the roll chases the + * same bearing, so the nose can circle the target for ever. A vertical orbit + * needs no roll, and it tracked 100%. The horizontal and tilted orbits need + * both axes, and they fell to 17%. + * + * THREE MEASURES CHOSE THE VALUE, and the review's own probe is two of them. It + * flies the shipped controller and the real flight model against prescribed + * paths. Time on the gun means inside `hitCone` and `LASER_RANGE`, after the + * first ten seconds. The third is 72 exercises against real pirate brains, with + * real shots. Its figure is the share of engaged frames with a live hostile + * inside the gun cone. + * + * | gate | 21 paths, 60s | 54 orbits, 90s | 72 fights | + * | 0 | 64.9%, 7.5% | 53.9%, 3.8% | 39.5% | + * | 0.15 | 98.5%, 87.6% | 98.0%, 80.2% | 55.4% | + * | 0.20 | 98.5%, 87.8% | 97.9%, 79.2% | 58.9% | + * | 0.27 | 98.3%, 88.5% | 97.6%, 77.2% | 57.5% | + * | 0.30 | 98.7%, 90.8% | 97.7%, 78.2% | 57.8% | + * | 0.40 | 98.4%, 84.4% | 97.1%, 72.4% | 60.2% | + * + * The second figure in each pair is the worst case, not the mean. + * + * WHY 0.27. It is the middle of a plateau rather than a peak. The prescribed + * paths hold about 98% from 0.08 to 0.3, and they fall away by 0.4. The fights + * rise to about 58% by 0.2 and stay there. Any value from 0.2 to 0.3 measures + * the same, so the exact figure inside that band is not load-bearing. It is + * about 15 degrees, against the course pilot's `COURSE_ROLL_GATE` of 0.05. A + * course has one fixed heading to reach. This one chases a heading that moves. + * + * WHAT IT COSTS. Acquisition is slower, because the nose waits for the bank. + * The worst first lock over the 21 paths went from 3.28 to 3.87 seconds. A gate + * of 0.08 pushed the same figure to 8.02 seconds, which is why the band is not + * tighter. + * + * It is a feel setting, and no brain flies through it. The prescribed paths + * carry no shots, no damage, and no opponent that fights back. + * + * @rule copilot.rollGate + */ +export const COMBAT_ROLL_GATE = 0.27; + /** * How many world units of range weigh as much as one radian of off-nose turn. * The co-pilot ranks targets by how easy they are to lock @@ -204,3 +289,70 @@ export const ENGAGED_CONE = 0.6; * @rule copilot.targetDistWeight */ export const TARGET_DIST_WEIGHT = 800; + +/** + * How many SECONDS the co-pilot aims ahead of a target, per radian that its nose + * still has to swing. It is a gain, in seconds per radian. + * + * The CAP on it is `MAX_LEAD_SECONDS` (constants/pass-aim.ts), which the attack + * run already owns. That is one rule, not two. Each is the furthest ahead a ship + * aims, and the same pitch rate bounds each. A target dead astern asks for about + * pi radians of swing. This gain alone turns that into six seconds of lead. That + * aim point is nowhere near the fight. + * + * WHY A LEAD AT ALL, when the laser is hitscan. The gun needs none. The NOSE + * does. A swing takes time. The target moves while the swing runs. So an aim at + * where the target IS puts the nose where the target WAS. This lead cancels the + * swing time. It is lead for the ship, not for the shot. + * + * WHY IT SHRINKS WITH THE ERROR. A FIXED lead was measured first, and it is + * WORSE THAN NO LEAD AT ALL. A fixed lead is only right while the nose lags. + * Once the nose catches up, the lead becomes a standing miss. The nose parks + * ahead of the target and waits. The aim point then runs on again each frame. A + * lead set by the REMAINING swing falls to zero as the nose arrives. So it never + * does that. + * + * The measurement is a probe of this control law and of `player.ts`'s flight + * model. It flies 40 scripted targets, at real pirate speeds and at a real + * pirate radius. Each number below is the share of frames with the target inside + * the gun cone. + * + * 27.5% is the pursuit before this rule. + * 24.2% is a fixed 0.30 second lead. + * 21.1% is a fixed 0.30 second lead, with the radial throttle. + * 31.2% is the radial throttle alone. + * 41.7% is this lead alone. + * 46.7% is this lead, with the radial throttle. + * + * WHY 2.0. It is a plateau with a hole beside it. On a 13-target probe at 2.0 a + * fast, close circler scored 86%, and at 3.0 the same target scored 23%, while + * every other target held. Sweep this on the exercise wave harness before you + * move it. A 13-target sample and a 40-target sample disagreed about the radial + * throttle's worth, so a small sample is not enough to retune on. + * + * RE-MEASURED AT `COMBAT_ROLL_GATE`, on the review of 2026-09-12. That review + * asked for the lead to be revisited once the bank was fixed. A lead cost the + * straight-crossing case 74.3%, against 98.7% with none. At the gate this + * repository settled on, that trade is gone. The same case scores 89% WITH the + * lead. The sweep over the review's two grids and 72 real fights: + * + * | gain | 21 paths, 60s | 54 orbits, 90s | 72 fights | + * | 0 | 93.4%, 32.7% | 93.3%, 50.2% | 33.8% | + * | 1 | 97.8%, 81.7% | 96.9%, 70.3% | 60.0% | + * | 2 | 98.3%, 88.5% | 97.6%, 77.2% | 57.5% | + * | 3 | 98.9%, 86.1% | 93.4%, 0.0% | 59.7% | + * + * The second figure of each pair is the worst case. 2.0 holds the best worst + * case on both grids. At 3.0 one orbit is lost outright, which is the hole the + * paragraph above names, found again on a different grid. + * + * THE HOME IS THIS FILE, and the owner check disagrees. It reads the words + * "rule" and "bounded" above and reaches for the law domain. This is a feel + * setting of the SCRIPTED co-pilot. It sits in the `PURSUIT_*` block that the + * header of this file gives to that pilot. Nothing legal reads it, and no brain + * flies through it. + * + * @rule copilot.leadGain + * @domain combat-computer + */ +export const PURSUIT_LEAD_GAIN = 2.0; diff --git a/src/constants/combat-record.ts b/src/constants/combat-record.ts index 33c8761d..3c6d6e63 100644 --- a/src/constants/combat-record.ts +++ b/src/constants/combat-record.ts @@ -40,6 +40,12 @@ export const SIX_CONE = Math.PI / 3; * passes at this threshold. */ export const PASS_CLOSE = 400; +/** + * ...and the far end of that pair: the range a ship must break back out to + * for the pass to count. The comment above explains both. + * + * @rule combatrecord.passFar + */ export const PASS_FAR = 600; /** diff --git a/src/constants/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 new file mode 100644 index 00000000..60ccc500 --- /dev/null +++ b/src/constants/course.ts @@ -0,0 +1,346 @@ +// 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. + +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. + * + * 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; + +/** + * How near the nose a course counts its target as straight ahead, in radians. + * Inside this cone the course pilot asks for no pitch and no roll. + * + * THE SHIP ROLLED ALL THE WAY TO THE STATION WITHOUT IT (Chris, 2026-09-12: + * *"we seem to be constantly rotating when heading towards something"*). The + * steering is `bankToTurn` (game/pitch-roll-steer.ts). Its roll ask is a + * BEARING: how far round the clock the target sits from the vertical. That + * bearing stays large for a target a hair off the nose, and the roll fade has + * a floor. So the course rolled for ever to chase the last fraction of a + * degree. A measurement of 2026-09-12 counted 41 full turns on a median trip + * to the station, with the roll moving in 92% of the samples. + * + * The combat computer never had the fault. It passes the gun's own hit cone, + * which is wide up close, and it holds the sticks still inside it. + * + * 0.02 radians is 1.1 degrees. At the hand-over range of 1,500 units it is 30 + * units off the line, which the last of the approach closes. It is a fifth of + * `COURSE_TORUS_CONE`, so the drive stays engaged inside it. + * + * @domain course + */ +export const COURSE_AIM_DEADZONE = 0.02; + +/** + * How near its bank a course pilot must be before it pulls the nose, in + * radians. Above this angle the steering asks for no pitch at all. + * + * IT IS WHAT STOPS THE SHIP ROLLING ALL THE WAY THERE. `bankToTurn` gates the + * pitch by the cosine of the roll error, which still leaves a little pitch at + * a wide bank. Near the nose that little is enough to hold a cone: the nose + * circles the target, and the angle never closes. The comment on `bankToTurn` + * (game/pitch-roll-steer.ts) holds the measurement. + * + * 0.05 radians is 2.9 degrees. A sweep of 2026-09-12 over 6 trips to the + * station measured the median count of full turns on the way: + * + * | gate | full turns | + * | --- | --- | + * | none | 41 | + * | 0.2 | 5.5 | + * | 0.1 | 2.9 | + * | 0.05 | 1.3 | + * + * A tighter gate costs a little time in a big turn, because the roll must + * finish first. The roll is the faster axis, so the cost is small. + * + * @domain course + */ +export const COURSE_ROLL_GATE = 0.05; + +/** + * How far from its target the course pilot drops the torus drive, in world + * units, before an arrival. + * + * 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; + +/** + * 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; + +/** + * Below this lead in top speed, in world units a second, the run row says the + * ship is only a little faster (docs/TODO/206 M5). + * + * The player's Cobra tops out at 400. The fastest pirate and the fastest + * bounty hunter reach 381. A run from one gains 19 u/s. The lasers of both + * reach 3,500 units. A lead under 50 u/s takes more than a minute to + * open that range, under fire. A police Viper, at 320, falls behind at 80. + * + * @rule course.runCloseMargin + * @domain course + */ +export const RUN_CLOSE_MARGIN = 50; + +/** + * How far ahead the run course aims, in world units, on the line away from + * the hostile ships (docs/TODO/206 M5). The point only gives the line a + * direction, so it sits far beyond scanner range. + * + * @rule course.runReach + * @domain course + */ +export const COURSE_RUN_REACH = 50_000; + +/** + * How fast the collect course flies onto a canister, in world units a second + * (docs/TODO/206 M6). + * + * The scoop takes a canister inside `SCOOP_RANGE`, which is 45 units. At this + * speed the ship covers that in more than half a second, so the scoop has + * frames to catch it. A canister drifts, and the course arrives at the speed + * the drift needs rather than at a stop. + * + * @rule course.collectSpeed + * @domain course + */ +export const COURSE_COLLECT_SPEED = 60; + +/** + * The furthest ahead of a drifting canister the collect course will aim, in + * seconds. + * + * Chris, 2026-09-12: *"Collecting cargo often seems to be difficult - we miss + * it quite a lot - especially when it is moving."* The course aimed at where + * the canister WAS. A canister drifts at up to 45 units a second, and the scoop + * reaches 45. So an aim at its old place arrives a whole scoop behind it. + * + * The lead itself is the time to cover the gap at `COURSE_COLLECT_SPEED`, which + * is an intercept rather than a guess. THE CAP IS WHAT IS FITTED. `arrive` + * flies faster than the collect speed while it is far out, so the raw time + * over-leads at range. An uncapped aim then chases a point the canister never + * reaches. + * + * Measured over 80 runs a row, five canisters a run, at four drifts and four + * scatter directions. The figure is how many of 240 were aboard inside two + * minutes, across the three moving drifts: + * + * | cap | collected | mean time | + * | 0 | 191/240 | 75.0s | + * | 2 | 210/240 | 58.6s | + * | 3 | 216/240 | 60.9s | + * | 4 | 217/240 | 55.1s | + * | 5 | 231/240 | 50.8s | + * | 6 | 219/240 | 59.4s | + * + * Cargo at rest is untouched at every cap, because a still canister has no + * velocity to lead on. It was 80 of 80 in 21.6 seconds throughout. + * + * @rule course.collectLead + * @domain course + */ +export const COURSE_COLLECT_LEAD = 5; + +/** + * The fastest the collect course flies on its way to a canister, in world + * units a second. + * + * Chris, 2026-09-12: *"collecting cargo seems to be broken sometimes. I seem to + * run at maximum speed, then slow down and then miss it and then run at maximum + * speed."* That is the trace exactly. `arrive` picks a speed from the braking + * curve, which is right for a big target on a straight line. It reached 400 + * with a canister 250 units off. + * + * A SHIP THAT FAST CANNOT CORRECT. Its tightest turn has a radius of 276 units, + * and the scoop reaches 45. So a lateral error the steering leaves at that + * point cannot be closed at all. The ship sails past, and comes round again. + * Held on one canister it loops for ever, which is what "broken" looked like. + * + * It is twice `COURSE_COLLECT_SPEED`, the speed the course settles at. That is + * a turn radius of 83 units, which is under twice the scoop's reach. + * + * Measured over 64 runs, five canisters a run, at four drifts and four scatter + * directions. The figure is how many runs left cargo behind after two minutes: + * + * | cap | left behind | mean time | + * | 400 | 4 of 64 | 43.5s | + * | 180 | 4 of 64 | 35.9s | + * | 150 | 0 of 64 | 32.0s | + * | 120 | 0 of 64 | 29.8s | + * | 90 | 0 of 64 | 32.6s | + * | 60 | 36 of 64 | | + * + * At 60 it is the collect speed itself, and the course can no longer catch a + * canister that drifts at 45. + * + * @rule course.collectCap + * @domain course + */ +export const COURSE_COLLECT_CAP = COURSE_COLLECT_SPEED * 2; + +/** + * How far from the station the station course hands the ship to the pilot, + * in world units (docs/TODO/207 M1). + * + * A ship with a docking computer is handed to that computer further out, at + * `DOCK_COMPUTER_RANGE`. A pilot flies the last stretch, and 1,500 units is + * about four seconds at the speed the approach arrives with. The concepts + * page of 2026-09-11 started its trial here, and Chris picked one of them. + * + * @rule course.dockHandover + * @domain course + */ +export const COURSE_DOCK_HANDOVER = 1500; + +/** + * How far clear of a solid thing's HULL a course flies, in world units. + * + * Chris, 2026-09-12: *"I visited a rock hermit and then when I left clicked fly + * to the station - the computer crashed me straight into the rock hermit - we + * need to have some avoidance of obstacles"*. The line went round the planet + * and round a policeman, and through everything else. Measured, it killed the + * commander outright at 399 units a second. + * + * IT IS THE SHIP'S OWN TURNING CIRCLE, not a number chosen by eye. At top + * speed the tightest circle the commander can fly has a radius of speed over + * turn rate. A quarter circle moves a ship one radius sideways. So a line that + * passes that far from a hull leaves exactly the room a quarter turn needs. + * `sidestep` then aims half as far again, which is the margin. + * + * It moves with the envelope rather than against it. A faster or less agile + * hull needs a wider berth, and this says so without being retuned. + * + * FROM THE HULL, as the docking standoff is (docs/TODO/211). A hermit is 120 + * units across the radius, and the derelict is 340. A clearance from the CENTRE + * would be a different rule for each of them. + * + * @domain course + */ +export const COURSE_OBSTACLE_CLEARANCE = PLAYER_FLIGHT.maxSpeed / PLAYER_FLIGHT.maxPitch; diff --git a/src/constants/docking.ts b/src/constants/docking.ts index dfd5174c..bebe1a08 100644 --- a/src/constants/docking.ts +++ b/src/constants/docking.ts @@ -108,7 +108,9 @@ export const LINED_UP_LATERAL = 45; * scale. The Coriolis reaches 160 against a 160 slot plane. The Dodo's five * tallest vertices reach 243 against a 196 one. 50 clears both, and it does not * let a ship slip past a vertex and be reported clear. - */ + * + * @rule docking.hullBoxMargin +*/ export const HULL_BOX_MARGIN = 50; /** The same cube for every NPC. It is the SAME RULE as the player's, so NPC @@ -125,16 +127,185 @@ export const NPC_HULL_BOX_MARGIN = HULL_BOX_MARGIN; export const SLOT_HALF_ACROSS = 26; export const SLOT_HALF_ALONG = 62; -/** How far into the -Z face puts a ship in the channel, in world units. */ +/** How far into the -Z face puts a ship in the channel, in world units. * + * @rule docking.slotDepth +*/ export const SLOT_DEPTH = 60; /** - * The wings against the slot's long axis, in radians: how badly you may be rolled - * and still fit through the letterbox. It is a quarter turn's tolerance either - * side, and it is symmetric, so a ship upside down in the slot still fits. + * The wings against the slot's long axis, in radians, FOR A HAND ON THE STICK. + * It is how badly you may be rolled and still fit through the letterbox. It is + * symmetric, so a ship upside down in the slot still fits. + * + * DOCKING IS SUPPOSED TO BE HARD (Chris, 2026-09-12). It was 0.65, about 37 + * degrees, which is 41% of every angle the slot can present. A probe of the + * mini game flew it 20 times, at a different slot angle each time. A pilot who + * matched the spin docked 20 times, first go. A pilot who never touched the + * roll ALSO docked 20 times, after one scrape. The skill saved a shield. It was + * not what got the ship in. + * + * What the whole channel is worth, measured at 0.65: over a uniform sample of + * approach offsets and rolls, the fraction that docks is 6.68%. That is the + * number to measure again if this tolerance or the half-widths above ever move. + * + * @rule docking.rollTolerance + */ +export const ROLL_TOLERANCE = 0.24; + +/** + * ...and the same angle for a BOUGHT DOCKING COMPUTER, which is wider. + * + * It is a deliberate cheat (Chris, 2026-09-12: *"we can 'cheat' for the docking + * computer and give it more leeway"*). The computer is what a commander buys to + * stop docking being hard, so the slot is kinder to it than to a hand. + * + * 0.65 is what BOTH used to be, so this is the old rule under its own name and + * the hand's is the one that moved. + * + * THE COMPUTER CANNOT MEET THE HAND'S FIGURE. `npm run dock-probe` reports it + * arriving up to 13.8 degrees off the slot's long axis. `ROLL_TOLERANCE` at + * 0.24 rad is 13.75 degrees, so the worst approach falls outside it. Measured, + * the shared constant at that figure cost 2 scrapes over 504 approaches. + * + * A commander who paid for the computer should not lose a shield to that. So + * the licence is wide, and the hand's is not. With the two split, the probe is + * back to 504 of 504 with no scrape. + * + * It has its own rule id, and the two must not be fused. One is a difficulty + * setting. The other is the equipment's licence. + * + * @rule docking.computerRollTolerance + */ +export const COMPUTER_ROLL_TOLERANCE = 0.65; + +/** + * How fast a ship may be going when it reaches the slot, in world units a + * second (docs/TODO/207 M3). + * + * The slot took any speed at all until now, and the dock was a test of the + * roll alone. A pilot flies the last stretch since 207, so the speed is the + * second half of the manoeuvre. The docking computer settles at 110 on its + * own approach (`planDocking`), so it keeps 10 units a second of room. + * + * `LAUNCH_SPEED` is also 120, and the two rules are independent. That one is + * the push a station gives a ship on the way out. + * + * It belongs here, with the rest of the slot's rules, and not with the flight + * envelopes. The slot decides what it will take, and `dockingOutcome` next + * door is the one reader. + * + * @rule docking.slotSpeedLimit + * @domain docking + */ +export const SLOT_SPEED_LIMIT = 120; + +/** + * How far off the slot axis the ship may be when the rails take it, in world + * units (docs/TODO/212). + * + * THE PILOT USED TO GET THE SHIP AT 1,500 UNITS, AND IT WAS NOT LINED UP. A + * measurement of 2026-09-12 put the hand-over 407 to 886 units off the axis, + * at 224 units a second. The slot channel is 26 units across the half-width. + * So the computer still had the whole of the lining up to do, with the pitch + * alone, because the pilot owned the roll. An ideal pilot docked 0 times in 4. + * + * IT IS A SANITY BOUND, NOT THE LINE ITSELF. The rails close the last of the + * error themselves, and that is their job. The docking computer reaches its + * last leg 610 units out, with 220 units of error. The error is down to 18 + * units by 280 units out. To wait for the small number would leave the pilot + * two seconds of game. So the rails take the ship at the start of the last leg, + * and they pull it onto the line while it flies in. + * + * @rule docking.railsLateral + * @domain docking + */ +export const RAILS_LATERAL = 250; + +/** + * How far out the rails take the ship, in world units (docs/TODO/212). + * + * It is the length of the mini game. At the slot's speed limit of 120 units a + * second, 900 units is about seven seconds. The station turns a half circle in + * 12 seconds, so the pilot sees the slot come round at least once. + * + * @rule docking.railsRange + * @domain docking + */ +export const RAILS_RANGE = 900; + +/** + * How hard the rails pull the ship onto the axis, per second (docs/TODO/212). + * + * The worst error the rails take is about 220 units. At 2 a second the first + * frame moves the ship 7 units sideways, and the error is gone in about two + * seconds. A hard snap would look like a teleport. + * + * @rule docking.railsPull + * @domain docking + */ +export const RAILS_PULL = 2; + +/** + * How far off the slot axis the ship's NOSE may point when the PILOT is given + * the slot, in radians. + * + * THE HAND-OVER USED TO IGNORE THE NOSE ALTOGETHER (Chris, 2026-09-12: *"we + * jump to the on rails version before it's actually lined up"*). `railsReached` + * asked for the last leg, `RAILS_LATERAL` and `RAILS_RANGE`. None of those three + * says which way the ship points. A trace of the shipped approach put the nose + * 69.7 degrees off the axis on the frame the rails took it. + * + * THE COMPUTER FLIES THE SHIP TO THIS ANGLE. It is the pointing half of the + * hand-over, and `RAILS_RANGE` is the distance half. The ship is stopped by + * then, so `world-step.ts` steers the last turn with `bankToTurn` rather than + * `dockingSticks`. That law spends the roll on the letterbox and pitches onto + * the heading, and a pitch alone cannot answer a sideways error from a stop. + * + * 0.03 rad is about 1.7 degrees. It was 0.18 rad, about 10 degrees, and Chris + * called that too wide on 2026-09-12. + * + * IT SITS JUST ABOVE THE CRAWL. A trace of the turn with the gate as good as + * open measured the whole curve. The nose falls from 68 degrees to 1.8 in one + * second. It rings once, and it settles near 1.2 degrees by two seconds. Below + * that it creeps: 1.2 degrees to 0.6 takes eight more seconds, because the + * steering saturates and the roll fades. So a cone under about 1 degree buys + * fractions of a degree for whole seconds of wait. + * + * It is a band rather than a zero, because the rails hold the line from there + * on. To wait for zero is to wait for ever. + * + * @rule docking.railsCone + * @domain docking + */ +export const RAILS_CONE = 0.03; + +/** + * How hard the rails turn the nose onto the axis, per second. + * + * It is `RAILS_PULL` for the rotation, and it exists for the same reason. The + * position was always eased. The rotation was not: it went on in full, every + * frame, so any error left at the hand-over went in one frame. The module + * comment on `game/dock-rails.ts` claimed both were eased, and only one was. + * + * 3 a second clears what `RAILS_CONE` lets through, which is under 2 degrees, + * in a fraction of a second. The computer flies the big turn, so this only ever + * answers the residue, and the station's own drift under it. + * + * @rule docking.railsTurn + * @domain docking + */ +export const RAILS_TURN = 3; + +/** + * The speed under which the ship counts as stopped, in world units a second + * (docs/TODO/212). + * + * The computer brakes the ship to a halt on the slot axis, and the rails then + * take it. The pilot thrusts in from there. A ship thrusts at 220 units a + * second per second, so 2 is under a hundredth of a second of thrust. It is a + * band rather than a zero, because the brake is a sign and not a gain. * - * What the whole channel is worth, measured: over a uniform sample of approach - * offsets and rolls, the fraction that docks is 6.68%. That is the number to - * measure again if this tolerance or the half-widths above ever move. + * @rule docking.railsStopped + * @domain docking */ -export const ROLL_TOLERANCE = 0.65; +export const RAILS_STOPPED = 2; diff --git a/src/constants/encounters.ts b/src/constants/encounters.ts index b1cdedf7..ce463338 100644 --- a/src/constants/encounters.ts +++ b/src/constants/encounters.ts @@ -19,7 +19,9 @@ */ export const TRADER_GAP = 100; -/** ...and the jitter on top, drawn flat, so the lane never runs to a metronome. */ +/** ...and the jitter on top, drawn flat, so the lane never runs to a metronome. * + * @rule encounters.traderGapJitter +*/ export const TRADER_GAP_JITTER = 60; /** @@ -32,7 +34,9 @@ export const TRADER_GAP_JITTER = 60; * continuous trader stream, held back only by `MAX_TRADERS`. Live, a median * system runs its lane at about 90s plus jitter. The richest runs at about 53s * plus jitter. - */ + * + * @rule encounters.traderGapBusyMax +*/ export const TRADER_GAP_BUSY_MAX = 50; /** @@ -43,7 +47,9 @@ export const TRADER_GAP_BUSY_MAX = 50; * This is therefore the exchange rate between the 1984 figure and a Harmless * clock. It is the only place the two scales meet, and that is why neither can be * re-based without the other. - */ + * + * @rule encounters.productivityPerSecond +*/ export const PRODUCTIVITY_PER_SECOND = 1200; /** @@ -69,7 +75,9 @@ export const TRADER_GAP_FIRST_JITTER = 40; * them. It is also the first wave's countdown when you arrive. It is one number, * because the first wave is the ladder's bottom rung, with no government term and * no jitter. - */ + * + * @rule encounters.pirateWaveGap +*/ export const PIRATE_WAVE_GAP = 60; /** @@ -125,10 +133,11 @@ export const ANARCHY_GOVERNMENT = 1; * IT WAS 4 UNTIL docs/TODO/188, AND CHRIS'S PLAYTEST IS WHY IT MOVED. He flew * the ambush and said it was too hard (GitHub #39). `npm run ambush-probe` * then measured the shipped defence over the real step, at two sizes. At 4 the - * co-pilot died in 7% to 15% of ambushes, and she ended with 54% of her pools. - * The drones dealt 70% of the damage. At 2 she survived every one, and she - * ended with 76% to 79%. A slower redeploy alone did nearly as well, but it - * left the peak count at 3.5, and the count was the complaint. + * co-pilot died in 7% to 15% of ambushes, and the commander ended with 54% of + * their pools. The drones dealt 70% of the damage. At 2 the commander + * survived every one, and ended with 76% to 79%. A slower redeploy alone did + * nearly as well, but it left the peak count at 3.5, and the count was the + * complaint. * * It has its own rule id. `THARGOID_AMBUSH_MIN` (witchspace.ts) is also 2, * and it is a different rule: that one is how many motherships wait. diff --git a/src/constants/exercise.ts b/src/constants/exercise.ts index d7376f65..9d0005d2 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; @@ -79,10 +81,16 @@ export const AMBUSH_CONE_DEG = 30; */ export const IN_VIEW_DEG = 20; -/** Where the exercise starts you, as a fraction of the ship's top speed. */ +/** + * Where the exercise starts you, as a fraction of the ship's top speed. + * + * @rule exercise.entryThrottle + */ export const ENTRY_THROTTLE = 0.25; -/** Seconds a scenario exercise may run before it times out. */ +/** Seconds a scenario exercise may run before it times out. * + * @rule exercise.scenarioTimeout +*/ export const SCENARIO_TIMEOUT = 120; /** diff --git a/src/constants/hermit-market.ts b/src/constants/hermit-market.ts index ee750fa8..d612fbec 100644 --- a/src/constants/hermit-market.ts +++ b/src/constants/hermit-market.ts @@ -69,7 +69,8 @@ export const HERMIT_FAVOUR = 0.2; /** * How near a rock hermit the commander must be to hear its hail. * - * It is also the range she has to LEAVE to hear it again. One number covers + * It is also the range the commander has to LEAVE to hear it again. One + * number covers * both, because a door you enter and a door you leave are the same door. A * second radius would give a band where the hail neither fires nor resets. * diff --git a/src/constants/hull-breach.ts b/src/constants/hull-breach.ts index 80eaa5c0..62a7f46b 100644 --- a/src/constants/hull-breach.ts +++ b/src/constants/hull-breach.ts @@ -15,6 +15,8 @@ * one time per penetrating hit. It never rolls per point, and the damage never * scales it. Pool size therefore does not change how often equipment breaks. * `test/systems.test.ts` counts the rolls. + * + * @rule hullbreach.equipmentDamageChance */ export const EQUIPMENT_DAMAGE_CHANCE = 0.25; diff --git a/src/constants/law.ts b/src/constants/law.ts index e79887fe..5c4fbc48 100644 --- a/src/constants/law.ts +++ b/src/constants/law.ts @@ -104,8 +104,16 @@ export const CONTRABAND: readonly number[] = [3, 6, 10]; /** * The fine for a dock with a record, capped at what you can actually pay. They * are in tenths of a credit (invariant 8), so these are 25 Cr and 75 Cr. + * + * @rule law.offenderFine */ export const OFFENDER_FINE = 250; +/** + * ...and the fine for a fugitive, which is three times as much. The comment + * above covers both. + * + * @rule law.fugitiveFine + */ export const FUGITIVE_FINE = 750; /** diff --git a/src/constants/mission-course.ts b/src/constants/mission-course.ts new file mode 100644 index 00000000..59ad430e --- /dev/null +++ b/src/constants/mission-course.ts @@ -0,0 +1,81 @@ +// How a mission's course flies the ship (docs/TODO/208). +// +// A live job is the first row of the course list. `game/course-pilot.ts` +// flies it in one of five shapes: a fight, a hold, an escort, a scoop and a +// slip past the police. These are the numbers the hold, the escort and the +// slip spend. They left `course.ts` when docs/TODO/213 M1 pushed that file +// over the size ceiling, and a mission's course is a subject of its own. + +import { SCAN_WARN_RANGE } from './law.ts'; +import { SCANNER_RANGE } from './console.ts'; + +/** + * How far from a ship the scan course holds, in world units + * (docs/TODO/208 M2). + * + * A scan counts seconds while the ship is inside `SCANNER_RANGE`, which is + * 6,000, and within `WATCH_CONE` of the nose. So the hold sits well inside + * the range, and near enough that the ship fills a useful part of the cone. + * It is far enough out that a trader's own wandering does not shake it off. + * + * @rule course.watchStandoff + * @domain mission-course + */ +export const COURSE_WATCH_STANDOFF = 1200; + +/** + * How far from its charge the escort course flies, in world units + * (docs/TODO/208 M2). + * + * The escort is safe when no hostile ship is within 3,500 units of the + * charge. A pilot who flies this close is inside that ring, and the fight + * comes to the pilot rather than to the charge. + * + * @rule course.escortStandoff + * @domain mission-course + */ +export const COURSE_ESCORT_STANDOFF = 600; + +/** + * How much faster than its charge the escort course may close, in world + * units a second, once it is inside three standoffs of it: sixty. + * + * The approach used to brake from full speed to the charge's own at the + * standoff, and it overshot into the hull (docs/TODO/213 M1). A ram is a hit + * from the commander, and a trader that is hit runs. So the escort's own + * course set its charge to flight in one run of eight. Sixty over the charge + * closes 1,800 units in half a minute, and it stops inside the tolerance. + * + * @rule course.escortClosing + * @domain mission-course + */ +export const COURSE_ESCORT_CLOSING = 60; + +/** + * How far the commander may fall behind their charge before it holds for + * them, in world units (docs/TODO/214 M3). It is two thirds of the scanner, + * which is four thousand. + * + * The charge flew to the station on its own, and the escort was a job the + * commander watched. It moves while they are inside the leash and holds + * where it is when they are not. The leash is well outside the escort + * standoff of 600, so the course never trips it. It derives from the + * scanner so that a charge that holds is always still on their scanner. + * + * @rule course.escortLeash + * @domain mission-course + */ +export const ESCORT_LEASH = SCANNER_RANGE * 2 / 3; + +/** + * How wide of a police ship the smuggling course flies, in world units + * (docs/TODO/208 M4). + * + * A policeman reads a hold inside `SCAN_RANGE`, which is 2,600 units. This is + * the warning band, `SCAN_WARN_RANGE`, so the course keeps a margin outside + * the range that would end the job. It is the same rule from the other side, + * so the two cannot drift apart. + * + * @domain mission-course + */ +export const COURSE_POLICE_CLEARANCE = SCAN_WARN_RANGE; diff --git a/src/constants/missions.ts b/src/constants/missions.ts index c9bdad7f..22cf9428 100644 --- a/src/constants/missions.ts +++ b/src/constants/missions.ts @@ -1,9 +1,12 @@ // The Navy mission, as numbers: what earns the briefing, how far away each leg // is laid, and what the Navy pays. // -// The five-stage machine that spends these is game/missions.ts. Money is in -// tenths of a credit (invariant 8), and distances are in tenths of a light year, -// as everywhere else. +// The skeletons under src/missions/skeletons/ spend these, and the machine +// in src/missions/machine.ts runs them (docs/TODO/190). Money is in tenths +// of a credit (invariant 8), and distances are in tenths of a light year, as +// everywhere else. + +import { RATINGS } from './rating.ts'; /** * Kills before the Navy considers you worth a word: 16, as the original demanded. @@ -27,7 +30,11 @@ export const MISSION_COURIER_RANGE = { min: 50, max: 90 } as const; /** What a kill of the Constrictor pays — 2,500 Cr, in tenths of a credit. */ export const CONSTRICTOR_BOUNTY = 25_000; -/** ...and what a delivery of the plans pays: 1,500 Cr. */ +/** + * ...and what a delivery of the plans pays: 1,500 Cr. + * + * @rule missions.courierPayment + */ export const COURIER_PAYMENT = 15_000; /** @@ -104,6 +111,98 @@ export const SIDE_JOB_PAY = { ambush: 6_000, smuggle: 7_000, escort: 6_000, scan: 2_500, } as const; +/** + * What the station pays for the whole gang on the side hunt, in tenths of + * a credit: 1,500 Cr (docs/TODO/217 M1). The gang is a Fer-de-Lance with + * an Asp, a Krait and a Mamba. That is four hulls, two missiles and two + * E.C.M. fits, against the lone Krait that paid `SIDE_JOB_PAY.hunt`. + * Each kill pays its own bounty on top, as any kill does. + * + * @rule missions.gangBounty + */ +export const GANG_BOUNTY = 15_000; + +/** + * ...and half of it when the gang is gone but its leader ran (docs/TODO/217 + * M1). The leader keeps its head, and the commander keeps three kills. + * + * @rule missions.gangBrokenBounty + */ +export const GANG_BROKEN_BOUNTY = GANG_BOUNTY / 2; + +/** + * Kills before the board offers the gang hunt (docs/TODO/217 M2). Chris + * asked for gates by kills on 2026-09-13. It is the kills of the second + * rung, Mostly Harmless. So a commander who reads that word on the status + * screen finds the job on the board. A gang of four kills a Harmless one. + * The Navy asks for twice as many (`MISSION_KILL_THRESHOLD`). + * + * @rule missions.gangHuntKills + */ +export const GANG_HUNT_KILLS = RATINGS[1][0]; + +/** + * Kills before the board offers a lane to clear or a trader to cover: half + * the gang hunt's (docs/TODO/217 M2). Both jobs meet a pack of three on + * the way, and both let a commander choose the range. The delivery, the + * scan and the smuggle stay open, and the two scoop jobs ask for scoops. + * + * @domain missions + * @rule missions.laneJobKills + */ +export const LANE_JOB_KILLS = GANG_HUNT_KILLS / 2; + +/** + * The rung the Dark Wheel's first whisper waits for (docs/TODO/219 M1): + * Above Average, 128 kills. Chris chose it on 2026-09-13. The Wheel finds + * a commander worth finding, and a Harmless one is not yet. + * + * @rule missions.wheelWhisperRung + */ +export const WHEEL_WHISPER_RUNG = RATINGS.findIndex(([, name]) => name === 'Above Average'); + +/** + * The rung the Wheel's door waits for (docs/TODO/219 M4): Competent, 512 + * kills. Dangerous is 2,560, which is fifty hours of hunting, and the layer + * should be reached. + * + * @rule missions.wheelDoorRung + */ +export const WHEEL_DOOR_RUNG = RATINGS.findIndex(([, name]) => name === 'Competent'); + +/** + * The governments whose boards carry the Wheel's word (docs/TODO/219 M1), + * by their 1984 names. The Wheel posts nothing where a government reads + * the boards. `galaxy.ts` owns the names, and the offers filter compares + * a world's own against this list. It is a narrower line than + * `LAWLESS_GOVERNMENT` in encounters.ts, which breeds pirate waves up to a + * dictatorship. A dictatorship reads its boards. + * + * @domain missions + * @rule missions.lawlessGovernments + */ +export const LAWLESS_GOVERNMENTS: readonly string[] = ['Anarchy', 'Feudal']; + +/** + * What a Wheel trial pays, in tenths of a credit (docs/TODO/219 M1). More + * than an arc leg of the same verb, because the Wheel asks more. The mark + * is a gang with two Asps. The door pays nothing: its reward is a fit. + * + * @rule missions.wheelPay + */ +export const WHEEL_PAY = { mark: 20_000, blockade: 20_000, pilot: 25_000 } as const; + +/** + * The target a leg placed in witchspace carries (docs/TODO/219 M3). A + * system is an index from 0 to 255, and witchspace is none of them. So the + * record holds this, and every reader of a target knows the word. A save + * carries it as any target. + * + * @domain missions + * @rule missions.witchspaceTarget + */ +export const WITCHSPACE_TARGET = -1; + /** * The lower fee a rescue pays when the pod is lost and the data still * arrives, in tenths of a credit. The scientist example in docs/TODO/190: @@ -223,8 +322,8 @@ export const ARC_HANDOVER_JUMPS = { min: 2, max: 4 } as const; * * One table, as `SIDE_JOB_PAY` is, and a separate rule. An arc pays more * than a side job of the same verb. An arc sends the commander across the - * galaxy, and a side job sends her one jump out and back. The - * same order holds inside the table, for the same reasons. The arcs under + * galaxy, and a side job sends them one jump out and back. The same order + * holds inside the table, for the same reasons. The arcs under * `missions/skeletons/arcs/` spend it (docs/TODO/192 M2). * * @rule missions.arcPay @@ -237,8 +336,8 @@ export const ARC_PAY = { /** * Days an arc leg allows before its deadline passes: a month, twice a side * job's fortnight, because an arc leg may be four jumps out. A deadline is - * what makes `failed` reachable on a delivery. So an arc she cannot finish - * fails on its own rather than holding a slot for good. + * what makes `failed` reachable on a delivery. So an arc the commander cannot + * finish fails on its own rather than holding a slot for good. * * @rule missions.arcLegDays */ diff --git a/src/constants/npc-gun.ts b/src/constants/npc-gun.ts index f0e860c7..ad382a57 100644 --- a/src/constants/npc-gun.ts +++ b/src/constants/npc-gun.ts @@ -53,10 +53,13 @@ export const NPC_MEAN_COOLDOWN = NPC_COOLDOWN_LO + NPC_COOLDOWN_SPREAD / 2; * * In the knife fight the pooled bearing error is 85 degrees, and docs/TODO/139 M3 * took that figure apart by the leg the ship flew. A quarter of the fight is - * `passing` and `extending`. Those two legs carry the nose past her and away BY - * DESIGN, at 102 and 142 degrees. The legs that want the nose ON her are - * `closing`, at 64 degrees, and `on your six`, at 37. So a pooled figure is not + * `passing` and `extending`. Those two legs carry the nose past the commander + * and away BY DESIGN, at 102 and 142 degrees. The legs that want the nose ON + * them are `closing`, at 64 degrees, and `on your six`, at 37. So a pooled + * figure is not * evidence about this gate, and M3 decided against a wider one. + * + * @rule npcgun.fireGate */ export const NPC_FIRE_GATE = 0.25; diff --git a/src/constants/ordnance.ts b/src/constants/ordnance.ts index a148addb..30dd249e 100644 --- a/src/constants/ordnance.ts +++ b/src/constants/ordnance.ts @@ -26,7 +26,9 @@ export const MISSILE_LIFE = 25; export const HOSTILE_MISSILE_LIFE = 30; /** Turn rate while it homes, radians per second. */ export const MISSILE_TURN = 2.5; -/** Close enough to detonate. */ +/** Close enough to detonate. * + * @rule ordnance.missileHitRange +*/ export const MISSILE_HIT_RANGE = 50; /** Lock cone: how near the crosshair a ship must be to be locked. */ @@ -58,6 +60,8 @@ export const MISSILE_LAST_STAND_GATE = Math.PI / 2; * Desperation widens the envelope INWARD, but not all the way. Inside this, the * missile arrives before the player can reach the E.C.M. or turn, and a weapon * you cannot dodge is not a fight. + * + * @rule ordnance.missileLastStandMinRange */ export const MISSILE_LAST_STAND_MIN_RANGE = 250; /** diff --git a/src/constants/pass-aim.ts b/src/constants/pass-aim.ts index 89a6e8aa..7a0971f7 100644 --- a/src/constants/pass-aim.ts +++ b/src/constants/pass-aim.ts @@ -3,6 +3,11 @@ // An attack run has to pass beside where the target WILL BE, on a line it has // room to get onto. These three numbers say how far beside, and how far ahead. // `game/pass-aim.ts` turns them into a heading. +// +// `MAX_LEAD_SECONDS` has a SECOND reader, and it is not an attack run. The +// scripted co-pilot caps its own lead with it (`game/scripted-co-pilot.ts`). +// The two lead for different reasons and take the same ceiling, which the +// comment on the constant states. /** * How far to the SIDE of its target a ship aims its attack run. 110 clears the @@ -17,6 +22,12 @@ export const PASS_MISS_DISTANCE = 110; * pitches at 1.45 rad/s, so half a second is already 41 degrees of heading * change. That is enough lead to matter, and it does not extrapolate a stale * straight line. + * + * TWO PILOTS TAKE IT, for two different reasons, and the ceiling is the same + * one. The attack run leads to predict a MERGE (`game/pass-aim.ts`). The + * scripted co-pilot leads to cancel the time its own NOSE takes to swing + * (`PURSUIT_LEAD_GAIN`). Both are "how far ahead a ship may aim", and both are + * bounded by that same pitch rate. It stays one rule with one home. */ export const MAX_LEAD_SECONDS = 0.5; diff --git a/src/constants/player-interest.ts b/src/constants/player-interest.ts index 9ad14f5e..2d98832a 100644 --- a/src/constants/player-interest.ts +++ b/src/constants/player-interest.ts @@ -22,12 +22,12 @@ export const PLAYER_INTEREST_RANGE = 9000; /** * How close the commander must be before an armed trader on the run turns and - * fights her, rather than whoever else is shooting at it. + * fights them, rather than whoever else is shooting at it. * * NARROWER THAN `PLAYER_INTEREST_RANGE` ABOVE, ON PURPOSE. That one is where a - * hostile starts to close on her. This is where a ship that is ALREADY fleeing - * decides she is the one worth turning on. A trader with a pirate behind it and - * the commander far off should answer the pirate. + * hostile starts to close on the commander. This is where a ship that is + * ALREADY fleeing decides the commander is the one worth turning on. A trader + * with a pirate behind it and the commander far off should answer the pirate. * * `game/npc.ts` spends it twice, on the scripted branch and on the trained * defence branch, because the choice of prey is the same either way. It was a diff --git a/src/constants/population.ts b/src/constants/population.ts index 59d5e002..042bc482 100644 --- a/src/constants/population.ts +++ b/src/constants/population.ts @@ -94,7 +94,8 @@ export const ASTEROIDS_VARIATION = 3; * * IT IS NOT `GENERATION_SHIP_RANGE` ABOVE, and the two are easy to confuse. * That one is where the hull is PLACED on an arrival, at 14,000 plus a span. - * This is how close she has to fly before the console says what it is. So the + * This is how close they have to fly before the console says what it is. So + * the * ship is put out of sight, and finding it is the event. * * `game/world-step.ts` spends it, once per career, behind `session.genShipSeen`. diff --git a/src/constants/rating.ts b/src/constants/rating.ts index ca475238..80d969b3 100644 --- a/src/constants/rating.ts +++ b/src/constants/rating.ts @@ -4,7 +4,7 @@ // That makes ten rungs, not nine. The functions that read it (`rating`, // `ratingLadder`) are in game/rating.ts. The manual renders the chart from the // same table. That is the fix for the day the manual listed the nine ranks it -// could remember. A commander could read her own rating off the status screen, +// could remember. A commander could read their own rating off the status screen, // and then fail to find it on the chart. // // The score that climbs the ladder is `combatScore`: kills weighted by threat diff --git a/src/constants/recharge.ts b/src/constants/recharge.ts index 7343a849..ec270ff3 100644 --- a/src/constants/recharge.ts +++ b/src/constants/recharge.ts @@ -31,12 +31,12 @@ import { MAX_SHIELD } from './pools.ts'; * knee and not the floor. * * THE BANK DID NOT MOVE, and that is a measurement rather than an oversight. 139 - * states the gate: an organised tier-2 gang must be able to drive her to - * `LOW_ENERGY` in a fight she would otherwise sit through. The shield alone meets - * it, at 28% of fights before and 50% after, with three attackers. The bank is - * the pool that keeps her alive. A cut to it compounds a lethality the item did - * not ask for. 40 seconds is the figure a Cobra flew before the pools - * grew. + * states the gate: an organised tier-2 gang must be able to drive the commander + * to `LOW_ENERGY` in a fight they would otherwise sit through. The shield alone + * meets it, at 28% of fights before and 50% after, with three attackers. The + * bank is the pool that keeps the commander alive. A cut to it compounds a + * lethality the item did not ask for. 40 seconds is the figure a Cobra flew + * before the pools grew. */ export const ENERGY_REGEN_FRACTION = 0.025; diff --git a/src/constants/separation.ts b/src/constants/separation.ts index 1e82a531..178410bb 100644 --- a/src/constants/separation.ts +++ b/src/constants/separation.ts @@ -13,5 +13,7 @@ export const SEPARATION_RANGE = 200; /** * How hard a ship that closes bends its aim to avoid a mate, in units of offset. * How close the mate is scales it. It sits a shade above `PASS_MISS_DISTANCE`. - */ + * + * @rule separation.push +*/ export const SEPARATION_PUSH = 120; diff --git a/src/constants/spawn-placement.ts b/src/constants/spawn-placement.ts index 20be1f1e..4b71402c 100644 --- a/src/constants/spawn-placement.ts +++ b/src/constants/spawn-placement.ts @@ -29,7 +29,9 @@ export const TRADER_SCATTER = 1800; * `PIRATE_SCATTER` plays for a gang. The police patrol the lane rather than guard * the slot, so a fugitive can still reach the slot to pay a fine (game/law.ts, * station.ts). - */ + * + * @rule spawn.policeScatter +*/ export const POLICE_SCATTER = 1200; /** @@ -279,7 +281,9 @@ export const STATION_DEFENCE_STANDOFF = 500; /** * ...and how much further out each one after it starts, so a pair does not arrive * inside each other. It is three times a Viper's 18.75 contact radius. - */ + * + * @rule spawn.stationDefenceStack +*/ export const STATION_DEFENCE_STACK = 120; /** @@ -322,3 +326,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/constants/station.ts b/src/constants/station.ts index 2daae854..087edc6f 100644 --- a/src/constants/station.ts +++ b/src/constants/station.ts @@ -50,7 +50,11 @@ export const BOUNCE_STANDOFF = 420; /** How far off the slot you sit when the bay spits you out, in world units. */ export const LAUNCH_STANDOFF = 450; -/** ...and how fast, in world units a second — a firm push, not a cruise. */ +/** + * ...and how fast, in world units a second — a firm push, not a cruise. + * + * @rule station.launchSpeed + */ export const LAUNCH_SPEED = 120; /** diff --git a/src/constants/tactic-choice.ts b/src/constants/tactic-choice.ts index 56c5feb7..4d38fe5e 100644 --- a/src/constants/tactic-choice.ts +++ b/src/constants/tactic-choice.ts @@ -36,7 +36,11 @@ export const RAM_MIN_SPEED = PLAYER_FLIGHT.maxSpeed * 0.7; */ export const TACTIC_HURT_HEALTH = 0.6; -/** ...and how hurt before a ram is on the table, and nothing else new is. */ +/** + * ...and how hurt before a ram is on the table, and nothing else new is. + * + * @rule tactic.lastStandHealth + */ export const TACTIC_LAST_STAND_HEALTH = 0.25; /** @@ -76,5 +80,7 @@ export const TACTIC_MIN_DWELL = 5; * does is not working. It is the anti-degeneracy trigger. 12 seconds is * comfortably past a whole attack run, which is 7.2s at the median and 9.98 at * the ninetieth. + * + * @rule tactic.sleeperSeconds */ export const TACTIC_SLEEPER_SECONDS = 12; diff --git a/src/constants/threat.ts b/src/constants/threat.ts index 20fcb693..467dd0d5 100644 --- a/src/constants/threat.ts +++ b/src/constants/threat.ts @@ -127,8 +127,17 @@ export const PRIZE_SATURATION = 25000; * Speed is deliberately absent: a fast hull is harder to catch, * not harder to beat. The weights are Harmless's. The numbers they multiply are * the source's. + * + * @rule threat.defenceWeight */ export const DEFENCE_WEIGHT = 12; +/** + * 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..6d657965 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; @@ -44,3 +46,20 @@ export const MASS_LOCK_PLANET_ALTITUDE = 4000; * @rule torus.massLockShip */ export const MASS_LOCK_SHIP = 4500; + +/** + * How far a ship must open back out before it can be announced a second time + * (game/close-pass.ts). A ship that comes inside `MASS_LOCK_SHIP` is named + * one time. A single line would repeat on every wobble across the radius. + * + * It is 1.5 times the lock radius. The hysteresis is 2,250 units, which is + * over 5 seconds of flight at the ordinary top speed. A pair of ships that + * hold station together therefore says nothing more. + * + * It lives here, beside `MASS_LOCK_SHIP`, because it is measured from that + * radius. A move would split one pair of numbers over two files. + * + * @rule torus.closePassClear + * @domain torus + */ +export const CLOSE_PASS_CLEAR = 6750; diff --git a/src/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/constants/witchspace.ts b/src/constants/witchspace.ts index a2ba0529..7408bf8f 100644 --- a/src/constants/witchspace.ts +++ b/src/constants/witchspace.ts @@ -60,7 +60,8 @@ export const THARGOID_AMBUSH_RANGE_SPAN = 2500; // Two constants used to end this file: STRANDED_HINT_FIRST and // STRANDED_HINT_REPEAT. They were the cadence of a console message that told -// you to press B, and they went with that message (docs/TODO/128). To be stranded is a situation, not an event. The cockpit's -// prompt line now carries the offer for as long as it is true. So there is no +// you to press B, and they went with that message (docs/TODO/128). To be +// stranded is a situation, not an event. The cockpit now offers the beacon as +// a button for as long as it is true (docs/TODO/206 M5). So there is no // repeat to time, and no letter to hard-code. The condition itself // (`WITCHSPACE_ESCAPE_COST` in the tank) is `game/prompts.ts`. diff --git a/src/constants/world-clock.ts b/src/constants/world-clock.ts index 481c5182..f13a3bdb 100644 --- a/src/constants/world-clock.ts +++ b/src/constants/world-clock.ts @@ -19,6 +19,8 @@ export const FIXED_DT = 1 / 60; * The longest real interval the loop will simulate before it drops the backlog. A * backgrounded tab, a breakpoint or a slow first paint can hand the loop seconds. * To simulate them lands the ship somewhere it was never flown to. + * + * @rule clock.maxFrameTime */ export const MAX_FRAME_TIME = 0.25; diff --git a/src/constants/wreck.ts b/src/constants/wreck.ts index 8fd3d2d2..e02e91de 100644 --- a/src/constants/wreck.ts +++ b/src/constants/wreck.ts @@ -23,7 +23,7 @@ export const ESCAPE_CHANCE = { trader: 0.45, other: 0.2 } as const; /** * Seconds the commander's beam registers nothing on a bystander, counted from - * the moment her own shot destroys a ship (GitHub #35). + * the moment their own shot destroys a ship (GitHub #35). * * A **bystander** is a ship that `isHostileToPlayer` says is not already in the * fight. Only a ship that was minding its own business is covered, and that is diff --git a/src/encyclopaedia/filters.ts b/src/encyclopaedia/filters.ts index 9da1e88e..1a84ea00 100644 --- a/src/encyclopaedia/filters.ts +++ b/src/encyclopaedia/filters.ts @@ -70,7 +70,7 @@ export function facetsOf(entries: Entry[]): Facets { return { // Economy and government keep their 1984 order. The game shows that order // everywhere else. A reader who moves between the two never has to find - // her place again. + // their place again. economies: [...eco.entries()].sort((a, b) => a[0] - b[0]) .map(([value, count]) => ({ value, label: ECONOMY_NAMES[value], count })), governments: [...gov.entries()].sort((a, b) => a[0] - b[0]) diff --git a/src/engine/browser-shell.ts b/src/engine/browser-shell.ts index db6fbb8a..65a759da 100644 --- a/src/engine/browser-shell.ts +++ b/src/engine/browser-shell.ts @@ -40,17 +40,30 @@ export function browserShell(canvas: HTMLCanvasElement, scene: THREE.Scene): She return { view, - // was `window.addEventListener('resize', ...)` in the constructor - onResize: (fn) => { window.addEventListener('resize', () => fn()); }, + // was `window.addEventListener('resize', ...)` in the constructor. The + // console is measured on the same path, and it changes size on its own + // when a font lands or a button wraps (docs/TODO/222). So the console is + // watched too, where the browser can watch an element. + onResize: (fn) => { + window.addEventListener('resize', () => fn()); + const hud = document.getElementById('hud'); + if (hud && typeof ResizeObserver !== 'undefined') new ResizeObserver(() => fn()).observe(hud); + }, // 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/205 M5 it listens on the course buttons too. Each carries + // a data-key, as a menu row does, so a click takes the same path. The + // target list is its own column since 2026-09-13, so it is listed here. onScreenClick: (fn) => { - document.getElementById('screen')!.addEventListener('click', (e) => { - const el = (e.target as HTMLElement).closest('[data-key],[data-row]'); - fn(el ?? e.target, e); - }); + for (const id of ['screen', 'courses', 'course-row', 'targets', 'target-row', 'guns']) { + document.getElementById(id)?.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 e96bbb35..e988fc54 100644 --- a/src/engine/flight-controls.ts +++ b/src/engine/flight-controls.ts @@ -29,6 +29,8 @@ export interface FlightControls { readonly mouseX: number; readonly mouseY: number; readonly mouseFire: boolean; + /** the roll strip's stick, -1 to 1, or null when nothing holds it (docs/TODO/207) */ + readonly rollStick?: number | null; } /** The ramped rates the demand continues from — the ship's own, in practice. */ @@ -61,6 +63,10 @@ export function flightDemand( let rollIn = (c.held(...keys.rollLeft) ? 1 : 0) - (c.held(...keys.rollRight) ? 1 : 0); let pitchIn = (c.held(...keys.pitchUp) ? 1 : 0) - (c.held(...keys.pitchDown) ? 1 : 0); + // The roll strip of a docking (docs/TODO/207 M2): an analogue roll for a + // mouse and a finger. A roll key held beats it, as it beats the mouse. + if (rollIn === 0 && c.rollStick != null) rollIn = -c.rollStick; + // mouse flight: analogue axes, keyboard still overrides when touched if (c.mouseFlight) { if (rollIn === 0) rollIn = -c.mouseX; diff --git a/src/engine/hold-buttons.ts b/src/engine/hold-buttons.ts new file mode 100644 index 00000000..f3809ad2 --- /dev/null +++ b/src/engine/hold-buttons.ts @@ -0,0 +1,38 @@ +// A button that holds a key down while a finger or the mouse holds it +// (docs/TODO/206 M3). +// +// The laser fires while its button is held, as it fires while its key is +// held. A click cannot say that, because a click is one moment. So a +// `data-hold` button presses its code on pointer down and releases it on +// pointer up, through `Input.press` and `Input.release`. The game then reads a +// held key, and it cannot tell a finger from the key. +// +// PLATFORM, like the rest of `Input`'s listeners, and nothing when the page +// has no document. Each pointer is tracked by its id, so a second finger that +// lifts does not let go of the first finger's button. + +/** The two calls a held button makes on the input. */ +export interface HoldTarget { + press(code: string): void; + release(code: string): void; +} + +export function attachHoldButtons(input: HoldTarget, doc: Document): void { + const held = new Map(); + doc.addEventListener('pointerdown', (e) => { + const el = (e.target as HTMLElement | null)?.closest?.('[data-hold]') as HTMLElement | null; + const code = el?.dataset.hold; + if (!code) return; + e.preventDefault(); + held.set(e.pointerId, code); + input.press(code); + }); + const letGo = (e: PointerEvent): void => { + const code = held.get(e.pointerId); + if (code === undefined) return; + held.delete(e.pointerId); + input.release(code); + }; + doc.addEventListener('pointerup', letGo); + doc.addEventListener('pointercancel', letGo); +} diff --git a/src/engine/input.ts b/src/engine/input.ts index ab9a2bb0..72dbcbfc 100644 --- a/src/engine/input.ts +++ b/src/engine/input.ts @@ -1,4 +1,6 @@ import { CARRY_LIMIT } from '../constants/world-clock.ts'; +import { attachHoldButtons } from './hold-buttons.ts'; +import { attachStripControl } from './strip-control.ts'; // Keyboard state with frame-oriented semantics: // - held(codes): live keydown state — every continuous control, the trigger @@ -67,6 +69,12 @@ export class Input { mouseX = 0; mouseY = 0; mouseFire = false; + /** + * The roll strip's stick, -1 to 1, or null when nothing holds it + * (docs/TODO/207 M2). A roll key held beats it, as a speed key beats a + * throttle slider. + */ + rollStick: number | null = null; private readonly canvas: HTMLElement | null; constructor() { @@ -79,6 +87,10 @@ export class Input { return; } this.canvas = document.getElementById('scene'); + // The laser button holds its key as a finger holds it (docs/TODO/206 M3). + attachHoldButtons(this, document); + // ...and the roll strip of the last stretch of a docking (docs/TODO/207). + attachStripControl(this, document); document.addEventListener('pointerlockchange', () => { this.mouseFlight = document.pointerLockElement === this.canvas; if (!this.mouseFlight) { @@ -137,6 +149,24 @@ export class Input { if (code === 'Slash') this.down.delete('Question'); } + /** + * 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); + } + + 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/src/engine/keymap.ts b/src/engine/keymap.ts index cec8ca80..193aabf3 100644 --- a/src/engine/keymap.ts +++ b/src/engine/keymap.ts @@ -111,5 +111,6 @@ export function refreshHelpPanel(): void { set('help-decel', 'SPACE · X or /'); set('help-fire', 'F'); } - set('help-layout', `ACTIVE LAYOUT: ${active.toUpperCase()} — toggle with B when docked`); + // The switch is a station menu row since docs/TODO/202, and not a key. + set('help-layout', `ACTIVE LAYOUT: ${active.toUpperCase()} — KEYBOARD LAYOUT on the station menu switches it`); } diff --git a/src/engine/phone-chrome.ts b/src/engine/phone-chrome.ts new file mode 100644 index 00000000..a03b968a --- /dev/null +++ b/src/engine/phone-chrome.ts @@ -0,0 +1,46 @@ +// The phone's chrome: no zoom, and no address bar (docs/TODO/220). +// +// Platform code, beside `browser-shell.ts`. `main.ts` calls the two at boot, +// and a headless game never sees them. +// +// TWO FIXES FOR EACH FAULT, because the phones differ. The viewport meta +// and `touch-action` refuse a pinch and a double tap where a browser honours +// them. The iPhone honours neither in full, so the listeners below cancel +// the gesture itself. A web app manifest hides the address bar once the page +// is on the home screen. Android's browser also hides it on a fullscreen +// request, which the iPhone does not offer, so the request is a try. + +/** Cancel a pinch and a double tap that the stylesheet could not refuse. */ +export function holdZoom(doc: Document = document): void { + // Safari's proprietary gesture events carry the pinch on the iPhone. + doc.addEventListener('gesturestart', (e) => e.preventDefault(), { passive: false }); + doc.addEventListener('gesturechange', (e) => e.preventDefault(), { passive: false }); + // A two-finger move is a pinch. The event's `scale` is Safari's, and a + // browser without it reports one finger per touch instead. + doc.addEventListener('touchmove', (e) => { + const scale = (e as TouchEvent & { scale?: number }).scale; + if ((scale !== undefined && scale !== 1) || e.touches.length > 1) e.preventDefault(); + }, { passive: false }); + // A second tap inside the double tap window is a zoom on the iPhone. + let last = 0; + doc.addEventListener('touchend', (e) => { + const now = Date.now(); + if (now - last < 300) e.preventDefault(); + last = now; + }, { passive: false }); +} + +/** + * Ask for the whole screen on the first touch, on a coarse pointer alone. A + * desktop click never asks. A refusal is silent, because the manifest is the + * fix that holds where the request does not exist. + */ +export function askFullscreen(doc: Document = document, win: Window = window): void { + if (!win.matchMedia?.('(pointer: coarse)').matches) return; + const ask = (): void => { + const root = doc.documentElement as HTMLElement & { requestFullscreen?: () => Promise }; + if (doc.fullscreenElement || !root.requestFullscreen) return; + root.requestFullscreen().catch(() => {}); + }; + doc.addEventListener('touchend', ask, { once: true, passive: true }); +} diff --git a/src/engine/strip-control.ts b/src/engine/strip-control.ts new file mode 100644 index 00000000..a66aa7e0 --- /dev/null +++ b/src/engine/strip-control.ts @@ -0,0 +1,50 @@ +// A strip you drag with a finger or the mouse (docs/TODO/207 M2). +// +// The last stretch of a docking asks for one thing a button cannot give: how +// MUCH roll, held steady. So a `data-strip` element reads the pointer's +// distance from its own middle, and reports it as a stick between -1 and 1. +// The ship then rolls at that share of its rate, exactly as an arrow key +// rolls it at the whole rate. +// +// PLATFORM, like the held buttons next door, and nothing when the page has no +// document. The strip moves its own knob. The painter rebuilds the markup only +// when the words change, and the knob moves every frame a thumb does. + +/** What a strip reports to. */ +export interface StripTarget { + /** the stick, -1 to 1, or null when nothing holds the strip */ + rollStick: number | null; +} + +export function attachStripControl(input: StripTarget, doc: Document): void { + let held: { id: number; el: HTMLElement } | null = null; + + const move = (el: HTMLElement, clientX: number): void => { + const box = el.getBoundingClientRect(); + const half = box.width / 2; + const off = Math.max(-1, Math.min(1, (clientX - (box.left + half)) / half)); + input.rollStick = off; + const knob = el.querySelector('.knob'); + if (knob) knob.style.transform = `translateX(${off * (half - knob.offsetWidth / 2)}px)`; + }; + + doc.addEventListener('pointerdown', (e) => { + const el = (e.target as HTMLElement | null)?.closest?.('[data-strip]') as HTMLElement | null; + if (!el) return; + e.preventDefault(); + held = { id: e.pointerId, el }; + move(el, e.clientX); + }); + doc.addEventListener('pointermove', (e) => { + if (held && e.pointerId === held.id) move(held.el, e.clientX); + }); + const letGo = (e: PointerEvent): void => { + if (!held || e.pointerId !== held.id) return; + const knob = held.el.querySelector('.knob'); + if (knob) knob.style.transform = 'translateX(0)'; + held = null; + input.rollStick = null; + }; + doc.addEventListener('pointerup', letGo); + doc.addEventListener('pointercancel', letGo); +} diff --git a/src/galaxy/galaxies.ts b/src/galaxy/galaxies.ts new file mode 100644 index 00000000..1867831f --- /dev/null +++ b/src/galaxy/galaxies.ts @@ -0,0 +1,23 @@ +// The eight galaxies, generated once each and kept. +// +// `generateGalaxy` is a pure function of its number, and it costs a few +// milliseconds. Three readers want a galaxy the commander is not in. The +// mission bridge names a lead's world. The story names a journal entry's +// world. The LOG draws the route (docs/TODO/213 M4). One memo, so no reader +// keeps a second copy of the same 256 systems. The bridge kept a memo of +// one galaxy at a time before this file. A story that names two galaxies +// thrashes a memo of one. + +import { generateGalaxy, type StarSystem } from './galaxy.ts'; + +const memo = new Map(); + +/** The systems of `galaxy`, generated on the first ask and kept. */ +export function galaxySystems(galaxy: number): readonly StarSystem[] { + let s = memo.get(galaxy); + if (!s) { + s = generateGalaxy(galaxy); + memo.set(galaxy, s); + } + return s; +} diff --git a/src/game/autopilot.ts b/src/game/autopilot.ts index 3b395fc5..318c18d9 100644 --- a/src/game/autopilot.ts +++ b/src/game/autopilot.ts @@ -36,12 +36,14 @@ import type { FlightDemand } from '../player.ts'; import type { Brain } from '../ai-training/policy.ts'; import type { V3 } from '../ai-training/observation.ts'; import { hostilesNear } from './hostility.ts'; +import { pickedTarget } from './targets.ts'; import { defenceBrainNameFor } from './brain-names.ts'; import type { CombatComputer } from './combat-computer.ts'; import { ScriptedCoPilot } from './scripted-co-pilot.ts'; import type { SoundEvent } from './sounds.ts'; import type { GameState } from './state.ts'; import { DOCK_COMPUTER_RANGE } from '../constants/docking-computer.ts'; +import { THREAT_RANGE } from '../constants/combat-computer.ts'; /** What an autopilot reports for the orchestrator to say and play. */ export type AutopilotEvent = @@ -132,24 +134,72 @@ export class Autopilot { } /** - * The combat computer, on or off. + * Is there a fight for the computer to line the ship up in? A hostile ship + * inside the CO-PILOT's reach, or a target the pilot picked. + * + * IT USED TO ASK THE CONDITION LIGHT, which looks 9,000 units out. The + * co-pilot only looks `THREAT_RANGE`, which is 6,500. A pirate in that band + * engaged the computer, and the co-pilot refused it in the same frame. That + * happened on EVERY frame. Measured at a pirate 7,500 units off, it was 300 + * engagements in 5 seconds. Each one carried its own sound and its own AREA + * CLEAR on the console (Chris, 2026-09-12: *"why does it go on for so long? + * Several long seconds?"*). + * + * A picked target has no range test, because the co-pilot has none for one + * either. The pilot's pick comes first, at any distance. + */ + private fightOn(): boolean { + const s = this.state; + return pickedTarget(s.world.npcs) !== null || hostilesNear( + s.world.npcs, s.player.position, s.commander.legalStatus, this.playerToStation, + THREAT_RANGE, s.session.cloaked); + } + + /** + * A fight starts, and the computer takes the stick to line the ship up + * (docs/TODO/206 M2). Every pilot gets the aim now. The bought combat + * computer adds the trigger and the E.C.M., and the Game applies those. + * + * It does nothing while the pilot flies by hand. A flight key set that, and + * a pick of a course or a target clears it. So a pilot who takes the stick + * keeps it (Chris, 2026-09-11: *"hitting the keyboard will take control + * during combat"*). + */ + autoEngage(): AutopilotEvent[] { + const s = this.state; + if (s.session.ccEngaged || s.session.handFlown || !this.fightOn()) return []; + // A ship that runs does not turn to fight (docs/TODO/206 M5). + if (s.session.course === 'run') return []; + // The LIVE BRAINS row can set the co-pilot to NONE outright. + if (defenceBrainNameFor(s.brains) === 'scripted') return []; + s.session.ccEngaged = true; + s.session.view = 0; // it aims the front laser + // NO CONSOLE LINE. A fight starts at the very moment the lines that + // matter arrive, such as what a shot cost the commander. The label over + // the view says that the computer aims, and the sound marks the moment. + return [{ kind: 'sound', name: 'combatComputerEngaged' }]; + } + + /** + * The computer's aim, on or off, on its key. + * + * Every pilot has the aim since docs/TODO/206, so an unfitted ship no longer + * refuses. Off hands the controls to the pilot, and the computer then waits + * to be asked. On hands the stick back. * * It refuses to engage with nothing to fight, and that is not a limitation. - * The policy trained to fly a defence. In an empty sky it would merely hold - * the ship, while the player wondered why the controls felt odd. + * In an empty sky it would merely hold the ship, while the player wondered + * why the controls felt odd. */ toggleCombat(): AutopilotEvent[] { const s = this.state; - if (!s.commander.equipment.combatComputer) { - return [say('NO COMBAT COMPUTER FITTED', 3), REFUSED]; - } if (s.session.ccEngaged) { s.session.ccEngaged = false; - return [say('COMBAT COMPUTER OFF', 2)]; + s.session.handFlown = true; + return [say('YOU HAVE THE CONTROLS', 2)]; } - if (!hostilesNear( - s.world.npcs, s.player.position, s.commander.legalStatus, this.playerToStation)) { - return [say('NO HOSTILES — COMBAT COMPUTER IDLE', 3), REFUSED]; + if (!this.fightOn()) { + return [say('NOTHING TO FIGHT', 3), REFUSED]; } // The LIVE BRAINS row can set the co-pilot to NONE outright. A refusal // here, in the row's own words, beats a pilot that engages and hands back @@ -158,9 +208,10 @@ export class Autopilot { return [say('COMBAT COMPUTER SET TO NONE — SEE THE COMBAT TRAINER', 4), REFUSED]; } s.session.ccEngaged = true; + s.session.handFlown = false; s.session.view = 0; // it aims the front laser return [ - say('COMBAT COMPUTER ENGAGED — ANY FLIGHT KEY OVERRIDES', 4), + say('THE COMPUTER IS LINING YOU UP — A FLIGHT KEY TAKES THE CONTROLS', 4), { kind: 'sound', name: 'combatComputerEngaged' }, ]; } @@ -187,9 +238,12 @@ export class Autopilot { missilePos, this.playerToStation); if (step.kind === 'disengage') { s.session.ccEngaged = false; + if (handsOn) s.session.handFlown = true; + // THE E.C.M. IS THE STEP'S, EVEN HERE. A warhead still closing is not + // answered by a clear area (the review of 2026-09-12). return { demand: null, - ecm: false, + ecm: step.ecm, events: [say(step.reason, step.reason === 'MANUAL OVERRIDE' ? 2 : 3)], }; } @@ -212,12 +266,14 @@ export class Autopilot { const s = this.state; const step = this.scripted.step( dt, s.player, s.world.npcs, s.commander.legalStatus, handsOn, missilePos, - this.playerToStation); + this.playerToStation, pickedTarget(s.world.npcs)); if (step.kind === 'disengage') { s.session.ccEngaged = false; + if (handsOn) s.session.handFlown = true; + // ...and the same here — see `combatDemand` above. return { demand: null, - ecm: false, + ecm: step.ecm, events: [say(step.reason, step.reason === 'MANUAL OVERRIDE' ? 2 : 3)], }; } diff --git a/src/game/bindings.ts b/src/game/bindings.ts index ec336ff6..46f94d54 100644 --- a/src/game/bindings.ts +++ b/src/game/bindings.ts @@ -33,8 +33,74 @@ // 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', + run: 'VirtCourseRun', + collect: 'VirtCourseCollect', +}; + +/** The launch list's row for the galactic chart, where a pilot sets a target. */ +export const COURSE_CHART_KEY = 'VirtCourseChart'; + +/** + * The button in flight that STOPS the course under way (docs/TODO/205 M5). It + * has no key: Chris, 2026-09-11, *"We don't need to use the keyboard. We have + * mouse and touch."* + * + * It used to open the course list over a running course, and close it again. + * Chris called that confusing on 2026-09-12: *"I think you should be able to + * click it to disengage it."* A lit button reads as ON, so a tap turns it off. + * The list then shows by itself, because it always does with no course. + */ +export const COURSE_STOP_KEY = 'VirtCourseStop'; + +/** The fast forward button in flight (docs/TODO/205 M7). It has no key either. */ +export const COURSE_SKIP_KEY = 'VirtCourseSkip'; + +/** + * The header that opens and folds the course list (docs/TODO/215 M2). The + * list is closed by default, and its rows are a row that scrolls sideways + * under the header. It has no key, as the rows have none. + */ +export const COURSE_LIST_KEY = 'VirtCourseList'; + +/** + * The target list's buttons in flight (docs/TODO/206 M3). There are three + * codes: the button that opens the list, the row that lets the computer + * choose, and the prefix of a row's code. A row's code ends in a number that + * names one ship for as long as the ship lives (`target-actions.ts`). None of + * them has a key. + */ +export const TARGETS_KEY = 'VirtTargets'; +export const TARGET_NONE_KEY = 'VirtTargetNone'; +export const TARGET_ROW_PREFIX = 'VirtTarget'; + +/** + * The roll strip of the last stretch into the slot (docs/TODO/207 M2). It is + * not a key at all: `engine/strip-control.ts` reads the pointer's distance + * from the strip's middle, and reports a stick. The code names the strip in + * the markup, as a button's code names the button. + */ +export const ROLL_STRIP_CODE = 'roll'; + export const GLOBAL_BINDINGS: readonly Binding[] = [ // ? toggles the controls guide (plain / is the classic decelerate key) { key: 'Question', command: 'toggleHelp' }, @@ -67,6 +133,8 @@ const FLIGHT_BINDINGS: readonly Binding[] = [ { key: 'KeyM', command: 'launchMissile' }, { key: 'KeyU', command: 'disarmMissile' }, { key: 'KeyE', command: 'fireEcm' }, + // Z, one of the two plain letters that were free (docs/TODO/219 M5). + { key: 'KeyZ', command: 'toggleCloak' }, { key: 'KeyK', command: 'toggleCombatComputer' }, { key: 'KeyV', command: 'toggleMouseFlight' }, { key: 'Tab', command: 'detonateEnergyBomb' }, @@ -242,7 +310,7 @@ export const BINDINGS: Record = { /** * The galactic drive, with missions held: it swallows every other key until - * she answers. Y jumps and fails them. Escape and N stay. The ship flies on + * they answer. Y jumps and fails them. Escape and N stay. The ship flies on * underneath, because a pause is its own key and this is not one. */ confirmGalacticJump: [ diff --git a/src/game/brains.ts b/src/game/brains.ts index d0288908..cd5a7d6c 100644 --- a/src/game/brains.ts +++ b/src/game/brains.ts @@ -17,7 +17,7 @@ import { import { observe, observePack, observeDefend, observeFor, } from '../ai-training/observation.ts'; -import { energyLeft, poolsLeft } from './systems.ts'; +import { energyLeft, poolsLeft } from './pools-left.ts'; import { SHIPPED_BRAINS, type BrainName, type BrainSelection, } from './brain-names.ts'; diff --git a/src/game/career.ts b/src/game/career.ts index eb19fd21..b57cf105 100644 --- a/src/game/career.ts +++ b/src/game/career.ts @@ -56,7 +56,7 @@ import type { GameState } from './state.ts'; * THE MODE IS READ TWICE AND THEY ARE DIFFERENT QUESTIONS, the same way they * are in `law-actions.ts`. `mode()` includes an open screen, so a death cannot * arrive while the game-over panel is up. `baseMode()` does not, because a - * screen CAN be open over a dead commander: the panel offers her the file. A + * screen CAN be open over a dead commander: the panel offers them the file. A * save asked which mode to write would then find `'saves'`. */ export interface CareerHost { @@ -152,7 +152,7 @@ export class Career { * * ONE HOME, because two places draw it: the death itself, and Escape off the * screen the panel opened (`showBaseScreen` in game.ts). The panel is a - * commander plus what her checkpoint holds, and the two callers had that + * commander plus what their checkpoint holds, and the two callers had that * three-part expression written out each. * * @internal — driven by src/game/game.ts, whose base screen redraws it. diff --git a/src/game/close-pass.ts b/src/game/close-pass.ts new file mode 100644 index 00000000..2862027b --- /dev/null +++ b/src/game/close-pass.ts @@ -0,0 +1,69 @@ +// Who came close, and was not announced yet (docs/TODO/209). +// +// Chris flew a trip to the station and reported that a neutral trader did not +// stop the ship. The mass lock rule did fire when the torus drive ran. It did +// nothing when the drive was already off, and the drive is off for most of a +// trip. So a trader could pass at 1,000 units and say nothing at all. +// +// His own words about the sky, of 2026-09-11: "When anything is in range we +// should show a list of objects that can be engaged - these can be outright +// hostiles or neutral ships that we want to pirate." The target list already +// holds every ship in scanner range. The pilot had no reason to open it. +// +// THIS FILE ONLY SPEAKS. It never stops the ship, and it never picks a target. +// A neutral ship must not interrupt a course, because the pilot chose that +// course. The line tells the pilot that the chance is there. +// +// IT NAMES A TRADER, AND NOTHING ELSE. A hostile ship announces itself: it +// shoots, it raises the condition light, and the course offers a way out. The +// law's ships are a risk rather than a chance. A line that invites an attack +// on a Viper beside the station is bad advice. A trader is the ship +// Chris named, and a trader carries the cargo. +// +// It also says nothing inside the station's own mass lock. The pilot is on +// the approach there, and traffic is thick. +// +// THE FLAG IS ON THE SHIP, as the target pick is (`targets.ts`). A ship has no +// stable id, and its state is saved with it. The flag clears when the ship +// opens back out past `CLOSE_PASS_CLEAR`, so a second approach speaks again. + +import type * as THREE from 'three'; +import type { NpcShip } from './npc.ts'; +import { isHostileToPlayer } from './hostility.ts'; +import { shipArticle } from './targets.ts'; +import { CLOSE_PASS_CLEAR, MASS_LOCK_SHIP, MASS_LOCK_STATION } from '../constants/torus.ts'; + +/** Everything the rule reads. */ +export interface CloseView { + readonly npcs: readonly NpcShip[]; + readonly playerPos: THREE.Vector3; + readonly legalStatus: number; + /** how far the commander is from the station, for the truce */ + readonly playerToStation: number; +} + +/** + * The lines to show for the ships that came close since the last step. + * + * It sets `announcedClose` on each ship it names. It clears the flag on a ship + * that opened back out. + */ +export function closePassLines(v: CloseView): string[] { + const out: string[] = []; + if (v.playerToStation < MASS_LOCK_STATION) return out; + for (const npc of v.npcs) { + if (npc.role !== 'trader') continue; + const range = npc.object.position.distanceTo(v.playerPos); + if (range > CLOSE_PASS_CLEAR) { npc.state.announcedClose = false; continue; } + if (range > MASS_LOCK_SHIP) continue; + if (!npc.state.alive || npc.state.docked) continue; + if (npc.state.announcedClose) continue; + if (isHostileToPlayer(npc, v.legalStatus, v.playerToStation)) continue; + // A ship the pilot already picked, or already shot at, is not an offer. + // The pilot made the choice. The line would be an answer to nobody. + if (npc.state.targeted || npc.state.provokedByPlayer) continue; + npc.state.announcedClose = true; + out.push(`${shipArticle(npc)} IS CLOSE — TAP \u25CE TO ATTACK IT`); + } + return out; +} diff --git a/src/game/cockpit-buttons.ts b/src/game/cockpit-buttons.ts new file mode 100644 index 00000000..4aba9e62 --- /dev/null +++ b/src/game/cockpit-buttons.ts @@ -0,0 +1,263 @@ +// The buttons over the flight view, built from what the cockpit sees +// (docs/TODO/205 M5, docs/TODO/206 M3 and M5). +// +// Two headers over the sky, and a row in the console. The course header sits +// top right, closed by default, and its list opens as one row under it that +// scrolls sideways (docs/TODO/215 M2). A course under way is the header, lit, +// with fast forward beside it. A run from a fight and the offers the +// situation raises sit under the header at all times, because each is +// urgent. The target header sits bottom left, and its list opens as one row +// above it. The guns are a row at the bottom of the console (docs/TODO/215 +// M1): the laser, the missile as two buttons, and the E.C.M. +// +// The guns and the target list sat bottom right until 2026-09-13, and the +// list opened upward over the action. Chris: *"the buttons cover up the +// area where all the action is happening"*, and then *"I think the buttons +// should be at the bottom"*. He drew the row, with the missile split in two. +// +// PURE, and it decides nothing. Each function takes what the cockpit already +// read and returns `HudButton`s. `hud/hud-buttons.ts` paints them, and a +// click or a tap sends the button's code, as a station menu row does. +// +// THE WORDS ARE THE PLAYER'S (Chris, 2026-09-11: *"A user does not have all +// our context."*). No button says "course". A button under way says what the +// ship is doing, and a button the ship cannot use says what it needs. + +import type { HudButton } from '../hud/hud-buttons.ts'; +import type { CoursePanel } from './course-actions.ts'; +import type { TargetPanel } from './target-actions.ts'; +import type { Prompt } from './prompts.ts'; +import type { ControlMode } from './controls.ts'; +import { COURSE_NAMES, type Course } from './courses.ts'; +import { + COURSE_KEYS, COURSE_LIST_KEY, COURSE_SKIP_KEY, COURSE_STOP_KEY, TARGET_NONE_KEY, TARGETS_KEY, +} from './bindings.ts'; +import { keyCodeIfBound, keyIfBound } from '../ui/key-help.ts'; +import { LASER_RANGE } from '../constants/player-gun.ts'; + +/** A course as a button: its words, and what stops it, under them. */ +const courseButton = (c: Course): HudButton => + ({ code: COURSE_KEYS[c.kind], label: c.what, ...(c.why === null ? {} : { note: c.why }) }); + +/** + * The course header (docs/TODO/205 M5, docs/TODO/215 M2). Over a course that + * flies, one lit button names it, and fast forward sits beside it. With no + * course, one header opens the list, closed by default. A run from a fight + * stays under it, because a pilot in a fight has no time for a tap. + */ +export function courseButtonsFor(p: CoursePanel): HudButton[] { + if (p.rows === null) { + return p.current === null ? [] : [ + { code: COURSE_STOP_KEY, label: COURSE_NAMES[p.current], lit: true, hint: 'TAP TO STOP' }, + skipButton(p), + ].filter((b): b is HudButton => b !== null); + } + const ways = p.rows.filter((c) => c.kind !== 'run'); + const out: HudButton[] = [{ + code: COURSE_LIST_KEY, label: p.open ? 'ACTIONS ▴' : 'ACTIONS ▾', lit: p.open, + hint: p.open ? 'TAP TO FOLD' : `${ways.length} WAYS TO GO`, + }]; + const run = p.rows.find((c) => c.kind === 'run'); + if (run) out.push(courseButton(run)); + return out; +} + +/** + * The course row (docs/TODO/215 M2): every course but the run, and the local + * chart, as one row that scrolls sideways under the header. Empty while the + * header is closed, or while a course flies. + */ +export function courseRowFor(p: CoursePanel, chart: string | null): HudButton[] { + if (p.rows === null || !p.open) return []; + const out = p.rows.filter((c) => c.kind !== 'run').map(courseButton); + if (chart) out.push({ code: chart, label: 'LOCAL CHART' }); + return out; +} + +/** What the pilot's buttons are built from (docs/TODO/206 M3). */ +export interface ActionSource { + /** the fire key of the live layout, which the laser button holds */ + readonly fireKey: string | null; + readonly missiles: number; + readonly armed: boolean; + readonly locked: boolean; + readonly armKey: string | null; + /** the disarm key, so the missile is two buttons: arm or disarm, and fire */ + readonly disarmKey: string | null; + readonly launchKey: string | null; + /** the E.C.M.'s key, or null when none is fitted */ + readonly ecmKey: string | null; + /** the cloak's key, or null with no cloaking device fitted (docs/TODO/219 M5) */ + readonly cloakKey: string | null; + /** ...and whether it runs */ + readonly cloaked: boolean; + /** a hostile missile is on its way, so the E.C.M. is the button to press */ + readonly missileInbound: boolean; + /** + * The docking computer's key, or null where none is fitted. It is offered as + * a button for the whole of the pilot's stretch at the slot (Chris, + * 2026-09-12). A commander who paid for the fitting may use it at any point, + * rather than fly a line-up they did not ask for. + */ + readonly dockKey: string | null; + /** the pilot flies the last stretch into the slot (docs/TODO/207 M2) */ + readonly trial: boolean; + /** ...and the rails have it, so the mini game is on (docs/TODO/212) */ + readonly rails: boolean; + /** the keys that open and close the throttle, for the two held buttons */ + readonly accelKey: string | null; + readonly decelKey: string | null; + /** the code the roll strip reports under */ + readonly rollStripCode: string; + readonly targets: TargetPanel | null; +} + +/** + * The target header, bottom left (docs/TODO/206 M3, docs/TODO/215 M2). It + * is one button that opens the list and folds it. Its hint is the count, or + * the pick. It is empty while the pilot flies the slot, when the guns wait, + * and while nothing is on the scanner. + */ +export function targetButtonsFor(a: ActionSource): HudButton[] { + if (a.trial) return []; + const t = a.targets; + if (!t || (t.rows.length === 0 && !t.open)) return []; + // An icon with the count (Chris, 2026-09-14, docs/TODO/222): a bullseye + // and how many are on the scanner. It reads red while any of them attacks + // the commander, so the icon is the threat tell too. Lit while it is open. + return [{ + code: TARGETS_KEY, label: `${targetsIcon} ${t.rows.length}`, icon: true, lit: t.open, + hostile: t.rows.some((r) => r.row.hostile), + }]; +} + +/** A bullseye: the mark for a target on every scanner since the arcade. */ +const targetsIcon = '\u25CE'; + +/** + * The target row (docs/TODO/215 M2): a button per ship with the range and + * the standing, and one that lets the computer choose again. It is one row + * that scrolls sideways above the header. Empty while the header is closed. + */ +export function targetRowFor(a: ActionSource): HudButton[] { + const t = a.targets; + if (a.trial || !t || !t.open) return []; + const out: HudButton[] = t.rows.map(({ code, row }) => { + const reach = row.range <= LASER_RANGE ? 'IN LASER RANGE' : 'OUT OF LASER RANGE'; + return { + code, label: row.name, lit: row.picked, hostile: row.hostile, + // A rock's name IS its standing, so the hint says the range alone. + hint: row.name === row.standing ? reach : `${row.standing} · ${reach}`, + ...(row.cost ? { note: row.cost } : {}), + }; + }); + if (t.picked) out.push({ code: TARGET_NONE_KEY, label: 'LET THE COMPUTER CHOOSE' }); + return out; +} + +/** + * The gun row, left to right (docs/TODO/215 M1). FIRE LASER is held. ARM A + * MISSILE or DISARM reads by the missile's state, with the count. FIRE THE + * MISSILE is lit on a lock. E.C.M. is lit while a missile is inbound. The + * row is always four buttons in the same places. A button the ship cannot + * use stays dim and says why, so a thumb can learn the row. During the + * pilot's stretch into the slot the row holds the stretch's own buttons. + */ +export function gunButtonsFor(a: ActionSource): HudButton[] { + // The last stretch into the slot asks for two things and nothing else: the + // roll, and the speed (docs/TODO/207 M2). So the pilot's buttons are those + // two while it runs, and the guns wait. + // THE FITTING THE COMMANDER PAID FOR, offered for the whole stretch. It + // flies the rest of the approach and the slot, and it ends the trial. + const handOver = (): HudButton[] => (a.dockKey === null ? [] + : [{ code: a.dockKey, label: 'DOCKING COMPUTER', hint: 'IT FLIES YOU IN' }]); + if (a.trial && !a.rails) { + // The computer is lining the ship up, and the pilot has nothing else to do + // yet (docs/TODO/212). A strip that did nothing would be a lie. + return [ + ...handOver(), + { code: 'dock-lining-up', label: 'LINING UP', hint: 'STAND BY FOR THE SLOT' }, + ]; + } + if (a.trial) { + const out: HudButton[] = [ + ...handOver(), + { code: a.rollStripCode, label: 'DRAG TO ROLL', strip: true }, + ]; + if (a.accelKey) { + out.push({ code: a.accelKey, label: 'THRUST', hint: 'HOLD TO GO IN', hold: true }); + } + if (a.decelKey) { + out.push({ code: a.decelKey, label: 'BRAKE', hint: 'HOLD TO SLOW DOWN', hold: true }); + } + return out; + } + const out: HudButton[] = []; + if (a.fireKey) out.push({ code: a.fireKey, label: 'FIRE LASER', hint: 'HOLD TO FIRE', hold: true }); + // The missile as two buttons (Chris, 2026-09-13): arm or disarm, and fire. + const left = `${a.missiles} LEFT`; + if (a.missiles === 0 || !a.armKey || !a.disarmKey) { + out.push({ code: a.armKey ?? 'missile-none', label: 'ARM MISSILE', note: 'NONE LEFT' }); + } else if (a.armed) { + out.push({ code: a.disarmKey, label: 'DISARM', lit: true, hint: left }); + } else { + out.push({ code: a.armKey, label: 'ARM MISSILE', hint: left }); + } + if (a.armed && a.launchKey) { + out.push({ + code: a.launchKey, label: 'FIRE MISSILE', lit: a.locked, + hint: a.locked ? 'LOCKED ON' : 'IT LOCKS ON A SHIP IN YOUR SIGHTS', + }); + } else { + out.push({ code: a.launchKey ?? 'missile-none', label: 'FIRE MISSILE', note: 'NOT ARMED' }); + } + if (a.ecmKey) { + out.push(a.missileInbound + ? { code: a.ecmKey, label: 'E.C.M.', lit: true, hint: 'A MISSILE IS COMING — PRESS NOW' } + : { code: a.ecmKey, label: 'E.C.M.', hint: 'DESTROYS MISSILES NEARBY' }); + } else { + out.push({ code: 'ecm-none', label: 'E.C.M.', note: 'NOT FITTED' }); + } + // The cloak, which no shop sells (docs/TODO/219 M5). The button stays, so + // a thumb learns the row, and it says why it does nothing. + if (a.cloakKey) { + out.push(a.cloaked + ? { code: a.cloakKey, label: 'CLOAK', lit: true, hint: 'IT DRAINS THE BANK — A SHOT GIVES YOU AWAY' } + : { code: a.cloakKey, label: 'CLOAK', hint: 'NOBODY SEES YOU WHILE IT RUNS' }); + } else { + out.push({ code: 'cloak-none', label: 'CLOAK', note: 'NOT FITTED' }); + } + 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; + // An icon, and no words (Chris, 2026-09-13: "let's just have it as an + // icon", docs/TODO/221). Lit while it runs, dim while it cannot start, + // and the console says why on the press. + return { + code: COURSE_SKIP_KEY, label: skipIcon, icon: true, + lit: p.skip.on, dim: !p.skip.on && p.skip.block !== null, + }; +} + +/** Two arrows, the mark on every player since the tape deck. */ +const skipIcon = '\u25B6\u25B6'; + + +/** + * The offers as buttons (docs/TODO/206 M5): what a key can do about the + * situation right now (`prompts.ts`). A click or a tap sends the command's own + * key, and the key is named under the words for a pilot at a keyboard. + * + * The E.C.M.'s offer is left out, because it lights the E.C.M. button instead + * of showing twice. + */ +export function offerButtons(offers: readonly Prompt[], mode: ControlMode): HudButton[] { + return offers.filter((p) => p.command !== 'fireEcm').flatMap((p) => { + const code = keyCodeIfBound(mode, p.command); + const key = keyIfBound(mode, p.command); + return code ? [{ code, label: p.what, ...(key ? { hint: `OR PRESS ${key}` } : {}) }] : []; + }); +} diff --git a/src/game/cockpit-view.ts b/src/game/cockpit-view.ts index d73c896a..ffed7b83 100644 --- a/src/game/cockpit-view.ts +++ b/src/game/cockpit-view.ts @@ -15,8 +15,13 @@ // // 1. the gunsight lamp; // 2. where the laser beams meet; -// 3. the prompt line; -// 4. the dashboard frame. +// 3. the offers the situation raises (`prompts.ts`); +// 4. the dashboard frame, and the buttons over the view. +// +// WHAT A BUTTON SAYS IS `cockpit-buttons.ts`'s, since docs/TODO/206 M5. This +// file reads the world and hands that file what it read. The split came when +// this one crossed the size ceiling. The buttons are a subject of their own: +// three columns of words, and no world. // // Each one reads the world and returns a picture. Not one of them decides // anything. @@ -31,8 +36,17 @@ import { Hud } from '../hud/hud.ts'; import { flightPrompts, type Prompt } from './prompts.ts'; import { hitCone } from './gunnery.ts'; import { viewDirection } from './views.ts'; -import { keyIfBound } from '../ui/key-help.ts'; -import type { ControlMode } from './controls.ts'; +import { keyCodeIfBound, keyIfBound } from '../ui/key-help.ts'; +import type { HudButton } from '../hud/hud-buttons.ts'; +import type { CoursePanel } from './course-actions.ts'; +import { ROLL_STRIP_CODE } from './bindings.ts'; +import { + courseButtonsFor, courseRowFor, gunButtonsFor, offerButtons, targetButtonsFor, targetRowFor, + type ActionSource, +} from './cockpit-buttons.ts'; +import type { TargetPanel } from './target-actions.ts'; +import { keymap } from '../engine/keymap.ts'; +import type { Command, ControlMode } from './controls.ts'; import type { ExerciseStrip } from './combat-sim-strip.ts'; import type { Ordnance } from './ordnance.ts'; import type { Presentation } from '../engine/shell.ts'; @@ -54,7 +68,7 @@ import { BEAM_Z } from '../engine/render-stack.ts'; * The fourth is the sight lamp, which lives on the shell. * * `inFlight` REPLACES THREE READS OF `mode`, and they were the same question - * asked three times. The sight, the prompt line and the dashboard each tested + * asked three times. The sight, the offers and the dashboard each tested * `mode === 'flight'`. One host method now, so the three cannot drift apart. * * `view` IS A METHOD RATHER THAN A CONSTRUCTOR ARGUMENT, and boot order is why. @@ -73,6 +87,10 @@ export interface CockpitHost { setSightLit(on: boolean): void; /** the eye and the beams parented to it, from `Shell.view` */ view(): Presentation; + /** what the course buttons show, or null where there are none (docs/TODO/205 M5) */ + coursePanel(): CoursePanel | null; + /** what the target buttons show, or null where there are none (docs/TODO/206 M3) */ + targetPanel(): TargetPanel | null; } export class CockpitView { @@ -168,8 +186,9 @@ export class CockpitView { } /** - * The prompt line: what a key can do about the situation, with the key the - * table really binds in front of it. + * The offers as text: what a key can do about the situation, with the key + * the table really binds in front of it. The cockpit paints them as buttons + * (`promptButtons`), and the tests read them here. * * The join between a pure rule and invariant 9. `prompts.ts` decides WHICH * commands are worth an offer, and what each is worth right now. `boundKey` @@ -184,7 +203,95 @@ export class CockpitView { * scrape of the painted line. `jettisonCargo` is driven directly the same * way. */ + /** + * The three button columns as the HUD paints them this frame. + * + * @internal — public so that test/run-and-offers.test.ts reads the buttons + * without a scrape of the page. + */ + buttons(): { + courses: HudButton[]; courseRow: HudButton[]; targetList: HudButton[]; targetRow: HudButton[]; guns: HudButton[]; + } { + return { + courses: this.courseButtons(), courseRow: this.courseRow(), + targetList: this.targetButtons(), targetRow: this.targetRow(), guns: this.gunButtons(), + }; + } + + /** The target header, bottom left, in flight only. */ + private targetButtons(): HudButton[] { + const source = this.actionSource(); + return source ? targetButtonsFor(source) : []; + } + + /** The target row above it, while the header is open (docs/TODO/215 M2). */ + private targetRow(): HudButton[] { + const source = this.actionSource(); + return source ? targetRowFor(source) : []; + } + + /** The course row under its header, while the header is open (docs/TODO/215 M2). */ + private courseRow(): HudButton[] { + const panel = this.host.inFlight() ? this.host.coursePanel() : null; + if (!panel) return []; + const mode = this.host.controlMode(); + return courseRowFor(panel, mode ? keyCodeIfBound(mode, 'openLocalChart') : null); + } + + /** The gun row, in flight only (docs/TODO/206 M3, docs/TODO/215 M1). */ + private gunButtons(): HudButton[] { + const source = this.actionSource(); + return source ? gunButtonsFor(source) : []; + } + + /** What both columns of the pilot's buttons are built from, or null out of flight. */ + private actionSource(): ActionSource | null { + const mode = this.host.controlMode(); + if (!this.host.inFlight() || !mode) return null; + const key = (c: Command): string | null => keyCodeIfBound(mode, c); + return { + fireKey: keymap().fire[0] ?? null, + missiles: this.state.commander.missiles, + armed: this.ordnance.armed, + locked: this.ordnance.targetLock !== null, + armKey: key('armMissile'), + disarmKey: key('disarmMissile'), + launchKey: key('launchMissile'), + ecmKey: this.state.commander.equipment.ecm ? key('fireEcm') : null, + cloakKey: this.state.commander.equipment.cloak ? key('toggleCloak') : null, + cloaked: this.state.session.cloaked, + missileInbound: this.ordnance.missileInbound, + dockKey: this.state.commander.equipment.dockingComputer + ? key('toggleDockingComputer') : null, + trial: this.state.session.dockTrial, + rails: this.state.session.dockRails, + accelKey: keymap().accel[0] ?? null, + decelKey: keymap().decel[0] ?? null, + rollStripCode: ROLL_STRIP_CODE, + targets: this.host.targetPanel(), + }; + } + + /** The course header and what sits under it, in flight only. */ + private courseButtons(): HudButton[] { + const panel = this.host.inFlight() ? this.host.coursePanel() : null; + if (!panel) return []; + const mode = this.host.controlMode(); + return [ + ...courseButtonsFor(panel), + ...(mode ? offerButtons(this.offers(), mode) : []), + ]; + } + keyPrompts(): string[] { + return this.offers().flatMap((p) => { + const line = this.renderPrompt(p); + return line ? [line] : []; + }); + } + + /** The commands worth an offer right now, in flight only. */ + private offers(): Prompt[] { const mode = this.host.controlMode(); if (!this.host.inFlight() || !mode) return []; return flightPrompts({ @@ -202,9 +309,6 @@ 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] : []; }); } @@ -253,10 +357,18 @@ export class CockpitView { inFlight: this.host.inFlight(), witchspace: this.state.session.witchspace, assist: this.state.session.ccEngaged, + cloaked: this.state.session.cloaked, + dockingComputer: this.state.session.dcEngaged, + trial: this.state.session.dockTrial, + rails: this.state.session.dockRails, ecmDetected: this.state.ecmDetectedTimer > 0, messageText: this.state.session.messageText, messageTimer: this.state.session.messageTimer, - prompts: this.keyPrompts(), + courses: this.courseButtons(), + courseRow: this.courseRow(), + targetList: this.targetButtons(), + targetRow: this.targetRow(), + guns: this.gunButtons(), // 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/collisions.ts b/src/game/collisions.ts index 2a007b66..5796b3a2 100644 --- a/src/game/collisions.ts +++ b/src/game/collisions.ts @@ -79,7 +79,8 @@ export function playerVsNpcs( * hunter. * * Two ships that bumped in a dogfight used to make the player a FUGITIVE. The - * station then scrambled its Vipers at her, for something she had no part in. + * station then scrambled its Vipers at them, for something they had no part + * in. */ export function npcVsNpcs( npcs: readonly NpcShip[], diff --git a/src/game/combat-computer.ts b/src/game/combat-computer.ts index 20d9e584..5c4dffeb 100644 --- a/src/game/combat-computer.ts +++ b/src/game/combat-computer.ts @@ -26,9 +26,8 @@ import { import type { NpcShip } from './npc.ts'; import { isHostileToPlayer } from './hostility.ts'; import { autopilotEcm } from './ordnance.ts'; -import { - aftShieldLeft, energyLeft, foreShieldLeft, poolsLeft, type ShipSystems, -} from './systems.ts'; +import { type ShipSystems } from './systems.ts'; +import { aftShieldLeft, energyLeft, foreShieldLeft, poolsLeft } from './pools-left.ts'; import { BRAIN_RATE_DECAY, BRAIN_RATE_RAMP, DECISION_INTERVAL, } from '../constants/brain-flight.ts'; @@ -38,8 +37,13 @@ import { import { ThreatLock } from './threat-lock.ts'; export type AutopilotStep = - /** hands off — the reason is for the player */ - | { kind: 'disengage'; reason: string } + /** + * Hands off — the reason is for the player. + * + * `ecm` rides along for the reason `CoPilotStep`'s does. A warhead in the air + * is a threat with or without a ship to steer at (the review of 2026-09-12). + */ + | { kind: 'disengage'; reason: string; ecm: boolean } /** * What it wants — and, separately, whether it reaches for the E.C.M. * @@ -57,6 +61,17 @@ export interface AutopilotShip { position: THREE.Vector3; quaternion: THREE.Quaternion; speed: number; + /** + * The rates the ship is ACTUALLY turning at. + * + * The scripted co-pilot ramps from these rather than from a copy of its own + * last ask (`scripted-co-pilot.ts`). `PlayerShip.update` writes them from the + * demand it flew, so in ordinary flight the two are the same number. They + * differ exactly where the review of 2026-09-12 found a defect: after a + * manual override, and after a restore. + */ + pitchRate: number; + rollRate: number; } /** @@ -157,7 +172,7 @@ export class CombatComputer { missilePos: V3 | null = null, playerToStation = Infinity, ): AutopilotStep { - if (manualInput) return { kind: 'disengage', reason: 'MANUAL OVERRIDE' }; + if (manualInput) return { kind: 'disengage', reason: 'MANUAL OVERRIDE', ecm: false }; // COMMITTED, not merely nearest. A fresh-every-frame pick flipped the // fought ship up to 26.8 times a minute, and the brain's bearing slots @@ -175,7 +190,11 @@ export class CombatComputer { ); if (!threat || !brain) { this.threatLock.clear(); - return { kind: 'disengage', reason: 'AREA CLEAR — COMBAT COMPUTER OFF' }; + return { + kind: 'disengage', + reason: 'AREA CLEAR — COMBAT COMPUTER OFF', + ecm: autopilotEcm(true, missilePos !== null), + }; } this.state.timer -= dt; @@ -185,11 +204,11 @@ export class CombatComputer { this.me.speed = player.speed; this.me.laserTemp = sys.laserTemp; this.me.laserCooldown = sys.laserCooldown; - // HOW HURT SHE IS, and whether a warhead is on its way. Those are the two - // things docs/TODO/71 and /72 found absent. They come from `systems.ts`'s - // own expressions, so the game and the trainer cannot come to compute - // them differently. A 14-input brain never reads them; `observeFor` - // decides. + // HOW HURT THE COMMANDER IS, and whether a warhead is on its way. Those + // are the two things docs/TODO/71 and /72 found absent. They come from + // `systems.ts`'s own expressions, so the game and the trainer cannot + // come to compute them differently. A 14-input brain never reads them; + // `observeFor` decides. this.me.hp = poolsLeft(sys); this.me.energy = energyLeft(sys); this.me.missileInbound = missilePos !== null; diff --git a/src/game/combat-sim-strip.ts b/src/game/combat-sim-strip.ts index cd83a562..3220859c 100644 --- a/src/game/combat-sim-strip.ts +++ b/src/game/combat-sim-strip.ts @@ -5,8 +5,8 @@ // exercise runs (docs/TODO/completed/33-exercise-hud.md). // // Without it, a pilot could not tell an exercise nearly up from one just begun. -// She could not tell an exercise from real space either, once the launch banner -// faded. +// They could not tell an exercise from real space either, once the launch +// banner faded. // // It counts nothing. Every figure comes from the round's own recorder, as a // `SimProgress`. That is the same accumulation `CombatSimRecorder.report()` @@ -26,7 +26,7 @@ import { * One frame of the exercise strip. * * Deliberately small. The cockpit is crowded, and the fight is what the pilot - * watches. Everything she cannot read at a glance mid-dogfight belongs in the + * watches. Everything they cannot read at a glance mid-dogfight belongs in the * report, which is two seconds away at the end of the exercise. */ export type { LiveContact }; @@ -54,7 +54,7 @@ export interface ExerciseStrip { * * The banner names a step on the wave that adds it, and then it is gone. For * the rest of the run, a pilot needs to know that everything out there - * carries a missile. The cockpit is where she looks. + * carries a missile. The cockpit is where they look. */ escalation: readonly string[] | null; shots: number; diff --git a/src/game/combat.ts b/src/game/combat.ts index f5a5fb9d..7dc70235 100644 --- a/src/game/combat.ts +++ b/src/game/combat.ts @@ -20,7 +20,7 @@ // consequences. // // ONE THING HERE DECIDES WHETHER A HIT COUNTS AT ALL. For `WRECK_BURST_GRACE` -// seconds after the commander's own shot destroys a ship, her beam registers +// seconds after the commander's own shot destroys a ship, their beam registers // nothing on a bystander. `inTheFireball` is the whole of that rule, and // `hostility.ts` says who counts as a bystander (docs/TODO/173). // @@ -149,7 +149,8 @@ export class Combat { // ONLY A BYSTANDER IS COVERED, and `isHostileToPlayer` is the one home of // that question (hostility.ts). A pirate out in the lane is hostile by its // role, so a queue of pirates costs the commander nothing there. A Viper - // she already provoked stays shootable, because she is in a fight with it. + // they already provoked stays shootable, because they are in a fight with + // it. // Inside the station's truce an unprovoked pirate is a bystander too, and // that follows the truce rather than widening this rule. const shot = this.inTheFireball(sys, traced, commander, playerPos, witchspace) diff --git a/src/game/command-help.ts b/src/game/command-help.ts index a89cc62c..a67e1c2a 100644 --- a/src/game/command-help.ts +++ b/src/game/command-help.ts @@ -133,6 +133,7 @@ export const COMMAND_HELP: Record = { launchMissile: { what: 'fire the armed missile', section: 'combat' }, disarmMissile: { what: 'unarm the missile', section: 'combat' }, fireEcm: { what: 'E.C.M. — destroys incoming missiles (if fitted)', section: 'combat' }, + toggleCloak: { what: 'cloaking device — nobody sees you while it runs (if you ever come by one)', section: 'combat' }, detonateEnergyBomb: { what: 'energy bomb — destroys everything close by (if fitted)', section: 'combat', diff --git a/src/game/commander.ts b/src/game/commander.ts index 927b03eb..71304f34 100644 --- a/src/game/commander.ts +++ b/src/game/commander.ts @@ -20,8 +20,8 @@ import { emptyMissionState } from '../missions/state.ts'; // directly for the headless campaign simulator (test/campaign.ts). // // The commander's own numbers — the commander's name, the grubstake, the tank, the rails -// and the two hold sizes — are constants/commander.ts. The shape of a job she -// holds is contract-record.ts. +// and the two hold sizes — are constants/commander.ts. The shape of a job they +// hold is contract-record.ts. export type LaserType = 'pulse' | 'beam' | 'military'; @@ -50,6 +50,11 @@ export interface Equipment { energyBomb: boolean; miningLaser: boolean; combatComputer: boolean; + /** + * The cloaking device, which no shop sells (docs/TODO/219 M4). The Dark + * Wheel's door grants it. A save from before the Wheel reads it as off. + */ + cloak: boolean; } /** Trumbles: cute, cheap, and a catastrophe. Kept outside Equipment @@ -71,6 +76,7 @@ export function defaultEquipment(): Equipment { energyBomb: false, miningLaser: false, combatComputer: false, + cloak: false, }; } diff --git a/src/game/controls.ts b/src/game/controls.ts index d1591572..48eae20b 100644 --- a/src/game/controls.ts +++ b/src/game/controls.ts @@ -92,6 +92,7 @@ export type Command = | 'launchMissile' | 'disarmMissile' | 'fireEcm' + | 'toggleCloak' | 'detonateEnergyBomb' | 'toggleCombatComputer' | 'toggleDockingComputer' diff --git a/src/game/course-actions.ts b/src/game/course-actions.ts new file mode 100644 index 00000000..421e4129 --- /dev/null +++ b/src/game/course-actions.ts @@ -0,0 +1,248 @@ +// 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'; +import type { Input } from '../engine/input.ts'; +import { COURSE_KEYS, COURSE_LIST_KEY, COURSE_SKIP_KEY, COURSE_STOP_KEY } from './bindings.ts'; +import type { ListFold } from './list-fold.ts'; +import { hostilesNear, hostilesOnScanner } from './hostility.ts'; +import { SKIP_SPEED } from '../constants/course.ts'; +import { SCANNER_RANGE } from '../constants/console.ts'; +import { missionCourse, missionHere } from './mission-course.ts'; + +/** + * What the course buttons show in flight: the list, or the course under way. + * `rows` is null while a course flies with the list closed. + */ +export interface CoursePanel { + readonly rows: readonly Course[] | null; + /** the list is open under its header (docs/TODO/215 M2) */ + readonly open: boolean; + 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[]; + +/** 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; + /** the STOP button: drop the course under way, and let go of the ship */ + stopCourse(): void; +} + +export class CourseActions { + private readonly state: () => GameState; + private readonly host: CourseHost; + /** the one fold the two lists share (docs/TODO/215 M2) */ + private readonly fold: ListFold; + /** + * The fast forward button is on (docs/TODO/205 M7). Like the list itself, 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, fold: ListFold) { + this.state = state; + this.host = host; + this.fold = fold; + } + + /** + * What the course buttons show in flight. With no course, the list shows by + * itself: at an arrival, after a course ends, and after a key takes the ship. + */ + panel(): CoursePanel { + const current = this.state().session.course; + return { + rows: current === null ? this.list('flight') : null, + open: this.fold.isOpen('courses'), + 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), undefined, s.session.cloaked)) { + 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 + * `COURSE_STOP_KEY`. The buttons are their only sender, so no key table + * spends a letter on them. A screen reads its own codes in the same way + * (invariant 13). + */ + read(i: Input): void { + // The lit button that stops the course under way. The list then shows by + // itself, because it always does with no course. + if (i.pressed(COURSE_STOP_KEY)) { + this.host.stopCourse(); + return; + } + // The header opens the list, or folds it (docs/TODO/215 M2). + if (i.pressed(COURSE_LIST_KEY)) { + this.fold.toggle('courses'); + 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])) { + this.pick(kind, 'flight'); + return; + } + } + } + + /** 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.fold.fold(); + this.state().session.course = kind; + this.state().session.handFlown = false; + // A run is the one course that flies in a fight, so it takes the stick + // from the aim (docs/TODO/206 M5). + if (kind === 'run') this.state().session.ccEngaged = false; + 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: situation === 'launch' ? null : this.missionRow(), + done: new Set(s.session.coursesDone), + threat: situation === 'launch' ? null : this.threat(), + loot: situation === 'launch' ? 0 : s.world.cargo.items + .filter((c) => c.object.position.distanceTo(s.player.position) <= SCANNER_RANGE).length, + }; + } + + /** + * The live leg's row: its words, and what stops the ship from flying it. A + * scoop needs fuel scoops, and without them the canister breaks on the hull + * (docs/TODO/208 M3). So the row says so, and the pick refuses. + */ + private missionRow(): CourseWorld['mission'] { + const s = this.state(); + const m = missionCourse(s.commander.missions, missionHere(s.session, s.commander), + s.world.npcs, s.world.cargo.items, s.world.station.position, s.player.position); + if (m === null) return null; + const needsScoops = m.how === 'scoop' && !s.commander.equipment.scoops; + return { what: m.what, why: needsScoops ? 'NEEDS FUEL SCOOPS' : null }; + } + + /** The fastest hostile ship on the scanner, against the ship's own speed. */ + private threat(): CourseWorld['threat'] { + const s = this.state(); + const hostile = hostilesOnScanner(s.world.npcs, s.player.position, + s.commander.legalStatus, s.player.position.distanceTo(s.world.station.position), s.session.cloaked); + if (hostile.length === 0) return null; + return { + fastest: Math.max(...hostile.map((n) => n.maxSpeed)), + own: s.player.maxSpeed, + }; + } +} diff --git a/src/game/course-clearance.ts b/src/game/course-clearance.ts new file mode 100644 index 00000000..e0995047 --- /dev/null +++ b/src/game/course-clearance.ts @@ -0,0 +1,148 @@ +// WHERE TO AIM, so that the line a course flies is clear of what is on it. +// +// Split from `course-pilot.ts` on 2026-09-12, when that file passed the +// 400-line ceiling. It is a different subject from the one next door. That file +// flies a course: which leg, what speed, which switches to ask for. This one is +// pure geometry over a line and a thing beside it. It knows nothing about a +// course, a demand or a ship. +// +// THREE THINGS ARE IN THE WAY, and each has its own clearance: +// +// - the PLANET, at `COURSE_PLANET_CLEARANCE` above its surface; +// - a SOLID in the sky — a rock hermit, an asteroid, the derelict — at +// `COURSE_OBSTACLE_CLEARANCE` from its hull; +// - a POLICEMAN, on a smuggling run alone. That one is about being SEEN +// rather than about hitting anything, which is why its clearance comes +// from the scan's own warning band. +// +// All three end in `sidestep`, which is the one rule. Aim beside the thing, on +// the side the line already passes, and half as far again. + +import * as THREE from 'three'; +import { COURSE_OBSTACLE_CLEARANCE, COURSE_PLANET_CLEARANCE } from '../constants/course.ts'; +import { COURSE_POLICE_CLEARANCE } from '../constants/mission-course.ts'; + +/** Something solid a course must not fly through — see `clearOfObstacles`. */ +export interface Obstacle { + at: THREE.Vector3; + /** the hull's own radius, because the clearance is measured from the hull */ + radius: number; +} + +const seg = new THREE.Vector3(); +const off = new THREE.Vector3(); + +/** + * Where to aim, so that the line to the target clears the planet + * (docs/TODO/205 M3). The clearance is the planet's own radius plus + * `COURSE_PLANET_CLEARANCE`, which is above the height the planet holds the + * torus drive down at. + * + * @returns `out`, holding the point to aim at. + */ +export function clearOfPlanet( + from: THREE.Vector3, to: THREE.Vector3, planet: THREE.Vector3, radius: number, + out: THREE.Vector3, +): THREE.Vector3 { + return sidestep(from, to, planet, radius + COURSE_PLANET_CLEARANCE, out); +} + +/** + * Where to aim, so that the line to the target keeps clear of the police + * (docs/TODO/208 M4). + * + * A police ship reads a hold inside `SCAN_RANGE`, which is 2,600 units, and + * the smuggling course must not be read. It aims wide of the nearest + * policeman in the way, at `COURSE_POLICE_CLEARANCE`, which is the warning + * band. The line from there is clear, and the ship then turns onto the + * target. Where no wide line exists, it takes the widest it can find. + */ +export function clearOfPolice( + from: THREE.Vector3, to: THREE.Vector3, police: readonly THREE.Vector3[], + out: THREE.Vector3, +): THREE.Vector3 { + let worst: { at: THREE.Vector3; miss: number } | null = null; + for (const at of police) { + const miss = distanceToSegment(from, to, at); + if (miss >= COURSE_POLICE_CLEARANCE) continue; + if (worst === null || miss < worst.miss) worst = { at, miss }; + } + return worst === null ? out.copy(to) + : sidestep(from, to, worst.at, COURSE_POLICE_CLEARANCE, out); +} + +/** + * Where to aim, so that the line keeps clear of everything solid in the way. + * + * Chris flew into a rock hermit on the station course (2026-09-12). The line + * went round the planet and round a policeman, and through everything else. + * + * It takes the WORST one, by how near the line passes its hull, and aims wide + * of that. One detour a frame is enough, because the ship re-plans on the next + * one. The line from a detour point is a new line, with its own worst thing in + * the way. Where nothing is in the way it returns the target unchanged. + * + * A thing at the END of the line is never in the way of it. That is + * `distanceToSegment`'s rule, and it is what lets the hermit course fly to a + * hermit. + * + * @returns `out`, holding the point to aim at. + */ +export function clearOfObstacles( + from: THREE.Vector3, to: THREE.Vector3, obstacles: readonly Obstacle[], + out: THREE.Vector3, +): THREE.Vector3 { + let worst: { o: Obstacle; over: number } | null = null; + for (const o of obstacles) { + const clear = o.radius + COURSE_OBSTACLE_CLEARANCE; + // How far INSIDE its clearance the line passes, so the worst is the one + // with least room rather than the one nearest the ship. + const over = clear - distanceToSegment(from, to, o.at); + if (over <= 0) continue; + if (worst === null || over > worst.over) worst = { o, over }; + } + return worst === null ? out.copy(to) + : sidestep(from, to, worst.o.at, worst.o.radius + COURSE_OBSTACLE_CLEARANCE, out); +} + +/** How near the line from `from` to `to` passes `at`. */ +function distanceToSegment( + from: THREE.Vector3, to: THREE.Vector3, at: THREE.Vector3, +): number { + seg.subVectors(to, from); + const len2 = seg.lengthSq(); + const t = len2 > 0 ? Math.max(0, Math.min(1, off.subVectors(at, from).dot(seg) / len2)) : 0; + // The nearest point is the target itself: nothing is between. + if (t >= 1) return Infinity; + return off.copy(from).addScaledVector(seg, t).sub(at).length(); +} + +/** + * Where to aim, so that the line keeps `clear` units from one thing in the + * way. It aims beside that thing, on the same side as the line, and half as + * far again. The line from there is clear, and the ship then turns onto the + * target. + * + * A target that is itself the nearest point needs no detour. docs/TODO/205 M3 + * found a hermit low over the planet. The line to it always counted as + * blocked, and the ship circled the detour point for ever. + * + * @returns `out`, holding the point to aim at. + */ +function sidestep( + from: THREE.Vector3, to: THREE.Vector3, at: THREE.Vector3, clear: number, + out: THREE.Vector3, +): THREE.Vector3 { + seg.subVectors(to, from); + const len2 = seg.lengthSq(); + const t = len2 > 0 ? Math.max(0, Math.min(1, off.subVectors(at, from).dot(seg) / len2)) : 0; + if (t >= 1) return out.copy(to); + off.copy(from).addScaledVector(seg, t).sub(at); + if (off.length() >= clear) return out.copy(to); + // A line through the centre has no side. Any direction square to it will do. + if (off.lengthSq() < 1e-6) { + off.set(seg.y, -seg.x, 0); + if (off.lengthSq() < 1e-6) off.set(0, seg.z, -seg.y); + } + return out.copy(at).addScaledVector(off.normalize(), clear * 1.5); +} diff --git a/src/game/course-pilot.ts b/src/game/course-pilot.ts new file mode 100644 index 00000000..e2f2249a --- /dev/null +++ b/src/game/course-pilot.ts @@ -0,0 +1,399 @@ +// 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, 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. +// 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. The canister the collect course holds is the same +// bargain again. A restore picks the nearest, and that is the pick it makes +// from cold anyway. +// +// THREE SHAPES OF COURSE fly today: +// +// - 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 WHAT IT WOULD HIT. Three things are in the way, and +// each has its own clearance: +// +// - the PLANET, at `COURSE_PLANET_CLEARANCE` above its surface; +// - a SOLID in the sky — a rock hermit, an asteroid, the derelict — at +// `COURSE_OBSTACLE_CLEARANCE` from its hull; +// - a POLICEMAN, but only on a smuggling run, which is a different rule +// about being seen rather than about hitting anything. +// +// A line that dips below the clearance altitude aims at a point beside the +// planet instead, until the line clears. A solid is the same trick at a smaller +// scale. It came from a flight, where the station course took the commander +// straight into the hermit they just left. +// The arrival at the witchpoint already sits on the station's side of the +// planet. A launch, the star and a rock far out have no such promise. + +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 { SLOT_SPEED_LIMIT } from '../constants/docking.ts'; +import { + COURSE_AIM_DEADZONE, COURSE_ROLL_GATE, COURSE_ARRIVE_BRAKE, COURSE_ARRIVE_TOLERANCE, COURSE_DERELICT_STANDOFF, + COURSE_COLLECT_CAP, COURSE_COLLECT_LEAD, COURSE_HERMIT_SPEED, COURSE_HERMIT_STANDOFF, + COURSE_COLLECT_SPEED, COURSE_PLANET_CLEARANCE, COURSE_RUN_REACH, COURSE_SKIM_DISTANCE, + COURSE_TORUS_CONE, COURSE_TORUS_DROP, +} from '../constants/course.ts'; +import { + COURSE_ESCORT_CLOSING, COURSE_ESCORT_STANDOFF, COURSE_WATCH_STANDOFF, +} from '../constants/mission-course.ts'; +import { + clearOfObstacles, clearOfPlanet, clearOfPolice, type Obstacle, +} from './course-clearance.ts'; +import type { MissionHow } from './mission-course.ts'; + +/** What the course pilot reads for one frame. A flat view, so a test needs no world. */ +export interface CourseView { + readonly course: CourseKind; + readonly position: THREE.Vector3; + 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; + /** where the hostile ships on the scanner are, for the run course */ + readonly threats: readonly THREE.Vector3[]; + /** + * The solid things within scanner range that a line must not pass through: + * a rock hermit, an asteroid and the derelict. Every course reads it. + * + * NOT every ship. A pirate is small and it moves, and a line bent round one + * would be a line bent round a fight. These are the things that sit still and + * are big enough to kill you (Chris, 2026-09-12). + */ + readonly obstacles: readonly Obstacle[]; + /** where the police ships within scanner range are, for the smuggling course */ + readonly police: readonly THREE.Vector3[]; + /** where the cargo adrift within scanner range is, nearest first */ + readonly loot: readonly Adrift[]; + /** + * What the mission asks for here (docs/TODO/208 M1): where its target is, + * how fast it moves, and what the ship does about it. Null when no live leg + * has work in this system. + */ + readonly mission: { + readonly at: THREE.Vector3; readonly speed: number; readonly how: MissionHow; + /** the target is on the run, so a fight becomes a chase (docs/TODO/214 M4) */ + readonly fleeing: boolean; + } | null; + /** the docking computer already has the ship */ + readonly dcEngaged: boolean; + /** + * How near the station the station course hands the ship over. A fitted + * docking computer takes the job from further out than a pilot does. + */ + readonly handOverRange: number; +} + +/** A canister the collect course can fly at, and where it is going. */ +export interface Adrift { + at: THREE.Vector3; + /** its drift, in world units a second — the collect course leads on it */ + velocity: THREE.Vector3; +} + +/** What the course pilot asks for this frame. */ +export interface CourseStep { + /** what it wants flown, or null where something else flies the ship */ + 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; + /** the course is finished, and it leaves the ship */ + readonly done: boolean; + /** why it ended before its work was done, for the console; absent for an ordinary end */ + readonly why?: string; +} + +const IDLE: CourseStep = { demand: null, torus: false, handOver: false, done: false }; + +/** + * 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 { + /** the fastest it may fly on the way, or undefined for the ship's own top speed */ + cap?: number; + readonly target: THREE.Vector3; + readonly standoff: number; + readonly speed: number; +} + +export class CoursePilot { + private mem: SteerMemory = freshSteerMemory(); + /** + * The canister the collect course is flying at, held until it is taken or it + * leaves the scanner. + * + * IT USED TO TAKE THE NEAREST, EVERY FRAME. Chris, 2026-09-12: *"Collecting + * cargo often seems to be difficult - we miss it quite a lot - especially + * when it is moving."* The ship closed on one canister, a second became + * nearer as it moved, and the course turned away from the first. Measured + * over five canisters, that was 7 missed passes with the cargo at rest and 16 + * with it adrift. One canister alone was never missed at all, at any drift, + * which is what named the fault. + * + * The identity is the canister's own position vector, which `flight-course.ts` + * passes by reference. A canister that is scooped or drifts out of range + * leaves the list, and the next pick is the nearest again. + */ + private held: THREE.Vector3 | null = null; + private readonly dir = new THREE.Vector3(); + private readonly fwd = new THREE.Vector3(); + private readonly aim = new THREE.Vector3(); + private readonly away = new THREE.Vector3(); + private readonly wideOf = new THREE.Vector3(); + /** the aim once it is clear of the planet AND of anything solid */ + private readonly clearAim = new THREE.Vector3(); + /** where a drifting canister will be when the ship gets there */ + private readonly lead = new THREE.Vector3(); + + /** Forget the bank, for a new course. */ + reset(): void { + this.mem = freshSteerMemory(); + this.held = null; + } + + step(v: CourseView, dt: number): CourseStep { + 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 }; + } + case 'run': return v.threats.length === 0 ? ended() : this.run(v, dt); + case 'collect': { + const next = this.holdLoot(v.loot); + if (next === null) return ended(); + // FLY ONTO WHERE IT WILL BE. A canister drifts at up to 45 units a + // second, and the scoop reaches 45. An aim at where it IS therefore + // arrives a whole scoop behind it (Chris, 2026-09-12). The lead is the + // time to cover the gap at the speed the course flies. That is the same + // shape the co-pilot leads a ship with. + const gap = v.position.distanceTo(next.at); + this.lead.copy(next.at).addScaledVector( + next.velocity, Math.min(COURSE_COLLECT_LEAD, gap / COURSE_COLLECT_SPEED)); + // The scoop takes it aboard inside `SCOOP_RANGE`, and the next one is + // then the nearest (docs/TODO/206 M6). + return { + ...this.arrive(v, { + target: this.lead, standoff: 0, + speed: COURSE_COLLECT_SPEED, cap: COURSE_COLLECT_CAP, + }, dt), + done: false, + }; + } + // A rock is fought, not flown to: `flight-instruments.ts` picks the next + // one as the target, and the computer's aim lines the ship up on it. + case 'mine': return IDLE; + case 'mission': { + const m = v.mission; + if (m === null) return ended(); + // A hunt is a fight: `flight-instruments.ts` picks the ship, and the + // computer's aim flies it, as it does for a rock. A target on the run + // is chased at full speed, until the computer takes the stick again + // when the target is near (docs/TODO/214 M4). + if (m.how === 'fight') { + if (!m.fleeing) return IDLE; + return { ...this.arrive(v, { target: m.at, standoff: 0, speed: PLAYER_FLIGHT.maxSpeed }, dt), done: false }; + } + // A slip is the station course on a line wide of the police + // (docs/TODO/208 M4). It hands the ship over as that course does. + if (m.how === 'slip') return this.toStation(v, dt, m.at); + // A TARGET BELOW THE CLEARANCE IS REFUSED (docs/TODO/213 M1). A ship + // does not crash, and a charge that ran flew through the planet with + // the course 600 units behind it. The commander crashed at 80 units + // with full shields. The line round the planet cannot help when the + // target itself is inside it. + if (m.at.distanceTo(v.planetPos) - v.planetRadius < COURSE_PLANET_CLEARANCE) { + return { ...ended(), why: 'THE TARGET IS TOO NEAR THE PLANET — COURSE OFF' }; + } + const standoff = m.how === 'hold' ? COURSE_WATCH_STANDOFF + : m.how === 'escort' ? COURSE_ESCORT_STANDOFF : 0; + const speed = m.how === 'scoop' ? COURSE_COLLECT_SPEED : m.speed; + // An escort closes gently. Inside three standoffs its speed is the + // charge's plus `COURSE_ESCORT_CLOSING`, so the approach cannot ram + // the charge and set it to flight (docs/TODO/213 M1). + const near = m.how === 'escort' + && v.position.distanceTo(m.at) <= COURSE_ESCORT_STANDOFF * 3; + const cap = near ? m.speed + COURSE_ESCORT_CLOSING : undefined; + return { ...this.arrive(v, { target: m.at, standoff, speed, cap }, dt), done: false }; + } + default: return IDLE; + } + } + + /** + * Run for it (docs/TODO/206 M5). It turns away from the hostile ships and + * opens the throttle. It runs the torus once the mass lock lets go. It aims at a + * point straight away from where the hostile ships sit on average. It ends + * when no hostile ship is left on the scanner. + */ + private run(v: CourseView, dt: number): CourseStep { + this.away.set(0, 0, 0); + for (const t of v.threats) this.away.add(this.dir.subVectors(v.position, t).normalize()); + if (this.away.lengthSq() < 1e-9) this.away.set(0, 0, -1).applyQuaternion(v.quaternion); + const target = this.dir.copy(v.position).addScaledVector(this.away.normalize(), COURSE_RUN_REACH); + const aim = clearOfPlanet(v.position, target, v.planetPos, v.planetRadius, this.aim); + return { ...this.pointAt(v, aim, 1, dt), handOver: false, done: false }; + } + + /** + * 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, wide?: THREE.Vector3): CourseStep { + if (v.dcEngaged) return IDLE; + // It ARRIVES at the hand-over, at the speed the slot will take + // (docs/TODO/207 M1). A ship handed over at full speed has less than four + // seconds to lose 280 units a second. That is no way to meet a pilot. + // The hand-over is a RANGE rather than an arrival. An arrival also asks + // for the speed to settle. A ship a few units a second over it would sail + // past the station while it waited. + if (v.position.distanceTo(v.stationPos) <= v.handOverRange) { + return { demand: null, torus: false, handOver: true, done: false }; + } + // A smuggling run keeps wide of the police on the way in (M4 of 208). + const aim = wide === undefined ? v.stationPos + : clearOfPolice(v.position, v.stationPos, v.police, this.wideOf); + return this.arrive(v, { + target: aim, standoff: aim === v.stationPos ? v.handOverRange : 0, speed: SLOT_SPEED_LIMIT, + }, dt); + } + + /** + * 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(a.cap ?? PLAYER_FLIGHT.maxSpeed, + a.speed + Math.sqrt(2 * COURSE_ARRIVE_BRAKE * PLAYER_FLIGHT.accel * Math.max(0, left))); + const band = PLAYER_FLIGHT.accel * dt; + const throttle = v.speed < wanted - band ? 1 : v.speed > wanted + band ? -1 : 0; + // ROUND THE PLANET FIRST, then round anything solid on what is left. The + // planet is the bigger detour, so a line bent round it is the line a rock + // can then be in the way of. + const wide = clearOfPlanet(v.position, a.target, v.planetPos, v.planetRadius, this.aim); + const aim = clearOfObstacles(v.position, wide, v.obstacles, this.clearAim); + const p = this.pointAt(v, aim, throttle, dt); + return { ...p, torus: p.torus && left > COURSE_TORUS_DROP, handOver: false, done: false }; + } + + /** + * Which canister to fly at: the one already held, while it is still there. + * + * A pilot who is nearly on a canister does not turn away because another + * drifted closer. It is the same rule the combat co-pilot keeps for a target + * it is lined up on, and the same fault it was fixed for. + */ + private holdLoot(loot: readonly Adrift[]): Adrift | null { + const still = loot.find((c) => c.at === this.held); + if (still !== undefined) return still; + this.held = loot[0]?.at ?? null; + return loot[0] ?? null; + } + + /** Bank and pull the nose onto a point, with this throttle. */ + private pointAt( + v: CourseView, point: THREE.Vector3, throttle: number, dt: number, + ): { demand: FlightDemand; torus: boolean } { + this.dir.subVectors(point, v.position); + // TWO NUMBERS KEEP THE SHIP FROM ROLLING ALL THE WAY THERE. The deadzone + // holds the sticks still inside 1.1 degrees of the nose. The roll gate + // holds the pitch still until the bank arrives, which is what stops the + // nose from circling the target. See both constants. + const stick = bankToTurn( + v.quaternion, this.dir, this.mem, COURSE_AIM_DEADZONE, COURSE_ROLL_GATE); + this.fwd.set(0, 0, -1).applyQuaternion(v.quaternion); + return { + demand: { + 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, + fire: 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 { + pitchRate: rampFlightRate(v.pitchRate, 0, false, dt), + rollRate: rampFlightRate(v.rollRate, 0, false, dt), + throttle: 1, + 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 }; +} diff --git a/src/game/courses.ts b/src/game/courses.ts new file mode 100644 index 00000000..88227086 --- /dev/null +++ b/src/game/courses.ts @@ -0,0 +1,226 @@ +// 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 type { Refusal } from './hyperspace.ts'; +import { MAX_FUEL } from '../constants/commander.ts'; +import { ASTEROIDS_MIN } from '../constants/population.ts'; +import { RUN_CLOSE_MARGIN } from '../constants/course.ts'; + +/** What the ship can do by itself. */ +export type CourseKind = + 'jump' | 'mission' | 'station' | 'derelict' | 'mine' | 'hermit' | 'skim' | 'run' + | 'collect'; + +/** + * 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'; + +/** + * What the button says while the ship flies a course. The player never sees + * the word "course". The words say what the ship is doing, as a pilot would. + */ +export const COURSE_NAMES: Readonly> = { + jump: 'JUMPING TO HYPERSPACE', + mission: 'ON THE MISSION', + station: 'HEADING TO THE STATION', + derelict: 'HEADING TO THE DERELICT SHIP', + mine: 'MINING THE ASTEROIDS', + hermit: 'HEADING TO THE ROCK HERMIT', + skim: 'SKIMMING THE STAR FOR FUEL', + run: 'RUNNING FOR IT', + collect: 'COLLECTING THE CARGO', +}; + +/** 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 live leg's own row, when a leg has work in this system: its words, + * and what stops the ship from flying it (docs/TODO/208). + */ + readonly mission: { readonly what: string; readonly why: string | null } | null; + /** the courses this visit already finished, so they leave the list */ + readonly done: ReadonlySet; + /** + * The fight, when there is one (docs/TODO/206 M5): the top speed of the + * fastest hostile ship on the scanner, and the ship's own. Null with no + * hostile ship on the scanner. + */ + readonly threat: { readonly fastest: number; readonly own: number } | null; + /** how many canisters and capsules drift within scanner range */ + readonly loot: number; +} + +/** + * 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 [ + runRow(w), + w.mission !== null && !w.done.has('mission') + ? { kind: 'mission', what: w.mission.what, why: w.mission.why } 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 SHIP', why: null } as const : null, + collectRow(w), + mineRow(w, false), + present(w, 'hermit', 'hermit') + ? { kind: 'hermit', what: 'VISIT THE ROCK HERMIT', why: null } as const : null, + skimRow(w), + jumpRow(w, false), + ].filter((c): c is Course => c !== null); +} + +/** + * A way out of a fight: run (docs/TODO/206 M5). It leads the list while a + * hostile ship is on the scanner. Its words say the margin, because a run + * from a fast ship is slow. The plan measured it on 2026-09-11. The player's + * Cobra beats the fastest pirate by 19 units a second. + */ +function runRow(w: CourseWorld): Course | null { + if (w.threat === null) return null; + const margin = w.threat.own - w.threat.fastest; + const words = margin <= 0 ? 'THEY ARE AS FAST AS YOU' + : margin < RUN_CLOSE_MARGIN ? 'YOU ARE ONLY A LITTLE FASTER' + : 'YOU ARE FASTER'; + return { kind: 'run', what: `RUN FOR IT — ${words}`, why: 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 TO ANOTHER SYSTEM' : `JUMP TO ${w.targetName.toUpperCase()}`, + why: w.jump.ok ? null : JUMP_WHY[w.jump.reason], + }; +} + +/** + * What a jump row says when the ship cannot make the jump. The words tell the + * player what to do, and not only what is wrong. The hyperspace key keeps its + * own shorter refusal, `refusalMessage` in `hyperspace.ts`. + */ +const JUMP_WHY: Readonly> = { + noTarget: 'CHOOSE A SYSTEM ON THE LOCAL CHART FIRST', + noFuel: 'NOT ENOUGH FUEL TO GET THERE', + alreadyJumping: 'THE JUMP HAS STARTED', +}; + +/** + * The cargo adrift within scanner range (docs/TODO/206 M6). It shows only + * with a clear scanner, because a ship that stops to scoop in a fight is a + * target. The scoops are what take a canister aboard. Without them a canister + * breaks on the hull (`world-step.ts`), so the row says what the ship needs. + */ +function collectRow(w: CourseWorld): Course | null { + if (w.loot === 0 || w.threat !== null) return null; + return { + kind: 'collect', + what: w.loot === 1 ? 'COLLECT THE CARGO' : 'COLLECT EVERYTHING', + why: w.commander.equipment.scoops ? null : 'NEEDS FUEL SCOOPS', + }; +} + +/** + * 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/src/game/derelict.ts b/src/game/derelict.ts new file mode 100644 index 00000000..3be67e44 --- /dev/null +++ b/src/game/derelict.ts @@ -0,0 +1,41 @@ +// What the scan finds when a ship reaches a derelict (docs/TODO/208 M5). +// +// A generation ship crosses on 8% of arrivals. It said one line and offered +// nothing else. The derelict course now flies to it, so the arrival is a +// place a player chose to go, and it says what is there. +// +// THE WORDS COME FROM THE SEED, and no draw from the world's stream. The same +// derelict at the same world tells the same story on every visit, and two +// worlds tell different ones. A draw would move every seeded outcome after +// it, which is `game/rng.ts`'s rule. +// +// They are the encounter's words, so they live beside the game rather than in +// a mission. A mission that wants a derelict can still use one: 208's plan +// leaves that to a later arc. + +import type { StarSystem } from '../galaxy/galaxy.ts'; + +/** + * What a scan of the hull reports. One line, in the game's voice, and no + * fiction beyond what a scan can see. + */ +const REPORTS: readonly string[] = [ + 'THE DRUM STOPPED TURNING LONG AGO. THE CROP DECKS ARE BARE ROCK.', + 'A HULL BREACH RUNS THE LENGTH OF THE SPINE. NOTHING INSIDE HOLDS AIR.', + 'THE REACTOR IS COLD. THE COLONY LIVED AND DIED ON STORED POWER.', + 'EVERY LOCK STANDS OPEN. WHOEVER LEFT, LEFT IN ORDER.', + 'THE BEACON STILL TRANSMITS A COURSE THIS SHIP CANNOT FLY.', + 'THE CARGO BAYS WERE EMPTIED FROM INSIDE. SOMEBODY PACKED.', + 'THE NAME ON THE BOW IS WORN AWAY. THE REGISTRY IS OLDER THAN THE CHARTS.', + 'A RESCUE CUT THROUGH THE FORWARD FRAMES. IT CAME TOO LATE TO MATTER.', +]; + +/** + * The derelict's report at this world. It is the same on every visit, because + * it is read off the world's own seed rather than drawn. + */ +export function derelictReport(system: StarSystem): string { + const seed = system.seed; + const at = Math.abs(seed[0] + seed[1] * 7 + system.index) % REPORTS.length; + return REPORTS[at] as string; +} diff --git a/src/game/dock-rails.ts b/src/game/dock-rails.ts new file mode 100644 index 00000000..cad7ba6d --- /dev/null +++ b/src/game/dock-rails.ts @@ -0,0 +1,113 @@ +// The rails that hold the ship on the slot axis (docs/TODO/212). +// +// Chris flew the pilot's stretch of 207 on a phone and it did not work. His +// words of 2026-09-12: *"lining up is not actually very accurate until the +// last few moments. So we aren't actually flying straight and rolling can send +// you off away from the slot."* He asked for a mini game instead: *"I'm +// wondering if we actually get lined up by the computer and then hand off to a +// 'mini' docking game. Something that is completely on rails."* +// +// THE FAULT WAS ONE STICK WITH TWO JOBS. The ship has no yaw axis, so a roll +// is how it aims. The slot wants that same roll for something else: the wings +// lined up with the letterbox. The docking computer reconciles the two +// (`docking-sticks.ts`). The pilot's stretch could not, because the pilot held +// the roll and the computer held only the pitch. A measurement of 2026-09-12 +// put the hand-over 407 to 886 units off the axis, and an ideal pilot docked 0 +// times in 4. With no roll at all, the same pilot docked 2 times in 4. +// +// SO THE RAILS HOLD THE LINE, AND THE PILOT HOLDS THE SPIN. This file is the +// one place in the game that moves the commander's ship other than by flying +// it. `test/docking.test.ts` scans for that, and it names this file. +// +// IT IS NOT A TELEPORT, AND THE ROTATION USED TO BE ONE. This comment claimed +// that both corrections were eased. The position was. The rotation went on in +// full, every frame, so whatever error the hand-over left went in ONE frame. +// Chris flew it on 2026-09-12: *"we jump to the on rails version before it's +// actually lined up"*. A trace put that jump at 69.7 degrees. +// +// Two things answer it. `railsAligned` holds the hand-over until the ship is +// the right distance out AND pointed at the port. The COMPUTER flies it to +// both, with its own sticks, so the error the rails see is small. `RAILS_TURN` +// then eases what is left, as `RAILS_PULL` always eased the position. +// +// The nose turns by the SHORTEST rotation onto the axis, which carries no twist +// about the nose. An ease along that same arc adds none either. So the pilot's +// own roll is untouched, and the roll is what the slot measures. + +import * as THREE from 'three'; +import type { PlayerShip } from '../player.ts'; +import type { DockPlan } from './docking.ts'; +import { slotNormal } from '../world/slot.ts'; +import { + RAILS_CONE, RAILS_LATERAL, RAILS_PULL, RAILS_RANGE, RAILS_STOPPED, RAILS_TURN, +} from '../constants/docking.ts'; + +const _out = new THREE.Vector3(); +const _rel = new THREE.Vector3(); +const _fwd = new THREE.Vector3(); +const _turn = new THREE.Quaternion(); +const _ease = new THREE.Quaternion(); + +/** + * Is the ship at the place where the computer stops it? + * + * Three conditions, and each one matters. The plan must be on its last leg. + * The ship must be on the axis, inside `RAILS_LATERAL`. `RAILS_RANGE` then + * keeps the game short. + */ +export function railsReached(plan: DockPlan): boolean { + return plan.phase === 'run' && plan.lateral < RAILS_LATERAL + && plan.along < RAILS_RANGE; +} + +/** + * Is the nose near enough the slot axis to give the ship to the pilot? + * + * THE SECOND QUESTION THE HAND-OVER NEVER ASKED. `railsReached` above is about + * WHERE the ship is. This is about WHICH WAY IT POINTS, and without it the + * rails turned the ship up to 69.7 degrees in one frame. See `RAILS_CONE`. + * + * Two readers, one line. `world-step.ts` flies the computer's last turn until + * this is true. `holdOnRails` below corrects onto the same axis afterward. So + * the gate and the correction cannot come to disagree. + */ +export function railsAligned(player: PlayerShip, station: THREE.Object3D): boolean { + const inward = slotNormal(station, _out).multiplyScalar(-1); + return player.getForward(_fwd).angleTo(inward) < RAILS_CONE; +} + +/** + * Is the ship stopped, so the rails can take it? + * + * THE COMPUTER BRINGS THE SHIP TO A HALT FIRST (Chris, 2026-09-12: *"it's a + * bit too easy I think. Maybe reducing the speed to nothing so the user has to + * thrust forward would be good."*). The pilot then owns the whole run in, from + * a standing start. A ship that arrives with the speed already made needs only + * a hand on the roll. + */ +export function stopped(speed: number): boolean { + return speed <= RAILS_STOPPED; +} + +/** + * One frame on the rails. It runs AFTER `PlayerShip.update`, so it corrects + * the frame the ship just flew. + * + * @param dt the length of the frame, in seconds + */ +export function holdOnRails(player: PlayerShip, station: THREE.Object3D, dt: number): void { + const outward = slotNormal(station, _out); + const rel = _rel.copy(player.position).sub(station.position); + const along = rel.dot(outward); + // `rel` becomes the part of the offset that lies ACROSS the axis, once the + // part along the axis is taken out of it. + rel.addScaledVector(outward, -along); + player.position.addScaledVector(rel, -Math.min(1, RAILS_PULL * dt)); + const fwd = player.getForward(_fwd); + _turn.setFromUnitVectors(fwd, outward.multiplyScalar(-1)); + // ...and EASE it, as the position above is eased. A slerp from no rotation + // along that same shortest arc keeps the arc's axis, so it still adds no + // twist about the nose. The pilot's roll stays the pilot's. + _ease.identity().slerp(_turn, Math.min(1, RAILS_TURN * dt)); + player.quaternion.premultiply(_ease).normalize(); +} diff --git a/src/game/docked.ts b/src/game/docked.ts index b2a9e557..c91af8fe 100644 --- a/src/game/docked.ts +++ b/src/game/docked.ts @@ -1,4 +1,4 @@ -// What a commander does while she is docked. +// What a commander does while they are docked. // // One half of the orchestrator. docs/TODO/155 M1 split it from the other, on // Chris's rule of 2026-08-14: *"It makes sense to split docked from flight - @@ -9,10 +9,10 @@ // ONE RESPONSIBILITY: what a commander does once the ship stops. That is: // // - the arrival and the departure; -// - the menu she reads; -// - the market and the outfitters she trades at; -// - the board she takes work from; -// - the one question the station will not let her leave unanswered. +// - the menu they read; +// - the market and the outfitters they trade at; +// - the board they take work from; +// - the one question the station will not let them leave unanswered. // // "STOPPED" RATHER THAN "DOCKED", and one member is why. A hermit opens his // door to a ship that is still in flight, so `openHermitTrade` is not a dock by @@ -193,6 +193,8 @@ export class Docked { // the permanent way back (docs/TODO/106). const brief = this.state.commander.briefingSeen < BRIEFING_VERSION; if (brief) this.state.commander.briefingSeen = BRIEFING_VERSION; + // A docked ship is seen. The cloak drops here (docs/TODO/219 M5). + this.state.session.cloaked = false; this.applyStation(this.station.dock(arrival)); if (brief) this.host.openScreen('briefing'); // ...and the question the station will not proceed without an answer to, @@ -242,7 +244,7 @@ export class Docked { this.host.openScreen('market'); // ...and the ship is still FLYING. The hermit is the one market a commander // reaches without a dock. So the base mode under the screen stays flight, - // and Escape puts her back in the cockpit. + // and Escape puts them back in the cockpit. this.host.setBaseMode('flight'); this.state.player.speed = 0; sfx.dock(); @@ -366,7 +368,7 @@ export class Docked { if (!e) return; // The law and the region come first, so the SALE has the console after // them. `raiseLegal` QUEUES what the record now means (docs/TODO/130). So - // the line the player reads first is the one that explains what she did. + // the line the player reads first is the one that explains what they did. if (e.kind === 'sold') { this.state.living.addNotoriety(c.systemIndex, e.heat); this.host.raiseLegal(e.offence); diff --git a/src/game/docking-sticks.ts b/src/game/docking-sticks.ts index f7c442e9..d7dd684d 100644 --- a/src/game/docking-sticks.ts +++ b/src/game/docking-sticks.ts @@ -19,7 +19,9 @@ import * as THREE from 'three'; -import { LINED_UP_LATERAL, SLOT_HALF_ACROSS, ROLL_TOLERANCE } from '../constants/docking.ts'; +import { + LINED_UP_LATERAL, SLOT_HALF_ACROSS, COMPUTER_ROLL_TOLERANCE, +} from '../constants/docking.ts'; import { DC_SLOT_MARGIN, DC_TURN_FADE_ANGLE, DC_ROLL_LEAD, } from '../constants/docking-computer.ts'; @@ -118,7 +120,7 @@ export function dockingSticks( // last correction and nothing else (docs/TODO/137, `DC_SLOT_MARGIN`). const onAxis = Math.max(0, Math.min(1, (LINED_UP_LATERAL - plan.lateral) / (LINED_UP_LATERAL - SLOT_HALF_ACROSS))); - const budget = Math.PI / 2 + (ROLL_TOLERANCE * DC_SLOT_MARGIN - Math.PI / 2) * onAxis; + const budget = Math.PI / 2 + (COMPUTER_ROLL_TOLERANCE * DC_SLOT_MARGIN - Math.PI / 2) * onAxis; // The attitude the SLOT asks for, but only once the approach COMMITS to the // letterbox. A slot 1,500 units away on a hull that turns has no opinion // worth the flight. To track it out there is a roll that never stops. A ship in a diff --git a/src/game/docking.ts b/src/game/docking.ts index ddb30aa3..c642db25 100644 --- a/src/game/docking.ts +++ b/src/game/docking.ts @@ -32,10 +32,11 @@ import * as THREE from 'three'; import { GATE_HALF_WIDTHS, LINED_UP_LATERAL, HULL_BOX_MARGIN, - SLOT_HALF_ACROSS, SLOT_HALF_ALONG, SLOT_DEPTH, ROLL_TOLERANCE, + SLOT_HALF_ACROSS, SLOT_HALF_ALONG, SLOT_DEPTH, ROLL_TOLERANCE, SLOT_SPEED_LIMIT, } from '../constants/docking.ts'; import { slotNormal } from '../world/slot.ts'; import { dockPath, makeDockPath } from './dock-path.ts'; +import { freshSteerMemory, type SteerMemory } from './pitch-roll-steer.ts'; export type DockPhase = /** still on the turn — the path decides where the ship goes */ @@ -55,12 +56,30 @@ export interface DockPlan { arrived: boolean; /** distance off the slot axis, for HUD and tests */ lateral: number; + /** + * How far along the slot axis the ship is, in front of the station. It is + * negative behind it. The rails read it (docs/TODO/212). + */ + along: number; /** * The plane this ship turns in, held across frames. `dock-path.ts` reads and * writes it. It is saved state, like the phase: a ship restored mid-approach * carries on the way round it already took. */ swing: THREE.Vector3; + /** + * Which vertical the LAST turn onto the axis banks through + * (`pitch-roll-steer.ts`). It is held across frames for the reason `swing` + * above is, and it is saved in the same walk. + * + * `dockingSticks` does not read it. That law spends the roll on the letterbox + * and pitches onto the heading, so it cannot answer a sideways error at all. + * It never had to while the ship flew, because the motion sweeps the pitch + * plane round. The computer makes its last turn from a STOP, so nothing + * sweeps. `bankToTurn` is the law that points a yaw-less ship from there + * (world-step.ts, docs/TODO/212). + */ + steer: SteerMemory; } const _rel = new THREE.Vector3(); @@ -91,6 +110,7 @@ export function planDocking( // perpendicular distance from the axis const lateral = _rel.addScaledVector(_slotN, -along).length(); out.lateral = lateral; + out.along = along; // The station's local X, and not its Y. `lookAt(heading, up)` puts the ship's // RIGHT perpendicular to the up-hint. The wings must lie along the slot's // LONG axis, which is the station's local Y (see the header). The Y put every @@ -172,9 +192,11 @@ export function planDocking( /** A fresh plan object to hand to `planDocking` each frame. */ export function makeDockPlan(): DockPlan { return { + along: 0, heading: new THREE.Vector3(0, 0, -1), up: new THREE.Vector3(0, 1, 0), speed: 0, + steer: freshSteerMemory(), phase: 'gate', arrived: false, lateral: 0, @@ -213,8 +235,10 @@ export function inSlotChannel(localX: number, localY: number): boolean { * the angle away from it. Both magnitudes are absolute: a ship upside down in * the slot still fits through it. */ -export function rollAlignedWithSlot(rightX: number, rightY: number): boolean { - return slotRollOffset(rightX, rightY) < ROLL_TOLERANCE; +export function rollAlignedWithSlot( + rightX: number, rightY: number, tolerance: number = ROLL_TOLERANCE, +): boolean { + return slotRollOffset(rightX, rightY) < tolerance; } /** How far off the slot's long axis the wings are, in radians. */ @@ -229,12 +253,19 @@ export type DockingOutcome = | 'docked' /** in the channel but rolled wrong */ | 'slotMiss' + /** in the channel, lined up, and going too fast to be taken (docs/TODO/207) */ + | 'tooFast' /** flew into the hull */ | 'hull'; /** - * Where a ship is relative to the slot. + * Where a ship is relative to the slot, and whether the slot will take it. * + * The slot asks two things of a ship in the channel: the roll, and the speed + * (docs/TODO/207 M3). It is one answer, so the caller cannot hold half of the + * rule. + * + * @param speed how fast the ship is going, against `SLOT_SPEED_LIMIT` * @param scratch a Vector3 and a Quaternion to work in; this runs every frame. */ export function dockingOutcome( @@ -242,7 +273,16 @@ export function dockingOutcome( quat: THREE.Quaternion, station: THREE.Object3D, dockZ: number, + speed: number, scratch: { v: THREE.Vector3; q: THREE.Quaternion; r: THREE.Vector3 }, + /** + * How far the wings may be off the slot's long axis, in radians. The CALLER + * says, because it depends on who holds the stick: `ROLL_TOLERANCE` for a + * hand, and the wider `COMPUTER_ROLL_TOLERANCE` for a bought docking + * computer. The default is the hard one, so a caller that forgets is strict + * rather than lax. + */ + rollTolerance: number = ROLL_TOLERANCE, ): DockingOutcome { const box = dockZ + HULL_BOX_MARGIN; const local = scratch.v.copy(pos); @@ -256,5 +296,6 @@ export function dockingOutcome( scratch.q.copy(station.quaternion).invert().multiply(quat); const right = scratch.r.set(1, 0, 0).applyQuaternion(scratch.q); - return rollAlignedWithSlot(right.x, right.y) ? 'docked' : 'slotMiss'; + if (!rollAlignedWithSlot(right.x, right.y, rollTolerance)) return 'slotMiss'; + return speed > SLOT_SPEED_LIMIT ? 'tooFast' : 'docked'; } diff --git a/src/game/fire-resolution.ts b/src/game/fire-resolution.ts index 54a88544..bd8e47e1 100644 --- a/src/game/fire-resolution.ts +++ b/src/game/fire-resolution.ts @@ -101,7 +101,7 @@ export interface FireWorld { export type NpcShot = /** a round left the rail: the rack is spent and the warhead is in the sky */ | { weapon: 'missile'; launch: OrdnanceOutcome } - /** a bolt at the hunted ship, and what it cost her pools */ + /** a bolt at the hunted ship, and what it cost the target's pools */ | { weapon: 'laser'; at: 'target'; range: number; hit: boolean; damage: number } /** a bolt at another ship, and what came off its bank */ | { weapon: 'laser'; at: NpcShip; hit: boolean; damage: number; destroyed: boolean }; diff --git a/src/game/flight-course.ts b/src/game/flight-course.ts new file mode 100644 index 00000000..1550964a --- /dev/null +++ b/src/game/flight-course.ts @@ -0,0 +1,290 @@ +// The COURSE at the controls: the one the pilot picked, and how it ends. +// +// Split from `flight-instruments.ts` on 2026-09-12, when that file passed the +// 400-line ceiling `tools/sizes.mjs` calls a detector. That file held six +// switches. The course was the one with a machine of its own. It holds a frame +// of flight, three ways to end, and the two hand-overs it throws on the way. +// +// ONE RESPONSIBILITY: who the course is, while it is flying, and what ends it. +// Three things end a course, and each is a different actor: +// +// - the WORK is done, or the pilot takes the stick (`course`, `endCourse`); +// - the PILOT taps the lit button (`stopCourse`); +// - a HOSTILE SHIP turns up, and the computer takes the stick (`dropForFight`). +// +// `course-pilot.ts` decides where the ship goes. `courses.ts` decides which +// courses the situation allows. This holds the course while it flies, and it +// says what happened. +// +// It throws two switches that are NOT its own: the torus drive, and the +// hand-over to the docking computer. The drive belongs to +// `flight-instruments.ts`, so it reaches it through `CourseHost` rather than +// holding a second copy of the rule. + +import { CoursePilot } from './course-pilot.ts'; + +/** + * The roles a course steers round. See `CourseView.obstacles` for why these + * three and no ship. + */ +const OBSTACLE_ROLES = new Set(['hermit', 'asteroid', 'generation']); +import type { CourseKind } from './courses.ts'; +import { MAX_FUEL } from '../constants/commander.ts'; +import { hostilesNear, hostilesOnScanner } from './hostility.ts'; +import { pickTarget, pickedTarget } from './targets.ts'; +import { derelictReport } from './derelict.ts'; +import type { StarSystem } from '../galaxy/galaxy.ts'; +import { missionCourse, missionHere } from './mission-course.ts'; +import { SCANNER_RANGE } from '../constants/console.ts'; +import { DOCK_COMPUTER_RANGE } from '../constants/docking-computer.ts'; +import { COURSE_DOCK_HANDOVER } from '../constants/course.ts'; +import type { FlightDemand } from '../player.ts'; +import type { GameState } from './state.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> = { + skim: 'FUEL TANK FULL', + run: 'YOU GOT AWAY', + mine: 'NO ROCKS LEFT WITHIN RANGE', + collect: 'EVERYTHING IS ABOARD', +}; + +/** The console, and the drive switch that belongs to the instruments. */ +export interface CourseSwitchHost { + showMessage(text: string, seconds: number): void; + /** a message event, said or queued as it asks */ + sayEvent(e: { text: string; seconds: number; queued?: boolean }): void; + /** the drive's own rule — `flight-instruments.ts` holds it */ + massLocked(): boolean; + /** ...and its switch, which a course throws on a long leg */ + toggleTorus(): void; +} + +export class FlightCourse { + private readonly state: GameState; + private readonly coursePilot = new CoursePilot(); + private readonly host: CourseSwitchHost; + + constructor(state: GameState, host: CourseSwitchHost) { + this.state = state; + this.host = host; + } + + /** + * 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; + s.handFlown = true; + this.coursePilot.reset(); + this.host.showMessage('MANUAL CONTROL — AUTOPILOT OFF', 2); + return null; + } + const p = this.state.player; + const w = this.state.world; + // The mining course fights the rocks: it picks the next one, and the + // computer's aim lines the ship up on it (docs/TODO/206 M6). + if (s.course === 'mine' && !this.aimAtNextRock()) { + this.endCourse('mine'); + return null; + } + // A mission's own work here, and what the ship does about it + // (docs/TODO/208 M1). A hunt is a fight, so the ship it names is picked + // as the target, exactly as a rock is. + const mission = s.course !== 'mission' ? null + : missionCourse(this.state.commander.missions, missionHere(s, this.state.commander), + w.npcs, w.cargo.items, w.station.position, p.position); + if (mission?.how === 'fight' && mission.ship !== null + && pickedTarget(w.npcs) !== mission.ship) { + pickTarget(w.npcs, mission.ship); + s.handFlown = false; + } + const live = (role: string) => w.npcs.find((n) => n.state.alive && n.role === role) ?? null; + const derelict = live('generation'); + const step = this.coursePilot.step({ + course: s.course, + position: p.position, + quaternion: p.quaternion, + pitchRate: p.pitchRate, + rollRate: p.rollRate, + 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, + police: w.npcs + .filter((n) => n.state.alive && n.role === 'police' + && n.object.position.distanceTo(p.position) <= SCANNER_RANGE * 2) + .map((n) => n.object.position), + loot: w.cargo.items + .filter((c) => c.object.position.distanceTo(p.position) <= SCANNER_RANGE) + .map((c) => ({ at: c.object.position, velocity: c.velocity })) + .sort((a, b) => a.at.distanceTo(p.position) - b.at.distanceTo(p.position)), + threats: hostilesOnScanner(w.npcs, p.position, this.state.commander.legalStatus, + p.position.distanceTo(w.station.position), s.cloaked).map((n) => n.object.position), + // THE SOLID THINGS, so a line does not go through one. Three roles sit + // still and are big enough to kill the commander. A rock hermit is 120 + // units across the radius, the derelict is 340, and a rock is 54. Every + // other ship moves, and a course that bent round those would bend round + // a fight. + obstacles: w.npcs + .filter((n) => n.state.alive && OBSTACLE_ROLES.has(n.role) + && n.object.position.distanceTo(p.position) <= SCANNER_RANGE) + .map((n) => ({ at: n.object.position, radius: n.radius })), + dcEngaged: s.dcEngaged, + mission: mission === null ? null + : { at: mission.at, speed: mission.speed, how: mission.how, fleeing: mission.fleeing }, + handOverRange: this.state.commander.equipment.dockingComputer + ? DOCK_COMPUTER_RANGE : COURSE_DOCK_HANDOVER, + }, dt); + if (step.handOver) this.handOver(); + if (step.torus !== s.torusEngaged && (!step.torus || !this.host.massLocked())) { + this.host.toggleTorus(); + } + if (step.done) this.endCourse(s.course, step.why); + return step.demand; + } + + /** + * The pilot tapped the lit course button, so the course stops + * (docs/TODO/205 M5). The list shows again by itself. + * + * It does NOT set `handFlown`, and a flight key does. A key means the pilot + * wants the stick. This means only that the ship goes nowhere in particular. + */ + stopCourse(): void { + const s = this.state.session; + if (s.course === null) return; + s.course = null; + this.coursePilot.reset(); + this.host.showMessage('COURSE OFF — CHOOSE WHERE TO GO', 3); + } + + /** + * A hostile ship turned up, and the combat computer took the stick. The + * course goes with it (Chris, 2026-09-12: *"it should also be disengaged by + * combat or other events"*, and *"anything that breaks the journey should let + * you reconsider what you are doing"*). + * + * `flight.ts` asks the co-pilot before the course. So the course already flew + * nothing while it waited. It then took the ship back the moment the fight + * ended, and the button said the ship was heading somewhere all along. + * + * Three things the shape of it turns on: + * + * - A HOSTILE SHIP is the test, not whether the computer took the stick. A + * picked rock takes it too, and to drop on that would end every mine run + * at the first rock. + * - EVERY FRAME, not the frame the fight starts. A pilot can pick a course + * mid-fight, and an edge test would miss it. + * - With no co-pilot, `ccEngaged` stays false and the course flies on. The + * LIVE BRAINS row set to NONE leaves the course the only pilot there is. + * + * @internal — driven by `flight-instruments.ts`, once per fixed step. + */ + dropForFight(): void { + const s = this.state; + if (!s.session.ccEngaged || s.session.course === null) return; + if (!hostilesNear(s.world.npcs, s.player.position, s.commander.legalStatus, + s.player.position.distanceTo(s.world.station.position), undefined, s.session.cloaked)) return; + s.session.course = null; + this.coursePilot.reset(); + // QUEUED, not said. The torus drive drops in the same frame, on the same + // ship being close, and a said line would take the console off that cause. + this.host.sayEvent({ text: 'HOSTILE SHIP — COURSE OFF', seconds: 3, queued: true }); + } + + /** + * The pilot's stretch ends when the ship leaves the docking computer's + * range, which is the width of the whole approach. The course list then + * offers the station again. + * + * @internal — driven by src/game/flight.ts, once per fixed step. + */ + watchDockTrial(): void { + const s = this.state.session; + if (!s.dockTrial) return; + const out = this.state.player.position + .distanceTo(this.state.world.station.position) > DOCK_COMPUTER_RANGE; + if (!out) return; + s.dockTrial = false; + s.dockRails = false; + this.host.showMessage('THE STATION IS BEHIND YOU', 3); + } + + /** + * The station course reaches the hand-over, and the ship changes hands + * (docs/TODO/207 M1, in the shape docs/TODO/212 gave it). + * + * ONE LINE-UP, WHOEVER FLIES THE SLOT (Chris, 2026-09-12: *"I think we should + * merge both paths?"*). The computer flies the ship to the right distance and + * turns it to face the port, and it does that for every commander. Only then + * does it ask who takes the ship in. A fitted docking computer takes it. A + * commander with none gets the rails and the mini game. `world-step.ts`'s + * `dockTrialStep` is where that one question is asked. + * + * It used to branch HERE instead, so a fitted computer flew the whole + * approach on its own and never showed a line-up at all. + */ + private handOver(): void { + const s = this.state.session; + s.dockTrial = true; + s.dockRails = false; + s.course = null; + this.coursePilot.reset(); + this.host.showMessage('THE COMPUTER IS LINING THE SHIP UP — STAND BY', 4); + } + + /** + * The mining course keeps a rock picked as the target, so the computer aims + * at it and the pilot fires. + * + * @returns false when no rock is left within scanner range. + */ + private aimAtNextRock(): boolean { + const { player, world } = this.state; + if (pickedTarget(world.npcs)?.role === 'asteroid') return true; + const rocks = world.npcs + .filter((n) => n.state.alive && n.role === 'asteroid' + && n.object.position.distanceTo(player.position) <= SCANNER_RANGE) + .sort((a, b) => a.object.position.distanceTo(player.position) + - b.object.position.distanceTo(player.position)); + if (rocks.length === 0) return false; + pickTarget(world.npcs, rocks[0]); + this.state.session.handFlown = false; + return true; + } + + /** + * 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, why?: string): void { + const s = this.state.session; + s.course = null; + // A course that ended for a reason is not done: the list offers it again. + if (why === undefined && !s.coursesDone.includes(kind)) s.coursesDone.push(kind); + this.coursePilot.reset(); + // The derelict's own words, read off the world's seed (docs/TODO/208 M5). + const said = why ?? (kind === 'derelict' + ? derelictReport(this.state.systems[this.state.commander.systemIndex] as StarSystem) + : COURSE_ENDS[kind]); + if (said) this.host.showMessage(said, 6); + } +} diff --git a/src/game/flight-instruments.ts b/src/game/flight-instruments.ts index a379d8b3..252fa183 100644 --- a/src/game/flight-instruments.ts +++ b/src/game/flight-instruments.ts @@ -7,20 +7,27 @@ // // ONE RESPONSIBILITY: the instruments a pilot switches on. Five of them: // -// - the two computers that fly the ship for her; +// - the two computers that fly the ship for them; // - the drive that crosses the system; -// - the mouse she flies with; -// - the view she flies by. +// - the mouse they fly with; +// - the view they fly by. // // Every one is a switch that changes who or what is at the controls. Not one of // them decides anything about the world. // +// THE COURSE LEFT ON 2026-09-12, to `flight-course.ts`, and it took the file +// back under the 400-line ceiling. The course has a machine of its own: a frame +// of flight, three ways to end, and two hand-overs. `courses` below holds it, +// and it reaches the DRIVE switch back through a host. +// // `autopilot.ts` owns what a computer does with the stick. `world-step.ts` owns // what a mass lock IS. This file holds the switch, and says what happened. import { sfx } from '../audio.ts'; import { Autopilot, type AutopilotEvent } from './autopilot.ts'; -import { massLocked } from './world-step.ts'; +import { FlightCourse } from './flight-course.ts'; + +import { massLocked, massLockCause } from './world-step.ts'; import { boundKey } from '../ui/key-help.ts'; import { defenceBrain } from './brains.ts'; import { defenceBrainNameFor } from './brain-names.ts'; @@ -68,6 +75,13 @@ export class Instruments { * seat. */ private readonly autopilot: Autopilot; + /** the seat that flies a picked course (docs/TODO/205 M3) */ + /** + * The course at the controls, split out on 2026-09-12 — see + * `flight-course.ts`. It is public because `flight.ts` flies it and + * `game.ts` stops it, exactly as they reach these switches. + */ + readonly courses: FlightCourse; private readonly host: InstrumentHost; constructor( @@ -79,6 +93,14 @@ export class Instruments { this.ordnance = ordnance; this.host = host; this.autopilot = new Autopilot(state, combatComputer); + // The course reaches the DRIVE switch through here, rather than holding a + // second copy of the mass-lock rule (`flight-course.ts`). + this.courses = new FlightCourse(state, { + showMessage: (text, seconds) => host.showMessage(text, seconds), + sayEvent: (e) => host.sayEvent(e), + massLocked: () => this.massLocked(), + toggleTorus: () => this.toggleTorus(), + }); } /** @@ -102,6 +124,30 @@ export class Instruments { return { demand: auto.demand ?? null, ecm: auto.ecm }; } + /** + * A fight starts, and the computer takes the stick, unless the pilot flies + * by hand (docs/TODO/206 M2). `autopilot.ts` decides. + * + * A COURSE UNDER WAY IS DROPPED WITH IT (Chris, 2026-09-12: *"it should also + * be disengaged by combat or other events"*). Dropping it is the consequence, + * and a consequence is the orchestrator's (invariant 15). The whole of the + * reasoning is `flight-course.ts`'s `dropForFight`. + * + * Three things the shape of it turns on: + * + * - A HOSTILE SHIP is the test, not the computer's own. A picked rock also + * takes the stick, and to drop on that would end every mine run at the + * first rock. + * - EVERY FRAME, not the frame the fight starts. A pilot can pick a course + * mid-fight, and an edge test would miss it. + * - With no co-pilot, `ccEngaged` stays false and the course flies on. The + * LIVE BRAINS row set to NONE leaves the course the only pilot there is. + */ + autoEngage(): void { + this.applyAutopilot(this.autopilot.autoEngage()); + this.courses.dropForFight(); + } + /** A hit worth a break: the co-pilot keeps its own record * (scripted-co-pilot.ts). */ noteUnderFire(): void { this.autopilot.noteUnderFire(); } @@ -115,8 +161,20 @@ export class Instruments { */ massLocked(): boolean { return massLocked(this.state); } + /** + * The docking computer, on or off, from the key or from the button. + * + * IT ENDS THE TRIAL, because the two cannot both fly the ship. The button is + * offered for the whole of the pilot's stretch (`cockpit-buttons.ts`), so a + * commander who fumbles the slot can still hand it over. `watchDockTrial` + * would clear the flag eventually, on range alone, and the buttons would + * read LINING UP while the computer flew. + */ dockingComputer(): void { + const s = this.state.session; + const was = s.dcEngaged; this.applyAutopilot(this.autopilot.toggleDocking()); + if (!was && s.dcEngaged) { s.dockTrial = false; s.dockRails = false; } } /** @@ -129,6 +187,22 @@ export class Instruments { this.applyAutopilot(this.autopilot.toggleCombat()); } + /** + * The cloak, on or off, on its key or its button (docs/TODO/219 M5). A + * ship without the fit is told so. The drain and the drop are the world + * step's, because they run on time and on the shot. + */ + toggleCloak(): void { + const s = this.state; + if (!s.commander.equipment.cloak) { + this.host.showMessage('NO CLOAKING DEVICE FITTED', 3); + return; + } + s.session.cloaked = !s.session.cloaked; + this.host.showMessage(s.session.cloaked + ? 'CLOAKED — NOBODY OUT THERE SEES YOU' : 'UNCLOAKED', 3); + } + toggleMouseFlight(): void { if (this.input.mouseFlight) { this.input.releaseMouseFlight(); @@ -143,8 +217,12 @@ export class Instruments { } toggleTorus(): void { - if (this.massLocked()) { - this.host.showMessage('MASS LOCKED', 2); + // The refusal names what holds the drive down, as the drop does + // (docs/TODO/209). The message said "MASS LOCKED" before. A pilot cannot + // tell from those two words that a trader stopped the drive. + const cause = massLockCause(this.state); + if (cause !== null) { + this.host.showMessage(`${cause} IS TOO CLOSE FOR THE TORUS DRIVE`, 2); sfx.refused(); return; } diff --git a/src/game/flight-weapons.ts b/src/game/flight-weapons.ts index e5acf268..94b8ca7b 100644 --- a/src/game/flight-weapons.ts +++ b/src/game/flight-weapons.ts @@ -35,6 +35,10 @@ import { sfx } from '../audio.ts'; import { Combat, BEAM_FLASH, type DamageSource } from './combat.ts'; import type { CombatEvent } from './combat-events.ts'; import { firePlayerLaser, damagePlayer } from './combat-player.ts'; +import { traceShot } from './shot.ts'; +import { viewDirection } from './views.ts'; +import { isHostileToPlayer } from './hostility.ts'; +import { pickedTarget } from './targets.ts'; import { CombatInstrumentation, type CombatObserver } from './instrumentation.ts'; import { fireEcm, ordnanceMessage, type Ordnance, type OrdnanceOutcome } from './ordnance.ts'; import { dealToNpc } from './damage-dealt.ts'; @@ -190,7 +194,7 @@ export class Weapons { if (!reply) return; const m = ordnanceMessage(reply); // A refusal with an answer names the COMMAND (ordnance.ts); the letter is - // this side's business, from the same table the prompt line reads. + // this side's business, from the same table the offer buttons read. const offer = m.offer ? this.cockpit.renderPrompt(m.offer) : null; this.host.showMessage(offer ? `${m.text} — ${offer}` : m.text, m.seconds); } @@ -238,6 +242,49 @@ export class Weapons { this.applyCombat(firePlayerLaser(this.state, this.combat, this.combatScratch)); } + /** + * May the COMPUTER pull the trigger this frame? The human's is never asked. + * + * @param want whether the co-pilot asked for the trigger at all. + * + * THE COMPUTER SHOT AT WHATEVER THE BEAM MET FIRST, and the review of + * 2026-09-12 found what that costs. A trader flying between the commander and + * a pirate took the automatic shot, and the commander became an Offender for + * it. The co-pilot works in ANGLES: a target inside the gun cone is a target + * it will shoot, and nothing asked what stands in the way. + * + * So the same trace the shot itself uses answers first (`shot.ts`). Three + * rules, and each one has a reason: + * + * - A MISS is allowed. The gun grazes a silhouette the ray can pass by + * (gunnery.ts), and to refuse every clean ray would refuse most shots. + * - THE PILOT'S OWN PICK is allowed, whatever it is. They may choose to + * shoot a trader, and the computer aims what they picked (docs/TODO/206). + * - ANYTHING ELSE FIRST holds the shot: another ship that is not hostile, + * the station, or a canister. + * + * THE VIEW IS THE OTHER HALF of the same finding. `autoEngage` turns the + * front view on when it takes the stick. The pilot may look astern + * afterwards, and the trigger then fired the REAR laser at whatever was + * behind. The computer never aimed at that, and never saw it. So the + * automatic shot is the front view's alone, and a pilot who looks away keeps + * their own trigger. + */ + autoFireAllowed(want: boolean): boolean { + const { commander, player, session, world } = this.state; + if (!want || session.view !== 0) return false; + const dir = viewDirection(player.quaternion, 0, this.combatScratch.b); + const hit = traceShot( + player.position, dir, world.npcs, world.cargo.items, + session.witchspace ? null : world.station, + this.combatScratch.ray, this.combatScratch.a); + if (hit.kind === 'miss') return true; + if (hit.kind !== 'ship') return false; + if (hit.ship === pickedTarget(world.npcs)) return true; + return isHostileToPlayer(hit.ship, commander.legalStatus, + player.position.distanceTo(world.station.position)); + } + /** * Destruction credited to the player. * diff --git a/src/game/flight.ts b/src/game/flight.ts index 1ed41cab..c1e53f25 100644 --- a/src/game/flight.ts +++ b/src/game/flight.ts @@ -1,4 +1,4 @@ -// What a commander does while she flies. +// What a commander does while they fly. // // The other half of the orchestrator. docs/TODO/155 M2 split it from // `docked.ts`, on Chris's rule of 2026-08-14: *"It makes sense to split docked @@ -6,7 +6,7 @@ // machine, the frame skeleton and the routing. This holds everything that only // happens with the ship in the sky. // -// ONE RESPONSIBILITY: what a commander does while she flies. That is one slice +// ONE RESPONSIBILITY: what a commander does while they fly. That is one slice // of time advanced, and who is at the controls for it. It is also what the guns // and the racks spend, and what the hull takes. The exercise belongs here too, // because it is ordinary flight with a different step behind it. @@ -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,15 @@ 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 fight gives every pilot the computer's aim (docs/TODO/206 M2). + this.instruments.autoEngage(); + this.instruments.courses.watchDockTrial(); + // A picked course flies when no co-pilot does (docs/TODO/205 M3). The + // trigger stays the pilot's, as it does under the co-pilot. + if (!this.state.session.ccEngaged) { + const course = this.instruments.courses.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 @@ -329,18 +337,20 @@ export class Flight { // The trained one flies at its fitted CC_* caps. So the Game flies either // the same way, and the HUD reads both. const auto = this.instruments.coPilot(dt, this.handsOn()); - // A co-pilot that can answer a warhead — the same button, the same price - // and the same messages as the player's own E.C.M. key (docs/TODO/72). It - // is applied here rather than inside the autopilot, because the spend from - // the bank is a consequence, and consequences are the orchestrator's. - if (auto.ecm) this.weapons.triggerEcm(); - return auto.demand - ? { ...auto.demand, fire: auto.demand.fire || hands.fire } - : hands; + // ONLY THE BOUGHT COMBAT COMPUTER PULLS THE TRIGGER AND FIRES THE E.C.M. + // (docs/TODO/206). Every other pilot gets the aim and keeps the trigger. + // Both are applied here rather than inside the autopilot. A shot and a + // spend from the bank are consequences, and those are the orchestrator's + // (docs/TODO/72). + const bought = this.state.commander.equipment.combatComputer; + if (auto.ecm && bought) this.weapons.triggerEcm(); + // The hands are never asked. `autoFireAllowed` holds the computer's rule. + const shoots = bought && this.weapons.autoFireAllowed(auto.demand?.fire ?? false); + return auto.demand ? { ...auto.demand, fire: shoots || hands.fire } : hands; } /** - * Is the human on the controls? Both autopilots let go the moment she is — + * Is the human on the controls? Both autopilots let go the moment they are — * the combat computer hands the ship back, the docking computer breaks off. */ private handsOn(): boolean { diff --git a/src/game/game.ts b/src/game/game.ts index 6178b674..2c170044 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -11,7 +11,7 @@ // half takes the frame and the other is reached by keys alone. // // THE TWO HALVES ARE THE SHAPE (docs/TODO/155). `docked.ts` holds what a -// commander does once the ship stops. `flight.ts` holds what she does in the +// commander does once the ship stops. `flight.ts` holds what they do in the // sky, over `flight-weapons.ts` and `flight-instruments.ts`. Neither half // reaches into the other. // @@ -55,7 +55,7 @@ import * as THREE from 'three'; import { COMMODITIES, type StarSystem } from '../galaxy/galaxy.ts'; import { createStarfield, SpaceDust } from '../world/starfield.ts'; import { Input } from '../engine/input.ts'; -import { layoutName, toggleLayout, refreshHelpPanel } from '../engine/keymap.ts'; +import { toggleLayout, refreshHelpPanel } from '../engine/keymap.ts'; import { Hud } from '../hud/hud.ts'; import { TunnelEffect } from '../hud/tunnel.ts'; import { sfx, type Place } from '../audio.ts'; @@ -108,13 +108,17 @@ 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, type CoursePanel } from './course-actions.ts'; +import { TargetActions, type TargetPanel } from './target-actions.ts'; +import { ListFold } from './list-fold.ts'; import { ScreenHost } from '../ui/screen-host.ts'; 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 } from '../ui/key-help.ts'; +import { 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'; @@ -266,7 +270,7 @@ export class Game { this.state, this.ordnance, this.combatComputer, this.persistenceHost()); /** - * What a commander does while she is docked (docs/TODO/155 M1). + * What a commander does while they are docked (docs/TODO/155 M1). * * Four collaborators and thirteen host methods. The width is the point rather * than a cost. This is half an ORCHESTRATOR, and not a rule module. So what @@ -372,6 +376,26 @@ 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. + */ + /** which of the two lists over the view is open (docs/TODO/215 M2) */ + private readonly lists_ = new ListFold(); + + 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(), + stopCourse: () => this.flight_.switches.courses.stopCourse(), + } satisfies CourseHost, this.lists_); + + /** The target list's buttons, and the pick applied (docs/TODO/206 M3). */ + private readonly targets_ = new TargetActions(() => this.state, this.lists_); + /** * What a career keeps when a flight ends (docs/TODO/150 M5). * @@ -435,8 +459,47 @@ export class Game { exerciseStrip: () => this.flight_.strip, 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). During a + // tunnel the cockpit reads no button, so it shows none. + // It shows none while the pilot flies the slot either. The ship is in + // the station's mouth then. The only things to fly are the roll and the + // speed (docs/TODO/207 M2). + coursePanel: () => (this.flight_.inSimulator() || this.tunnel.active + || this.state.session.dockTrial ? null : this.courses_.panel()), + // The target buttons, in career flight and in an exercise alike: a + // training fight is a real fight (docs/TODO/206 M3). + targetPanel: () => (this.tunnel.active ? null : this.targets_.panel()), } satisfies CockpitHost); + /** + * What the course buttons show now (docs/TODO/205 M5). + * + * @internal — a delegate for test/course-buttons.test.ts, which reads the + * panel without a scrape of the painted buttons. + */ + coursePanel(): CoursePanel | null { + return this.flight_.inSimulator() ? null : this.courses_.panel(); + } + + /** + * Both button columns as the HUD paints them (docs/TODO/206 M5). + * + * @internal — a delegate for test/run-and-offers.test.ts. + */ + hudButtons(): ReturnType { + return this.cockpit_.buttons(); + } + + /** + * What the target buttons show now (docs/TODO/206 M3). + * + * @internal — a delegate for test/fight-buttons.test.ts. + */ + targetPanel(): TargetPanel { + return this.targets_.panel(); + } + /** * What the cockpit is offering right now. * @@ -487,7 +550,7 @@ export class Game { } /** - * What a commander does while she is flying (docs/TODO/155 M2). + * What a commander does while they are flying (docs/TODO/155 M2). * * Six collaborators and seventeen host methods. The width is what a HALF * costs, rather than a fault. @@ -691,6 +754,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 @@ -705,14 +772,9 @@ export class Game { // same in both layouts, so the binding table paints them once. refreshHelpPanel(); paintCommandGuide(); - // ...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 - // (docs/TODO/202). - this.showMessage( - `PRESS ${boundKey('docked', 'toggleHelp')} FOR CONTROLS` - + ` — ${layoutName().toUpperCase()} LAYOUT` - + ' (SWITCH IT ON THE STATION MENU)', 8); + // No line at boot says PRESS ? FOR CONTROLS any more (Chris, 2026-09-13, + // docs/TODO/220). A phone has no ? key, and the station menu carries the + // guide and the layout toggle as rows. // all screens accept mouse input; the shell owns the listener and hands // back the element that carries data-key/data-row @@ -760,16 +822,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); }); } @@ -991,7 +1056,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. @@ -1013,6 +1080,7 @@ export class Game { } this.tunnel.update(dt); if (this.mode === 'flight') this.flight_.update(dt, elapsed); + this.courses_.watchSkip(); this.finishStep(dt); } @@ -1074,6 +1142,12 @@ export class Game { const mode = this.controlMode(); if (!mode) return; + // The course buttons in flight send codes that no key table holds + // (docs/TODO/205 M5). The course actions read them, as a screen reads its + // own. A paused cockpit and an exercise read none. + if (mode === 'flight' && !pausedOnly) this.courses_.read(i); + // ...and so do the target buttons, in an exercise too (docs/TODO/206 M3). + if ((mode === 'flight' || mode === 'simulator') && !pausedOnly) this.targets_.read(i); for (const c of commandsFor(mode, i)) { if (!pausedOnly || WHILE_PAUSED.includes(c)) this.runCommand(c); } @@ -1118,7 +1192,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'), @@ -1167,6 +1243,7 @@ export class Game { launchMissile: () => this.flight_.racks.launchMissile(), disarmMissile: () => this.flight_.racks.disarmMissile(), fireEcm: () => this.flight_.racks.triggerEcm(), + toggleCloak: () => this.flight_.switches.toggleCloak(), detonateEnergyBomb: () => this.flight_.racks.detonateEnergyBomb(), toggleCombatComputer: () => this.flight_.switches.toggleCombatComputer(), toggleDockingComputer: () => this.flight_.switches.dockingComputer(), diff --git a/src/game/gunnery.ts b/src/game/gunnery.ts index 3578c965..bc59b052 100644 --- a/src/game/gunnery.ts +++ b/src/game/gunnery.ts @@ -142,7 +142,7 @@ export interface ArmedCommander { * one cooldown and one heat budget. * * It takes the COMMANDER now rather than the equipment, because the hull is - * half the answer. Which of the 15 flyable ships she flies decides how hard the + * half the answer. Which of the 15 flyable ships they fly decides how hard the * fitted laser hits (`playerLaserHit`). Fitting behaviour is untouched. */ export function laserForView(c: ArmedCommander, view: number): LaserSpec | null { @@ -279,9 +279,9 @@ export function npcHitChance(dist: number): number { } /** - * The MOST this build's gun can ever be worth against this hull, in her pool - * points a second. It is point blank (`npcHitChance(0)`, the cap), never out of - * the gate, and never short of a reload. + * The MOST this build's gun can ever be worth against this hull, in the + * commander's pool points a second. It is point blank (`npcHitChance(0)`, the + * cap), never out of the gate, and never short of a reload. * * A ceiling, and the only honest way to compare a gun with a shield. Nothing in * a fight reaches it, because `npm run aim-probe` measures 7-27% of it. So a diff --git a/src/game/hostility.ts b/src/game/hostility.ts index 862fb48b..89eb8eb4 100644 --- a/src/game/hostility.ts +++ b/src/game/hostility.ts @@ -20,6 +20,7 @@ // It came out of `game/npc.ts`, where it sat inside a class file of 1,676 // lines (docs/TODO/169 M2). +import { SCANNER_RANGE } from '../constants/console.ts'; import type * as THREE from 'three'; import type { NpcRole } from './ship-roles.ts'; @@ -98,16 +99,22 @@ export function isHostileToPlayer( /** * Is this ship both cross with you and close enough to act on it? * - * The same range the ship itself engages at, from - * `constants/player-interest.ts`. One home for it, because everything that - * answers "who is in this fight" has to agree. Those are the condition light - * below, and the bribe key, which may only buy off a ship that is on you. + * One home for it, because everything that answers "who is in this fight" has + * to agree. The condition light and the bribe key take the default range, which + * is the one the ship itself engages at (`constants/player-interest.ts`). + * + * THE RANGE IS THE CALLER'S, from 2026-09-12, and it had to become one. The + * combat computer looks a shorter way out than the light does, and the two + * disagreeing cost 60 engagements a second. See `Autopilot.fightOn`. */ -function engaging( +export function engaging( npc: HostileShip, playerPos: THREE.Vector3, legalStatus: number, playerToStation: number, + range: number = PLAYER_INTEREST_RANGE, cloaked = false, ): boolean { + // A cloaked ship is nobody's business (docs/TODO/219 M5). + if (cloaked) return false; return isHostileToPlayer(npc, legalStatus, playerToStation) - && npc.object.position.distanceTo(playerPos) < PLAYER_INTEREST_RANGE; + && npc.object.position.distanceTo(playerPos) < range; } /** @@ -118,9 +125,24 @@ function engaging( */ export function hostilesNear( npcs: readonly HostileShip[], playerPos: THREE.Vector3, legalStatus: number, - playerToStation: number, + playerToStation: number, range?: number, cloaked = false, ): boolean { - return npcs.some((npc) => engaging(npc, playerPos, legalStatus, playerToStation)); + return npcs.some((npc) => engaging(npc, playerPos, legalStatus, playerToStation, range, cloaked)); +} + +/** + * Every ship that attacks the commander within scanner range (docs/TODO/206). + * The run course flies away from them, and its row weighs their speed. It is + * the same rule as `hostilesNear`, over the scanner's reach rather than the + * condition light's. + */ +export function hostilesOnScanner( + npcs: readonly T[], playerPos: THREE.Vector3, legalStatus: number, + playerToStation: number, cloaked = false, +): T[] { + if (cloaked) return []; + return npcs.filter((npc) => isHostileToPlayer(npc, legalStatus, playerToStation) + && npc.object.position.distanceTo(playerPos) <= SCANNER_RANGE); } /** @@ -128,12 +150,12 @@ export function hostilesNear( * explain. * * A **grudge** is one ship's private quarrel with the commander. - * `NpcShip.takeDamage` sets `provokedByPlayer` for damage from her gun, + * `NpcShip.takeDamage` sets `provokedByPlayer` for damage from their gun, * whatever the role. The flag never comes down, and the legal record has * nothing to do with it. * * So the console can say `LEGAL STATUS: OFFENDER — BOUNTY HUNTERS WILL ATTACK - * YOU` while the ship shooting at her is a police Viper she grazed. That is + * YOU` while the ship shooting at them is a police Viper they grazed. That is * docs/TODO/175, and GitHub #35 reported it in the player's own words. * * `recordVerdict` is right to read `lawTakesInterest` alone. It is the one home @@ -146,7 +168,7 @@ export function hostilesNear( * * It then drops every role the record already accounts for. So the line it * feeds never repeats the line beside it, and a Fugitive hears nothing at all: - * her record explains both roles already. + * their record explains both roles already. * * The roles come back in `LAW_ROLE_NAMES` order, so one sky gives one sentence. */ diff --git a/src/game/hunt-warning.ts b/src/game/hunt-warning.ts index 34a4dfd6..c5a5bda0 100644 --- a/src/game/hunt-warning.ts +++ b/src/game/hunt-warning.ts @@ -1,4 +1,5 @@ -// What her gun is worth against the ship she is sent to kill, in two numbers. +// What the commander's gun is worth against the ship they are sent to kill, +// in two numbers. // // DERIVED, every time, through the same two functions a live shot goes // through. The first is the fitted laser's byte (`playerLaser`). The second is @@ -19,6 +20,12 @@ // The one thing a commander must not do is fly forty light years to find that // out. The beam laser is the trap: it is the upgrade, and it is worse here // than the gun it replaced. +// +// IT SPEAKS FOR A TARGET THAT HALVES A HIT, AND FOR NO OTHER (docs/TODO/213 +// M3). A military laser scores more than a pulse laser against every hull. +// So the words TARGET ARMOUR HALVES LASER FIRE were said on every side hunt +// and every arc hunt. Only the Constrictor's policy halves a player hit, and +// the numbers are its signpost. A Krait needs no signpost. import type { CommanderData, LaserType } from './commander.ts'; import { playerLaser } from './gunnery.ts'; @@ -51,11 +58,12 @@ export function huntGunCheck(commander: CommanderData, target: NpcSpec): GunChec * The patron's word on what the job needs, beyond where to go. * * It states two NUMBERS and lets the commander decide. It issues no - * instruction. It returns '' when she already holds the best gun for the job. + * instruction. It returns '' when they already hold the best gun for the job. * So the line means something on the day it appears, rather than a line the * player learns to skip. */ export function huntWarning(commander: CommanderData, target: NpcSpec, patron = 'NAVY'): string { + if (npcEnergyPolicy(target.profileId).playerLaserMultiplier >= 1) return ''; const g = huntGunCheck(commander, target); if (g.fitted === g.best) return ''; return `${patron}: TARGET ARMOUR HALVES LASER FIRE — YOUR ${g.fitted.toUpperCase()} LASER` diff --git a/src/game/hyperspace-actions.ts b/src/game/hyperspace-actions.ts index ce7642cd..2363b1b3 100644 --- a/src/game/hyperspace-actions.ts +++ b/src/game/hyperspace-actions.ts @@ -1,4 +1,4 @@ -// How a commander leaves a system, and how she arrives in one. +// How a commander leaves a system, and how they arrive in one. // // The ORCHESTRATION half of the jump, split out of `game.ts` by docs/TODO/150 // M4. `hyperspace.ts` next door owns the RULES, and states them at the head of @@ -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'; @@ -91,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 @@ -101,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); @@ -174,6 +183,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(); @@ -279,9 +289,9 @@ export class HyperspaceActions { const from = this.state.commander.galaxy; const jump = resolveGalacticJump(this.state.commander, this.host.system()); this.state.systems = jump.systems; - // EVERY HELD MISSION FAILS, and every lead moves with her (docs/TODO/190). + // EVERY HELD MISSION FAILS, and every lead moves with them (docs/TODO/190). // The machine decides both. It is told the new galaxy's systems, so the - // lead it relocates is a world she can reach from where she arrives. + // lead it relocates is a world they can reach from where they arrive. for (const m of runMissions(this.state.commander, { kind: 'galaxyChanged', from, to: jump.galaxy }, jump.systems)) { this.host.sayEvent(m); @@ -308,8 +318,8 @@ export class HyperspaceActions { * null when nothing is held and the jump needs no answer. * * It names the keys off the binding table (invariant 9). A refusal the - * drive would give anyway is not asked about. The jump is refused when she - * confirms, as it was refused on the one key before. + * drive would give anyway is not asked about. The jump is refused when they + * confirm, as it was refused on the one key before. */ galacticJumpWarning(): string | null { const held = this.state.commander.missions.live.length; diff --git a/src/game/jettison.ts b/src/game/jettison.ts index 07712d71..c523bec8 100644 --- a/src/game/jettison.ts +++ b/src/game/jettison.ts @@ -1,4 +1,4 @@ -// How a commander buys her way out of a fight. +// How a commander buys their way out of a fight. // // Pirates came for cargo, not for you. Give them enough of it, and the // opportunists break off to go collect. That turns "I can't win this fight" diff --git a/src/game/law-actions.ts b/src/game/law-actions.ts index 32d8e163..eb3ed1f1 100644 --- a/src/game/law-actions.ts +++ b/src/game/law-actions.ts @@ -1,4 +1,4 @@ -// What the law does to a commander, and what she can do about it. +// What the law does to a commander, and what they can do about it. // // The ORCHESTRATION half of the law, split out of `game.ts` by docs/TODO/150. // `law.ts` next door owns the RULES: what a bribe costs, what a patrol is @@ -142,8 +142,8 @@ export class LawActions { * * ONE HOME FOR BOTH LINES, because both call sites above say the first one * and both need the second. `raiseLegal` is the case docs/TODO/175 is named - * for: the verdict says BOUNTY HUNTERS while a police Viper she grazed is the - * ship on her. `lowerLegal` is the sharper one. A commander who works her + * for: the verdict says BOUNTY HUNTERS while a police Viper they grazed is the + * ship on them. `lowerLegal` is the sharper one. A commander who works their * record off over five pirates reads a status that is clean, and the Viper is * still there. * diff --git a/src/game/law.ts b/src/game/law.ts index b92f8f73..b331bff3 100644 --- a/src/game/law.ts +++ b/src/game/law.ts @@ -283,10 +283,11 @@ export function recordVerdict(legalStatus: number): string { } /** - * What the console says about a ship that is on the commander for a reason her - * record cannot explain. It answers null where the sky and the record agree. + * What the console says about a ship that is on the commander for a reason + * their record cannot explain. It answers null where the sky and the record + * agree. * - * A **grudge** is one ship's private quarrel with her, and + * A **grudge** is one ship's private quarrel with the commander, and * `hostility.ts`'s `grudgeRolesNear` decides which roles hold one. Only the * WORDS are this function's. * @@ -305,8 +306,9 @@ export function recordVerdict(legalStatus: number): string { * a player nothing. Chris, 2026-08-17: *"this is just gibberish for a player - * they won't understand what you are talking about"*. * - * SO THE CLAUSE STATES THE MOTIVE RATHER THAN THE MECHANIC. This ship is on her - * because she shot it, and that is the whole difference from the line above. + * SO THE CLAUSE STATES THE MOTIVE RATHER THAN THE MECHANIC. This ship is on + * them because they shot it, and that is the whole difference from the line + * above. * A player needs no rule to read two words. * * IT NAMES NO REMEDY, AND TWO MEASUREMENTS SAY WHY. The bribe reaches @@ -338,12 +340,12 @@ export function offenceFor(role: string, destroyed: boolean): number { /** * What the console says about a ship the commander's own laser just turned - * against her — or null where the shot was nobody's business. + * against them — or null where the shot was nobody's business. * * The console never had a line for this deed. A commander read `LEGAL STATUS: * OFFENDER` at best, and nothing at all where the record did not move - * (docs/TODO/173). So the sky changed who was shooting at her, and no line said - * why. + * (docs/TODO/173). So the sky changed who was shooting at them, and no line + * said why. * * **The SET is `offenceFor`'s, and this restates none of it.** A role that the * law does not protect is a no-op here, exactly as it is at `raiseLegal`. Only diff --git a/src/game/list-fold.ts b/src/game/list-fold.ts new file mode 100644 index 00000000..25a48762 --- /dev/null +++ b/src/game/list-fold.ts @@ -0,0 +1,30 @@ +// Which of the two lists over the flight view is open: the courses, the +// targets, or neither (docs/TODO/215 M2). +// +// Each list folds to one header, closed by default. A tap on a header opens +// its row, and a tap that opens one row folds the other. So the two lists +// share ONE flag, and neither can be open behind the other. The Game owns +// it and hands it to both `course-actions.ts` and `target-actions.ts`. +// +// It is what the buttons show, so no save carries it, as the target list's +// own flag never did. + +export type FlightList = 'courses' | 'targets'; + +export class ListFold { + private open: FlightList | null = null; + + isOpen(which: FlightList): boolean { + return this.open === which; + } + + /** Open `which`, or fold it when it is the one open. The other folds either way. */ + toggle(which: FlightList): void { + this.open = this.open === which ? null : which; + } + + /** A pick was made, or a screen opened: nothing stays open. */ + fold(): void { + this.open = null; + } +} diff --git a/src/game/mission-arrival.ts b/src/game/mission-arrival.ts index eb18d995..06a0592b 100644 --- a/src/game/mission-arrival.ts +++ b/src/game/mission-arrival.ts @@ -11,6 +11,7 @@ // It is pure. The arrival (hyperspace-actions.ts) gathers the sightings and // hands them in, so a test needs no world. +import { WITCHSPACE_TARGET } from '../constants/missions.ts'; import type * as THREE from 'three'; import type { StarSystem } from '../galaxy/galaxy.ts'; import { routeTable } from '../galaxy/route.ts'; @@ -77,6 +78,12 @@ export function arrivalLines( const s = skeletonById(live.skeleton, skeletons); if (!s) continue; const leg = legOf(s, live.leg); + // A leg in witchspace has no jumps to count: the way there is an armed + // mis-jump (docs/TODO/219 M3). + if (live.target === WITCHSPACE_TARGET) { + out.push('YOUR JOB IS IN WITCHSPACE. PAUSE, ARM THE MIS-JUMP, AND JUMP.'); + continue; + } if (live.target !== null && live.target !== here) { routes ??= routeTable(systems, here); const jumps = routes.jumps[live.target]; diff --git a/src/game/mission-bridge.ts b/src/game/mission-bridge.ts index f1fd6208..6b56a81a 100644 --- a/src/game/mission-bridge.ts +++ b/src/game/mission-bridge.ts @@ -11,7 +11,8 @@ // ONE OPERATION. The record and its effects land together, before any save, // so a reload cannot find the credits paid and the leg still owed. -import { COMMODITIES, generateGalaxy, type StarSystem } from '../galaxy/galaxy.ts'; +import { COMMODITIES, type StarSystem } from '../galaxy/galaxy.ts'; +import { galaxySystems } from '../galaxy/galaxies.ts'; import { afterDeed } from './character.ts'; import { cargoCapacity, cargoTonnes, type CommanderData } from './commander.ts'; import { random } from './rng.ts'; @@ -20,7 +21,9 @@ import { huntWarning } from './hunt-warning.ts'; import { dossierWord } from '../missions/dossiers.ts'; import { stepMissions } from '../missions/machine.ts'; import { legOf } from '../missions/lookups.ts'; -import type { CommanderFacts, LiveMission, MissionInput, Skeleton } from '../missions/model.ts'; +import { liveLegs } from '../missions/queries.ts'; +import { jobRole, verbJob, verbNeedsShip } from '../missions/verbs/registry.ts'; +import type { CommanderFacts, LiveMission, MissionInput, Skeleton, TaggedShip } from '../missions/model.ts'; import { skeletonById } from '../missions/skeletons/index.ts'; import { FUGITIVE } from '../constants/law.ts'; import type { Command } from './controls.ts'; @@ -42,39 +45,49 @@ export function missionFacts(c: CommanderData): CommanderFacts { return { galaxy: c.galaxy, systemIndex: c.systemIndex, kills: c.kills, combatScore: c.combatScore, legalStatus: c.legalStatus, day: c.day, - cargo: c.cargo, + cargo: c.cargo, scoops: c.equipment.scoops, }; } -/** - * The systems of a galaxy, for a caller that holds only the commander. - * - * The wreck resolver and the world step have no `GameState.systems` in reach. - * The galaxy is a pure function of its number, and a memo of one galaxy at a - * time keeps the generator off the hot path. - */ -let memo: { galaxy: number; systems: StarSystem[] } | null = null; -function systemsOf(galaxy: number): readonly StarSystem[] { - if (!memo || memo.galaxy !== galaxy) memo = { galaxy, systems: generateGalaxy(galaxy) }; - return memo.systems; +/** What one input left for the caller: the lines to say, and the ships to spawn. */ +export interface MissionOutcome { + /** the lines to say, in order. A `say` takes the console; a `later` waits behind it (session.ts) */ + messages: MissionMessage[]; + /** ships an ambush asks for, around the commander now. Only a caller with the world can spawn them */ + spawns: TaggedShip[]; } /** * Run one input through the machine, install the record, apply the costs. * - * @returns the lines to say, in order. A `say` takes the console; a `later` - * waits behind it (session.ts). + * @returns the lines to say, in order. A caller that holds the world and can + * hear a spawn order calls `applyMissions` instead (docs/TODO/214 M2). */ export function runMissions( c: CommanderData, input: MissionInput, - systems: readonly StarSystem[] = systemsOf(c.galaxy), rng: () => number = random, + systems: readonly StarSystem[] = galaxySystems(c.galaxy), rng: () => number = random, skeletons?: readonly Skeleton[], ): MissionMessage[] { + return applyMissions(c, input, systems, rng, skeletons).messages; +} + +/** + * `runMissions`, with the spawn orders too. A spawn is a world change, and + * the bridge has no world, so the order goes back to the caller. A caller + * that cannot spawn drops it, and an ambush no caller can hear is a + * skeleton fault the flight probe would show. + */ +export function applyMissions( + c: CommanderData, input: MissionInput, + systems: readonly StarSystem[] = galaxySystems(c.galaxy), rng: () => number = random, + skeletons?: readonly Skeleton[], +): MissionOutcome { const { state, effects } = stepMissions(c.missions, input, { commander: missionFacts(c), systems, rng, skeletons, }); c.missions = state; const out: MissionMessage[] = []; + const spawns: TaggedShip[] = []; // A dossier's line replaces the skeleton's where one exists (docs/TODO/191 // M3). The machine named the line and never read it. A word with no // dossier and no plain text is silence, as the skeleton meant it. @@ -83,6 +96,8 @@ export function runMissions( for (const e of effects) { switch (e.kind) { case 'pay': c.credits += e.tenths; break; + // A fit no shop sells goes on the ship (docs/TODO/219 M4). + case 'grant': c.equipment.cloak = true; break; case 'deed': c.disrepute = afterDeed(c.disrepute ?? 0, e.deed.disrepute); break; case 'legal': c.legalStatus = Math.max(0, Math.min(FUGITIVE, c.legalStatus + e.delta)); @@ -97,12 +112,16 @@ export function runMissions( if (text) out.push({ kind: 'message', text, seconds: 6, queued: true }); break; } - case 'lead': + // The lead's own galaxy names the world. An arc that fails on a galactic + // jump leaves its lead in the galaxy it came from (docs/TODO/213 M2). + case 'lead': { + const named = e.galaxy === c.galaxy ? systems : galaxySystems(e.galaxy); out.push({ kind: 'message', queued: true, seconds: 6, - text: `THERE IS A LEAD. ASK AT ${systems[e.world].name.toUpperCase()}.`, + text: `THERE IS A LEAD. ASK AT ${named[e.world].name.toUpperCase()}.`, }); break; + } // The patron's goods go aboard, as far as the hold allows. A hold too // full for all of them is a leg that starts short. The smuggle verb then // fails a dock with fewer tonnes than it wants. @@ -122,6 +141,11 @@ export function runMissions( }); break; } + case 'spawn': spawns.push(...e.ships); break; + // The goods delivered leave the hold, and no more than are aboard. + case 'unload': + c.cargo[e.commodity] = Math.max(0, c.cargo[e.commodity] - e.tonnes); + break; // Passengers a finished mission left aboard are survivors now, once. case 'survivors': c.survivors += e.people; break; // A change to a world is kept on the record until its day, and the @@ -134,24 +158,31 @@ export function runMissions( break; } } - return out; + return { messages: out, spawns }; } -/** The roster row for a mission ship, by the design its entity names. */ +/** + * The roster row for a mission ship, by the design its entity names and the + * role its leg flies it under. Every tagged ship was looked up as a pirate + * until docs/TODO/213 M4, so a restored Python escort took a pirate's guns. + */ export function missionShipSpec(c: CommanderData, tag: string): NpcSpec | undefined { const e = c.missions.entities[tag]; - return e ? specForDesign('pirate', e.ship) : undefined; + if (!e) return undefined; + const leg = liveLegs(c.missions).find(({ live }) => live.tag === tag)?.leg; + const role = leg && verbNeedsShip(leg.verb) ? jobRole(verbJob(leg.verb)) : 'pirate'; + return specForDesign(role, e.ship); } /** - * What her gun is worth against a live hunt's target, or '' when it will do - * or the leg is not a hunt. + * What the commander's gun is worth against a live hunt's target, or '' when + * it will do or the leg is not a hunt. */ export function missionWarning(c: CommanderData, live: LiveMission): string { const s = skeletonById(live.skeleton); if (!s) return ''; const verb = legOf(s, live.leg).verb; if (verb.kind !== 'hunt') return ''; - const spec = specForDesign('pirate', verb.ship); + const spec = specForDesign(jobRole(verbJob(verb)), verb.ship); return spec ? huntWarning(c, spec, s.patron.kind === 'navy' ? 'NAVY' : 'PATRON') : ''; } diff --git a/src/game/mission-course.ts b/src/game/mission-course.ts new file mode 100644 index 00000000..cd323bbc --- /dev/null +++ b/src/game/mission-course.ts @@ -0,0 +1,145 @@ +// What a mission asks the ship to do here, as a course (docs/TODO/208 M1). +// +// A mission was a line on a screen and a line on the console. It is a button +// over the view now: the first row of the course list, in the words of the +// job. Chris asked for that on 2026-09-11: *"the missions are becoming much +// more important - they need to feel part of the game"*. +// +// It reads the live legs (`missions/queries.ts`) and the sky. It matches the +// leg's tag to the ship or the canister that the world spawned for it +// (`spawning.ts`). What comes back is the words for the row, and what the +// ship must DO about it. +// +// FOUR SHAPES cover the five verbs that need a flight: +// +// - a HUNT is a fight, so the course picks the ship and the aim flies it; +// - a SCAN is a hold: stay near it, and keep the nose on it; +// - an ESCORT flies alongside, and the fight comes to the pilot; +// - a RECOVER and a RESCUE are a scoop; +// - a SMUGGLE is a slip: the station, on a line wide of every policeman. +// +// A deliver and an ambush need no course of their own. Both end at the +// station, and the station course flies there. + +import type * as THREE from 'three'; +import type { NpcShip } from './npc.ts'; +import type { Canister } from './cargo.ts'; +import { liveLegs } from '../missions/queries.ts'; +import type { MissionState, Skeleton } from '../missions/model.ts'; +import { SKELETONS } from '../missions/skeletons/index.ts'; +import { WITCHSPACE_TARGET } from '../constants/missions.ts'; + +/** + * Where the commander is, as a leg's target names it: the system's index, + * or the witchspace sentinel while the ship is in limbo (docs/TODO/219 M3). + */ +export function missionHere(session: { witchspace: boolean }, commander: { systemIndex: number }): number { + return session.witchspace ? WITCHSPACE_TARGET : commander.systemIndex; +} + +/** What the ship does about this leg. */ +export type MissionHow = 'fight' | 'hold' | 'escort' | 'scoop' | 'slip'; + +/** The mission's course: its words, what to do, and what to do it to. */ +export interface MissionCourse { + /** the row's words, in the job's own terms */ + readonly what: string; + readonly how: MissionHow; + /** the ship the leg is about, or null when the leg is about a canister */ + readonly ship: NpcShip | null; + readonly at: THREE.Vector3; + /** how fast that target is moving, so a hold and an escort can match it */ + readonly speed: number; + /** the target is on the run, so the course chases it (docs/TODO/214 M4) */ + readonly fleeing: boolean; +} + +/** + * What this system's live leg asks for, or null when nothing here does. + * + * The first live leg that has work in this system wins. A commander with two + * jobs in one system flies them one at a time. + */ +export function missionCourse( + st: MissionState, + here: number, + npcs: readonly NpcShip[], + items: readonly Canister[], + stationPos: THREE.Vector3, + /** where the commander is, so a gang's row can point at the nearest member */ + playerPos: THREE.Vector3, + skeletons: readonly Skeleton[] = SKELETONS, +): MissionCourse | null { + for (const { live, leg } of liveLegs(st, skeletons)) { + if (live.target !== here) continue; + // A smuggling run has no tagged thing in the sky: what it asks for is a + // way past the police. Every other course below needs its target, and a + // leg with no tag has none. + const ship = live.tag === null ? null + : npcs.find((n) => n.state.alive && n.state.missionTag === live.tag) ?? null; + const item = live.tag === null ? null + : items.find((c) => c.missionTag === live.tag) ?? null; + const name = ship?.object.name.toUpperCase() ?? ''; + switch (leg.verb.kind) { + case 'hunt': { + // A gang's row counts what the record still holds, and it points at + // the leader while it lives, then at the nearest member + // (docs/TODO/217 M1). + if (leg.verb.gang !== undefined && leg.verb.gang.length > 0 && live.tag !== null) { + const prefix = `${live.tag}#gang-`; + const left = Object.entries(st.entities) + .filter(([tag, e]) => (tag === live.tag || tag.startsWith(prefix)) && e.alive).length; + const members = npcs.filter((n) => n.state.alive && n.state.missionTag !== null + && n.state.missionTag.startsWith(prefix)); + const target = ship ?? members.reduce((best, n) => ( + best === null || n.object.position.distanceTo(playerPos) < best.object.position.distanceTo(playerPos) ? n : best + ), null); + if (!target) break; + const fleeing = target.state.fleeing; + return { + what: `${fleeing ? 'CHASE THE LEADER' : 'HUNT THE GANG'} — ${left} LEFT`, how: 'fight', ship: target, + at: target.object.position, speed: target.state.speed, fleeing, + }; + } + if (ship) { + // A target that runs is chased, and the row says so (docs/TODO/214 M4). + const fleeing = ship.state.fleeing; + return { + what: `${fleeing ? 'CHASE' : 'HUNT'} THE ${name}`, how: 'fight', ship, + at: ship.object.position, speed: ship.state.speed, fleeing, + }; + } + break; + } + case 'scan': + if (ship) { + return { what: `SCAN THE ${name}`, how: 'hold', ship, at: ship.object.position, speed: ship.state.speed, fleeing: false }; + } + break; + case 'escort': + if (ship) { + return { what: `ESCORT THE ${name}`, how: 'escort', ship, at: ship.object.position, speed: ship.state.speed, fleeing: false }; + } + break; + case 'recover': + if (item) { + return { what: 'RECOVER THE CARGO', how: 'scoop', ship: null, at: item.object.position, speed: 0, fleeing: false }; + } + break; + case 'rescue': + if (item) { + return { what: 'PICK UP THE SURVIVOR', how: 'scoop', ship: null, at: item.object.position, speed: 0, fleeing: false }; + } + break; + case 'smuggle': + return { + what: 'SLIP PAST THE POLICE', how: 'slip', ship: null, at: stationPos, speed: 0, fleeing: false, + }; + default: + // A deliver and an ambush both end at the station, and the station + // course flies there. + break; + } + } + return null; +} diff --git a/src/game/mission-desk.ts b/src/game/mission-desk.ts index b522d350..c25ee139 100644 --- a/src/game/mission-desk.ts +++ b/src/game/mission-desk.ts @@ -1,10 +1,10 @@ -// The mission desk: what is on offer where she stands, and the two things -// she can do about a mission from the MISSIONS screen. +// The mission desk: what is on offer where the commander stands, and the two +// things they can do about a mission from the MISSIONS screen. // // A mission STARTS here, and nowhere else, since docs/TODO/190. Acceptance is -// the station's, as a contract's is. Abandonment is hers anywhere. Both go -// through the bridge (`mission-bridge.ts`), which installs the record and -// applies what the machine paid, and the checkpoint follows. +// the station's, as a contract's is. Abandonment is the commander's anywhere. +// Both go through the bridge (`mission-bridge.ts`), which installs the record +// and applies what the machine paid, and the checkpoint follows. // // It is a child of the Game rather than a part of `docked.ts`. Docked work is // the station's business, and this desk opens in flight too, because that is @@ -33,7 +33,10 @@ export class MissionDesk { this.host = host; } - /** The skeletons on offer where she stands. Empty in flight, as the board is. */ + /** + * The skeletons on offer where the commander stands. Empty in flight, as + * the board is. + */ offers(): Skeleton[] { if (this.host.baseMode() !== 'docked') return []; const c = this.state.commander; diff --git a/src/game/npc-fighter.ts b/src/game/npc-fighter.ts index 44c91c4a..e3d6e6bc 100644 --- a/src/game/npc-fighter.ts +++ b/src/game/npc-fighter.ts @@ -7,9 +7,13 @@ // 2. is there another ship worth attacking? // 3. otherwise, amble. // +// A SHIP ON THE RUN ANSWERS NONE OF THEM (docs/TODO/214 M4). A hunt's target +// that is nearly dead runs for the edge and jumps out, and `runOut` below +// flies that before the three questions are asked. +// // THE ORDER IS LOAD-BEARING. A ship that can reach the commander does that // before it looks at an NPC target. So a pirate mid-duel with a trader breaks -// off for her, rather than the other way about. `game/npc.ts` ran these three in +// off for them, rather than the other way about. `game/npc.ts` ran these three in // this order before docs/TODO/184 M1 moved them. // // WHICH PILOT FLIES IT is `brain-names.ts`'s answer and not this file's. A live @@ -35,6 +39,7 @@ import { STATION_TRUCE } from '../constants/law.ts'; import { AMBLE_ARRIVED, AMBLE_NEAR, AMBLE_SPAN } from '../constants/amble.ts'; import { PLAYER_INTEREST_RANGE } from '../constants/player-interest.ts'; import { HUNT_HOLD_RANGE } from '../constants/hunt-ranges.ts'; +import { TRADER_JUMP_OUT } from '../constants/spawn-placement.ts'; import { approach, velocityOf } from './flight-maths.ts'; import { random, randomDirection } from './rng.ts'; import { attack } from './npc-attack-run.ts'; @@ -52,11 +57,13 @@ class Fighter implements NpcBehaviour { ship: BehaviourShip, dt: number, player: PlayerRef, view: WorldView, ): FireEvent | null { const { station, fleet, playerLegal, brains } = view; + if (ship.state.fleeing) return runOut(ship, dt); const toPlayer = tmpDir.copy(player.position).sub(ship.object.position); const distPlayer = toPlayer.length(); - const aggressiveToPlayer = - isHostileToPlayer(ship, playerLegal, view.playerToStation ?? Infinity) + // A cloaked commander is nobody's business (docs/TODO/219 M5). + const aggressiveToPlayer = view.playerCloaked !== true + && isHostileToPlayer(ship, playerLegal, view.playerToStation ?? Infinity) && distPlayer < PLAYER_INTEREST_RANGE; if (aggressiveToPlayer) { @@ -107,5 +114,23 @@ class Fighter implements NpcBehaviour { } } +/** + * The run for the edge of the system (docs/TODO/214 M4). `NpcShip.takeDamage` + * set the waypoint `DEEP_TRADER_RUN` away from the shot. The ship flies at + * it at full speed, and it jumps out `TRADER_JUMP_OUT` short of it, as a + * departing trader does. The world step then sends `fled` for it. Nothing + * ends the run. A trader's calm brings a trader back, and this ship is gone. + */ +function runOut(ship: BehaviourShip, dt: number): null { + ship.state.flownBy = 'fleeing'; + ship.steerToward(ship.state.waypoint, dt); + ship.state.speed = approach(ship.state.speed, ship.maxSpeed, 150 * dt); + ship.advance(dt); + if (ship.object.position.distanceTo(ship.state.waypoint) < TRADER_JUMP_OUT) { + ship.state.wantsDespawn = true; + } + return null; +} + /** The behaviour every fighting role flies. */ export const fighterBehaviour = (): NpcBehaviour => new Fighter(); diff --git a/src/game/npc-state.ts b/src/game/npc-state.ts index 3f88d74e..5bd449bb 100644 --- a/src/game/npc-state.ts +++ b/src/game/npc-state.ts @@ -112,10 +112,41 @@ export interface NpcState { * names. */ missionTag: string | null; + /** + * The pilot picked this ship on the target list (docs/TODO/206 M1). One + * ship at most carries it. The computer then aims at this ship rather than + * at the threat it would choose. It is saved, because it decides what the + * computer flies at. + */ + targeted: boolean; + /** + * This ship was already named as a close pass (docs/TODO/209). A neutral + * ship inside the mass lock radius is announced one time, so the pilot can + * pirate it. `close-pass.ts` owns the rule. It is saved, so a reload does + * not repeat the line. + */ + announcedClose: boolean; /** seconds under the scanner lock so far: a scan leg's clock */ observed: number; /** the escort or the scan verdict was sent once; it is never sent again */ missionReported: boolean; + /** + * A mission's charge waits, because the commander is beyond the leash + * (docs/TODO/214 M3). The world step decides it each frame, and the + * working life reads it as a speed of zero. + */ + holding: boolean; + /** the console said the charge is holding, once */ + holdSaid: boolean; + /** + * This ship may run for the edge of the system when it is nearly dead + * (docs/TODO/214 M4). The world step stamps it each frame on the target + * of a hunt that `canEscape`, and on nothing else. `takeDamage` reads it + * against `HUNT_FLEE_FRACTION`. + */ + canFlee: boolean; + /** the console said the target is on the run, once */ + runSaid: boolean; fleeing: boolean; /** where this ship is in its attack run — see break-off.ts */ attackPhase: AttackPhase; @@ -139,6 +170,12 @@ export interface NpcState { flownBy: 'brain' | 'scripted' | 'pursuit' | 'fleeing' | 'none'; /** seconds of evasive flying left after the last hit taken — see break-off.ts */ underFire: number; + /** + * Seconds since the last hit this ship took. A trader that fled reads it + * against `TRADER_CALM_SECONDS` to go back to work (docs/TODO/213 M1). It + * is saved with the rest, and an old save reads it as 0. + */ + calm: number; /** * How far out THIS run goes before turning back, rolled from the band in * break-off.ts every time the ship starts extending. State for `hasEcm`'s @@ -261,7 +298,7 @@ export function freshNpcState(maxEnergy: number): NpcState { tumbleAxis: randomDirection(new THREE.Vector3()), energy: maxEnergy, regenCarry: 0, alive: true, provoked: false, provokedByPlayer: false, missiles: 0, - missionTag: null, observed: 0, missionReported: false, fleeing: false, attackPhase: 'closing', underFire: 0, flownBy: 'none', + missionTag: null, targeted: false, announcedClose: false, observed: 0, missionReported: false, holding: false, holdSaid: false, canFlee: false, runSaid: false, fleeing: false, attackPhase: 'closing', underFire: 0, calm: 0, flownBy: 'none', extendRange: EXTEND_RANGE_MAX, passSide: 1, passesMade: 0, tactic: 'run', tacticClock: 0, dryFor: 0, tradeTimer: 0, diff --git a/src/game/npc-trader.ts b/src/game/npc-trader.ts index 38dedce8..c947858e 100644 --- a/src/game/npc-trader.ts +++ b/src/game/npc-trader.ts @@ -31,6 +31,7 @@ import * as THREE from 'three'; import { defenceBrain } from './brains.ts'; import { defenceBrainNameFor } from './brain-names.ts'; import { TURN_AND_FIGHT_RANGE } from '../constants/player-interest.ts'; +import { TRADER_CALM_SECONDS } from '../constants/attack-run.ts'; import { approach, velocityOf } from './flight-maths.ts'; import { attack } from './npc-attack-run.ts'; import { brainFly } from './npc-brain-pilot.ts'; @@ -54,6 +55,14 @@ class Trader implements NpcBehaviour { const distPlayer = tmpDir.copy(player.position) .sub(ship.object.position).length(); + // THE RUN ENDS. A trader that took no hit for the calm, with nobody left + // hunting it, goes back to its working life (docs/TODO/213 M1). It used + // to run for the rest of its life, so an escort's charge that was grazed + // once never reached the station. + if (ship.state.fleeing && ship.state.calm >= TRADER_CALM_SECONDS + && ship.nearestAttacker(dt) === null) { + ship.state.fleeing = false; + } if (ship.state.fleeing) { // Armed traders turn and fight. WHICH pilot is brain-names.ts's answer. // The shipped answer is the hand-written three-phase attack run, pointed diff --git a/src/game/npc.ts b/src/game/npc.ts index d4846370..f8b0256c 100644 --- a/src/game/npc.ts +++ b/src/game/npc.ts @@ -112,7 +112,8 @@ import type { NpcEnergyPoints } from './damage-units.ts'; import { random, randomDirection, randomQuaternion } from './rng.ts'; import { PursuitPilot } from './npc-pursuit.ts'; import type { PilotShip } from './npc-pilot.ts'; -import { MIN_CRUISE_FRACTION, UNDER_FIRE_SECONDS } from '../constants/attack-run.ts'; +import { HUNT_FLEE_FRACTION, MIN_CRUISE_FRACTION, UNDER_FIRE_SECONDS } from '../constants/attack-run.ts'; +import { DEEP_TRADER_RUN } from '../constants/spawn-placement.ts'; import type { NpcBehaviour } from './npc-behaviour.ts'; import { derelictIdle, hermitIdle, rockIdle } from './npc-idle.ts'; import { fighterBehaviour } from './npc-fighter.ts'; @@ -152,6 +153,8 @@ export interface WorldView { fleet: readonly NpcShip[]; /** 0 clean, 1 offender, 2 fugitive */ playerLegal: number; + /** the commander's cloak runs, so no ship sees the commander (docs/TODO/219 M5) */ + playerCloaked?: boolean; /** * Is a hostile missile ALREADY homing on the player? * @@ -789,12 +792,19 @@ export class NpcShip { // funnels through, because damage-dealt.ts routes lasers, ordnance and rams // here. So the attack run answers all of them, and not gunfire alone. this.state.underFire = UNDER_FIRE_SECONDS; + this.state.calm = 0; if (byPlayer) this.state.provokedByPlayer = true; + this.state.energy = energyAfterDamage(this.state.energy, points); if (from && this.role === 'trader') { this.state.fleeFrom.copy(from); this.state.fleeing = true; } - this.state.energy = energyAfterDamage(this.state.energy, points); + // A HUNT'S TARGET RUNS WHEN IT IS NEARLY DEAD (docs/TODO/214 M4). It runs + // once, and it never turns back. `npc-fighter.ts` flies the run. + if (from && this.state.canFlee && !this.state.fleeing + && this.state.energy < this.maxEnergy * HUNT_FLEE_FRACTION) { + this.runOut(from); + } if (isDestroyed(this.state.energy) && this.state.alive) { this.state.alive = false; return true; @@ -802,6 +812,21 @@ export class NpcShip { return false; } + /** + * Set the run for the edge: away from the shot, `DEEP_TRADER_RUN` out, as + * a departing trader flies (docs/TODO/214 M4). `npc-fighter.ts` flies at + * the waypoint and jumps out `TRADER_JUMP_OUT` short of it. A shot from the + * ship's own place has no direction, so the ship then runs the way it + * points. It allocates, and it runs once in a ship's life. + */ + private runOut(from: THREE.Vector3): void { + this.state.fleeFrom.copy(from); + this.state.fleeing = true; + const away = new THREE.Vector3().subVectors(this.object.position, from); + if (away.lengthSq() < 1e-6) away.set(0, 0, -1).applyQuaternion(this.object.quaternion); + this.state.waypoint.copy(this.object.position).addScaledVector(away.normalize(), DEEP_TRADER_RUN); + } + /** * EVERYTHING THAT RUNS ON ELAPSED TIME, whatever the ship is doing. * @@ -819,6 +844,7 @@ export class NpcShip { tickClocks(dt: number): void { this.regenerate(dt); this.state.underFire = Math.max(0, this.state.underFire - dt); + this.state.calm += dt; this.state.missileReload = Math.max(0, this.state.missileReload - dt); } diff --git a/src/game/orders.ts b/src/game/orders.ts index 4ee29264..f566227e 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -3,8 +3,8 @@ // A STANDING ORDER is an obligation that outlives the moment it is announced. // The game has two kinds: a signed contract, and a live mission. Until // docs/TODO/144 they shared one line under the station header, and the -// contract won it. So a commander who took any job before the Navy briefed her -// was never told where the Constrictor was (GitHub #27). +// contract won it. So a commander who took any job before the Navy briefed +// them was never told where the Constrictor was (GitHub #27). // // The two kinds were never comparable before, because nothing had ever asked // them the same question. This is that question, asked once, so that the menu @@ -44,7 +44,7 @@ export interface MissionOrder { readonly destination: number | null; /** what the leg pays when it goes right, in tenths of a credit */ readonly reward: number; - /** what her gun is worth against the target, or '' when it will do */ + /** what the commander's gun is worth against the target, or '' when it will do */ readonly warning: string; /** the mission this leg belongs to */ readonly live: LiveMission; @@ -85,8 +85,8 @@ export type StandingOrder = MissionOrder | ContractOrder; * * A mission sorts above the contracts, and the reason is not taste. A board * re-offers work every day. A patron briefs a commander one time. The - * contracts then sort by deadline, so the row that decides when she must leave - * is the row at the top of them. + * contracts then sort by deadline, so the row that decides when they must + * leave is the row at the top of them. */ export function standingOrders( c: CommanderData, systems: StarSystem[], @@ -126,8 +126,8 @@ export function standingOrders( * half of GitHub #27 that bites in FLIGHT. The chart is where a pilot picks a * destination, and the Constrictor's system looked like any other world. * - * A SET, because two jobs to one world are one diamond. The Navy can send her - * to a world she already owes a delivery to. + * A SET, because two jobs to one world are one diamond. The Navy can send the + * commander to a world they already owe a delivery to. */ export function orderDestinations(c: CommanderData): ReadonlySet { const marks = new Set(contractDestinations(c)); @@ -136,10 +136,10 @@ export function orderDestinations(c: CommanderData): ReadonlySet { } /** - * Every world a saved lead points to, in the galaxy she is in. + * Every world a saved lead points to, in the galaxy the commander is in. * * A DIFFERENT MARK from an order's (docs/TODO/190 M3). A lead is not an - * obligation. Nobody briefed her, and nothing is owed. It is where the next + * obligation. Nobody briefed them, and nothing is owed. It is where the next * arc starts, and the chart says so with a pointer rather than a diamond. */ export function leadDestinations(c: CommanderData): ReadonlySet { @@ -150,11 +150,11 @@ export function leadDestinations(c: CommanderData): ReadonlySet { /** * What the chart says about the system under the cursor, or null when nothing - * sends her there. + * sends the commander there. * * A CONTRACT ANSWERS FIRST where one system carries both, and that is not * arbitrary. A contract has a deadline and the Navy mission does not. So the - * contract is the line that tells her when she must leave. + * contract is the line that tells them when they must leave. * * `daysAway` is the journey the painter measured. It has the same three * meanings that `contractVerdict` gives it: @@ -181,7 +181,7 @@ export function orderVerdict( const name = missionName(c.missions, live, systems); // No deadline, so nothing here can be late. `NO ROUTE` is red all the same: - // it is not a deadline she will miss, it is a world she cannot reach. + // it is not a deadline they will miss, it is a world they cannot reach. if (daysAway === null) return { text: `${name} · NO ROUTE`, late: true }; if (daysAway === 0) return { text: `${name} · YOU ARE HERE`, late: false }; return { text: `${name} · ${dayWord(daysAway)} AWAY`, late: false }; @@ -205,7 +205,8 @@ export function orderVerdict( * to keep it one line"* (2026-08-13). A joined line still WRAPPED at that * width — it broke wherever the column ran out, which was usually mid-order. * - * Empty when she is under no orders at all. The menu then draws nothing. + * Empty when the commander is under no orders at all. The menu then draws + * nothing. */ export function ordersSummary(orders: readonly StandingOrder[]): string[] { const lines: string[] = []; diff --git a/src/game/pitch-roll-steer.ts b/src/game/pitch-roll-steer.ts index 7be1ba20..19cd6620 100644 --- a/src/game/pitch-roll-steer.ts +++ b/src/game/pitch-roll-steer.ts @@ -87,6 +87,32 @@ function wrap(a: number): number { * target. It is the caller's gun cone, which is WIDE up close, because a near * target subtends a wide angle. Inside it the controller asks for NOTHING. * + * `rollGate` is the angle inside which the BANK counts as finished. Above it + * this asks for no pitch at all. Zero keeps the soft gate alone, which is the + * `cos` of the roll error. The course pilot passes `COURSE_ROLL_GATE`, and the + * combat co-pilot passes `COMBAT_ROLL_GATE`. The docking computer does not come + * through here at all. + * + * THE SOFT GATE ALONE CAN CONE (Chris, 2026-09-12: *"we seem to be constantly + * rotating when heading towards something"*). A target a hair off the nose + * makes the bearing very sensitive to pitch, because the bearing turns by + * `pitch / theta`. The ship then finds an equilibrium. It rolls at a steady + * rate. The small pitch that the soft gate still allows turns the bearing back + * at the same rate. The nose circles the target for ever, and + * `theta` never closes. A trace of 2026-09-12 caught it: 0.25 radians a second + * of roll held against 0.0087 of pitch, at a fixed 0.035 off the nose. + * + * A hard gate breaks the equilibrium. With no pitch, the roll alone turns the + * bearing, the bank arrives, and the pitch then closes the angle. On a course + * to the station it took a median trip from 41 full turns to 1.3. + * + * THE COMBAT COMPUTER TAKES IT TOO, and this comment said the opposite until + * 2026-09-12. It held that a target which manoeuvres cannot afford a pitch held + * still, and that the wide gun cone hid the fault anyway. A review measured + * both claims false. The co-pilot circled a horizontal orbit at 17% time on the + * gun, and the gate took the same grid to 98%. `COMBAT_ROLL_GATE` is its value, + * and the whole measurement is beside it. + * * That is the seasickness fix. A target that already fills the gun still has a * bearing, and that bearing swings as it drifts a hair off centre. A bank to * chase the last degree chatters the roll axis for a correction the gun does @@ -101,6 +127,7 @@ function wrap(a: number): number { */ export function bankToTurn( quat: THREE.Quaternion, dir: THREE.Vector3, mem: SteerMemory, nullBand = 0, + rollGate = 0, ): StickCommand { if (dir.lengthSq() < 1e-12) return { pitch: 0, roll: 0 }; dirNorm.copy(dir).normalize(); @@ -173,7 +200,10 @@ export function bankToTurn( const pitchSat = localZ > 0 ? STEER_PITCH_SATURATION + (STEER_SATURATION - STEER_PITCH_SATURATION) * (1 - localZ) : STEER_SATURATION; - const pitch = Math.max(-1, Math.min(1, theta / pitchSat)) + // `rollGate` HOLDS THE PITCH STILL UNTIL THE BANK ARRIVES. It is 0 for the + // combat computer, which keeps the soft `cos` gate alone. See the parameter. + const pitch = rollGate > 0 && Math.abs(rollErr) > rollGate ? 0 + : Math.max(-1, Math.min(1, theta / pitchSat)) * mem.side * Math.max(0, Math.cos(rollErr)); return { pitch, roll }; diff --git a/src/game/pools-left.ts b/src/game/pools-left.ts new file mode 100644 index 00000000..90fd69d6 --- /dev/null +++ b/src/game/pools-left.ts @@ -0,0 +1,70 @@ +// What is left of the pools, 0..1, for a policy and a HUD to read. +// +// Five reporters, out of `systems.ts` for the size gate (docs/TODO/219 M5). +// They READ the pools and write none, so the gate on who writes a pool is +// not theirs. `systems.ts` still owns the pools and every write to them. +// +// ONE HOME for each number: `observeDefend` reads them in two worlds, the +// trainer's target and the game's combat computer, and a copy would drift. + +import { MAX_ENERGY, MAX_SHIELD } from '../constants/pools.ts'; +import type { ShipSystems } from './systems.ts'; + +/** + * How much damage this ship can absorb before energy reaches zero, in POOL + * POINTS. It is one shield face plus the whole energy bank. It is both faces + * for a commander who manoeuvres, so that hits land front and back. + * + * There is no multiplier into energy. The facing shield takes a hit, and the + * remainder spills straight into the bank. So this is a plain sum, and the + * balance harness reads it. + */ +export function durability(bothFaces = false): number { + return (bothFaces ? MAX_SHIELD * 2 : MAX_SHIELD) + MAX_ENERGY; +} + +/** + * HOW MUCH OF THIS SHIP IS LEFT, 0..1 — both faces and the bank, over + * everything they can hold. + * + * ONE HOME: it is the number a defence policy observes (`observeDefend` slot + * 14), observed in two worlds (the trainer's `TargetShip` and the game's combat + * computer). Written out twice it would drift. The policy would then fly out of + * the distribution it was fitted in, and no gate would say so. + */ +export function poolsLeft(sys: ShipSystems): number { + return (sys.foreShield + sys.aftShield + sys.energy) / durability(true); +} + +/** + * The ENERGY BANK alone, 0..1 — `observeDefend` slot 15, and the same one-home + * argument as `poolsLeft`. + * + * It is separate from `poolsLeft`, because the bank is three things at once: + * + * 1. what the ship DIES at; + * 2. what the shields will not recover past (`energyLow`); + * 3. what the E.C.M. spends a quarter of. + * + * A full pair of shields hides an empty one. + */ +export function energyLeft(sys: ShipSystems): number { + return sys.energy / MAX_ENERGY; +} + +/** + * Each shield FACE alone, 0..1 — `observeDefend` slots 27 and 28, same one-home + * argument as the two above. + * + * The pair exists because `poolsLeft` hides the split. An attacker on your six + * spends a different face from one head-on (`applyDamage`, `hitFromAhead`). So + * "keep the good face toward him" is flyable only if the policy can see which + * face is the good one. + */ +export function foreShieldLeft(sys: ShipSystems): number { + return sys.foreShield / MAX_SHIELD; +} + +export function aftShieldLeft(sys: ShipSystems): number { + return sys.aftShield / MAX_SHIELD; +} diff --git a/src/game/rating.ts b/src/game/rating.ts index 5331d3aa..b573d872 100644 --- a/src/game/rating.ts +++ b/src/game/rating.ts @@ -6,7 +6,7 @@ // catalogue in behind it. // // That is not hypothetical. The manual page listed the nine ranks it could -// remember, and left BELOW AVERAGE out. So a commander could read her own +// remember, and left BELOW AVERAGE out. So a commander could read their own // rating off the status screen, and fail to find it on the chart. // // The page rendered from the table is the fix. It is the same bargain as the diff --git a/src/game/screens/chart-search.ts b/src/game/screens/chart-search.ts new file mode 100644 index 00000000..37f3d6a2 --- /dev/null +++ b/src/game/screens/chart-search.ts @@ -0,0 +1,103 @@ +// Type-to-find on a chart: the letters, the match, and what the page shows +// while the search runs. +// +// A sub-mode of the chart rather than a screen on the stack. It swallows the +// keyboard while it runs, and Escape ends it rather than the chart. It lived +// in `screens/chart.ts` until the key grid put that file over the size gate +// (docs/TODO/216). The chart still owns the cursor: this returns the match, +// and the chart moves its cursor to it. +// +// It reads keys and it paints. The page it paints is the one the two chart +// painters made. That page holds the info line under its id, an empty +// element for the key grid, and the button row under `chart-buttons`. + +import type { Input } from '../../engine/input.ts'; +import type { StarSystem } from '../../galaxy/galaxy.ts'; +import { keyGrid } from '../../ui/key-grid.ts'; +import { maybeById } from '../../ui/screen-shell.ts'; + +export class ChartSearch { + /** typed prefix while the search runs, or null when it does not */ + private prefix: string | null = null; + /** + * Whether the key grid is on the page (docs/TODO/216 M2). A phone types + * the search on it. The chart repaints on every cursor move, so the grid + * is written when the search starts and cleared when it ends, not on each + * move. + */ + private keysShown = false; + private readonly local: boolean; + + constructor(local: boolean) { + this.local = local; + } + + get active(): boolean { + return this.prefix !== null; + } + + /** Start with nothing typed. */ + begin(): void { + this.prefix = ''; + } + + /** End it, at a chart's open. A key ends it through `type`. */ + end(): void { + this.prefix = null; + } + + /** + * One frame of typed keys. A letter extends the prefix, Backspace shortens + * it, and Enter or Escape end the search and drop the rest of the frame. + * + * @returns `changed` when the page needs a repaint, and `match` when the + * prefix changed and a system's name starts with it. + */ + type(i: Input, systems: readonly StarSystem[]): { changed: boolean; match: StarSystem | null } { + let changed = false; + for (const code of i.drainPresses()) { + if (code.startsWith('Key')) { + this.prefix += code.slice(3); + changed = true; + } else if (code === 'Backspace') { + this.prefix = this.prefix!.slice(0, -1); + changed = true; + } else if (code === 'Enter' || code === 'Escape') { + this.prefix = null; + return { changed: true, match: null }; + } + } + if (!changed || !this.prefix) return { changed, match: null }; + const want = this.prefix.toUpperCase(); + return { changed, match: systems.find((s) => s.name.toUpperCase().startsWith(want)) ?? null }; + } + + /** + * What the page shows. The FIND line while the search runs, the key grid + * with it, and the button row hidden for as long. The search reads every + * key as a letter, so a tap on DATA ON SYSTEM would type a D. The grid's + * ENTER ends the search, and the row comes back. + * + * Through the seam, and not through `document`. The two painters read + * these same ids the same way. A direct lookup here threw under node, so + * no headless test could drive type-to-find at all (docs/TODO/163). + */ + paint(): void { + const wanted = this.prefix !== null; + if (wanted) { + const info = maybeById(this.local ? 'local-info' : 'chart-info'); + if (info) info.textContent = `FIND: ${this.prefix}_`; + } + if (wanted === this.keysShown) return; + this.keysShown = wanted; + const keys = maybeById(this.local ? 'local-keys' : 'chart-keys'); + if (keys) keys.innerHTML = wanted ? keyGrid('find') : ''; + maybeById('chart-buttons')?.classList.toggle('hidden', wanted); + } + + /** A full paint wrote the page anew, with an empty grid element. */ + repainted(): void { + this.keysShown = false; + this.paint(); + } +} diff --git a/src/game/screens/chart.ts b/src/game/screens/chart.ts index 4962b3ba..a48858a2 100644 --- a/src/game/screens/chart.ts +++ b/src/game/screens/chart.ts @@ -6,7 +6,7 @@ // 2. which render/draw pair they call; // 3. how a click maps to chart coordinates. // -// Everything else is identical: the cursor, the target, type-to-find, and the +// Everything else is identical: the cursor, the target, the search, and the // market estimate. All of it was already written once, and branched on // `mode === 'local'` throughout. // @@ -20,6 +20,7 @@ // market estimate are not places you navigate to. They are states of the chart // itself. They swallow the keyboard while active, and Escape leaves the // sub-mode rather than the screen. On the stack, Escape would be ambiguous. +// The search's letters and its page are `chart-search.ts` (docs/TODO/216). import type { ChartState } from '../chart-state.ts'; import { @@ -29,7 +30,7 @@ import { renderLocalChart, drawLocalChart, renderMarketEstimate, localCoordsFromClick, } from '../../ui/chart-local.ts'; import { nearestSystem } from '../../ui/chart-readout.ts'; -import { maybeById } from '../../ui/screen-shell.ts'; +import { ChartSearch } from './chart-search.ts'; import type { Screen, ScreenOutcome, ScreenId } from '../../ui/screen-host.ts'; import type { CommanderData } from '../commander.ts'; import type { StarSystem } from '../../galaxy/galaxy.ts'; @@ -86,8 +87,8 @@ export class ChartScreen implements Screen { readonly id: ScreenId; private readonly local: boolean; private readonly ctx: () => ChartContext; - /** typed prefix while type-to-find is active, or null when it is not */ - private find: string | null = null; + /** type-to-find, a sub-mode: its letters and its page */ + private readonly search: ChartSearch; /** * Where the mouse last was, in chart coordinates. It is null until the mouse * first crosses this canvas. @@ -106,11 +107,12 @@ export class ChartScreen implements Screen { this.id = id; this.local = id === 'local'; this.ctx = ctx; + this.search = new ChartSearch(this.local); } open(): void { const { chart, system } = this.ctx(); - this.find = null; + this.search.end(); this.estimate = false; // the mouse never crossed THIS canvas yet, whatever it did on the other this.pointer = null; @@ -125,6 +127,7 @@ export class ChartScreen implements Screen { const overlays = this.overlays(); if (this.local) renderLocalChart(systems, commander, chart, overlays); else renderChart(systems, commander, chart, overlays); + this.search.repainted(); } /** @@ -191,13 +194,7 @@ export class ChartScreen implements Screen { const overlays = this.overlays(); if (this.local) drawLocalChart(systems, commander, chart, overlays); else drawChart(systems, commander, chart, overlays); - if (this.find !== null) { - // Through the seam, and not through `document`. The two painters read - // these same two ids the same way. A direct lookup here threw under node, - // so no headless test could drive type-to-find at all (docs/TODO/163). - const info = maybeById(this.local ? 'local-info' : 'chart-info'); - if (info) info.textContent = `FIND: ${this.find}_`; - } + this.search.paint(); } /** The system under the cursor, if any. */ @@ -207,7 +204,7 @@ export class ChartScreen implements Screen { } tick(dt: number, i: Input): void { - if (this.find !== null || this.estimate) return; + if (this.search.active || this.estimate) return; this.moveCursor(dt, i); } @@ -219,8 +216,15 @@ export class ChartScreen implements Screen { } return 'stay'; } - if (this.find !== null) { - this.typeToFind(i); + if (this.search.active) { + // Letters filter, and the cursor jumps to the first match. + const { chart, systems } = this.ctx(); + const { changed, match } = this.search.type(i, systems); + if (match) { + chart.cursorX = match.x; + chart.cursorY = match.y; + } + if (changed) this.redraw(); return 'stay'; } if (i.pressed('KeyM')) { @@ -245,7 +249,7 @@ export class ChartScreen implements Screen { return { open: 'data' }; } if (i.pressed('KeyF')) { - this.find = ''; + this.search.begin(); this.redraw(); return 'stay'; } @@ -276,7 +280,7 @@ export class ChartScreen implements Screen { * cursor step. */ hoverAt(target: HTMLElement, e: MouseEvent): void { - if (this.estimate || this.find !== null) return; + if (this.estimate || this.search.active) return; const was = this.described; this.pointer = target instanceof HTMLCanvasElement ? this.chartCoords(target, e) @@ -306,7 +310,7 @@ export class ChartScreen implements Screen { } clickAt(target: HTMLElement, e: MouseEvent): boolean { - if (this.estimate || this.find !== null) return false; + if (this.estimate || this.search.active) return false; if (!(target instanceof HTMLCanvasElement)) return false; const { chart, system, systems } = this.ctx(); @@ -357,31 +361,4 @@ export class ChartScreen implements Screen { this.redraw(); } } - - /** Letters filter, the cursor jumps to the first match. */ - private typeToFind(i: Input): void { - const { systems, chart } = this.ctx(); - let changed = false; - for (const code of i.drainPresses()) { - if (code.startsWith('Key')) { - this.find += code.slice(3); - changed = true; - } else if (code === 'Backspace') { - this.find = this.find!.slice(0, -1); - changed = true; - } else if (code === 'Enter' || code === 'Escape') { - this.find = null; - this.redraw(); - return; - } - } - if (changed && this.find) { - const match = systems.find((s) => s.name.toUpperCase().startsWith(this.find!.toUpperCase())); - if (match) { - chart.cursorX = match.x; - chart.cursorY = match.y; - } - } - if (changed) this.redraw(); - } } diff --git a/src/game/screens/combat-sim-setup.ts b/src/game/screens/combat-sim-setup.ts index d706fa8e..bbac218c 100644 --- a/src/game/screens/combat-sim-setup.ts +++ b/src/game/screens/combat-sim-setup.ts @@ -97,7 +97,7 @@ export interface FitDraft { missiles: number; } -/** Everything the pilot told the picker, and nothing about how she told it. */ +/** Everything the pilot told the picker, and nothing about how they told it. */ export interface SimDraft { mode: SimMode; /** index into SCENARIOS */ diff --git a/src/game/screens/combat-sim.ts b/src/game/screens/combat-sim.ts index d04c0784..e26ff2a5 100644 --- a/src/game/screens/combat-sim.ts +++ b/src/game/screens/combat-sim.ts @@ -251,9 +251,14 @@ export class CombatSimScreen implements Screen { this.record = cycle(this.record, n, right ? 1 : -1); return this.repaint(); } - const all = i.held('ShiftLeft', 'ShiftRight'); - if (i.pressed('KeyC')) return this.copy(this.json(all), plural(all ? n : 1)); - if (i.pressed('KeyX')) return this.download(this.json(all), this.stem(all), plural(all ? n : 1)); + // The modifier rides on the tap when a button sent it (docs/TODO/216 M3), + // and a Shift key held answers for a key press. `tapShift` peeks, so it + // is read before `pressed` consumes the tap. + const shift = i.held('ShiftLeft', 'ShiftRight'); + const allC = i.tapShift('KeyC') || shift; + if (i.pressed('KeyC')) return this.copy(this.json(allC), plural(allC ? n : 1)); + const allX = i.tapShift('KeyX') || shift; + if (i.pressed('KeyX')) return this.download(this.json(allX), this.stem(allX), plural(allX ? n : 1)); return 'stay'; } diff --git a/src/game/screens/contracts.ts b/src/game/screens/contracts.ts index 9759303c..8ea7af2c 100644 --- a/src/game/screens/contracts.ts +++ b/src/game/screens/contracts.ts @@ -19,7 +19,7 @@ export interface ContractsContext { /** * Is there a board to read? A bulletin board is a station's, and this screen * opens in flight too since docs/TODO/145. In flight the ACCEPTED half is - * what she came for, and nothing can be signed. + * what they came for, and nothing can be signed. */ readonly atStation: boolean; /** the board's word about a lead a few jumps out, or null (missions/hints.ts) */ diff --git a/src/game/screens/courses.ts b/src/game/screens/courses.ts new file mode 100644 index 00000000..0cfeeba0 --- /dev/null +++ b/src/game/screens/courses.ts @@ -0,0 +1,59 @@ +// 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 local 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'; + } + } + // The LOCAL chart, because it shows what the tank can reach (Chris, + // 2026-09-12). The galactic chart is the wider view, and a destination + // is picked from the short one. + if (i.pressed(COURSE_CHART_KEY)) return { open: 'local' }; + if (i.pressed('Escape')) return 'back'; + return 'stay'; + } +} diff --git a/src/game/screens/log.ts b/src/game/screens/log.ts index 84f7195e..fe531711 100644 --- a/src/game/screens/log.ts +++ b/src/game/screens/log.ts @@ -9,23 +9,27 @@ import { renderLog } from '../../ui/screens-log.ts'; import type { CommanderData } from '../commander.ts'; import type { StarSystem } from '../../galaxy/galaxy.ts'; import type { Input } from '../../engine/input.ts'; -import type { Patron } from '../../missions/model.ts'; +import type { Patron } from '../../missions/words.ts'; import { NAVY_PATRON, patronFor } from '../../missions/patrons.ts'; import { storyPages, type StoryPage } from '../../missions/story.ts'; import { routeMapSvg } from '../../missions/route-map.ts'; import { skeletonById } from '../../missions/skeletons/index.ts'; import { missionFacts } from '../mission-bridge.ts'; +import { galaxySystems } from '../../galaxy/galaxies.ts'; export interface LogContext { readonly commander: CommanderData; readonly systems: StarSystem[]; } -/** The latest page's patron, by name and face. A local patron ran the origin's station. */ -function patronOf(page: StoryPage | undefined, c: CommanderData, systems: StarSystem[]): Patron { +/** + * The latest page's patron, by name and face. A local patron ran the + * origin's station, in the page's own galaxy (docs/TODO/213 M4). + */ +function patronOf(page: StoryPage | undefined, c: CommanderData): Patron { const s = page ? skeletonById(page.skeleton) : null; if (!page || !s) return NAVY_PATRON; - return patronFor(s.patron, missionFacts(c), systems, page.origin); + return patronFor(s.patron, { ...missionFacts(c), galaxy: page.galaxy }, galaxySystems(page.galaxy), page.origin); } export class LogScreen implements Screen { @@ -42,9 +46,13 @@ export class LogScreen implements Screen { render(): void { const { commander, systems } = this.ctx(); - const pages = storyPages(commander.missions, systems); - const worlds = pages.flatMap((p) => p.worlds); - const patron = patronOf(pages[pages.length - 1], commander, systems); + // Every page names its worlds through its own galaxy. The route is the + // chart of the galaxy the commander is in, so it draws the pages from this + // one. + const pages = storyPages(commander.missions, + (galaxy) => (galaxy === commander.galaxy ? systems : galaxySystems(galaxy))); + const worlds = pages.filter((p) => p.galaxy === commander.galaxy).flatMap((p) => p.worlds); + const patron = patronOf(pages[pages.length - 1], commander); renderLog({ pages, route: routeMapSvg(systems, worlds), diff --git a/src/game/screens/missions.ts b/src/game/screens/missions.ts index 60c975d9..70efcb77 100644 --- a/src/game/screens/missions.ts +++ b/src/game/screens/missions.ts @@ -1,15 +1,16 @@ -// The MISSIONS screen: what is on offer here, what she holds, and the two -// keys that change either. +// The MISSIONS screen: what is on offer here, what the commander holds, and +// the two keys that change either. // // The screen GitHub #27 asked for. A briefing said one time, for five seconds, // and then unreachable, is the same as no briefing at all. So every live -// mission has a row here, with its world, its fee and what her gun is worth. +// mission has a row here, with its world, its fee and what the commander's gun +// is worth. // // It is also where a mission STARTS, since docs/TODO/190. Nothing starts on -// its own any more, the Constrictor included. An offer is a row until she -// accepts it. Acceptance is the station's, as a contract's is. Abandonment is -// hers anywhere, because a mission she cannot finish should not hold a slot -// until she next docks. +// its own any more, the Constrictor included. An offer is a row until they +// accept it. Acceptance is the station's, as a contract's is. Abandonment is +// the commander's anywhere, because a mission they cannot finish should not +// hold a slot until they next dock. // // THE PATRONS' ORDERS ONLY, since docs/TODO/145. Board work has its own // screen. A contract and a mission are two kinds of thing. @@ -20,7 +21,8 @@ import { renderMissions, type HeldRow, type OfferRow } from '../../ui/screens.ts import type { Screen, ScreenOutcome } from '../../ui/screen-host.ts'; import type { CommanderData } from '../commander.ts'; import { standingOrders, type MissionOrder } from '../orders.ts'; -import type { Dossier, Skeleton } from '../../missions/model.ts'; +import type { Skeleton } from '../../missions/model.ts'; +import type { Dossier } from '../../missions/words.ts'; import { dossierFor } from '../../missions/dossiers.ts'; import { leadLine } from '../../missions/hints.ts'; import { patronFor } from '../../missions/patrons.ts'; @@ -35,7 +37,7 @@ import type { Input } from '../../engine/input.ts'; export interface MissionsContext { readonly commander: CommanderData; readonly systems: StarSystem[]; - /** the skeletons on offer where she stands; empty in flight */ + /** the skeletons on offer where the commander stands; empty in flight */ readonly offers: readonly Skeleton[]; /** a mission starts at a station, as a contract does */ readonly atStation: boolean; @@ -73,7 +75,7 @@ export class MissionsScreen implements Screen { /** * The rows, each with its patron's name (docs/TODO/191 M1). An offer's * local patron runs this station. A held job's ran the station it was - * taken at, which is not where she reads the row. An offer shows its + * taken at, which is not where they read the row. An offer shows its * dossier's title and briefing, with the patron and this world filled in. * It shows the skeleton's plain pitch when no dossier exists (M3). */ @@ -92,7 +94,7 @@ export class MissionsScreen implements Screen { }); // A held row keeps the offer's title and pages (docs/TODO/201). The // `{HERE}` slot is the world it was accepted at, which is where the - // patron spoke, and not where she reads the row. + // patron spoke, and not where they read the row. const heldRows: HeldRow[] = this.held().map((o) => { const s = skeletonById(o.live.skeleton); const origin = acceptedAt(commander.missions, o.live.skeleton); diff --git a/src/game/screens/quit.ts b/src/game/screens/quit.ts index ff8a7692..6fec1455 100644 --- a/src/game/screens/quit.ts +++ b/src/game/screens/quit.ts @@ -1,4 +1,4 @@ -// A commander gives up on a flight: the confirmation, and where it puts her. +// A commander gives up on a flight: the confirmation, and where it puts them. // // It is the one way out of the cockpit that is not a dock, a death or the // distress beacon. It costs exactly what a death costs — the flight — because diff --git a/src/game/screens/survivors.ts b/src/game/screens/survivors.ts index 5a2526bd..b2d59b34 100644 --- a/src/game/screens/survivors.ts +++ b/src/game/screens/survivors.ts @@ -65,7 +65,7 @@ export class SurvivorsScreen implements Screen { } // Everything else, Escape included, is refused and asked again. The // station will not forget the person in your ship. The noise is how a - // player learns that this is not a screen she can leave. + // player learns that this is not a screen they can leave. if (i.pressed('Escape')) { sfx.refused(); this.render(); diff --git a/src/game/scripted-co-pilot.ts b/src/game/scripted-co-pilot.ts index 3e1d7678..cba9e0b7 100644 --- a/src/game/scripted-co-pilot.ts +++ b/src/game/scripted-co-pilot.ts @@ -1,17 +1,28 @@ // The scripted combat computer: a PURSUIT DOGFIGHTER at the stick of YOUR ship. // -// A person gets on the opponent's six and shoots it up. She hauls the throttle -// back to swing the nose round, so that she stays on a target that crosses her. +// A person gets on the opponent's six and shoots it up. They haul the throttle +// back to swing the nose round, so that they stay on a target that crosses +// them. // -// That is pure pursuit. Point the nose AT the target: the laser is hitscan, so -// there is no lead, and you aim where the target is. The line then curves you -// onto its tail as it turns and runs. A throttle holds a gun-range standoff -// behind it, and comes off hard when the nose has a long way to swing. +// That is pursuit. The aim line curves you onto its tail as it turns and runs. +// A throttle holds a gun-range standoff behind it, and comes off hard when the +// nose has a long way to swing. +// +// It aims a little AHEAD of the target, and the gun is not the reason. The laser +// is hitscan, so the SHOT needs no lead. The NOSE needs one. A swing takes time. +// The target moves while the swing runs. So an aim at where the target IS puts +// the nose where the target WAS. The swing that is LEFT sets the lead. The lead +// falls to zero as the nose arrives (`PURSUIT_LEAD_GAIN`). +// +// The throttle matches the target's RADIAL speed rather than its whole speed. A +// ship that circles you barely recedes, so the commander stops and turns like a +// turret. A ship that runs recedes at its full speed, so the commander chases. // // It flies `pursuit.ts` rather than the attack run. The pirates fly their own // pursuit: hold the six, then break into a fast pass when faced (npc.ts // `pursue`). That is a separate ship and a separate decision. The two share -// `pursuitSpeed`, so they cannot drift. +// `pursuitSpeed`, so the standoff rule cannot drift. They hand it a different +// speed to match, and `pursuitThrottle` below says why. // // It DECIDES and reports, like every module here. What comes back is a // `FlightDemand` — ramped pitch and roll rates, a throttle, a trigger — and one @@ -27,24 +38,35 @@ import * as THREE from 'three'; import { ThreatLock } from './threat-lock.ts'; import type { NpcShip } from './npc.ts'; -import { isHostileToPlayer } from './hostility.ts'; +import { engaging } from './hostility.ts'; import type { AutopilotShip } from './combat-computer.ts'; import { hitCone } from './gunnery.ts'; import { autopilotEcm } from './ordnance.ts'; import { bankToTurn, freshSteerMemory, type SteerMemory } from './pitch-roll-steer.ts'; import { pursuitSpeed } from './pursuit.ts'; +import { velocityOf } from './flight-maths.ts'; import { rampFlightRate, type FlightDemand } from '../player.ts'; import { LASER_RANGE } from '../constants/player-gun.ts'; import { UNDER_FIRE_SECONDS } from '../constants/attack-run.ts'; import { - THREAT_RANGE, PURSUIT_SPEED_DEADBAND, ENGAGED_CONE, TARGET_DIST_WEIGHT, + THREAT_RANGE, PURSUIT_SPEED_DEADBAND, ENGAGED_CONE, ENGAGED_PATIENCE, + TARGET_DIST_WEIGHT, PURSUIT_LEAD_GAIN, COMBAT_ROLL_GATE, } from '../constants/combat-computer.ts'; import { PLAYER_FLIGHT } from '../constants/player-flight.ts'; +import { MAX_LEAD_SECONDS } from '../constants/pass-aim.ts'; import type { V3 } from '../ai-training/observation.ts'; export type CoPilotStep = - /** hands off — the reason is for the player */ - | { kind: 'disengage'; reason: string } + /** + * Hands off — the reason is for the player. + * + * IT STILL ANSWERS A WARHEAD. A missile in the air is a threat with or without + * a SHIP to steer at. The review of 2026-09-12 found the two fused. With the + * last hostile dead and a warhead still closing, the co-pilot said AREA CLEAR + * and asked for no E.C.M. One press is a complete answer, because `Ordnance` + * caps the sky at one warhead. So the press on this frame clears it. + */ + | { kind: 'disengage'; reason: string; ecm: boolean } /** * What the pursuit wants this frame. It is the SAME FlightDemand that a pair * of hands produces (player.ts): ramped pitch and roll rates, a throttle, @@ -63,9 +85,9 @@ export class ScriptedCoPilot { private readonly lock = new ThreatLock(); private readonly toThreat = new THREE.Vector3(); private readonly nose = new THREE.Vector3(); - /** ramped turn rates, so the co-pilot's turn continues smoothly frame to frame */ - private pitchRate = 0; - private rollRate = 0; + /** where the target goes, and where the nose is sent — see `step` */ + private readonly threatVel = new THREE.Vector3(); + private readonly aim = new THREE.Vector3(); /** which vertical the bank-to-turn is committed to — see pitch-roll-steer.ts */ private readonly steerMem: SteerMemory = freshSteerMemory(); /** @@ -78,17 +100,32 @@ export class ScriptedCoPilot { * evasive behaviour needs no new wiring. */ private underFire = 0; + /** + * Seconds since the held target was last inside the gun cone, and the ship + * the count belongs to. Together they are "is this attack going anywhere". + * `ENGAGED_PATIENCE` is what reads them. + */ + private sinceGunOn = 0; + private counting: NpcShip | null = null; noteHit(): void { this.underFire = UNDER_FIRE_SECONDS; } - /** Let go of the fight entirely — the next step starts from nothing. */ + /** + * Let go of the fight entirely — the next engagement starts from nothing. + * + * EVERY field goes, and `steerMem.side` used to survive. A reset controller + * then banked the opposite way from a fresh one at the same geometry (the + * review of 2026-09-12). There is no state here a new engagement should + * inherit, so there is nothing to choose between. + */ reset(): void { this.lock.clear(); - this.pitchRate = 0; - this.rollRate = 0; + this.steerMem.side = freshSteerMemory().side; this.underFire = 0; + this.sinceGunOn = 0; + this.counting = null; } step( @@ -99,17 +136,27 @@ export class ScriptedCoPilot { manualInput: boolean, missilePos: V3 | null, playerToStation = Infinity, + picked: NpcShip | null = null, ): CoPilotStep { - if (manualInput) return { kind: 'disengage', reason: 'MANUAL OVERRIDE' }; + // MANUAL OVERRIDE ASKS FOR NO E.C.M. The pilot took the ship, and the + // E.C.M. is a key they hold. Every other way out still answers a warhead. + if (manualInput) return { kind: 'disengage', reason: 'MANUAL OVERRIDE', ecm: false }; this.underFire = Math.max(0, this.underFire - dt); // How far off the nose a candidate is — the turn it would cost to lock. const offNose = (npc: NpcShip): number => this.nose.set(0, 0, -1) .applyQuaternion(player.quaternion) .angleTo(this.toThreat.copy(npc.object.position).sub(player.position)); - const threat = this.lock.pick( + // The pilot's pick comes first (docs/TODO/206 M2). It may be any ship on + // the target list, a trader or a rock too. With no pick, the lock chooses, + // by its own rule, which two other places share. + const threat = picked ?? this.lock.pick( dt, - npcs.filter((npc) => isHostileToPlayer(npc, legalStatus, playerToStation) - && npc.object.position.distanceTo(player.position) < THREAT_RANGE), + // THE SAME PREDICATE `Autopilot.fightOn` ASKS, at the same range. They + // were two spellings of one rule, and they disagreed about the range. The + // engage test looked 9,000 units out, and this one 6,500. So a pirate in + // that band engaged and was refused every frame. + npcs.filter((npc) => engaging(npc, player.position, legalStatus, + playerToStation, THREAT_RANGE)), // EASIEST to lock, not nearest. It ranks by the off-nose angle, which is // the turn it costs to get guns on. Distance is the secondary tiebreak. // @@ -122,15 +169,24 @@ export class ScriptedCoPilot { + npc.object.position.distanceTo(player.position) / TARGET_DIST_WEIGHT, // ENGAGED means do not switch. The target is in front and roughly on the // nose, so this is the kill in progress, rather than the easiest lock. A - // pilot does not drop a ship she is lined up on because another became + // pilot does not drop a ship they are lined up on because another became // easier (Chris). The ranking hands over a better target only when the // co-pilot is NOT engaged, which means the current one ran wide or ran // behind. - (npc) => offNose(npc) < ENGAGED_CONE, + // + // ...AND THAT THE KILL IS GOING SOMEWHERE. A cone alone held a target + // 3,000 units off at 23 degrees while a second hostile sat 500 units dead + // ahead (the review of 2026-09-12). `ENGAGED_PATIENCE` is how long a + // target may go unshot and still block the switch. + (npc) => offNose(npc) < ENGAGED_CONE && this.sinceGunOn < ENGAGED_PATIENCE, ); if (!threat) { this.reset(); - return { kind: 'disengage', reason: 'AREA CLEAR — COMBAT COMPUTER OFF' }; + return { + kind: 'disengage', + reason: 'AREA CLEAR', + ecm: autopilotEcm(true, missilePos !== null), + }; } const targetPos = threat.object.position; const dist = targetPos.distanceTo(player.position); @@ -139,34 +195,78 @@ export class ScriptedCoPilot { // no bank to centre a target that is already inside it. const cone = hitCone(threat.radius, dist); - // PURE PURSUIT: bank-to-turn straight at where the target IS. The gun is - // hitscan, so there is nothing to lead. An aim AT the target rather than - // ahead of it is what walks the nose onto the six as the target turns and - // runs. It ramps through the commander's own envelope (PLAYER_FLIGHT), so - // the co-pilot flies your ship as your hands would. + // How far off the nose the target is. The steer, the throttle and the + // trigger all read it. It is taken before the Game integrates the demand, + // so one frame's turn of about 0.02 rad is not in it. + this.toThreat.copy(targetPos).sub(player.position); + const facing = this.nose.set(0, 0, -1).applyQuaternion(player.quaternion) + .angleTo(this.toThreat); + + // Where the target goes. `velocityOf` is the nose-and-thrust rule that the + // pirates lead their shots with, and that the HUD's lead marker draws. One + // rule, so the aid and the co-pilot cannot come to disagree. + velocityOf(threat.object.quaternion, threat.state.speed, this.threatVel); + + // PURSUIT, aimed at where the nose can ARRIVE. See the module header for why + // a hitscan gun still wants a lead. The lead is the swing that is LEFT times + // `PURSUIT_LEAD_GAIN`, capped, so it falls to zero as the nose closes. A + // fixed lead instead fights the radial throttle below, and it measured worse + // than no lead at all. The ceiling is the attack run's `MAX_LEAD_SECONDS`, + // which is the same rule: how far ahead a ship may aim. + this.aim.copy(targetPos).addScaledVector( + this.threatVel, Math.min(MAX_LEAD_SECONDS, PURSUIT_LEAD_GAIN * facing)); + // BANK FIRST, THEN PULL. `COMBAT_ROLL_GATE` holds the pitch still until the + // roll arrives. Without it the pitch moves the target's bearing. The roll + // then chases that same bearing, and the nose circles the target instead of + // closing on it. See the constant for what that cost, measured. + // + // It ramps through the commander's own envelope (PLAYER_FLIGHT), so the + // co-pilot flies your ship as your hands would. const cmd = bankToTurn(player.quaternion, - this.toThreat.copy(targetPos).sub(player.position), this.steerMem, cone); - this.pitchRate = rampFlightRate( - this.pitchRate, cmd.pitch * PLAYER_FLIGHT.maxPitch, cmd.pitch !== 0, dt); - this.rollRate = rampFlightRate( - this.rollRate, cmd.roll * PLAYER_FLIGHT.maxRoll, cmd.roll !== 0, dt); + this.aim.sub(player.position), this.steerMem, cone, COMBAT_ROLL_GATE); + // THE RAMP READS THE SHIP, not a copy of the last ask. `PlayerShip.update` + // writes these from the demand it flew, so in ordinary flight the two are + // the same number. They differ where it matters (the review of + // 2026-09-12). A manual override leaves the ship somewhere the co-pilot's + // copy does not know about. A restored save puts the ship's own rates back, + // while a fresh controller's copy reads zero. So this needs no rate state, + // and nothing has to save one. + const pitchRate = rampFlightRate( + player.pitchRate, cmd.pitch * PLAYER_FLIGHT.maxPitch, cmd.pitch !== 0, dt); + const rollRate = rampFlightRate( + player.rollRate, cmd.roll * PLAYER_FLIGHT.maxRoll, cmd.roll !== 0, dt); - // How far off the nose the target is. It is taken AFTER this frame's ramp - // is decided, and before the Game integrates it. One frame's turn is - // ~0.02 rad, which neither the throttle nor the fire cone can see. - const facing = this.nose.set(0, 0, -1).applyQuaternion(player.quaternion) - .angleTo(this.toThreat.copy(targetPos).sub(player.position)); + // How fast the target RECEDES, which is the speed that holds a standoff. Its + // whole speed is the wrong number. A ship that circles you holds its range + // while it flies at 300. A chase at 300 then carries the commander past it. + // Negative means it closes, and the standoff term alone answers that. + // + // It leaves `toThreat` a UNIT vector. That is the last read of it in this + // step. Copy it again before you use it as a distance below this line. + const recede = Math.max(0, this.threatVel.dot(this.toThreat.normalize())); + + // IS THIS ATTACK GOING ANYWHERE? The count is per target, so a switch + // gives the new one a full `ENGAGED_PATIENCE` before it can be dropped. + const onGun = dist <= LASER_RANGE && facing < cone; + if (threat !== this.counting) { this.counting = threat; this.sinceGunOn = 0; } + this.sinceGunOn = onGun ? 0 : this.sinceGunOn + dt; return { kind: 'fly', demand: { - pitchRate: this.pitchRate, - rollRate: this.rollRate, - throttle: this.pursuitThrottle(player.speed, threat.state.speed, dist, facing), + pitchRate, + rollRate, + // THE STANDOFF IS MEASURED FROM THE HULL (docs/TODO/211). A rock is + // 54 units across the radius, and the derelict is 340. A range held + // to the centre put the commander 160 units off the derelict's hull, + // which reads as a ram in the window. The gun below keeps the true + // distance, because a shot travels to the hull by itself. + throttle: this.pursuitThrottle( + player.speed, recede, dist - threat.radius, facing), // the trigger only when the shot would count: the player gun's own cone // and range (gunnery.ts). The laser's heat and cooldown pace it from // there, which is what makes this a marksman rather than a sprayer - fire: dist <= LASER_RANGE && facing < cone, + fire: onGun, }, // a warhead is always answered. Whether one is on its way is the world's // fact, and the gate is the same one every E.C.M. press goes through @@ -181,12 +281,26 @@ export class ScriptedCoPilot { * shares it, so the two cannot drift. This turns that speed into the * throttle SIGN that `FlightDemand` wants. A deadband makes it coast at the * held speed rather than pump around it. + * + * WHICH speed to match is the CALLER's to choose, and the two callers choose + * differently. The pirate matches its target's whole speed. This one matches + * how fast the target RECEDES, so a target that circles is met with a stop + * rather than with a chase. See `step`. + * + * @param recede how fast the target opens the range, in world units a second. */ private pursuitThrottle( - ownSpeed: number, targetSpeed: number, dist: number, facing: number, + ownSpeed: number, recede: number, dist: number, facing: number, ): number { - const want = pursuitSpeed(targetSpeed, dist, facing, PLAYER_FLIGHT.maxSpeed); + const want = pursuitSpeed(recede, dist, facing, PLAYER_FLIGHT.maxSpeed); const diff = want - ownSpeed; + // A STOP IS A STOP (docs/TODO/211). The deadband holds a speed steady, and + // it must not hold a drift. A target that sits still asks for a wanted + // speed of zero inside the standoff. The commander then coasted in at up + // to 6 units a second. A rock 500 units away took two minutes to arrive at + // the hull (Chris, 2026-09-12). The band applies to a speed the ship is + // asked to HOLD, and zero is not one. + if (want === 0) return ownSpeed > 0 ? -1 : 0; return Math.abs(diff) < PURSUIT_SPEED_DEADBAND ? 0 : Math.sign(diff); } } diff --git a/src/game/session.ts b/src/game/session.ts index f2840c27..70ceb579 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. * @@ -85,8 +87,53 @@ export interface SessionState { */ view: number; ccEngaged: boolean; + /** + * The cloak runs (docs/TODO/219 M5). No ship is hostile to the commander + * while it does, and the bank drains. A shot drops it, and so does the + * last bank. It is a session's state, so a restore starts uncloaked. + */ + cloaked: 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[]; + /** + * The pilot took the controls with a flight key (docs/TODO/206 M2). Until + * the pilot picks a course or a target, no computer takes the stick back: + * not a course, and not the aim in a fight. It is saved, because it + * decides who flies. + */ + handFlown: boolean; + /** + * The pilot flies the last of the approach (docs/TODO/207). The computer + * holds the ship on the slot axis, and the pilot matches the station's spin + * and the speed. It is saved, because it decides who flies. + */ + dockTrial: boolean; + /** + * The ship is on the rails, and the pilot flies the mini game + * (docs/TODO/212). It is the second half of `dockTrial`. The first half is + * the computer's, and it lines the ship up. It is saved, as `dockTrial` is. + */ + dockRails: boolean; +} + +/** + * 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 = []; + state.handFlown = false; + state.dockTrial = false; + state.dockRails = false; } /** Put a message in canonical state; the HUD only paints these fields. */ diff --git a/src/game/shield-face.ts b/src/game/shield-face.ts index d1a8f89a..fd7b2eb7 100644 --- a/src/game/shield-face.ts +++ b/src/game/shield-face.ts @@ -13,8 +13,9 @@ // a shot lands on decides which pool it spends, and the commander has two of // them (docs/TODO/64). // -// HERS, and only hers. A ship carries one bank and has no facing at all -// (npc.ts `takeDamage`), so there is nothing here for an NPC to ask. +// THE COMMANDER'S, AND ONLY THE COMMANDER'S. A ship carries one bank and has +// no facing at all (npc.ts `takeDamage`), so there is nothing here for an NPC +// to ask. // // No allocation: the two scratch objects are the caller's, because this is on // the path every hit in the game takes. diff --git a/src/game/spawning.ts b/src/game/spawning.ts index 5e067c31..1d3f1d7b 100644 --- a/src/game/spawning.ts +++ b/src/game/spawning.ts @@ -20,8 +20,10 @@ 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 { jobRole } from '../missions/verbs/registry.ts'; import { memberTier } from './threat.ts'; import { slotNormal } from '../world/slot.ts'; import { random, randomInt, randomDirection } from './rng.ts'; @@ -34,7 +36,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 +77,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 +108,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 +130,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 +141,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 +151,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 +162,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 +181,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. @@ -171,30 +196,7 @@ export function spawnPopulation( 3 + randomInt(4), ORDINARY_GOODS); } - // A mission ship flies the roster row for its design, under the role its - // job wants. A hunt's target is a pirate, as the Constrictor always was. An - // escort's charge and a scan's subject are traders. An escort's charge - // arrives with the commander and flies for the slot, so she can see it in. - // A design the roster cannot fly in that role is skipped, and the skeleton - // lint is the place to catch that. - const spawned: NpcShip[] = []; - for (const tagged of missionShips) { - const role = tagged.job === 'hunt' ? 'pirate' : 'trader'; - const spec = specForDesign(role, tagged.ship); - if (!spec) continue; - 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); - 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 - // trader's clock starts at zero, so it left at once. Half docked without - // a word. Half jumped out and failed the job before the player could - // find it. The world step starts its clock when the scan is done. - if (tagged.job === 'scan') ship.state.tradeTimer = Number.POSITIVE_INFINITY; - spawned.push(ship); - } + const spawned = spawnTaggedShips(world, playerPos, missionShips, MISSION_TARGET_RANGE, MISSION_TARGET_RANGE_SPAN); // A mission's canister or pod drifts at the same reach as a mission ship, // so the scanner shows it in the same place. for (const item of missionItems) { @@ -223,6 +225,42 @@ export function spawnPopulation( * @param forward where the commander is pointed. The cone is about that, so the * ship arrives where somebody can see it. */ +/** + * The tagged ships a mission asks for, `range` to `range + span` from `at` + * in a random direction each. The arrival calls it for the ships that wait + * at the jump-in. An ambush calls it for the ships that jump in on a scoop + * (docs/TODO/214 M2). + * + * A mission ship flies the roster row for its design, under the role its + * job wants. A hunt's target is a pirate, as the Constrictor always was. An + * escort's charge and a scan's subject are traders. An escort's charge + * arrives with the commander and flies for the slot, so they can see it in. + * A design the roster cannot fly in that role is skipped, and the skeleton + * lint is the place to catch that. + */ +export function spawnTaggedShips( + world: World, at: THREE.Vector3, ships: readonly TaggedShip[], range: number, span: number, +): NpcShip[] { + const spawned: NpcShip[] = []; + for (const tagged of ships) { + const role = jobRole(tagged.job); + const spec = specForDesign(role, tagged.ship); + if (!spec) continue; + const pos = at.clone() + .add(randomDirection(new THREE.Vector3()).multiplyScalar(range + random() * span)); + const ship = world.spawn(role, aboveGround(world, 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 + // trader's clock starts at zero, so it left at once. Half docked without + // a word. Half jumped out and failed the job before the player could + // find it. The world step starts its clock when the scan is done. + if (tagged.job === 'scan') ship.state.tradeTimer = Number.POSITIVE_INFINITY; + spawned.push(ship); + } + return spawned; +} + export function spawnPassingTrader( world: World, playerPos: THREE.Vector3, forward: THREE.Vector3, ): NpcShip { diff --git a/src/game/state.ts b/src/game/state.ts index f337201e..21ae239c 100644 --- a/src/game/state.ts +++ b/src/game/state.ts @@ -132,6 +132,7 @@ export function freshSession(): SessionState { messageTimer: 0, hyperCountdown: -1, torusEngaged: false, + cloaked: false, witchspace: false, // No arrival happened yet, so no set is in force. See SessionState. blueprintSet: '', @@ -156,6 +157,11 @@ export function freshSession(): SessionState { ccEngaged: false, beamTimer: 0, dcEngaged: false, + course: null, + coursesDone: [], + handFlown: false, + dockTrial: false, + dockRails: false, }; } 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/src/game/systems.ts b/src/game/systems.ts index 51e3116f..5d980f7b 100644 --- a/src/game/systems.ts +++ b/src/game/systems.ts @@ -26,6 +26,7 @@ // across — no migration path. `test/damage-paths.test.ts` fails if the old // rescaling names come back. +import { CLOAK_ENERGY_PER_SECOND, CLOAK_MIN_ENERGY } from '../constants/cloak.ts'; import { random } from './rng.ts'; import { LOW_ENERGY, MAX_ENERGY, MAX_SHIELD } from '../constants/pools.ts'; import { @@ -61,7 +62,7 @@ export interface ShipSystems { laserCooldown: number; /** * Seconds of `WRECK_BURST_GRACE` left, after the commander's own shot - * destroyed a ship. Above zero, her beam registers nothing on a bystander. + * destroyed a ship. Above zero, their beam registers nothing on a bystander. * * THE GUN'S THIRD CLOCK, and it is here because the other two are. The heat, * the cadence and this all describe what the trigger just did. @@ -111,6 +112,17 @@ export function spendLaserEnergy(sys: ShipSystems): void { sys.energy -= LASER_ENERGY_COST; } +/** + * The cloak's draw for one step (docs/TODO/219 M5). The pool is written + * here, because this file owns it. + * + * @returns true when the bank is down to its last one, so the cloak drops. + */ +export function spendCloakEnergy(sys: ShipSystems, dt: number): boolean { + sys.energy -= CLOAK_ENERGY_PER_SECOND * dt; + return sys.energy <= CLOAK_MIN_ENERGY; +} + /** * The recharge rating that `constants/recharge.ts`'s fractions were anchored * on. It is read from the catalogue rather than written as `1`. So a hull rated @@ -157,65 +169,6 @@ export function repairAtStation(sys: ShipSystems): void { sys.laserTemp = 0; } -/** - * How much damage this ship can absorb before energy reaches zero, in POOL - * POINTS. It is one shield face plus the whole energy bank. It is both faces - * for a commander who manoeuvres, so that hits land front and back. - * - * There is no multiplier into energy. The facing shield takes a hit, and the - * remainder spills straight into the bank. So this is a plain sum, and the - * balance harness reads it. - */ -export function durability(bothFaces = false): number { - return (bothFaces ? MAX_SHIELD * 2 : MAX_SHIELD) + MAX_ENERGY; -} - -/** - * HOW MUCH OF THIS SHIP IS LEFT, 0..1 — both faces and the bank, over - * everything they can hold. - * - * ONE HOME: it is the number a defence policy observes (`observeDefend` slot - * 14), observed in two worlds (the trainer's `TargetShip` and the game's combat - * computer). Written out twice it would drift. The policy would then fly out of - * the distribution it was fitted in, and no gate would say so. - */ -export function poolsLeft(sys: ShipSystems): number { - return (sys.foreShield + sys.aftShield + sys.energy) / durability(true); -} - -/** - * The ENERGY BANK alone, 0..1 — `observeDefend` slot 15, and the same one-home - * argument as `poolsLeft`. - * - * It is separate from `poolsLeft`, because the bank is three things at once: - * - * 1. what the ship DIES at; - * 2. what the shields will not recover past (`energyLow`); - * 3. what the E.C.M. spends a quarter of. - * - * A full pair of shields hides an empty one. - */ -export function energyLeft(sys: ShipSystems): number { - return sys.energy / MAX_ENERGY; -} - -/** - * Each shield FACE alone, 0..1 — `observeDefend` slots 27 and 28, same one-home - * argument as the two above. - * - * The pair exists because `poolsLeft` hides the split. An attacker on your six - * spends a different face from one head-on (`applyDamage`, `hitFromAhead`). So - * "keep the good face toward him" is flyable only if the policy can see which - * face is the good one. - */ -export function foreShieldLeft(sys: ShipSystems): number { - return sys.foreShield / MAX_SHIELD; -} - -export function aftShieldLeft(sys: ShipSystems): number { - return sys.aftShield / MAX_SHIELD; -} - export interface DamageResult { /** the hit got past the shields to the hull */ reachedHull: boolean; diff --git a/src/game/target-actions.ts b/src/game/target-actions.ts new file mode 100644 index 00000000..af6e2cf5 --- /dev/null +++ b/src/game/target-actions.ts @@ -0,0 +1,102 @@ +// What the Game does with the target list: the list it shows, and the pick it +// applies (docs/TODO/206 M3). +// +// `targets.ts` decides the list, and it holds the pick as a flag on a ship. +// This file joins the list to the buttons. It gives each ship a code for its +// button, and it applies a press. It is the target list's twin of +// `course-actions.ts`. +// +// A ROW'S CODE NAMES ONE SHIP, not a place in the list. The list is in order +// of range, and the order moves every frame. A code built from a row's place +// could pick the ship that moved into that place between the paint and the +// tap. So each ship gets a number the first time it is listed, and keeps it +// while it lives. The number is for the buttons only, and no save carries it. + +import type { Input } from '../engine/input.ts'; +import type { NpcShip } from './npc.ts'; +import type { GameState } from './state.ts'; +import { pickTarget, pickedTarget, targetList, type TargetRow } from './targets.ts'; +import { TARGET_NONE_KEY, TARGET_ROW_PREFIX, TARGETS_KEY } from './bindings.ts'; +import type { ListFold } from './list-fold.ts'; + +/** What the target buttons show. */ +export interface TargetPanel { + /** the list is open */ + readonly open: boolean; + /** each row with the code its button sends */ + readonly rows: readonly { readonly code: string; readonly row: TargetRow }[]; + /** the picked ship's row, if the pick still lives and is listed */ + readonly picked: TargetRow | null; +} + +export class TargetActions { + private readonly state: () => GameState; + /** the one fold the two lists share: the list is open when it says so (docs/TODO/215 M2) */ + private readonly fold: ListFold; + private readonly ids = new WeakMap(); + private next = 1; + + constructor(state: () => GameState, fold: ListFold) { + this.state = state; + this.fold = fold; + } + + private codeFor(ship: NpcShip): string { + let id = this.ids.get(ship); + if (id === undefined) { + id = this.next++; + this.ids.set(ship, id); + } + return `${TARGET_ROW_PREFIX}${id}`; + } + + private list(): TargetRow[] { + const s = this.state(); + return targetList({ + npcs: s.world.npcs, + playerPos: s.player.position, + legalStatus: s.commander.legalStatus, + playerToStation: s.player.position.distanceTo(s.world.station.position), + }); + } + + panel(): TargetPanel { + const rows = this.list(); + return { + open: this.fold.isOpen('targets'), + rows: rows.map((row) => ({ code: this.codeFor(row.ship), row })), + picked: rows.find((r) => r.picked) ?? null, + }; + } + + /** + * A target button was pressed. A row picks its ship and hands the stick to + * the computer, as a course pick does. The last row lets the computer choose + * again. A pick closes the list. + */ + read(i: Input): void { + const s = this.state(); + if (i.pressed(TARGETS_KEY)) { + this.fold.toggle('targets'); + return; + } + if (i.pressed(TARGET_NONE_KEY)) { + pickTarget(s.world.npcs, null); + this.fold.fold(); + return; + } + for (const row of this.list()) { + if (i.pressed(this.codeFor(row.ship))) { + pickTarget(s.world.npcs, row.ship); + s.session.handFlown = false; + this.fold.fold(); + return; + } + } + } + + /** The picked ship, for a caller that needs the ship rather than a row. */ + picked(): NpcShip | null { + return pickedTarget(this.state().world.npcs); + } +} diff --git a/src/game/targets.ts b/src/game/targets.ts new file mode 100644 index 00000000..36e489ad --- /dev/null +++ b/src/game/targets.ts @@ -0,0 +1,131 @@ +// What the ship can fight, and which one the pilot picked (docs/TODO/206 M1). +// +// In a fight the computer lines the ship up, and the pilot fires. The pilot +// chooses what to fight from this list: every ship, rock and derelict within +// scanner range. Chris's words of 2026-09-11: a trader is on the list, and +// the law answers as it does today. +// +// THE LIST IS DERIVED STATE, as the course list is (`courses.ts`). The pick +// is saved, because it decides what the computer flies at. It is one flag on +// the picked ship's own state, `NpcState.targeted`. A ship has no stable id, +// and its state is saved with it, so the flag goes where the ship goes. +// +// THE PICK SITS ON TOP OF `threat-lock.ts`, and it never changes that rule. +// Three places share the lock: the co-pilot, an armed trader, and the +// training episode. With no pick, the co-pilot takes the lock's threat. +// +// It is pure over the fleet. It spends no rule of its own: who attacks the +// commander is `hostility.ts`, and whom the law protects is `law.ts`. + +import type * as THREE from 'three'; +import type { NpcShip } from './npc.ts'; +import { isHostileToPlayer } from './hostility.ts'; +import { harmVerdict } from './law.ts'; +import { SCANNER_RANGE } from '../constants/console.ts'; + +/** One row of the list. */ +export interface TargetRow { + readonly ship: NpcShip; + /** what the player sees the ship called, as the arrival lines name it */ + readonly name: string; + readonly range: number; + /** what the ship is to the pilot, in the player's words */ + readonly standing: string; + /** it attacks the commander, so its button reads red (docs/TODO/222) */ + readonly hostile: boolean; + /** what an attack costs, where it costs something beyond the fight */ + readonly cost: string | null; + readonly picked: boolean; +} + +/** Everything the list is raised from. */ +export interface TargetView { + readonly npcs: readonly NpcShip[]; + readonly playerPos: THREE.Vector3; + readonly legalStatus: number; + /** for the station's truce, as `isHostileToPlayer` asks */ + readonly playerToStation: number; +} + +/** What each role is to the pilot, when it is not attacking. */ +const STANDING: Readonly> = { + trader: 'TRADER', + police: 'POLICE', + pirate: 'PIRATE', + hunter: 'BOUNTY HUNTER', + thargoid: 'THARGOID', + thargon: 'THARGON', + asteroid: 'ASTEROID', + hermit: 'ROCK HERMIT', + generation: 'DERELICT SHIP', +}; + +/** + * The ships the pilot can fight, in order. The ships that attack come first, + * nearest first. Every other ship follows, nearest first. The picked ship is + * always on the list, even beyond scanner range. + */ +export function targetList(v: TargetView): TargetRow[] { + const rows = v.npcs + .filter((n) => n.state.alive && !n.state.docked + && (n.state.targeted || n.object.position.distanceTo(v.playerPos) <= SCANNER_RANGE)) + .map((ship) => { + const hostile = isHostileToPlayer(ship, v.legalStatus, v.playerToStation); + const standing = hostile ? 'HOSTILE' : STANDING[ship.role] ?? 'SHIP'; + return { + row: { + ship, + name: shipName(ship), + range: ship.object.position.distanceTo(v.playerPos), + standing, + hostile, + cost: costOf(ship.role), + picked: ship.state.targeted, + }, + hostile, + }; + }); + rows.sort((a, b) => Number(b.hostile) - Number(a.hostile) || a.row.range - b.row.range); + return rows.map((r) => r.row); +} + +/** + * What an attack costs beyond the fight. The law's own rule says whom it + * protects (`harmVerdict`). The hermit is not the law's, and a dead hermit + * costs the commander's name (`DISREPUTE_HERMIT_KILL`). + */ +function costOf(role: string): string | null { + if (harmVerdict(role) !== null) return 'THE LAW PROTECTS THIS SHIP'; + if (role === 'hermit') return 'KILLING THE HERMIT HURTS YOUR REPUTATION'; + return null; +} + +/** + * What the player calls one ship. A rock carries no name of its own, so it + * is called what it is. + * + * The mass lock message also names a ship (docs/TODO/209). It calls this, + * so the list and the message cannot use two different words for one ship. + */ +export function shipName(ship: NpcShip): string { + return (ship.object.name || STANDING[ship.role] || 'SHIP').toUpperCase(); +} + +/** + * ...and the same name with its article, where the words make a sentence. + * The mass lock and the close pass both need one (docs/TODO/209). + */ +export function shipArticle(ship: NpcShip): string { + const name = shipName(ship); + return `${'AEIOU'.includes(name[0]) ? 'AN' : 'A'} ${name}`; +} + +/** The ship the pilot picked, if it still lives. */ +export function pickedTarget(npcs: readonly NpcShip[]): NpcShip | null { + return npcs.find((n) => n.state.targeted && n.state.alive) ?? null; +} + +/** Pick one ship, and let go of any other. Null lets go of all of them. */ +export function pickTarget(npcs: readonly NpcShip[], ship: NpcShip | null): void { + for (const n of npcs) n.state.targeted = n === ship; +} diff --git a/src/game/trader-flight.ts b/src/game/trader-flight.ts index 406666c1..a32b8edd 100644 --- a/src/game/trader-flight.ts +++ b/src/game/trader-flight.ts @@ -51,6 +51,8 @@ export interface TraderState { waypointTimer: number; /** Seconds of business left at the station. */ tradeTimer: number; + /** a mission's charge waits for the commander, so its speed is zero (docs/TODO/214 M3) */ + holding: boolean; /** Decided at spawn: does this one have business at the station? */ docksHere: boolean; /** On final approach into the slot — the station must not shove it away. */ @@ -108,7 +110,7 @@ export function stepTrader(ship: TraderShip, dt: number, world: TraderWorld): vo switch (state.traderPhase) { case 'arriving': { steerToward(ship, home, dt); - state.speed = approach(state.speed, ship.maxSpeed * 0.85, 90 * dt); + state.speed = approach(state.speed, state.holding ? 0 : ship.maxSpeed * 0.85, 90 * dt); if (ship.object.position.distanceTo(home) < TRADER_ARRIVED) { state.traderPhase = 'trading'; } @@ -131,7 +133,7 @@ export function stepTrader(ship: TraderShip, dt: number, world: TraderWorld): vo .add(randomDirection(new THREE.Vector3()).multiplyScalar(600 + random() * 1200)); } steerToward(ship, state.waypoint, dt); - state.speed = approach(state.speed, ship.maxSpeed * 0.35, 60 * dt); + state.speed = approach(state.speed, state.holding ? 0 : ship.maxSpeed * 0.35, 60 * dt); if (state.tradeTimer <= 0) { // about half put in at the station; the rest jump out from here if (state.docksHere) { diff --git a/src/game/views.ts b/src/game/views.ts index 14fdb73a..55fcb1bb 100644 --- a/src/game/views.ts +++ b/src/game/views.ts @@ -52,7 +52,7 @@ export function viewDirection( * * It takes the VIEW rather than the hull, and that is the same argument * `viewDirection` makes above. The pilot acts through the window in front of - * her. So a ship on the left of the screen is on the left in rear view as well. + * them. So a ship on the left of the screen is on the left in rear view as well. * * An ear bolted to the hull would put that ship on the wrong side of the * cockpit the moment the pilot turned to it. That reads as a fault rather than diff --git a/src/game/world-build.ts b/src/game/world-build.ts index b0ca32c2..92cb5a72 100644 --- a/src/game/world-build.ts +++ b/src/game/world-build.ts @@ -30,7 +30,9 @@ import { specsForSet } from './set-roster.ts'; import { missionItems, missionOverride, missionSpawns } from '../missions/queries.ts'; import { planPopulation } from './population.ts'; import { markOf, pirateThreat } from './threat.ts'; -import { spawnPopulation } from './spawning.ts'; +import { spawnPopulation, spawnTaggedShips } from './spawning.ts'; +import { WITCHSPACE_TARGET } from '../constants/missions.ts'; +import { MISSION_TARGET_RANGE, MISSION_TARGET_RANGE_SPAN } from '../constants/spawn-placement.ts'; import { random, randomDirection } from './rng.ts'; import type { NpcShip } from './npc.ts'; import type { NpcRole } from './ship-roles.ts'; @@ -147,6 +149,16 @@ export class WorldBuild { .multiplyScalar(THARGOID_AMBUSH_RANGE + random() * THARGOID_AMBUSH_RANGE_SPAN), i); } this.state.encounterTimers.thargon = THARGON_REDEPLOY; + // A leg placed in witchspace spawns its ships and its things here, at a + // mission target's reach, beside the trap (docs/TODO/219 M3). + const missions = this.state.commander.missions; + spawnTaggedShips(this.state.world, this.state.player.position, + missionSpawns(missions, WITCHSPACE_TARGET), MISSION_TARGET_RANGE, MISSION_TARGET_RANGE_SPAN); + for (const item of missionItems(missions, WITCHSPACE_TARGET)) { + const pos = randomDirection(new THREE.Vector3()) + .multiplyScalar(MISSION_TARGET_RANGE + random() * MISSION_TARGET_RANGE_SPAN); + this.state.world.cargo.spawnMission(pos, item.kind, item.tag); + } this.host.hyperspaceSound(); this.host.startTunnel(1.1); this.host.showMessage('WITCH-SPACE — THARGOID AMBUSH', 6); diff --git a/src/game/world-step.ts b/src/game/world-step.ts index 189f423e..681440f1 100644 --- a/src/game/world-step.ts +++ b/src/game/world-step.ts @@ -43,18 +43,27 @@ import { CHARACTER_LINE_SECONDS, DISREPUTE_CAUGHT } from '../constants/character import { queueMessage } from './session.ts'; import { playerVsNpcs, npcVsNpcs, npcsVsStation } from './collisions.ts'; import { assignNpcTargets } from './npc-targeting.ts'; +import { shipArticle } from './targets.ts'; +import { closePassLines } from './close-pass.ts'; import { stepEncounters } from './encounters.ts'; -import { spawnArrivingTrader, spawnPassingTrader } from './spawning.ts'; +import { spawnArrivingTrader, spawnPassingTrader, spawnTaggedShips } from './spawning.ts'; import { STATION_TRUCE } from '../constants/law.ts'; import { PIRATE_WAVE_RANGE, PIRATE_WAVE_RANGE_SPAN, THARGON_DEPLOY_RANGE, TRADER_ARRIVAL_RANGE, } from '../constants/spawn-placement.ts'; -import { planDocking, dockingOutcome } from './docking.ts'; +import { + planDocking, dockingOutcome, type DockPlan, type DockingOutcome, +} from './docking.ts'; +import { holdOnRails, railsAligned, railsReached, stopped } from './dock-rails.ts'; +import { bankToTurn } from './pitch-roll-steer.ts'; +import { slotNormal } from '../world/slot.ts'; import { dockingSticks } from './docking-sticks.ts'; -import { NPC_HULL_BOX_MARGIN } from '../constants/docking.ts'; +import { + NPC_HULL_BOX_MARGIN, COMPUTER_ROLL_TOLERANCE, ROLL_TOLERANCE, +} from '../constants/docking.ts'; import { BOUNCE_STANDOFF } from '../constants/station.ts'; -import { regenerate, updateCabinTemp, scoopFuel, energyLow } from './systems.ts'; +import { regenerate, updateCabinTemp, scoopFuel, energyLow, spendCloakEnergy } from './systems.ts'; import { SUN_KILL_DIST } from '../constants/sun.ts'; import { PLANET_CRASH_ALTITUDE } from '../constants/planet.ts'; import { @@ -72,10 +81,11 @@ import { Ordnance, ordnanceMessage, type OrdnanceOutcome } from './ordnance.ts'; import type { NpcShip, FireEvent, WorldView } from './npc.ts'; import { nearestNpc } from './hostility.ts'; import type { SoundEvent, SoundName } from './sounds.ts'; -import { runMissions } from './mission-bridge.ts'; -import { scanSecondsFor } from '../missions/queries.ts'; +import { applyMissions, runMissions, type MissionOutcome } from './mission-bridge.ts'; +import { huntCanFlee, scanSecondsFor } from '../missions/queries.ts'; import { DOCK_COMPUTER_RANGE } from '../constants/docking-computer.ts'; import { ESCORT_ENEMY_ROLES, WATCH_CONE } from '../constants/missions.ts'; +import { ESCORT_LEASH } from '../constants/mission-course.ts'; import { SCANNER_RANGE } from '../constants/console.ts'; import { random, randomInt, randomDirection } from './rng.ts'; import type { GameState } from './state.ts'; @@ -96,22 +106,36 @@ import { AUTOSAVE_INTERVAL } from '../constants/saves.ts'; const WARHEAD_FLASH = rgb24(HUD.amber); /** - * Anything close enough to hold the torus drive down. + * WHAT is close enough to hold the torus drive down, in the player's words, + * or null for a clear sky (docs/TODO/209). + * + * The lock said only that it happened. Chris flew the trip in. He could not tell + * that a neutral trader stopped the drive. So the lock names what + * stopped it, and the console line carries the name. * * A free function over the state, so the flight keys and the step share one * rule and `window.__game.massLocked()` keeps working for the harnesses. The * three radii live together in constants/torus.ts, beside the drive they cut. */ -export function massLocked(state: GameState): boolean { +export function massLockCause(state: GameState): string | null { const { player, world } = state; - if (player.position.distanceTo(world.station.position) < MASS_LOCK_STATION) return true; + if (player.position.distanceTo(world.station.position) < MASS_LOCK_STATION) return 'THE STATION'; if (player.position.distanceTo(world.planetPos) - world.planetRadius - < MASS_LOCK_PLANET_ALTITUDE) return true; + < MASS_LOCK_PLANET_ALTITUDE) return 'THE PLANET'; + // A ship comes with its article, because the message reads as a sentence. + let nearest: { name: string; range: number } | null = null; for (const npc of world.npcs) { - if (npc.state.alive && npc.role !== 'asteroid' && - npc.object.position.distanceTo(player.position) < MASS_LOCK_SHIP) return true; + if (!npc.state.alive || npc.role === 'asteroid') continue; + const range = npc.object.position.distanceTo(player.position); + if (range >= MASS_LOCK_SHIP) continue; + if (nearest === null || range < nearest.range) nearest = { name: shipArticle(npc), range }; } - return false; + return nearest?.name ?? null; +} + +/** Is anything close enough to hold the torus drive down? */ +export function massLocked(state: GameState): boolean { + return massLockCause(state) !== null; } /** @@ -230,6 +254,13 @@ export interface PilotInput { * Holds the state, the missiles and the host — and its own scratch vectors, so * stepping at 60Hz allocates nothing. */ +/** What the console says about a dock that did not take (docs/TODO/207 M3). */ +const SCRAPE_SAID: Partial> = { + slotMiss: 'DOCKING FAILURE — MATCH THE SLOT ROTATION', + tooFast: 'TOO FAST FOR THE SLOT — SLOW DOWN AND TRY AGAIN', + hull: 'COLLISION', +}; + export class WorldStep { private readonly state: GameState; private readonly ordnance: Ordnance; @@ -244,6 +275,8 @@ export class WorldStep { private readonly fire: FireWorld; private readonly tmp = new THREE.Vector3(); + /** the slot axis the computer's last turn points down — see `dockTrialStep` */ + private readonly dockAxis = new THREE.Vector3(); private readonly tmp2 = new THREE.Vector3(); private readonly tmpQ = new THREE.Quaternion(); /** scratch for collisions.ts, so a per-frame call allocates nothing */ @@ -298,14 +331,18 @@ export class WorldStep { // used to write the quaternion after the fact. So the demand is decided // before the ship flies, rather than applied on top of it. Where it hands // the ship back mid-frame, the pilot's own demand stands, as before. - const dc = session.dcEngaged ? this.dockingComputerStep(dt, pilot, out) : null; + const dc = session.dcEngaged ? this.dockingComputerStep(dt, pilot, out) + : session.dockTrial ? this.dockTrialStep(dt, pilot, out) : null; player.update(dt, dc ?? pilot.demand); + // The rails correct the frame the ship just flew (docs/TODO/212). + if (session.dockRails) holdOnRails(player, world.station, dt); // torus drive if (session.torusEngaged) { - if (this.massLocked()) { + const cause = massLockCause(this.state); + if (cause !== null) { session.torusEngaged = false; - out.push(say('MASS LOCK — TORUS DISENGAGED', 3)); + out.push(say(`TORUS DRIVE OFF — ${cause} IS TOO CLOSE`, 3)); out.push(heard('torusDropped')); } else { // ONE LESS THAN THE MULTIPLIER. `player.update()` above already flew @@ -342,15 +379,32 @@ export class WorldStep { private dockingComputerStep( dt: number, pilot: PilotInput, out: StepEvent[], ): FlightDemand | null { - const { player, session, world } = this.state; + const { session } = this.state; if (pilot.handsOn) { session.dcEngaged = false; out.push({ kind: 'dockingMusic', on: false }); out.push(say('MANUAL OVERRIDE', 2)); return null; } - const plan = planDocking(player.position, world.station, world.stationDockZ, - player.maxSpeed, this.state.dockPlan); + return this.dockingDemand(dt, pilot, this.dockingPlan()); + } + + /** The approach the computer is flying this frame. */ + private dockingPlan(): DockPlan { + const s = this.state; + return planDocking(s.player.position, s.world.station, s.world.stationDockZ, + s.player.maxSpeed, s.dockPlan); + } + + /** + * What the docking computer asks of the ship this frame: both sticks, and + * the plan's own speed. + * + * The pilot's stretch shares it (docs/TODO/212). The computer lines the ship + * up there too, and it hands over only when the ship is ON the axis. + */ + private dockingDemand(dt: number, pilot: PilotInput, plan: DockPlan): FlightDemand { + const { player } = this.state; const sticks = dockingSticks(player.quaternion, plan, player.rollRate); // Bang-bang on the throttle, with a deadband of one frame's thrust. A // demand can only ask for full ahead, full astern or coast, because that is @@ -372,6 +426,77 @@ export class WorldStep { }; } + /** + * One frame of the pilot's own stretch of the approach (docs/TODO/207, and + * docs/TODO/212 for its shape). + * + * IT IS TWO STAGES. The computer lines the ship up first, with both sticks, + * exactly as the docking computer does. Then the rails take the ship, and + * the pilot plays the mini game: match the slot, and go in slowly. + * + * Chris asked for that on 2026-09-12: *"I'm wondering if we actually get + * lined up by the computer and then hand off to a 'mini' docking game. + * Something that is completely on rails."* One stick cannot hold a line and + * match a spin at the same time. The rails hold the line, so the stick is + * free for the spin. + */ + private dockTrialStep(dt: number, pilot: PilotInput, out: StepEvent[]): FlightDemand { + const { player, session, world } = this.state; + const plan = this.dockingPlan(); + if (!session.dockRails) { + if (!railsReached(plan)) return this.dockingDemand(dt, pilot, plan); + // On the axis, and the computer stops the ship there. The pilot then + // flies the whole run in (docs/TODO/212). + if (!stopped(player.speed)) { + return { ...this.dockingDemand(dt, pilot, plan), throttle: -1 }; + } + // STOPPED IS NOT LINED UP. The brake used to end the line-up, and the + // ship was still up to 69.7 degrees off the axis (Chris, 2026-09-12: + // *"we jump to the on rails version before it's actually lined up"*). + // + // THE COMPUTER FLIES THIS LAST TURN, with both sticks, through the + // commander's own envelope. The nose comes round at the rate the hull + // turns. The HUD needles read it. Nothing moves the ship other than by + // flying it. + // + // `bankToTurn` is the law, rather than `dockingSticks`. That one spends + // the roll on the letterbox, and it pitches onto the heading. A pitch + // alone cannot answer a sideways error from a stop. The slot has no claim + // on the roll yet, because the pilot does not hold the ship. + if (!railsAligned(player, world.station)) { + const axis = slotNormal(world.station, this.dockAxis).multiplyScalar(-1); + const cmd = bankToTurn(player.quaternion, axis, this.state.dockPlan.steer); + return { + pitchRate: rampFlightRate( + player.pitchRate, cmd.pitch * PLAYER_FLIGHT.maxPitch, cmd.pitch !== 0, dt), + rollRate: rampFlightRate( + player.rollRate, cmd.roll * PLAYER_FLIGHT.maxRoll, cmd.roll !== 0, dt), + throttle: 0, + fire: pilot.demand.fire, + }; + } + // LINED UP, AND THE SLOT IS THE PILOT'S. It used to ask here whether a + // docking computer was fitted, and hand the ship to it. Chris, + // 2026-09-12: *"I have a docking computer - but instead of it being + // activated I still get the lining and only once that is done does the + // computer activate."* A fitting the commander paid for should not wait + // on a line-up they did not ask for. It is a BUTTON now, offered for the + // whole of the trial (`cockpit-buttons.ts`). The line-up is what they get + // if they do not press it. + session.dockRails = true; + out.push(say('THE SLOT IS YOURS — THRUST IN, AND MATCH ITS SPIN', 5)); + } + // ON THE RAILS. The pitch is nobody's: `holdOnRails` owns the line. The + // roll and the throttle are the pilot's, and they are the whole game. + return { + pitchRate: rampFlightRate(player.pitchRate, 0, false, dt), + rollRate: pilot.demand.rollRate, + throttle: pilot.demand.throttle, + fire: pilot.demand.fire, + }; + } + + /** Everyone else: decisions, despawns, collisions, and who else turns up. */ private stepNpcs(dt: number, out: StepEvent[]): void { const s = this.state; @@ -383,6 +508,15 @@ export class WorldStep { // the same frame. const playerToStation = player.position.distanceTo(world.station.position); + // A neutral ship that comes close says so, one time (docs/TODO/209). The + // mass lock only spoke with the torus drive running, and the drive is off + // for most of a trip. The rule is `close-pass.ts`, and this pushes what it + // decided (invariant 15). + for (const line of closePassLines({ + npcs: world.npcs, playerPos: player.position, + legalStatus: s.commander.legalStatus, playerToStation, + })) out.push(say(line, 4)); + // periodic NPC-vs-NPC targeting: pirates prey on traders, the law hunts pirates session.npcTargetTimer -= dt; if (session.npcTargetTimer <= 0) { @@ -403,6 +537,7 @@ export class WorldStep { dockZ: world.stationDockZ, fleet: world.npcs, playerLegal: s.commander.legalStatus, + playerCloaked: s.session.cloaked, brains: s.brains, missileInbound: this.ordnance.missileInbound, sunPos: world.sunPos, @@ -421,10 +556,15 @@ export class WorldStep { { count: 10, speed: 120, duration: 0.7 }); } world.despawn(npc); - // A tagged ship that jumped out escaped its hunt. The machine decides - // whether the leg minds (`canEscape`), and says so in this stream. + // A tagged ship that leaves is gone from its leg, and HOW it left + // decides what the leg makes of it (docs/TODO/208 M3). A ship that + // ran from the commander FLED. Any other one jumped out, and escaped. + // Three arcs have a branch for a ship that flees, and nothing sent + // that word until now. The machine says what each costs. if (npc.state.missionTag !== null && !npc.state.docked) { - out.push(...runMissions(s.commander, { kind: 'escaped', tag: npc.state.missionTag })); + out.push(...runMissions(s.commander, { + kind: npc.state.fleeing ? 'fled' : 'escaped', tag: npc.state.missionTag, + })); } continue; } @@ -512,9 +652,13 @@ export class WorldStep { * The two verdicts only the world can give (docs/TODO/190 M4). * * An ESCORT is safe when its ship is alive, inside `DOCK_COMPUTER_RANGE` of - * the station, and no enemy is inside that same radius of it. All three at - * once, and it is sent ONCE. `missionReported` latches, so a fight after - * the fee cannot undo it. The ship goes on to dock as any trader does. + * the station. The commander must be inside that radius of it, and no + * enemy may be. All four at once, and it is sent ONCE. + * `missionReported` latches, so a fight after the fee cannot undo it. The + * ship goes on to dock as any trader does. The commander's own place was + * not measured until docs/TODO/214 M3, and the escort was a job they + * watched. The charge also HOLDS for them beyond `ESCORT_LEASH`, and the + * console says so once. * * A SCAN counts the seconds a tagged ship spends under the scanner lock, * and sends `scanned` when the leg's seconds are up, once. @@ -535,6 +679,17 @@ export class WorldStep { for (const npc of world.npcs) { const tag = npc.state.missionTag; if (tag === null || npc.state.missionReported || !npc.state.alive) continue; + if (npc.role !== 'trader') { + // A hunt's target may run for the edge when it is nearly dead + // (docs/TODO/214 M4). The stamp is here, each frame, so a restored + // ship carries it again at once. The console says the run once. + npc.state.canFlee = huntCanFlee(commander.missions, tag); + if (npc.state.fleeing && !npc.state.runSaid) { + npc.state.runSaid = true; + out.push(say(`THE ${npc.object.name.toUpperCase()} IS RUNNING FOR IT. CHASE IT.`, 4)); + } + continue; + } if (npc.role === 'trader') { const wanted = scanSecondsFor(commander.missions, tag); if (wanted !== null) { @@ -550,12 +705,19 @@ export class WorldStep { if (npc.state.observed >= wanted) { npc.state.missionReported = true; npc.state.tradeTimer = 0; // watched; it may go about its business now (docs/TODO/203 M2) - out.push(...runMissions(commander, { kind: 'scanned', tag })); + out.push(...this.springAmbush(applyMissions(commander, { kind: 'scanned', tag }))); } continue; } const at = npc.object.position; + const away = player.position.distanceTo(at); + npc.state.holding = away > ESCORT_LEASH; + if (npc.state.holding && !npc.state.holdSaid) { + npc.state.holdSaid = true; + out.push(say(`THE ${npc.object.name.toUpperCase()} IS HOLDING FOR YOU. STAY WITH IT.`, 4)); + } if (at.distanceTo(world.station.position) > DOCK_COMPUTER_RANGE) continue; + if (away > DOCK_COMPUTER_RANGE) continue; const threatened = world.npcs.some((other) => other !== npc && other.state.alive && ESCORT_ENEMY_ROLES.includes(other.role) && other.object.position.distanceTo(at) <= DOCK_COMPUTER_RANGE); if (threatened) continue; @@ -565,6 +727,20 @@ export class WorldStep { } } + /** + * The ships a mission's step asked for jump in around the commander, at a + * pirate wave's reach, and the console says so (docs/TODO/214 M2). The + * lines come back as they were, for the caller to say. + */ + private springAmbush(outcome: MissionOutcome): StepEvent[] { + const { world, player } = this.state; + if (outcome.spawns.length > 0) { + spawnTaggedShips(world, player.position, outcome.spawns, PIRATE_WAVE_RANGE, PIRATE_WAVE_RANGE_SPAN); + return [say('PIRATE SIGNATURES DETECTED', 4), ...outcome.messages]; + } + return outcome.messages; + } + /** Cargo, missiles, and the things that are only ever seen. */ private stepProjectilesAndEffects(dt: number, out: StepEvent[]): void { const { world, player, commander } = this.state; @@ -585,7 +761,7 @@ export class WorldStep { // A MISSION'S THING, and the machine says what it was. It is a // canister that never enters the hold, or a pod whose passenger rides // under the pod's own tag (docs/TODO/190 M4). - out.push(...runMissions(commander, { kind: 'scooped', tag: c.missionTag })); + out.push(...this.springAmbush(applyMissions(commander, { kind: 'scooped', tag: c.missionTag }))); out.push(heard(c.kind === 'capsule' ? 'survivorScooped' : 'cargoScooped')); } else if (c.kind === 'capsule') { // A person, not stock. See CommanderData.survivors — a capsule is not @@ -659,6 +835,17 @@ export class WorldStep { if (demand.fire) this.host.fireLaser(); regenerate(sys, dt, { shipId: commander.shipId, energyUnit: commander.equipment.energyUnit }); + // THE CLOAK (docs/TODO/219 M5). A shot is a flare, so the trigger drops + // it. The bank pays for the rest, and the last bank is never spent on it. + if (session.cloaked) { + if (demand.fire) { + session.cloaked = false; + out.push(say('UNCLOAKED — A SHOT IS A FLARE', 3)); + } else if (spendCloakEnergy(sys, dt)) { + session.cloaked = false; + out.push(say('THE CLOAK FAILED — ENERGY LOW', 3)); + } + } const sunDist = player.position.distanceTo(world.sunPos); if (updateCabinTemp(sys, dt, sunDist)) { @@ -815,28 +1002,33 @@ export class WorldStep { } /** - * Are we down, bounced, or clear? The geometry is docking.ts's; what it - * costs is ours. + * Are we down, bounced, or clear? The rule is docking.ts's; what it costs is + * ours. */ private checkStation(out: StepEvent[]): void { - const { player, world } = this.state; + const { player, world, session } = this.state; const station = world.station; + // WHICH TOLERANCE depends on who holds the stick. A bought docking computer + // gets the wider one, and that is the point of buying it + // (`COMPUTER_ROLL_TOLERANCE`). A hand gets the hard one. const outcome = dockingOutcome( - player.position, player.quaternion, station, world.stationDockZ, - { v: this.tmp, q: this.tmpQ, r: this.tmp2 }); + player.position, player.quaternion, station, world.stationDockZ, player.speed, + { v: this.tmp, q: this.tmpQ, r: this.tmp2 }, + session.dcEngaged ? COMPUTER_ROLL_TOLERANCE : ROLL_TOLERANCE); if (outcome === 'clear') return; if (outcome === 'docked') { this.host.dock(); return; } - // hit the hull, or fluffed the slot + // hit the hull, or fluffed the slot. The rails let go, so the computer + // lines the ship up again for another go (docs/TODO/212). + this.state.session.dockRails = false; const away = this.tmp2.copy(player.position).sub(station.position).normalize(); player.position.copy(station.position).addScaledVector(away, BOUNCE_STANDOFF); player.speed = 0; this.host.applyPlayerDamage( playerImpactDamage(IMPACT.stationScrape), station.position, 'station'); - out.push(say( - outcome === 'slotMiss' ? 'DOCKING FAILURE — MATCH SLOT ROTATION' : 'COLLISION', 3)); + out.push(say(SCRAPE_SAID[outcome] ?? 'COLLISION', 3)); } /** While armed, lock onto whatever enters the sight. Ordnance reports; we say it. */ diff --git a/src/hud/hud-binding.ts b/src/hud/hud-binding.ts index e8ee1132..e8ba9029 100644 --- a/src/hud/hud-binding.ts +++ b/src/hud/hud-binding.ts @@ -30,6 +30,7 @@ import type { World } from '../game/world.ts'; import type { Missile } from '../game/ordnance.ts'; import type { Canister } from '../game/cargo.ts'; import { MAX_FUEL } from '../constants/commander.ts'; +import { COMPUTER_ROLL_TOLERANCE, ROLL_TOLERANCE } from '../constants/docking.ts'; import { SCANNER_RANGE, SUNSKIM_COMPASS_RANGE, STATION_COMPASS_RADII, } from '../constants/console.ts'; @@ -57,18 +58,26 @@ export interface HudSources { readonly inFlight: boolean; readonly witchspace: boolean; readonly assist: boolean; + /** the cloak runs (docs/TODO/219 M5) */ + readonly cloaked: boolean; + /** a bought docking computer holds the stick, so the slot is kinder to it */ + readonly dockingComputer: boolean; + /** the pilot flies the last stretch into the slot (docs/TODO/207) */ + readonly trial: boolean; + readonly rails: boolean; readonly ecmDetected: boolean; readonly messageText: string; readonly messageTimer: number; - /** - * The key prompts, already rendered — see `HudState.prompts`. - * - * It arrives finished, for the same reason the exercise strip does. WHICH - * keys are worth an offer is `game/prompts.ts`. Which letter each is bound to - * is the binding table's answer, through `boundKey`. That function lives in - * `ui/`, so a rule module cannot reach it. The dashboard is handed the line. - */ - readonly prompts: readonly string[]; + /** the course buttons, finished — see `HudState.courses` */ + readonly courses: HudState['courses']; + /** the course row, finished — see `HudState.courseRow` */ + readonly courseRow: HudState['courseRow']; + /** the target header, finished — see `HudState.targetList` */ + readonly targetList: HudState['targetList']; + /** the target row, finished — see `HudState.targetRow` */ + readonly targetRow: HudState['targetRow']; + /** the guns, finished — see `HudState.guns` */ + readonly guns: HudState['guns']; /** * The training exercise in progress, or null in career flight. * @@ -142,7 +151,8 @@ export function buildHudFrame(s: HudSources, scratch: HudScratch): HudFrame { if (s.inFlight && !s.witchspace) { ({ dockAid, slotMarker } = dockingAid( world.station, world.stationDockZ, playerPos, s.playerQuat, s.playerForward, - s.camera, { a: scratch.a, b: scratch.b, q: scratch.q })); + s.camera, { a: scratch.a, b: scratch.b, q: scratch.q }, + s.dockingComputer ? COMPUTER_ROLL_TOLERANCE : ROLL_TOLERANCE)); } // The tags of the things live missions sent the player for, so the scanner @@ -176,7 +186,11 @@ export function buildHudFrame(s: HudSources, scratch: HudScratch): HudFrame { return { messageText: s.messageText, messageTimer: s.messageTimer, - prompts: s.prompts, + courses: s.courses, + courseRow: s.courseRow, + targetList: s.targetList, + targetRow: s.targetRow, + guns: s.guns, playerPos: s.playerPos, playerQuat: s.playerQuat, contacts: scannerContacts( @@ -222,6 +236,9 @@ export function buildHudFrame(s: HudSources, scratch: HudScratch): HudFrame { threatMarker, missionMarker, assist: s.assist, + cloaked: s.cloaked, + trial: s.trial, + rails: s.rails, armed: s.missileArmed, stationInRange: s.inFlight && !s.witchspace && playerPos.distanceTo(world.station.position) < SCANNER_RANGE, diff --git a/src/hud/hud-buttons.ts b/src/hud/hud-buttons.ts new file mode 100644 index 00000000..0a765e6c --- /dev/null +++ b/src/hud/hud-buttons.ts @@ -0,0 +1,74 @@ +// A strip of buttons over the flight view (docs/TODO/205 M5). +// +// The course list in flight is a row of buttons, and not a screen. Under a +// screen the flight world stops. So a list that must stay up while the ship +// flies cannot be one. docs/TODO/204 found the same shape for its flight menu. +// +// Each button carries the code it sends in `data-key`, as a menu row does. +// The shell turns a click into that keystroke, and the Game answers it. So a +// button is one more way to press a key, and it decides nothing. +// +// A PAINTER, like the rest of the HUD. It rebuilds its markup only when the +// buttons change, because it runs every frame. A tap that lands between two +// identical frames must still find its button in place. + +/** One button: the code it sends, and what it says. */ +export interface HudButton { + readonly code: string; + readonly label: string; + /** why the button cannot do its work now. A pick of it refuses, and says so */ + readonly note?: string; + /** the button is on: a course the ship flies now */ + readonly lit?: boolean; + /** a second, quieter line: what a press does */ + readonly hint?: string; + /** + * The button is an icon (docs/TODO/221): a glyph, small, and beside the + * header rather than under it. It carries no hint and no note. A refusal + * is said on the console when it is pressed, and `dim` shows it. + */ + readonly icon?: boolean; + /** the button refuses now, and says why on the console when it is pressed */ + readonly dim?: boolean; + /** the ship it names attacks the commander, so it reads red (docs/TODO/222) */ + readonly hostile?: boolean; + /** + * The button holds its code down while it is held, as the laser button + * does, rather than sending it once (`engine/hold-buttons.ts`). + */ + readonly hold?: boolean; + /** + * The button is a strip to drag rather than a button to press + * (`engine/strip-control.ts`). It reports how far the pointer is from its + * middle, which is how much roll the last stretch of a docking asks for. + */ + readonly strip?: boolean; +} + +/** Plain text only: a label is words, so the markup cannot carry any. */ +const text = (s: string): string => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +export class ButtonStrip { + private readonly el: HTMLElement; + /** what the strip shows now, so a steady strip is not rebuilt */ + private shown = ''; + + constructor(el: HTMLElement) { + this.el = el; + } + + paint(buttons: readonly HudButton[]): void { + const html = buttons.map((b) => (b.strip + ? `
    ` + + `${text(b.label)}
    ` + : + `
    ` + + `${text(b.label)}${b.note ? `${text(b.note)}` : ''}` + + `${b.hint ? `${text(b.hint)}` : ''}
    `)).join(''); + if (html === this.shown) return; + this.shown = html; + this.el.innerHTML = html; + } +} diff --git a/src/hud/hud-model.ts b/src/hud/hud-model.ts index c06c60d5..5ee90a28 100644 --- a/src/hud/hud-model.ts +++ b/src/hud/hud-model.ts @@ -191,6 +191,8 @@ export function dockingAid( playerForward: THREE.Vector3, camera: THREE.Camera, scratch: { a: THREE.Vector3; b: THREE.Vector3; q: THREE.Quaternion }, + /** the slot's roll tolerance for whoever holds the stick — see `dockingOutcome` */ + rollTolerance: number, ): { dockAid: HudState['dockAid']; slotMarker: HudState['slotMarker'] } { const none = { dockAid: null, slotMarker: null }; const dist = playerPos.distanceTo(station.position); @@ -215,7 +217,7 @@ export function dockingAid( // channel and the roll tolerance. So the aid and the dock test could // disagree, and once the letterbox turned upright they did. const inSlot = inSlotChannel(local.x, local.y); - const rollOk = rollAlignedWithSlot(right.x, right.y); + const rollOk = rollAlignedWithSlot(right.x, right.y, rollTolerance); return { slotMarker, dockAid: { diff --git a/src/hud/hud.ts b/src/hud/hud.ts index 7af274ba..26305c37 100644 --- a/src/hud/hud.ts +++ b/src/hud/hud.ts @@ -1,4 +1,5 @@ import { elementById, fillWith } from '../engine/inert-dom.ts'; +import { ButtonStrip, type HudButton } from './hud-buttons.ts'; import * as THREE from 'three'; import type { StarSystem } from '../galaxy/galaxy.ts'; import { describeSystem } from '../galaxy/galaxy.ts'; @@ -8,6 +9,8 @@ import { SCANNER_RANGE, LASER_GAUGE_WARN, CABIN_GAUGE_WARN, } from '../constants/console.ts'; import { HUD } from '../palette.ts'; +import { SLOT_SPEED_LIMIT } from '../constants/docking.ts'; +import { PLAYER_FLIGHT } from '../constants/player-flight.ts'; // The classic console: elliptical 3D scanner (dot + vertical stick per // contact), station compass, gauge bars, and the message line. @@ -68,15 +71,26 @@ export interface HudState { messageText: string; messageTimer: number; /** - * What a key can do about what is happening right now, ALREADY RENDERED — - * each entry is the bound key and what it does ("L PAY 141.0 Cr"). - * - * Finished strings for the same reason `messageText` is one: the painter - * reads state and paints it. WHICH commands are worth offering is - * `game/prompts.ts`. Which letter each is bound to is `controls.ts` through - * `boundKey`. Neither is a question a painter may answer. + * The course buttons over the view (docs/TODO/205 M5): the list when the + * ship has no course, or the one course it flies. Finished strings, as the + * message is. Which courses exist is `game/courses.ts`. + */ + courses: readonly HudButton[]; + /** the course row under its header, while it is open (docs/TODO/215 M2) */ + courseRow: readonly HudButton[]; + /** + * The target header, bottom left. Finished, as the courses are. `targets` + * above is the marks over the ships; this is the header of the list. */ - prompts: readonly string[]; + targetList: readonly HudButton[]; + /** the target row above its header, while it is open (docs/TODO/215 M2) */ + targetRow: readonly HudButton[]; + /** + * The guns as buttons (docs/TODO/206 M3, docs/TODO/215 M1). They are the + * laser, the missile as two buttons, and the E.C.M., in a row at the + * bottom of the console. Finished, as the courses are. + */ + guns: readonly HudButton[]; speedFrac: number; rollFrac: number; // -1..1 pitchFrac: number; // -1..1 @@ -150,6 +164,12 @@ export interface HudState { missionMarker: { x: number; y: number; behind: boolean } | null; /** combat computer engaged (shown in the view label slot) */ assist: boolean; + /** the cloak runs, and the label says so before anything else (docs/TODO/219 M5) */ + cloaked: boolean; + /** the pilot flies the last stretch into the slot (docs/TODO/207) */ + trial: boolean; + /** ...and the rails have it, so the mini game is on (docs/TODO/212) */ + rails: boolean; /** missile armed but not yet locked (yellow pylon) */ armed: boolean; /** console 'S': the space station is within scanner range */ @@ -223,7 +243,6 @@ export class Hud { private readonly energyEl = byId('g-energy'); /** built on the first frame, from the bank count the frame brings */ private energySegs: HTMLElement[] = []; - private readonly missileEls: HTMLElement[]; private readonly lockEl = byId('lock'); private readonly indS = byId('ind-s'); private readonly indE = byId('ind-e'); @@ -231,9 +250,11 @@ export class Hud { private readonly creditsEl = byId('credits-display'); private readonly dayEl = byId('day-display'); private readonly messageEl = byId('message'); - private readonly promptsEl = byId('prompts'); - /** what the prompt line currently says, so a steady list is not repainted */ - private promptsShown = ''; + private readonly courseStrip = new ButtonStrip(byId('courses')); + private readonly courseRowStrip = new ButtonStrip(byId('course-row')); + private readonly targetStrip = new ButtonStrip(byId('targets')); + private readonly targetRowStrip = new ButtonStrip(byId('target-row')); + private readonly gunStrip = new ButtonStrip(byId('guns')); private readonly flashEl = byId('damage-flash'); private readonly exerciseEl = byId('exercise'); private readonly exScenarioEl = byId('ex-scenario'); @@ -246,10 +267,14 @@ export class Hud { private readonly invQ = new THREE.Quaternion(); constructor() { + // The mark on the speed bar: the fastest the slot will take, as a share of + // the ship's own top speed (docs/TODO/207 M3). It is placed from the two + // rules rather than written into the page. + byId('g-speed-limit').style.left = + `${(SLOT_SPEED_LIMIT / PLAYER_FLIGHT.maxSpeed) * 100}%`; this.scanner = (byId('scanner') as HTMLCanvasElement).getContext('2d')!; this.reticle = (byId('reticle') as HTMLCanvasElement).getContext('2d')!; this.compass = (byId('compass') as HTMLCanvasElement).getContext('2d')!; - this.missileEls = Array.from(byId('missiles').querySelectorAll('span')); } setSystem(system: StarSystem): void { @@ -265,7 +290,11 @@ export class Hud { render(_dt: number, frame: HudFrame): void { this.messageEl.textContent = frame.messageTimer > 0 ? frame.messageText : ''; - this.paintPrompts(frame.prompts); + this.courseStrip.paint(frame.courses); + this.courseRowStrip.paint(frame.courseRow); + this.targetStrip.paint(frame.targetList); + this.targetRowStrip.paint(frame.targetRow); + this.gunStrip.paint(frame.guns); 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}%`; @@ -277,16 +306,13 @@ export class Hud { this.altEl.style.width = `${Math.min(100, frame.altitudeFrac * 100)}%`; this.cabinEl.style.width = `${Math.min(100, frame.cabinTemp * 100)}%`; this.cabinEl.style.background = frame.cabinTemp > CABIN_GAUGE_WARN ? RED : ''; - this.viewEl.textContent = frame.assist ? '◆ COMBAT COMPUTER ◆' : (VIEW_NAMES[frame.view] ?? ''); + this.viewEl.textContent = frame.cloaked ? '◆ CLOAKED ◆' + : frame.rails ? '◆ THRUST IN, AND MATCH THE SLOT ◆' + : frame.trial ? '◆ THE COMPUTER IS LINING THE SHIP UP ◆' + : frame.assist ? '◆ THE COMPUTER IS AIMING ◆' : (VIEW_NAMES[frame.view] ?? ''); this.crosshairEl.style.display = frame.hasLaser ? '' : 'none'; this.shipIdEl.textContent = frame.shipId; this.drawEnergy(frame); - this.missileEls.forEach((m, i) => { - const active = i === frame.missiles - 1; - m.classList.toggle('spent', i >= frame.missiles); - m.classList.toggle('armed', frame.armed && active); - m.classList.toggle('locked', frame.locked && active); - }); this.indS.classList.toggle('lit', frame.stationInRange); this.indE.classList.toggle('lit-amber', frame.ecmDetected); this.lockEl.textContent = ''; // lock is shown by the bracket + missile pylon @@ -305,31 +331,6 @@ export class Hud { this.drawCompass(frame.playerPos, frame.playerQuat, frame.compassTarget); } - /** - * The prompt line: the keys worth pressing about what is happening. - * - * It is rebuilt only when the list CHANGES. This runs every frame, and the - * prompts are steady for seconds at a time. A patrol takes four and a half of - * them to cross its warning band. - * - * The key is separated from the words so the stylesheet can light it. That is - * the only reason this is markup rather than `textContent`. The strings - * themselves are built upstream, and never here. - */ - 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; - this.promptsEl.innerHTML = prompts - .map((p) => { - const gap = p.indexOf(' '); - const key = gap < 0 ? p : p.slice(0, gap); - const what = gap < 0 ? '' : p.slice(gap); - return `${key}${what}`; - }) - .join(' '); - } - /** * The energy gauge: one bank per segment, and red once you are into the last. * 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); } diff --git a/src/main.ts b/src/main.ts index 4cfe2b94..0e4b4131 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,11 +3,15 @@ import { Game } from './game/game.ts'; import { browserShell } from './engine/browser-shell.ts'; import { setCoronaTextureFactory } from './world/sun.ts'; import { createCoronaTexture } from './world/corona-texture.ts'; +import { askFullscreen, holdZoom } from './engine/phone-chrome.ts'; // The one place a canvas becomes a game. // // Everything platform-bound lives inside `browserShell`; `Game` itself names no // DOM API at all — see engine/shell.ts for why that seam is where it is. setCoronaTextureFactory(createCoronaTexture); +// A phone keeps its zoom and loses its address bar (docs/TODO/220). +holdZoom(); +askFullscreen(); const canvas = document.getElementById('scene') as HTMLCanvasElement; new Game((scene: THREE.Scene) => browserShell(canvas, scene)); diff --git a/src/manual.ts b/src/manual.ts index 66eae2ed..e070e8c0 100644 --- a/src/manual.ts +++ b/src/manual.ts @@ -61,8 +61,8 @@ for (const el of document.querySelectorAll('[data-bound-key]')) { } // The combat ladder, from the same table `rating()` reads. It was hand-written -// here once, and it silently dropped BELOW AVERAGE. A commander could read her -// own rating off the status screen, and not find it on the chart. +// here once, and it silently dropped BELOW AVERAGE. A commander could read +// their own rating off the status screen, and not find it on the chart. const ladder = document.getElementById('rating-ladder'); if (ladder) { const ranks = ratingLadder(); diff --git a/src/missions/dossiers.ts b/src/missions/dossiers.ts index 94e47670..efb94614 100644 --- a/src/missions/dossiers.ts +++ b/src/missions/dossiers.ts @@ -9,7 +9,8 @@ import type { StarSystem } from '../galaxy/galaxy.ts'; import { DOSSIER_FILES } from './dossiers/index.ts'; -import type { CommanderFacts, Dossier, DossierWord } from './model.ts'; +import type { CommanderFacts, DossierWord } from './model.ts'; +import type { Dossier } from './words.ts'; import { patronFor } from './patrons.ts'; import { skeletonById } from './skeletons/index.ts'; import { fillSlots } from './text.ts'; diff --git a/src/missions/dossiers/arc-edle.json b/src/missions/dossiers/arc-edle.json index 2b9bb47b..4ac2f05a 100644 --- a/src/missions/dossiers/arc-edle.json +++ b/src/missions/dossiers/arc-edle.json @@ -1,79 +1,79 @@ { - "promptVersion": 3, + "promptVersion": 4, "model": "claude-sonnet-5 via claude -p", - "generated": "2026-09-07", + "generated": "2026-09-13", "usage": { - "requests": 21, - "inputTokens": 164690, - "outputTokens": 100025 + "requests": 20, + "inputTokens": 175941, + "outputTokens": 98405 }, "dossier": { "briefing": [ - "Manifest first: sealed transport crates, state guard chain of custody, no exceptions. This is {PATRON}, colonel of the state guard at {HERE}.", - "You carry the manifest to the drop point, you see the transporter through to safe range after, and you finish the Asp marked for the guard's justice. The order does not change once given.", - "Follow the order as given. The state pays for compliance, not for good intentions." + "One manifest, one Transporter, one Asp Mk II marked for destruction. Carry the first, guard the second, finish the third.", + "I am {PATRON}, colonel of the state guard at {HERE}. The state pays for a job finished, not for one attempted.", + "Take the contract or leave the dock. I will not ask twice." ], + "lead": "{PATRON} has orders waiting at {WORLD}, and no patience for a pilot who lingers.", "legs": { "manifest": { - "arrive": "Take the manifest to {TARGET} and collect {PAY} on delivery", - "success": "Manifest delivered to {TARGET}; the state credits {PAY} at once", - "fail": "Manifest undelivered at {TARGET}; the state voids {PAY} and the contract" + "arrive": "Take the manifest to {TARGET}. Payment on delivery: {PAY}", + "success": "Manifest delivered to {TARGET}. Payment cleared: {PAY}", + "fail": "The manifest failed to reach {TARGET}. No payment" }, "guard": { - "arrive": "See the transporter into station range at {TARGET} for {PAY}", - "success": "Transporter secured at {TARGET}; the state holds {PAY} ready", - "fail": "Transporter lost before {TARGET}; the state withdraws {PAY}" + "arrive": "Escort the Transporter to {TARGET}. Fee: {PAY}", + "success": "The Transporter reached {TARGET} under escort", + "fail": "The escort failed before {TARGET}. Contract closed" }, "wreck": { - "arrive": "Scoop the transporter's cargo adrift at {TARGET} for {PAY}", - "success": "Cargo recovered at {TARGET}; the state keeps {PAY} on the books", - "fail": "Cargo unrecovered at {TARGET}; the state cancels {PAY}" + "arrive": "Scoop the Transporter's wreckage adrift at {TARGET} for {PAY}", + "success": "Cargo recovered from the wreck at {TARGET}", + "fail": "The wreck at {TARGET} was lost. No recovery" }, "purge": { - "arrive": "Destroy the Asp last seen at {TARGET} and collect {PAY}", - "success": "Asp accounted for near {TARGET}; the state closes with {PAY}", - "fail": "Asp unaccounted near {TARGET}; the state cancels {PAY}" + "arrive": "Find and destroy the Asp last seen at {TARGET} for {PAY}", + "success": "The Asp is dealt with. File closed", + "fail": "The Asp slipped free of {TARGET}. Contract closed" } }, - "lead": "A tight-band signal from {PATRON} reaches the commander, routed through {WORLD}.", + "title": "Colonel Adalwyn's Convoy", "rumour": { - "far": "Traders mutter that {PATRON}'s state guard at {WORLD} still wants a courier for a live contract.", - "near": "{PATRON} sends word direct: the manifest waits at {WORLD}, no excuses." + "far": "Word on the board says the state guard at {WORLD} is hiring pilots for the colonel's convoy.", + "near": "{PATRON} sends word: report to {WORLD} before the manifest waits for someone else." }, - "news": "State guard postings at {WORLD} confirm {PATRON} is hiring a pilot for an active contract.", + "news": "The state guard at {WORLD} confirms {PATRON} is contracting pilots for convoy duty.", "story": { - "opening": "The commander took the colonel's contract at {WORLD} on day {DAY}.", + "opening": "On day {DAY} the commander took the colonel's contract at {WORLD}.", "closing": { - "complete": "The commander closed the colonel's contract out of {WORLD} on day {DAY}.", - "fail": "The commander's contract with {WORLD} collapsed on day {DAY}." + "complete": "The Asp fell silent near {WORLD}, and the colonel's contract closed clean on day {DAY}.", + "fail": "The contract collapsed near {WORLD} on day {DAY}, and the colonel filed no thanks." }, "legs": { "manifest": { - "success": "On day {DAY}, the commander delivered the manifest to {WORLD}.", - "failed": "On day {DAY}, the commander failed to deliver the manifest to {WORLD}." + "success": "The manifest reached {WORLD} on day {DAY}, delivered as ordered.", + "failed": "The manifest never reached {WORLD}; the deadline broke on day {DAY}." }, "guard": { - "success": "On day {DAY}, the commander guided the transporter safely into {WORLD}.", - "targetDestroyed": "On day {DAY}, the transporter was destroyed short of {WORLD}.", - "targetEscaped": "On day {DAY}, the transporter jumped clear of {WORLD}.", - "failed": "On day {DAY}, the commander lost the transporter before {WORLD}." + "success": "The Transporter reached station range at {WORLD} on day {DAY}, the escort held.", + "targetDestroyed": "The Transporter was destroyed near {WORLD} on day {DAY}, the escort broken.", + "targetEscaped": "The Transporter jumped clear of {WORLD} on day {DAY}, lost from the escort's sight.", + "failed": "The escort collapsed near {WORLD}; the deadline broke on day {DAY}." }, "wreck": { - "success": "On day {DAY}, the commander scooped the transporter's cargo near {WORLD}.", - "targetDestroyed": "On day {DAY}, the transporter's cargo was destroyed before {WORLD}.", - "failed": "On day {DAY}, the commander failed to recover the cargo near {WORLD}." + "success": "The commander scooped the Transporter's cargo adrift at {WORLD} on day {DAY}.", + "targetDestroyed": "The canister was destroyed before the scoop near {WORLD} on day {DAY}.", + "failed": "The wreck at {WORLD} slipped away before day {DAY} closed the deadline." }, "purge": { - "targetDestroyed": "On day {DAY}, the commander destroyed the Asp near {WORLD}.", - "targetEscaped": "On day {DAY}, the Asp escaped near {WORLD}.", - "targetFled": "On day {DAY}, the Asp fled before {WORLD}.", - "failed": "On day {DAY}, the commander failed to run down the Asp near {WORLD}." + "targetDestroyed": "The Asp Mk II was destroyed near {WORLD} on day {DAY}, the colonel's order done.", + "targetEscaped": "The Asp Mk II jumped clear of {WORLD} on day {DAY}, unfinished business for the state.", + "targetFled": "The Asp Mk II fled {WORLD} on day {DAY}, running rather than fighting.", + "failed": "The hunt for the Asp died near {WORLD}; the deadline broke on day {DAY}." } } }, - "title": "The Colonel's Reckoning", "skeleton": "arc-edle", - "hash": "6a37e590", + "hash": "bb0350de", "images": {} } } diff --git a/src/missions/dossiers/arc-lave.json b/src/missions/dossiers/arc-lave.json index 20190b14..c4cfa12b 100644 --- a/src/missions/dossiers/arc-lave.json +++ b/src/missions/dossiers/arc-lave.json @@ -1,70 +1,70 @@ { - "promptVersion": 3, + "promptVersion": 4, "model": "claude-sonnet-5 via claude -p", - "generated": "2026-09-07", + "generated": "2026-09-13", "usage": { - "requests": 21, - "inputTokens": 164690, - "outputTokens": 100025 + "requests": 20, + "inputTokens": 175941, + "outputTokens": 98405 }, "dossier": { "briefing": [ - "{PATRON} counts every sack of tree grub that leaves {HERE}, and the count does not lie.", - "The export ledger went missing off a courier's hull, adrift somewhere out past the routes, and a smuggler has it now.", - "Recover the canister, mark the smuggler for destruction, and {PATRON} settles the fee to the credit." + "The tree grub yield off {HERE} holds at full quota, but one export ledger canister never arrived. A smuggler has it, and the account will not close without it.", + "I am {PATRON}, governor of {HERE}. Recover the canister, confirm the Cobra that took it, then finish that Cobra.", + "Pay clears leg by leg, on delivery only. Treat this as a contract, not a favour." ], "legs": { "ledger": { - "arrive": "Scoop the ledger canister adrift near {TARGET}, and earn {PAY}", - "success": "The canister is recovered near {TARGET}, and {PAY} is credited", - "fail": "The canister is gone from {TARGET}, and {PAY} stays unearned" + "arrive": "Scoop the ledger canister adrift at {TARGET}.", + "success": "The canister is aboard, and {PAY} is logged for {TARGET}.", + "fail": "The ledger canister at {TARGET} is lost, and {PAY} is void." }, "witness": { - "arrive": "Hold the smuggler's Cobra on your scanner at {TARGET}, unarmed, for {PAY}", - "success": "The Cobra stayed in view near {TARGET}, and {PAY} is credited", - "fail": "The Cobra slipped the scan at {TARGET}, and {PAY} stays unearned" + "arrive": "Hold the partner's Cobra at {TARGET} on your scanner.", + "success": "Twenty seconds held at {TARGET}, and {PAY} stands confirmed.", + "fail": "The Cobra slipped the scan at {TARGET}, and {PAY} is void." }, "runner": { - "arrive": "Find the smuggler's Cobra near {TARGET}, and finish the contract for {PAY}", - "success": "The Cobra is finished near {TARGET}, and {PAY} is paid in full", - "fail": "The Cobra is gone from {TARGET}, and {PAY} stays unpaid" + "arrive": "Destroy the smuggler's Cobra, last seen at {TARGET}.", + "success": "The Cobra is down near {TARGET}, and {PAY} is paid in full.", + "fail": "The trail at {TARGET} went cold, and {PAY} is void." } }, - "lead": "The commander hears that {PATRON} wants a word, waiting on {WORLD}.", + "lead": "{PATRON} left word at {WORLD}, asking the commander to check in.", "rumour": { - "far": "Word on the boards says {PATRON} lost a ledger to a smuggler out past {WORLD}.", - "near": "{PATRON} sends word from {WORLD}, wanting the ledger back and the smuggler downed." + "far": "Word from {WORLD} says {PATRON} is short one grub ledger, and none too patient about it.", + "near": "{PATRON} sends word from {WORLD}: the ledger is late, and the clock is running." }, - "news": "{PATRON} still needs a pilot to settle the missing ledger at {WORLD}.", - "title": "The Governor's Missing Ledger", + "news": "{WORLD} reports that {PATRON} is signing pilots for ledger recovery work.", "story": { - "opening": "On day {DAY} the commander took the governor's contract at {WORLD}, to chase down a stolen ledger.", + "opening": "On day {DAY}, the commander took a governor's contract to find a missing grub ledger.", "closing": { - "complete": "By day {DAY} the smuggler's Cobra was finished, and the commander's business at {WORLD} was done.", - "fail": "By day {DAY} the contract lapsed, and the commander left {WORLD} with nothing to show for it." + "complete": "By day {DAY}, near {WORLD}, the smuggler's trail ended, and the ledger case closed.", + "fail": "By day {DAY}, near {WORLD}, the ledger case went cold, and the smuggler got away clean." }, "legs": { "ledger": { - "success": "At {WORLD} on day {DAY}, the commander scooped the ledger canister clean.", - "targetDestroyed": "At {WORLD} on day {DAY}, the ledger canister was destroyed before it could be recovered.", - "failed": "At {WORLD} on day {DAY}, the deadline passed and the ledger was lost for good." + "success": "At {WORLD} on day {DAY}, the commander scooped the ledger canister intact.", + "targetDestroyed": "At {WORLD} on day {DAY}, the ledger canister broke apart before the commander could reach it.", + "failed": "By day {DAY}, near {WORLD}, the ledger recovery ran past its deadline." }, "witness": { - "success": "At {WORLD} on day {DAY}, the commander held the smuggler's Cobra on the scanner, unarmed.", - "targetDestroyed": "At {WORLD} on day {DAY}, the watched Cobra was destroyed before the watch ended.", - "targetEscaped": "At {WORLD} on day {DAY}, the watched Cobra jumped clear before the watch ended.", - "failed": "At {WORLD} on day {DAY}, the watch lapsed and the Cobra slipped away." + "success": "At {WORLD} on day {DAY}, the commander held the smuggler's Cobra on the scanner for the full count.", + "targetDestroyed": "At {WORLD} on day {DAY}, another gun destroyed the watched Cobra before the count ran out.", + "targetEscaped": "At {WORLD} on day {DAY}, the watched Cobra jumped clear before the count ran out.", + "failed": "By day {DAY}, near {WORLD}, the watch on the Cobra lapsed past its deadline." }, "runner": { "targetDestroyed": "At {WORLD} on day {DAY}, the commander destroyed the smuggler's Cobra.", "targetEscaped": "At {WORLD} on day {DAY}, the smuggler's Cobra jumped clear of the commander.", "targetFled": "At {WORLD} on day {DAY}, the smuggler's Cobra fled the commander's guns.", - "failed": "At {WORLD} on day {DAY}, the deadline passed and the smuggler's Cobra was never found." + "failed": "By day {DAY}, near {WORLD}, the hunt for the smuggler's Cobra ran past its deadline." } } }, + "title": "The Grub Ledger", "skeleton": "arc-lave", - "hash": "b45b8131", + "hash": "6e604caf", "images": {} } } diff --git a/src/missions/dossiers/arc-rabedira.json b/src/missions/dossiers/arc-rabedira.json index 9cf0501b..a0025f8d 100644 --- a/src/missions/dossiers/arc-rabedira.json +++ b/src/missions/dossiers/arc-rabedira.json @@ -1,68 +1,68 @@ { - "promptVersion": 3, + "promptVersion": 4, "model": "claude-sonnet-5 via claude -p", - "generated": "2026-09-07", + "generated": "2026-09-13", "usage": { - "requests": 2, - "inputTokens": 16803, - "outputTokens": 11708 + "requests": 20, + "inputTokens": 175941, + "outputTokens": 98405 }, "dossier": { "briefing": [ - "Friend of the cause, well met on {HERE}. I am {PATRON}, and before I name a single fee, I promise you my gratitude runs deep and does not fade.", - "A truce paper must reach a neighbour world before the old quarrel flares again, and then you must see me safely to the meeting itself, aboard my Boa.", - "Do this and you will find {PATRON} generous past the fee alone: favours owed, doors opened, and a friend at {HERE} for as long as you fly." + "{PATRON} is glad you found your way to {HERE}, friend of the cause -- truly glad, and grateful before the terms are even spoken.", + "A truce paper is sealed and waiting. Carry it to a neighbour before old wounds reopen, then stay close: the envoy who signs it will need a steady ship home, and it should be yours.", + "Do this well, and {PATRON} will not forget it: fair payment first, then favour with the cause, then whatever door at {HERE} you find shut will open for you next." ], - "lead": "Word comes that {PATRON} wants a word with her at {WORLD}.", "legs": { "paper": { - "arrive": "Take the truce paper to {TARGET} for {PAY}", - "success": "The paper reached {TARGET} and earned {PAY}", - "fail": "The paper never reached {TARGET}, and {PAY} stays unpaid" + "arrive": "Get the truce paper to {TARGET} before the offer sours", + "success": "The truce paper reached {TARGET}, and the peace holds a little longer for {PAY}", + "fail": "The truce paper never reached {TARGET}, and the {PAY} promised dies with the peace" }, "convoy": { - "arrive": "See my Boa safely into range at {TARGET} for {PAY}", - "success": "The Boa reached {TARGET} safely, and {PAY} is yours", - "fail": "The Boa never reached {TARGET}, and {PAY} goes unpaid" + "arrive": "Fly at the Boa's side and see it safely into range at {TARGET}", + "success": "The Boa reached {TARGET} unharmed, and {PAY} lands in your account", + "fail": "The Boa never reached {TARGET}, and the {PAY} goes unpaid" }, "salvage": { - "arrive": "Scoop the strongbox adrift near {TARGET} for {PAY}", + "arrive": "Scoop the strongbox adrift at {TARGET} before someone else claims it", "success": "The strongbox came aboard at {TARGET}, and {PAY} is yours", - "fail": "The strongbox slipped away near {TARGET}, and {PAY} goes unpaid" + "fail": "The strongbox slipped away near {TARGET}, and {PAY} stays out of reach" } }, + "lead": "{PATRON} sends word from {WORLD}, warm as ever, asking for a moment of the commander's time.", "rumour": { - "far": "Traders say {PATRON} at {WORLD} is buying goodwill with fine words and finer pay.", - "near": "{PATRON} sends word from {WORLD}: the truce paper waits, friend, and so does the fee." + "far": "Talk on the board says an envoy at {WORLD} is buying goodwill with promises no one can spend.", + "near": "{PATRON} sends assurance from {WORLD}: friend of the cause, your errand is remembered and multiplied." }, - "news": "News circles say {PATRON} at {WORLD} still seeks a pilot willing to fly a truce.", - "title": "The Envoy's Passage", + "news": "{PATRON} is said to be greeting every docking pilot at {WORLD} as an old friend, with an errand ready for each one.", + "title": "Friend of the Cause", "story": { - "opening": "On day {DAY}, she took the envoy's job at {WORLD}.", + "opening": "The commander took the envoy's charge on day {DAY}, bound for {WORLD} with a truce paper and no assurances.", "closing": { - "complete": "By day {DAY}, the mission was complete, and {WORLD} lay behind her.", - "fail": "By day {DAY}, the mission had failed, and {WORLD} lay behind her." + "complete": "By day {DAY}, the truce paper had done its work, and the envoy reached {WORLD} unharmed.", + "fail": "By day {DAY}, the truce had come to nothing, and {WORLD} kept no memory of the promise." }, "legs": { "paper": { - "success": "On day {DAY}, the truce paper reached {WORLD} safely.", - "failed": "On day {DAY}, the truce paper never reached {WORLD}." + "success": "On day {DAY}, the truce paper reached {WORLD}, and the peace bought one more chance.", + "failed": "On day {DAY}, the truce paper never reached {WORLD}, and the peace collapsed unheard." }, "convoy": { - "success": "On day {DAY}, the Boa reached {WORLD} safely.", - "targetDestroyed": "On day {DAY}, the Boa was destroyed short of {WORLD}.", - "targetEscaped": "On day {DAY}, the Boa jumped away before {WORLD}.", - "failed": "On day {DAY}, the escort failed short of {WORLD}." + "success": "On day {DAY}, the Boa reached {WORLD} whole, and the envoy's cause held together.", + "targetDestroyed": "On day {DAY}, the Boa was destroyed short of {WORLD}, and only a strongbox remained to recover.", + "targetEscaped": "On day {DAY}, the Boa vanished into a jump short of {WORLD}, and the escort ended there.", + "failed": "On day {DAY}, the escort broke down before {WORLD}, and the Boa was left to its fate." }, "salvage": { - "success": "On day {DAY}, the strongbox came aboard at {WORLD}.", - "targetDestroyed": "On day {DAY}, the strongbox was destroyed before the scoop at {WORLD}.", - "failed": "On day {DAY}, the strongbox was lost near {WORLD}." + "success": "On day {DAY}, the strongbox was scooped safe near {WORLD}, and the cause salvaged what it could.", + "targetDestroyed": "On day {DAY}, the strongbox was destroyed before it could be reached near {WORLD}, and nothing survived the loss.", + "failed": "On day {DAY}, the strongbox drifted out of reach near {WORLD}, and the salvage came to nothing." } } }, "skeleton": "arc-rabedira", - "hash": "1d4e1a9f", + "hash": "07e31f89", "images": {} } } diff --git a/src/missions/dossiers/arc-vetitice.json b/src/missions/dossiers/arc-vetitice.json index b0ce031c..9f1c3906 100644 --- a/src/missions/dossiers/arc-vetitice.json +++ b/src/missions/dossiers/arc-vetitice.json @@ -1,76 +1,75 @@ { - "promptVersion": 3, + "promptVersion": 4, "model": "claude-sonnet-5 via claude -p", - "generated": "2026-09-07", + "generated": "2026-09-13", "usage": { - "requests": 21, - "inputTokens": 164690, - "outputTokens": 100025 + "requests": 20, + "inputTokens": 175941, + "outputTokens": 98405 }, "dossier": { "briefing": [ - "The quota stands, commander. Pirates hold the lane our shipment must cross, and the collective needs it clear.", - "Clear the lane, recover the surveyor's pod, and land the survivor alive. From there, carry the manifest onward, and the collective's contract closes.", - "This is {PATRON}, planning director at {HERE}. The collective states each term once, and confirms only that a contract stands fulfilled." + "We have a quota to meet. Pirates hold the lane the collective's shipment must cross, and the schedule at {HERE} does not bend for them.", + "Clear the lane, bring the surveyor in alive, then carry the manifest on to its next hand. Each term stands as stated; meet it, and the contract is fulfilled." ], - "lead": "A tight-band signal from {PATRON} at {WORLD} says the collective wants a word.", + "lead": "Word reaches the commander that {PATRON} at {WORLD} wants the lane cleared before the quota slips.", "rumour": { - "far": "Bulletin boards near {WORLD} say {PATRON} still needs a lane cleared for the collective's quota.", - "near": "{PATRON} sends word from {WORLD}: the collective's quota still waits on that lane." + "far": "Traders say pirates have shut the lane into {WORLD}, and {PATRON} is hiring a pilot to reopen it.", + "near": "{PATRON} sends word that the lane to {WORLD} stays closed until a pilot clears it." }, - "news": "{PATRON} confirms the collective at {WORLD} still needs pilots for its quota work.", + "news": "At {WORLD}, {PATRON} is recruiting a pilot to break the pirate hold on the shipping lane.", "title": "The Vetitice Quota", "legs": { "lane": { - "arrive": "Fly the lane to {TARGET} through whatever waits, and dock, for {PAY}", - "success": "The lane opened, and {TARGET} took the shipment through for {PAY}", - "fail": "The lane held the pirates, and the shipment never reached {TARGET}" + "arrive": "Fly the lane to {TARGET} through whatever holds it, and dock", + "success": "The lane is clear, and the shipment moves on for {PAY}", + "fail": "The lane held firm, and {PAY} stays unpaid" }, "pod": { - "arrive": "Scoop the surveyor's pod at {TARGET}, and land the survivor alive, for {PAY}", - "success": "The pod business at {TARGET} closes, and the collective's contract holds for {PAY}", - "fail": "The pod business at {TARGET} fails, and the contract lapses" + "arrive": "Scoop the surveyor's pod at {TARGET}, and land the passenger alive", + "success": "The surveyor lands alive, and the collective credits {PAY}", + "fail": "The surveyor did not reach dock alive, and {PAY} is void" }, "route": { - "arrive": "Bring the surveyor's route back to {TARGET}, and dock, for {PAY}", - "success": "The route reached {TARGET}, and the collective banks {PAY}", - "fail": "The route never reached {TARGET}, and the contract lapses" + "arrive": "Bring the surveyor's route back to {TARGET}, and dock", + "success": "The route reaches {TARGET}, and the collective pays {PAY}", + "fail": "The route never reached {TARGET}, and {PAY} is withheld" }, "manifest": { - "arrive": "Deliver the manifest to {TARGET}, and dock, for {PAY}", - "success": "The manifest reached {TARGET}, and the collective pays {PAY}", - "fail": "The manifest never reached {TARGET}, and the quota fails" + "arrive": "Deliver the manifest to {TARGET}, and close out the contract", + "success": "The manifest reaches {TARGET}, and the collective settles {PAY} in full", + "fail": "The manifest never reached {TARGET}, and the collective withholds {PAY}" } }, "story": { - "opening": "The commander took the collective's contract at {WORLD} on day {DAY}.", + "opening": "On day {DAY}, the commander took the collective's contract to break the pirate hold on the lane.", "closing": { - "complete": "The collective's manifest reached its stop, and the contract closed at {WORLD} on day {DAY}.", - "fail": "The collective's contract lapsed, and it ended at {WORLD} on day {DAY}." + "complete": "The manifest reached {WORLD} on day {DAY}, and the collective's quota was met.", + "fail": "By day {DAY}, the contract had collapsed short of {WORLD}, and the collective's quota went unmet." }, "legs": { "lane": { - "success": "The lane opened, and the shipment passed through to {WORLD} on day {DAY}.", - "failed": "The lane held the pirates, and the shipment never reached {WORLD} by day {DAY}." + "success": "On day {DAY}, the commander broke the pirate hold on the lane and docked at {WORLD}.", + "failed": "By day {DAY}, the lane to {WORLD} stayed closed, and the contract failed." }, "pod": { - "survivor:landed": "The survivor was landed alive at {WORLD} on day {DAY}.", - "survivor:sold": "The survivor was sold, and the collective's contract lapsed on day {DAY}.", - "targetDestroyed": "The pod was destroyed before the scoop, and the run turned back toward {WORLD} on day {DAY}.", - "failed": "The pod was never reached, and the contract lapsed by day {DAY}." + "survivor:landed": "On day {DAY}, the commander landed the surveyor alive at {WORLD}.", + "survivor:sold": "By day {DAY}, the surveyor had been sold instead of landed, and the contract failed.", + "targetDestroyed": "On day {DAY}, the surveyor's pod was destroyed before the commander reached {WORLD}.", + "failed": "By day {DAY}, the pod near {WORLD} was never scooped, and the contract failed." }, "route": { - "success": "The route closed at {WORLD} on day {DAY}.", - "failed": "The route never closed, and the contract lapsed by day {DAY}." + "success": "On day {DAY}, the commander carried the surveyor's route back to {WORLD}.", + "failed": "By day {DAY}, the route never reached {WORLD}, and the contract failed." }, "manifest": { - "success": "The manifest reached {WORLD}, and the collective's quota closed on day {DAY}.", - "failed": "The manifest never reached {WORLD}, and the quota went unmet by day {DAY}." + "success": "On day {DAY}, the commander delivered the manifest to {WORLD}.", + "failed": "By day {DAY}, the manifest never reached {WORLD}, and the contract failed." } } }, "skeleton": "arc-vetitice", - "hash": "7285f523", + "hash": "fe7d0d81", "images": {} } } diff --git a/src/missions/dossiers/arc-xeer.json b/src/missions/dossiers/arc-xeer.json index 305e8e66..042fcecc 100644 --- a/src/missions/dossiers/arc-xeer.json +++ b/src/missions/dossiers/arc-xeer.json @@ -1,69 +1,69 @@ { - "promptVersion": 3, + "promptVersion": 4, "model": "claude-sonnet-5 via claude -p", - "generated": "2026-09-07", + "generated": "2026-09-13", "usage": { - "requests": 21, - "inputTokens": 164690, - "outputTokens": 100025 + "requests": 20, + "inputTokens": 175941, + "outputTokens": 98405 }, "dossier": { "briefing": [ - "{PATRON} here, port administrator of {HERE}. The docking fee is due, and the harvest quota is short again.", - "An Anaconda is forging the harvest through the manifests, and it drags this port's numbers down with it. Hold it on your scanner — do not fire, I need it watched, not wrecked.", - "When it moves on, find the Fer-de-Lance that carries the false papers and end the run. A taxpayer with a berth expects results, not excuses." + "Your docking fee is paid and your quota is logged, so now you can listen. I am {PATRON}, port administrator of {HERE}, and my harvest numbers do not match my manifests.", + "An Anaconda is out there forging the count, so you will hold it on your scanner for twenty seconds and you will not fire a shot. Once it moves on, a Fer-de-Lance carries the false papers onward, and you will run it down.", + "I do not want excuses and I do not want a hero. I want the harvest counted straight and my queue moving again." ], "legs": { "watch": { - "arrive": "Hold the Anaconda at {TARGET} on your scanner, and hold your fire", - "success": "The Anaconda is marked, and the payout climbs by {PAY}", - "fail": "The Anaconda slipped away, and {PAY} slipped with it" + "arrive": "Hold the Anaconda at {TARGET} on your scanner and do not fire, for {PAY}.", + "success": "The Anaconda held on your scanner the full twenty seconds, clean and unfired.", + "fail": "The Anaconda broke off your scanner before the twenty seconds ran out." }, "chase": { - "arrive": "Destroy the Fer-de-Lance, last seen at {TARGET}", - "success": "The Fer-de-Lance is run down, and {PAY} is closer", - "fail": "The Fer-de-Lance broke off pursuit, and the case goes cold" + "arrive": "Destroy the Fer-de-Lance last seen at {TARGET}, for {PAY}.", + "success": "The Fer-de-Lance went down at {TARGET}, papers and all, for {PAY}.", + "fail": "The Fer-de-Lance slipped the hunt near {TARGET}, papers still running." }, "trail": { - "arrive": "Report the courier's trail at {TARGET}", - "success": "The trail closes at {TARGET}, and {PAY} is paid in full", - "fail": "The trail went cold, and {PAY} stays unpaid" + "arrive": "Dock at {TARGET} and report the courier's trail, for {PAY}.", + "success": "Docking at {TARGET} closed the courier's trail for good, for {PAY}.", + "fail": "The trail went cold before you reached {TARGET}." } }, - "lead": "A tightbeam from {WORLD} carries {PATRON}'s demand for a word, sharp and unfriendly.", + "lead": "{PATRON} has a message waiting at {WORLD}, and it is not friendly small talk.", "rumour": { - "far": "Traders near {WORLD} mutter that {PATRON} is hunting forged harvest papers again.", - "near": "{PATRON} sends word from {WORLD} that the forger is still loose and time is short." + "far": "Word on the boards says {PATRON} at {WORLD} is hunting a forged harvest count.", + "near": "{PATRON} sends word from {WORLD}: the ledger still does not balance, come in." }, - "news": "Port records at {WORLD} show {PATRON} still short of pilots for the harvest audit.", + "news": "Port administrator {PATRON} at {WORLD} is offering work to any pilot who can settle a harvest dispute.", "story": { - "opening": "On day {DAY}, the commander took the port administrator's contract for {WORLD}.", + "opening": "On day {DAY}, the commander took the port administrator's harvest case at {WORLD}.", "closing": { - "complete": "By day {DAY}, {WORLD} had its proof, and the courier's run was over.", - "fail": "By day {DAY}, {WORLD} still waited on proof that never came." + "complete": "By day {DAY}, the harvest case out of {WORLD} closed clean, the papers accounted for.", + "fail": "By day {DAY}, the harvest case out of {WORLD} went cold, unresolved." }, "legs": { "watch": { - "success": "On day {DAY}, the commander held the Anaconda steady on the scanner over {WORLD}.", - "targetDestroyed": "On day {DAY}, the Anaconda was destroyed over {WORLD} before the watch was done.", - "targetEscaped": "On day {DAY}, the Anaconda jumped clear of {WORLD} before the watch was done.", - "failed": "On day {DAY}, the watch over {WORLD} lapsed, and the trail was lost." + "success": "On day {DAY}, the commander held the Anaconda on the scanner near {WORLD}, unfired.", + "targetDestroyed": "On day {DAY}, the watched Anaconda was destroyed near {WORLD} before the scan finished.", + "targetEscaped": "On day {DAY}, the Anaconda jumped clear of {WORLD} mid-watch.", + "failed": "On day {DAY}, the watch on the Anaconda at {WORLD} lapsed past its deadline." }, "chase": { - "targetDestroyed": "On day {DAY}, the commander destroyed the Fer-de-Lance over {WORLD}.", - "targetEscaped": "On day {DAY}, the Fer-de-Lance escaped {WORLD}, and the chase moved on.", - "targetFled": "On day {DAY}, the Fer-de-Lance fled {WORLD} rather than fight.", - "failed": "On day {DAY}, the chase past {WORLD} ran out of time." + "targetDestroyed": "On day {DAY}, the commander destroyed the Fer-de-Lance near {WORLD}.", + "targetEscaped": "On day {DAY}, the Fer-de-Lance jumped clear of {WORLD} before the commander closed in.", + "targetFled": "On day {DAY}, the Fer-de-Lance fled {WORLD} rather than stand and fight.", + "failed": "On day {DAY}, the chase for the Fer-de-Lance out of {WORLD} ran past its deadline." }, "trail": { "success": "On day {DAY}, the commander docked at {WORLD} and closed the courier's trail.", - "failed": "On day {DAY}, the trail past {WORLD} went cold." + "failed": "On day {DAY}, the courier's trail out of {WORLD} went cold." } } }, - "title": "The Forged Harvest", + "title": "The Harvest Ledger", "skeleton": "arc-xeer", - "hash": "ceb4f75d", + "hash": "61904f5b", "images": {} } } diff --git a/src/missions/dossiers/constrictor.json b/src/missions/dossiers/constrictor.json index b61d6270..8faf0958 100644 --- a/src/missions/dossiers/constrictor.json +++ b/src/missions/dossiers/constrictor.json @@ -1,64 +1,65 @@ { - "promptVersion": 3, + "promptVersion": 4, "model": "claude-sonnet-5 via claude -p", - "generated": "2026-09-07", + "generated": "2026-09-13", "usage": { - "requests": 21, - "inputTokens": 164690, - "outputTokens": 100025 + "requests": 20, + "inputTokens": 175941, + "outputTokens": 98405 }, "dossier": { "briefing": [ - "Commander, a prototype hull was stolen from a Navy yard, and command wants it destroyed, not recovered.", - "Accept the contract and you receive its last known position, then report to any station for further orders." + "Commander. A prototype hull was stolen from a Navy yard. Designation: Constrictor.", + "Command does not want it recovered. Command wants it destroyed, and the drive plans inside it burned with it.", + "Accept, and you receive its last known position. Confirm the kill, then stand by for further orders." ], - "lead": "A fresh Navy transmission awaits her attention.", + "lead": "A cold Navy channel opens near {WORLD}, and it wants the commander's answer.", "legs": { "hunt": { - "arrive": "Find and destroy the Constrictor last seen at {TARGET}", - "success": "The Constrictor is destroyed and {PAY} is confirmed", - "fail": "The Constrictor escaped and the operation failed" + "arrive": "Find and destroy the Constrictor near {TARGET}, fee {PAY}", + "success": "The Constrictor is wreckage, {PAY} confirmed", + "fail": "The Constrictor escaped the search near {TARGET}, no {PAY}" }, "report": { - "arrive": "Dock at any station and await further orders", - "success": "Contact made, the plans are ready for courier", - "fail": "Contact lost and command lost patience" + "arrive": "Report to any station for further orders, {PAY} pending", + "success": "Orders received, {PAY} still pending", + "fail": "No station reported in time, {PAY} lost" }, "courier": { - "arrive": "Deliver the plans to {TARGET} for {PAY}", - "success": "The plans are delivered and {PAY} is paid in full", - "fail": "The plans never arrived and the mission failed" + "arrive": "Deliver the recovered plans to {TARGET} for {PAY}", + "success": "Plans delivered to {TARGET}, {PAY} banked", + "fail": "The plans never reached {TARGET}, {PAY} lost" } }, - "news": "The news wire carries a fresh Navy alert about the stolen prototype hull.", "rumour": { - "far": "Dock talk says the Navy wants a stolen prototype hunted down.", - "near": "A tight-band Navy signal warns that the stolen hull must be destroyed, not boarded." + "far": "Traders near {WORLD} say a Navy patrol lost a prototype hull, and wants it found.", + "near": "A tight Navy beam reaches the commander at {WORLD}, flat and urgent, no name attached." }, + "news": "Dock reports at {WORLD} say a Navy contract stands open for any pilot who can hunt down a stolen hull.", + "title": "Last Known Position", "story": { - "opening": "On day {DAY}, she accepted a Navy order to hunt down a stolen prototype hull.", + "opening": "On day {DAY}, at {WORLD}, a Navy signal reached the commander, ordering the destruction of a stolen prototype hull.", "closing": { - "complete": "By day {DAY}, the Constrictor was scrapped and the Navy's plans reached {WORLD}.", - "fail": "By day {DAY}, the hunt for the Constrictor collapsed, and the trail went cold near {WORLD}." + "complete": "By day {DAY}, at {WORLD}, the Navy contract closed complete, the Constrictor destroyed and its plans delivered.", + "fail": "By day {DAY}, at {WORLD}, the Navy contract lapsed, and the Constrictor's fate stayed unresolved." }, "legs": { "hunt": { - "targetDestroyed": "On day {DAY}, the Constrictor was destroyed at {WORLD}.", - "failed": "On day {DAY}, the Constrictor slipped away near {WORLD}." + "targetDestroyed": "At {WORLD}, on day {DAY}, the commander ran down and destroyed the stolen Constrictor hull.", + "failed": "At {WORLD}, on day {DAY}, the hunt for the Constrictor broke down." }, "report": { - "success": "On day {DAY}, she reported the kill at {WORLD} for new orders.", - "failed": "On day {DAY}, she failed to reach {WORLD} in time to report." + "success": "At {WORLD}, on day {DAY}, the commander reported in and took fresh Navy orders.", + "failed": "At {WORLD}, on day {DAY}, the commander failed to report before the deadline passed." }, "courier": { - "success": "On day {DAY}, she delivered the Navy's plans to {WORLD}.", - "failed": "On day {DAY}, the Navy's plans never reached {WORLD}." + "success": "At {WORLD}, on day {DAY}, the commander handed the recovered plans to Navy hands.", + "failed": "At {WORLD}, on day {DAY}, the plans failed to reach their destination." } } }, - "title": "The Constrictor Directive", "skeleton": "constrictor", - "hash": "5236c29f", + "hash": "cc0cc74d", "images": {} } } diff --git a/src/missions/dossiers/index.ts b/src/missions/dossiers/index.ts index 6a4518a6..f80e4085 100644 --- a/src/missions/dossiers/index.ts +++ b/src/missions/dossiers/index.ts @@ -4,7 +4,7 @@ // own to fall out of step with this directory. `--check` fails when a file // here is missing from this list. -import type { DossierFile } from '../model.ts'; +import type { DossierFile } from '../words.ts'; import d_arc_edle from './arc-edle.json' with { type: 'json' }; import d_arc_lave from './arc-lave.json' with { type: 'json' }; import d_arc_rabedira from './arc-rabedira.json' with { type: 'json' }; @@ -19,6 +19,10 @@ import d_side_recover from './side-recover.json' with { type: 'json' }; import d_side_rescue from './side-rescue.json' with { type: 'json' }; import d_side_scan from './side-scan.json' with { type: 'json' }; import d_side_smuggle from './side-smuggle.json' with { type: 'json' }; +import d_wheel_blockade from './wheel-blockade.json' with { type: 'json' }; +import d_wheel_door from './wheel-door.json' with { type: 'json' }; +import d_wheel_mark from './wheel-mark.json' with { type: 'json' }; +import d_wheel_pilot from './wheel-pilot.json' with { type: 'json' }; export const DOSSIER_FILES: readonly DossierFile[] = [ d_arc_edle as DossierFile, @@ -35,4 +39,8 @@ export const DOSSIER_FILES: readonly DossierFile[] = [ d_side_rescue as DossierFile, d_side_scan as DossierFile, d_side_smuggle as DossierFile, + d_wheel_blockade as DossierFile, + d_wheel_door as DossierFile, + d_wheel_mark as DossierFile, + d_wheel_pilot as DossierFile, ]; diff --git a/src/missions/dossiers/side-ambush.json b/src/missions/dossiers/side-ambush.json index 54281f7d..2b37b39e 100644 --- a/src/missions/dossiers/side-ambush.json +++ b/src/missions/dossiers/side-ambush.json @@ -1,47 +1,47 @@ { - "promptVersion": 3, + "promptVersion": 4, "model": "claude-sonnet-5 via claude -p", - "generated": "2026-09-07", + "generated": "2026-09-13", "usage": { - "requests": 21, - "inputTokens": 164690, - "outputTokens": 100025 + "requests": 20, + "inputTokens": 175941, + "outputTokens": 98405 }, "dossier": { "briefing": [ - "This is {PATRON}, and this station needs a lane cleared.", - "Pirates hold the way out of {HERE}, and every hull I send back limps home empty.", - "Fly it, fight through, and dock on the far side, and I will pay you well for it." + "This is {PATRON}, out of {HERE}. Pirates have the lane to our neighbour locked down tight, and it's bleeding this station dry.", + "Fly it. Fight through whatever's waiting. Dock on the far side, and you'll have proven the route's still open.", + "Do that, and you're paid in full. Fail, and the lane stays shut, and so does half our trade." ], - "lead": "{PATRON} wants a word about the lane out of {WORLD}.", "legs": { "lane": { - "arrive": "Fly to {TARGET} through the pirates on the lane, and dock for {PAY}", - "success": "You broke the lane and docked at {TARGET}, and earned {PAY}", - "fail": "The lane held, {TARGET} stayed out of reach, and the {PAY} was lost" + "arrive": "Fly to {TARGET} through the pirates, and dock, for {PAY}", + "success": "Docked at {TARGET} — the lane holds, and {PAY} is yours", + "fail": "Never reached {TARGET} — the lane stays shut, and {PAY} slips away" } }, + "lead": "{PATRON} wants a word, once the commander returns to {WORLD}.", "rumour": { - "far": "They say {PATRON} at {WORLD} pays well for anyone who can break the pirate lane.", - "near": "{PATRON} sends word from {WORLD}: the lane still needs clearing, and the pay still stands." + "far": "Word on the boards says {PATRON} at {WORLD} still needs a pilot for the pirate lane.", + "near": "{PATRON} sends word from {WORLD}: the lane's still waiting for someone with nerve." }, - "news": "Word around {WORLD} says {PATRON} still needs a pilot to break the pirate lane.", + "news": "Station traffic through {WORLD} stays thin, and {PATRON} is still hiring for the pirate lane.", "story": { - "opening": "On day {DAY}, she agreed to break the pirate lane out of {WORLD}.", + "opening": "On day {DAY}, the commander agreed to clear the pirate lane to {WORLD} for a station under siege.", "closing": { - "complete": "By day {DAY}, the lane out of {WORLD} was open again.", - "fail": "By day {DAY}, the lane out of {WORLD} was still shut." + "complete": "By day {DAY}, the commander had broken the blockade and opened the way to {WORLD}.", + "fail": "By day {DAY}, the lane to {WORLD} remained shut, and the commander's word carried no weight." }, "legs": { "lane": { - "success": "On day {DAY}, she broke the lane and docked at {WORLD}.", - "failed": "On day {DAY}, the lane beat her before she reached {WORLD}." + "success": "On day {DAY}, the commander fought through to {WORLD} and proved the lane still open.", + "failed": "On day {DAY}, the commander failed to reach {WORLD}, and the lane stayed in pirate hands." } } }, - "title": "Clear the Lane", + "title": "Run the Gauntlet", "skeleton": "side-ambush", - "hash": "9b23affa", + "hash": "2b349d54", "images": {} } } diff --git a/src/missions/dossiers/side-deliver.json b/src/missions/dossiers/side-deliver.json index fb61e4b5..4229f758 100644 --- a/src/missions/dossiers/side-deliver.json +++ b/src/missions/dossiers/side-deliver.json @@ -1,46 +1,46 @@ { - "promptVersion": 3, + "promptVersion": 4, "model": "claude-sonnet-5 via claude -p", - "generated": "2026-09-07", + "generated": "2026-09-13", "usage": { - "requests": 21, - "inputTokens": 164690, - "outputTokens": 100025 + "requests": 20, + "inputTokens": 175941, + "outputTokens": 98405 }, "dossier": { "briefing": [ - "This is {PATRON}. I run things here at {HERE}, and I have a packet that has to move fast.", - "Take it to the station one jump out. Do not ask what is inside, and do not ask about the paperwork. Just get it there before anyone notices it moved." + "I keep the books here at {HERE}, and some entries need a quiet hand. A sealed packet has to reach a neighbour, and neither end asks questions.", + "You don't open it, and you don't sit on it while you run other errands. One jump, one drop, and you're finished with me." ], - "lead": "{PATRON} sent word from {WORLD} and wants a moment of the commander's time.", + "lead": "{PATRON} sends word from {WORLD}, asking for a moment of the commander's time.", "legs": { "run": { - "arrive": "Dock at {TARGET} and hand off the packet for {PAY}", - "success": "The packet reached {TARGET} in time, {PAY} is banked", - "fail": "The packet missed {TARGET}, {PAY} stays unpaid" + "arrive": "Take the packet to {TARGET}, and don't ask what's inside.", + "success": "The packet reached {TARGET}, and {PAY} clears the books.", + "fail": "The packet never reached {TARGET}, and {PAY} stays uncollected." } }, + "news": "Dockhands at {WORLD} say {PATRON} is short a courier and paying well for silence.", "rumour": { - "far": "Traders say {PATRON} on {WORLD} pays well for a quiet delivery.", - "near": "{PATRON} sends word from {WORLD} that the packet is ready and waiting." + "far": "Talk on the boards says {PATRON} at {WORLD} has a packet nobody wants opened.", + "near": "{PATRON} sends word from {WORLD}: the packet is still waiting, and so is the fee." }, - "news": "Work waits at {WORLD}, where {PATRON} needs a packet moved without delay.", "story": { - "opening": "The commander picked up a sealed packet at {WORLD} on day {DAY}.", + "opening": "On day {DAY}, the commander agreed to carry a sealed packet from {WORLD}.", "closing": { - "complete": "By day {DAY}, the packet had reached its destination, and the job was done.", - "fail": "By day {DAY}, the packet never arrived, and the deal fell through." + "complete": "By day {DAY}, the packet had reached its neighbour, and {WORLD} heard nothing more of it.", + "fail": "By day {DAY}, the packet had gone nowhere, and {WORLD} was left waiting on it." }, "legs": { "run": { - "success": "On day {DAY}, the packet reached {WORLD} without incident.", - "failed": "On day {DAY}, the packet failed to reach {WORLD} in time." + "success": "On day {DAY}, the commander delivered the sealed packet to {WORLD} without incident.", + "failed": "On day {DAY}, the packet missed its window, and {WORLD} never saw it arrive." } } }, - "title": "The Sealed Packet Run", + "title": "No Questions Asked", "skeleton": "side-deliver", - "hash": "37aaca6d", + "hash": "45ef4567", "images": {} } } diff --git a/src/missions/dossiers/side-escort.json b/src/missions/dossiers/side-escort.json index 1d88b6d9..23ffd351 100644 --- a/src/missions/dossiers/side-escort.json +++ b/src/missions/dossiers/side-escort.json @@ -1,49 +1,49 @@ { - "promptVersion": 3, + "promptVersion": 4, "model": "claude-sonnet-5 via claude -p", - "generated": "2026-09-07", + "generated": "2026-09-13", "usage": { - "requests": 21, - "inputTokens": 164690, - "outputTokens": 100025 + "requests": 20, + "inputTokens": 175941, + "outputTokens": 98405 }, "dossier": { "briefing": [ - "This is {PATRON}. A Python is lifting off from {HERE} today, and she wants a gun riding beside her.", - "Take her out, hold formation, and see her safe into range at the far station.", - "Do that, and you will find the credit good when you dock back at {HERE}." + "{PATRON} here, calling from the trade floor at {HERE}. One of my regulars is taking a Python out to the neighbour, loaded past good sense, and she wants a gun riding beside her the whole way.", + "Stay on her hull until she reads station range at the far end. Once she does, you are paid and she is someone else's worry.", + "Lose her out there and I lose a customer, so do not lose her." ], "legs": { "cover": { - "arrive": "Escort the Python to {TARGET} and hold formation until she reaches station range", - "success": "The Python docked safe at {TARGET}, and {PAY} lands in your account", - "fail": "The Python never reached {TARGET}, and the fee of {PAY} stays unpaid" + "arrive": "Ride shotgun on the Python until she reads station range at {TARGET}, then claim {PAY}", + "success": "The Python reached station range at {TARGET}, and {PAY} is yours", + "fail": "The Python never reached station range at {TARGET}, and {PAY} stays unpaid" } }, - "lead": "{PATRON} wants a word about work waiting at {WORLD}.", + "lead": "{PATRON} wants a word next time the commander docks at {WORLD}.", "rumour": { - "far": "Word says {PATRON} at {WORLD} still needs a fighter to ride escort.", - "near": "{PATRON} sends word from {WORLD} that the Python still waits for her escort." + "far": "Word on the board says {PATRON} at {WORLD} is still short a gun for an escort run.", + "near": "{PATRON} sends word from {WORLD}: the Python still needs a gun riding beside her." }, - "news": "{PATRON} needs a pilot to fly escort out of {WORLD}.", + "news": "Traders at {WORLD} say {PATRON} is still looking for a gun to escort a Python out.", "story": { - "opening": "On day {DAY}, the commander signed on to escort a Python out of {WORLD}.", + "opening": "On day {DAY}, the commander took a job at {WORLD} to escort a Python to a neighbour.", "closing": { - "complete": "By day {DAY}, the Python reached {WORLD} safe, and the escort job was closed.", - "fail": "By day {DAY}, the escort had failed, and the commander left {WORLD} with nothing to show." + "complete": "By day {DAY}, the Python sat safe at {WORLD}, and the job was done.", + "fail": "By day {DAY}, the escort had failed, and {WORLD} saw no more of that trade." }, "legs": { "cover": { - "success": "By day {DAY}, the Python docked safe at {WORLD}, and the escort was complete.", - "targetDestroyed": "By day {DAY}, the Python was destroyed near {WORLD}, and the escort had failed.", - "targetEscaped": "By day {DAY}, the Python jumped clear of {WORLD} alone, and the escort was lost.", - "failed": "By day {DAY}, the escort broke down before reaching {WORLD}, and the job was over." + "success": "The Python reached station range at {WORLD} on day {DAY}.", + "targetDestroyed": "The Python was destroyed short of {WORLD} on day {DAY}.", + "targetEscaped": "The Python jumped away before reaching {WORLD} on day {DAY}.", + "failed": "The escort ran out of time before {WORLD} on day {DAY}." } } }, - "title": "A Gun Beside Her", + "title": "Gun Beside Her", "skeleton": "side-escort", - "hash": "13150f98", + "hash": "5a77a06a", "images": {} } } diff --git a/src/missions/dossiers/side-hunt.json b/src/missions/dossiers/side-hunt.json index 0f1195cc..0fb054a8 100644 --- a/src/missions/dossiers/side-hunt.json +++ b/src/missions/dossiers/side-hunt.json @@ -1,47 +1,49 @@ { - "promptVersion": 3, + "promptVersion": 4, "model": "claude-sonnet-5 via claude -p", - "generated": "2026-09-07", + "generated": "2026-09-13", "usage": { - "requests": 21, - "inputTokens": 164690, - "outputTokens": 100025 + "requests": 20, + "inputTokens": 175941, + "outputTokens": 98405 }, "dossier": { "briefing": [ - "This is {PATRON}. I run the docks at {HERE}, and a Krait has been picking off ships on our lane.", - "Find it, burn it, and the fee is yours the day you dock here again." + "A gang has been taking ships on the lane out of {HERE}, and this station has had enough of it.", + "My name is {PATRON}, and I run this station. Four raiders hunt together: a Fer-de-Lance leads an Asp, a Krait and a Mamba.", + "Find them near the lane and finish every one. The leader may run once it takes damage, so don't let up." ], - "lead": "Word comes in that {PATRON} at {WORLD} wants a word with you.", "legs": { "hunt": { - "arrive": "Destroy the Krait last seen at {TARGET} and claim {PAY}", - "success": "The Krait burns, and {PAY} is now yours", - "fail": "The Krait slipped away, and {PAY} stays on the board" + "arrive": "Hunt down the gang near {TARGET} and collect {PAY} for the kill", + "success": "The gang is finished near {TARGET} and {PAY} lands in your account", + "fail": "The trail near {TARGET} went cold and {PAY} stays unpaid" } }, - "news": "{PATRON} at {WORLD} still wants the lane Krait destroyed.", + "lead": "{PATRON} has a word for the commander at {WORLD}.", "rumour": { - "far": "Traders say {PATRON} at {WORLD} is paying well for a dead Krait.", - "near": "{PATRON} sends word from {WORLD} that the Krait bounty still stands." + "far": "Word on the board says {PATRON} at {WORLD} pays well to clear raiders off the lane.", + "near": "{PATRON} sends word from {WORLD}: the gang is still out there, and the bounty still stands." }, + "news": "{PATRON} at {WORLD} is offering a bounty on the gang working the lane.", "story": { - "opening": "She signed on to hunt a Krait at {WORLD} on day {DAY}.", + "opening": "The commander took the bounty on day {DAY}, with a gang loose near {WORLD}.", "closing": { - "complete": "By day {DAY}, the Krait bounty out of {WORLD} was closed for good.", - "fail": "By day {DAY}, the Krait bounty out of {WORLD} had gone unpaid." + "complete": "The commander closed the case by day {DAY}, and the lane near {WORLD} was safe again.", + "fail": "The commander lost the trail by day {DAY}, and the gang stayed loose near {WORLD}." }, "legs": { "hunt": { - "targetDestroyed": "At {WORLD} on day {DAY}, the Krait was destroyed and the bounty paid.", - "targetEscaped": "At {WORLD} on day {DAY}, the Krait jumped clear and the trail went cold.", - "failed": "By day {DAY}, the hunt near {WORLD} had run past its deadline." + "targetDestroyed": "By day {DAY}, the gang near {WORLD} was destroyed to the last ship.", + "targetFled": "By day {DAY}, the leader fled the fight near {WORLD}, and the bounty was paid.", + "targetEscaped": "By day {DAY}, the leader jumped clear of {WORLD} before the kill was made.", + "failed": "By day {DAY}, the gang near {WORLD} was still out there, and the bounty lapsed." } } }, - "title": "Krait on the Lane", + "title": "Bounty on the Lane", "skeleton": "side-hunt", - "hash": "cebda7e6", + "hash": "276bdfa5", "images": {} } } diff --git a/src/missions/dossiers/side-recover.json b/src/missions/dossiers/side-recover.json index fdbebb7d..dbfdec24 100644 --- a/src/missions/dossiers/side-recover.json +++ b/src/missions/dossiers/side-recover.json @@ -1,57 +1,56 @@ { - "promptVersion": 3, + "promptVersion": 4, "model": "claude-sonnet-5 via claude -p", - "generated": "2026-09-07", + "generated": "2026-09-13", "usage": { - "requests": 21, - "inputTokens": 164690, - "outputTokens": 100025 + "requests": 20, + "inputTokens": 175941, + "outputTokens": 98405 }, "dossier": { "briefing": [ - "I'm {PATRON}, and I run things here at {HERE}.", - "A canister broke loose from the rail and drifted one jump out, and I want it back.", - "Scoop it up, bring it home to {HERE}, and I will make it worth your time." + "This is {PATRON}, running things at {HERE}. A canister broke free from the station a single jump out, and it is still drifting.", + "Scoop it up and bring it back to {HERE}. Do that, and there is good pay waiting for the commander." ], "legs": { "find": { - "arrive": "Scoop the canister adrift at {TARGET} for {PAY}", - "success": "Scoop complete at {TARGET}, and {PAY} draws closer", - "fail": "The canister at {TARGET} is gone, and {PAY} is lost" + "arrive": "Scoop the canister adrift near {TARGET} for {PAY}.", + "success": "The canister is secured, ready for the run home for {PAY}.", + "fail": "The canister is gone, and the job ends without {PAY}." }, "home": { - "arrive": "Bring the canister back to {TARGET} for {PAY}", - "success": "Delivered to {TARGET}, and {PAY} is paid", - "fail": "The run to {TARGET} failed, and {PAY} is lost" + "arrive": "Carry the canister back to {TARGET} to collect {PAY}.", + "success": "The canister is delivered, and {PAY} is paid in full.", + "fail": "The canister never reached {TARGET}, and {PAY} stays unpaid." } }, - "lead": "Word reached the commander that {PATRON} wanted her attention at {WORLD}.", + "lead": "{PATRON} has left word at {WORLD}, asking for the commander directly.", "rumour": { - "far": "Some traders say {PATRON} at {WORLD} lost a canister worth chasing.", - "near": "{PATRON} sent word from {WORLD}, asking after the missing canister." + "far": "Word on the board says {PATRON} at {WORLD} lost a canister and wants it found.", + "near": "{PATRON} sends word from {WORLD}: the canister is still out there, and the offer stands." }, - "news": "{PATRON} at {WORLD} has work waiting for a pilot who can scoop and haul.", + "news": "{PATRON} confirms a canister is loose off {WORLD}, and salvage work waits for a taker.", "story": { - "opening": "The commander took on a recovery job at {WORLD} on day {DAY}.", + "opening": "On day {DAY}, the commander took on a canister recovery job at {WORLD}.", "closing": { - "complete": "By day {DAY}, the commander delivered the canister to {WORLD}, and the job was complete.", - "fail": "By day {DAY}, the canister never reached {WORLD}, and the mission failed." + "complete": "By day {DAY}, the canister was back at {WORLD}, and the job was done.", + "fail": "By day {DAY}, the canister was lost for good, and the job failed." }, "legs": { "find": { - "success": "On day {DAY}, the commander scooped the canister near {WORLD}.", - "targetDestroyed": "On day {DAY}, something destroyed the canister near {WORLD} before the commander could scoop it.", - "failed": "On day {DAY}, the search near {WORLD} failed, and the canister stayed lost." + "success": "On day {DAY}, the commander scooped the canister adrift near {WORLD}.", + "targetDestroyed": "On day {DAY}, the canister was destroyed near {WORLD} before it could be scooped.", + "failed": "On day {DAY}, the commander lost the trail of the canister near {WORLD}." }, "home": { "success": "On day {DAY}, the commander delivered the canister to {WORLD}.", - "failed": "On day {DAY}, the return to {WORLD} failed, and the canister never arrived." + "failed": "On day {DAY}, the commander failed to bring the canister back to {WORLD}." } } }, - "title": "The Drifting Canister", + "title": "Canister Adrift", "skeleton": "side-recover", - "hash": "70c27e6e", + "hash": "87692d2c", "images": {} } } diff --git a/src/missions/dossiers/side-rescue.json b/src/missions/dossiers/side-rescue.json index 892b5e4a..4413679b 100644 --- a/src/missions/dossiers/side-rescue.json +++ b/src/missions/dossiers/side-rescue.json @@ -1,58 +1,57 @@ { - "promptVersion": 3, + "promptVersion": 4, "model": "claude-sonnet-5 via claude -p", - "generated": "2026-09-07", + "generated": "2026-09-13", "usage": { - "requests": 21, - "inputTokens": 164690, - "outputTokens": 100025 + "requests": 20, + "inputTokens": 175941, + "outputTokens": 98405 }, "dossier": { "briefing": [ - "This is {PATRON}, calling from {HERE}. One of our survey pilots is drifting out there in an escape pod, and the air will not last.", - "Scoop that pod before something else does, and land her alive at any station you can reach.", - "Do this well, and {PATRON} will remember it long after you leave {HERE}." + "A survey pilot is adrift out there, one jump out from {HERE}. Find her pod, scoop her clear, and bring her home alive.", + "{PATRON} does not care how it is done. Just land her safe at a station and the pay is yours." ], + "lead": "{PATRON} at {WORLD} has left word for the commander to call.", "legs": { "pod": { - "arrive": "Scoop the pod adrift at {TARGET} and bring her home for {PAY}.", - "success": "She came in alive from {TARGET}, and {PAY} is yours.", - "fail": "She did not survive {TARGET}, and {PAY} goes unpaid." + "arrive": "Scoop the drifting pod at {TARGET} and get her aboard", + "success": "The pod is aboard alive, {PAY} is yours", + "fail": "The pod is gone, and the deal is off" }, "data": { - "arrive": "Take the survey data back to {TARGET} for {PAY}.", - "success": "The data reached {TARGET}, and {PAY} is paid in full.", - "fail": "The data never reached {TARGET}, and {PAY} is lost." + "arrive": "Take what data survived back to {TARGET}", + "success": "The data reached {TARGET}, and {PAY} is paid", + "fail": "The data never reached {TARGET}, no pay" } }, - "lead": "{PATRON} has left word at {WORLD}, asking for a private channel.", + "news": "{WORLD} station news: {PATRON} is offering pay for the safe return of a lost pilot", "rumour": { - "far": "Word on the board says {PATRON} at {WORLD} is short a pilot and paying well for her return.", - "near": "{PATRON} sends word from {WORLD}: a survey pilot is adrift, and time is short." + "far": "Word round the bars says {PATRON} at {WORLD} still needs a lost pilot found.", + "near": "{PATRON} sends word from {WORLD}: a pilot is still out there, unclaimed." }, - "news": "Word from {WORLD} says {PATRON} is offering fair pay to whoever brings a stranded pilot home.", "story": { - "opening": "On day {DAY}, out of {WORLD}, she took the call for a pilot adrift in a pod.", + "opening": "On day {DAY}, the commander took a rescue job for a drifting pilot near {WORLD}.", "closing": { - "complete": "By day {DAY}, the pilot was landed alive and the job out of {WORLD} was closed clean.", - "fail": "By day {DAY}, the rescue died on the vine, and {WORLD} logged another life lost." + "complete": "By day {DAY}, the missing pilot was home safe, and the job was closed.", + "fail": "By day {DAY}, the rescue had failed, and the pilot's trail off {WORLD} went cold." }, "legs": { "pod": { - "survivor:landed": "The pod gave up its pilot alive near {WORLD} on day {DAY}.", - "survivor:sold": "The pilot from {WORLD} was sold before day {DAY} closed, and the rescue failed.", - "targetDestroyed": "The pod was destroyed near {WORLD} on day {DAY}, and the search moved on.", - "failed": "The pod near {WORLD} was never scooped, and day {DAY} closed the chase." + "survivor:landed": "At {WORLD} on day {DAY}, the drifting pilot was pulled in alive.", + "survivor:sold": "At {WORLD} on day {DAY}, the rescued pilot was sold instead of freed.", + "targetDestroyed": "At {WORLD} on day {DAY}, the pod was destroyed before it could be scooped.", + "failed": "At {WORLD} on day {DAY}, the rescue at the pod ran out of time." }, "data": { - "success": "The survey data reached {WORLD} safely on day {DAY}.", - "failed": "The survey data never reached {WORLD}, and the trail went cold by day {DAY}." + "success": "At {WORLD} on day {DAY}, the recovered survey data was delivered as promised.", + "failed": "At {WORLD} on day {DAY}, the recovered data never reached its destination." } } }, - "title": "Bring Her In Alive", + "title": "The Pilot Adrift", "skeleton": "side-rescue", - "hash": "82bdcf15", + "hash": "d8739ecf", "images": {} } } diff --git a/src/missions/dossiers/side-scan.json b/src/missions/dossiers/side-scan.json index ee6330a9..91e80bd3 100644 --- a/src/missions/dossiers/side-scan.json +++ b/src/missions/dossiers/side-scan.json @@ -1,48 +1,49 @@ { - "promptVersion": 3, + "promptVersion": 4, "model": "claude-sonnet-5 via claude -p", - "generated": "2026-09-07", + "generated": "2026-09-13", "usage": { - "requests": 21, - "inputTokens": 164690, - "outputTokens": 100025 + "requests": 20, + "inputTokens": 175941, + "outputTokens": 98405 }, "dossier": { "briefing": [ - "This is {PATRON}, speaking from {HERE}. There is an Anaconda working the neighbour system, and I want eyes kept on it.", - "Hold it on your scanner for twenty seconds. Do not fire, do not provoke it, just watch and report back." + "This is {PATRON}. An Anaconda works the system next to {HERE}, and it makes this station nervous.", + "Take it on your scanner and hold it there for twenty seconds straight. Keep your weapons cold the whole time, no exceptions.", + "Bring back a clean read, and {HERE} pays for the watch, not for a fight." ], - "lead": "{PATRON} at {WORLD} has sent word, wanting a moment of the commander's time.", "legs": { "watch": { - "arrive": "Hold the Anaconda on your scanner at {TARGET} without firing, for {PAY}", - "success": "You held the Anaconda at {TARGET} without firing, and earned {PAY}", - "fail": "The Anaconda got away at {TARGET}, and {PAY} stays unpaid" + "arrive": "Hold the Anaconda at {TARGET} on your scanner, weapons cold.", + "success": "The watch held, and {PAY} lands in your account.", + "fail": "The Anaconda slipped the scan, and {PAY} stays right where it was." } }, - "news": "{PATRON} at {WORLD} is offering steady pay to any pilot who can hold a scan.", + "lead": "{PATRON} wants a word, and the signal reaches the commander at {WORLD}.", "rumour": { - "far": "Some say {PATRON} at {WORLD} is paying well to have a ship watched.", - "near": "{PATRON} sends word from {WORLD} that the ship still needs watching." + "far": "Talk on the board says {PATRON} at {WORLD} wants a ship watched, quietly.", + "near": "{PATRON} sends word from {WORLD}: the Anaconda job still stands, no shots fired." }, + "news": "Traders at {WORLD} say {PATRON} still pays for a quiet set of eyes on the neighbour.", + "title": "Eyes on the Anaconda", "story": { - "opening": "On day {DAY}, the commander took on a watching job at {WORLD}.", + "opening": "The commander took the watch job on day {DAY}, with orders to hold a ship on the scanner and do nothing more.", "closing": { - "complete": "By day {DAY}, the watch had paid off, and the commander's job at {WORLD} was done.", - "fail": "By day {DAY}, the watch had failed, and the job at {WORLD} was lost." + "complete": "By day {DAY} the watch was done, and the Anaconda left the system none the wiser.", + "fail": "By day {DAY} the watch failed, and the commander's contact went quiet." }, "legs": { "watch": { - "success": "On day {DAY}, the commander held the Anaconda at {WORLD} for the full watch.", - "targetDestroyed": "On day {DAY}, the Anaconda was destroyed at {WORLD} before the watch was done.", - "targetEscaped": "On day {DAY}, the Anaconda slipped away from the scanner at {WORLD}.", - "failed": "On day {DAY}, the watch at {WORLD} ran out before it was done." + "success": "At {WORLD} the commander held the Anaconda on the scanner for the full count.", + "targetDestroyed": "At {WORLD} the Anaconda was destroyed before the watch could finish.", + "targetEscaped": "At {WORLD} the Anaconda escaped before the watch could finish.", + "failed": "At {WORLD} the watch ran past its deadline, and the trail went cold." } } }, - "title": "Eyes on the Anaconda", "skeleton": "side-scan", - "hash": "5c5d6d6c", + "hash": "691e3976", "images": {} } } diff --git a/src/missions/dossiers/side-smuggle.json b/src/missions/dossiers/side-smuggle.json index 31f4c3fe..bb5ae38d 100644 --- a/src/missions/dossiers/side-smuggle.json +++ b/src/missions/dossiers/side-smuggle.json @@ -1,47 +1,47 @@ { - "promptVersion": 3, + "promptVersion": 4, "model": "claude-sonnet-5 via claude -p", - "generated": "2026-09-07", + "generated": "2026-09-13", "usage": { - "requests": 21, - "inputTokens": 164690, - "outputTokens": 100025 + "requests": 20, + "inputTokens": 175941, + "outputTokens": 98405 }, "dossier": { "briefing": [ - "This is {PATRON}, and this conversation never happened.", - "Three tonnes of narcotics need to move from {HERE} to a neighbour world, past every patrol on the way.", - "Land it unscanned and you are paid at the far end. Get scanned, and you were never here." + "Keep your voice down and your data slate closer. There's three tonnes of narcotics that needs to move, and it can't go through anyone who asks questions.", + "Fly it past the patrols and set it down clean, with no scanner catching sight of the hold. You get paid when it lands, not before.", + "{PATRON} doesn't advertise this kind of work, and {HERE} doesn't need to hear about it either." ], - "lead": "Word reaches the commander that {PATRON} wants a quiet talk at {WORLD}.", "legs": { "run": { - "arrive": "Land the narcotics at {TARGET} unscanned for {PAY}", - "success": "The narcotics reached {TARGET} unscanned, and {PAY} is yours", - "fail": "The narcotics never reached {TARGET}, and {PAY} stays unpaid" + "arrive": "Set the three tonnes down at {TARGET} unscanned, and {PAY} is waiting", + "success": "The narcotics reached {TARGET} clean, and {PAY} lands in the account", + "fail": "The scan caught the hold before {TARGET}, and {PAY} stays where it was" } }, - "news": "Locals say {PATRON} at {WORLD} is looking for a pilot willing to keep quiet.", + "lead": "Word reaches the commander that {PATRON} wants a quiet word, back at {WORLD}", "rumour": { - "far": "Rumour on the board says {PATRON} at {WORLD} pays well for quiet cargo runs.", - "near": "{PATRON} sends word from {WORLD}: the quiet job still needs a pilot." + "far": "Traders say a smuggler's run passes through {WORLD}, and {PATRON} arranges it", + "near": "{PATRON} sends word from {WORLD}: the cargo had better not be late" }, + "news": "{PATRON} is said to have quiet cargo work waiting at {WORLD}", "story": { - "opening": "On day {DAY}, the commander took a quiet cargo job tied to {WORLD}.", + "opening": "On day {DAY}, the commander agreed to run three tonnes of narcotics out of {WORLD}, no questions asked.", "closing": { - "complete": "By day {DAY}, the narcotics run was done, and the commander's ties to {WORLD} stayed hidden.", - "fail": "By day {DAY}, the deal collapsed, and the commander's name was linked to {WORLD}." + "complete": "The commander delivered the narcotics clean, and by day {DAY}, {WORLD} was none the wiser.", + "fail": "The deadline passed on day {DAY}, and the narcotics never reached a buyer near {WORLD}." }, "legs": { "run": { - "success": "By day {DAY}, the commander landed the narcotics at {WORLD} without a scan.", - "failed": "By day {DAY}, the commander failed to land the narcotics at {WORLD} before the deadline." + "success": "The commander slipped the narcotics past the patrols and collected the fee, far from {WORLD}, by day {DAY}.", + "failed": "A patrol scan caught the hold before delivery, and by day {DAY} the deal near {WORLD} was dead." } } }, - "title": "Past the Patrols", + "title": "Three Tonnes, No Questions", "skeleton": "side-smuggle", - "hash": "b1c227d0", + "hash": "8a21507a", "images": {} } } diff --git a/src/missions/dossiers/wheel-blockade.json b/src/missions/dossiers/wheel-blockade.json new file mode 100644 index 00000000..f425ee67 --- /dev/null +++ b/src/missions/dossiers/wheel-blockade.json @@ -0,0 +1,47 @@ +{ + "promptVersion": 4, + "model": "claude-sonnet-5 via claude -p", + "generated": "2026-09-13", + "usage": { + "requests": 1, + "inputTokens": 8194, + "outputTokens": 16936 + }, + "dossier": { + "briefing": [ + "A parcel waits one jump out. A patrol sits on that route. It reads every hold it stops.", + "Keep the parcel aboard. Stay clean the whole way there. Land it clean. You become one of us.", + "This note carries no name until the end. {PATRON}." + ], + "legs": { + "run": { + "arrive": "Land the parcel at {TARGET} without a scan", + "success": "The parcel landed at {TARGET} clean, and {PAY} was paid without question", + "fail": "The parcel never reached {TARGET}, and {PAY} goes unpaid" + } + }, + "lead": "Someone who signs nothing wants a word again.", + "rumour": { + "far": "Pilots swap a name at the bar, then swear they never said it.", + "near": "A note finds you again, unsigned, and it waits at the next dock." + }, + "news": "Quiet channels still move parcels for pilots who ask no questions.", + "story": { + "opening": "On day {DAY}, the commander accepted a note that named no one.", + "closing": { + "complete": "By day {DAY}, the commander delivered the parcel to {WORLD}, and no scanner saw it.", + "fail": "By day {DAY}, the parcel never reached {WORLD}, and the note went silent." + }, + "legs": { + "run": { + "success": "The commander landed the parcel at {WORLD} on day {DAY}, unseen and unquestioned.", + "failed": "The parcel never reached {WORLD}, and the deadline closed the job by day {DAY}." + } + } + }, + "title": "One Of Us", + "skeleton": "wheel-blockade", + "hash": "aa1ed076", + "images": {} + } +} diff --git a/src/missions/dossiers/wheel-door.json b/src/missions/dossiers/wheel-door.json new file mode 100644 index 00000000..1862001b --- /dev/null +++ b/src/missions/dossiers/wheel-door.json @@ -0,0 +1,47 @@ +{ + "promptVersion": 4, + "model": "claude-sonnet-5 via claude -p", + "generated": "2026-09-13", + "usage": { + "requests": 9, + "inputTokens": 75358, + "outputTokens": 107067 + }, + "dossier": { + "briefing": [ + "You have done enough to be asked. There is a door where no chart reaches, and something guards it.", + "Arm the mis-jump, and clear the guard. Some call what lies behind it Raxxla.", + "Ask no one who sent this word. When the door falls, you will know who asked: {PATRON}." + ], + "legs": { + "door": { + "arrive": "Clear the guard at {TARGET} to open the door beyond", + "success": "The guard falls at {TARGET}; the door opens at last", + "fail": "The deadline passes over {TARGET}; the door stays sealed" + } + }, + "lead": "An unsigned note asks for a word, and gives no world to find it.", + "rumour": { + "far": "Pilots trade rumours of a wheel that has no face and no name.", + "near": "An unsigned line reaches the commander, and asks for a word." + }, + "news": "Word passes through the underworld that unseen hands are hiring again.", + "story": { + "opening": "On day {DAY}, the commander accepted a note that named no sender.", + "closing": { + "complete": "By day {DAY}, the guard fell, and the door stood open at last.", + "fail": "By day {DAY}, the trail went cold, and the door stayed sealed." + }, + "legs": { + "door": { + "targetDestroyed": "On day {DAY}, the guard died in {WORLD}, and the door stood open.", + "failed": "By day {DAY}, the hunt in {WORLD} failed, and the door stayed shut." + } + } + }, + "title": "The Door Beyond Charts", + "skeleton": "wheel-door", + "hash": "b7561976", + "images": {} + } +} diff --git a/src/missions/dossiers/wheel-mark.json b/src/missions/dossiers/wheel-mark.json new file mode 100644 index 00000000..aeb5ef2b --- /dev/null +++ b/src/missions/dossiers/wheel-mark.json @@ -0,0 +1,49 @@ +{ + "promptVersion": 4, + "model": "claude-sonnet-5 via claude -p", + "generated": "2026-09-13", + "usage": { + "requests": 9, + "inputTokens": 75358, + "outputTokens": 107067 + }, + "dossier": { + "briefing": [ + "Somebody watched your last fight, out past {HERE}, and did not look away.", + "A Fer-de-Lance works the lanes with two Asps at its side. Bring its pilot down, and we will talk again.", + "You will not find our name in any register. Call us {PATRON}." + ], + "lead": "A note without a name waits for the commander at {WORLD}", + "legs": { + "mark": { + "arrive": "Bring down the Fer-de-Lance and its two Asps near {TARGET}", + "success": "The Fer-de-Lance is wreckage near {TARGET}, gang and all", + "fail": "The Fer-de-Lance walked away, and so did our chance near {TARGET}" + } + }, + "news": "Local traffic near {WORLD} carries talk that {PATRON} is paying for quiet work again", + "rumour": { + "far": "Traders near {WORLD} whisper that {PATRON} still watches every fight worth watching", + "near": "A short line reaches the commander at {WORLD}: {PATRON} has not forgotten the Fer-de-Lance" + }, + "story": { + "opening": "On day {DAY}, the commander took on an unsigned job that pointed toward {WORLD}.", + "closing": { + "complete": "By day {DAY}, the Fer-de-Lance and its Asps lay dead near {WORLD}, and the note went unanswered.", + "fail": "By day {DAY}, the Fer-de-Lance escaped the guns near {WORLD}, and the silence never spoke again." + }, + "legs": { + "mark": { + "targetDestroyed": "Near {WORLD} on day {DAY}, the Fer-de-Lance and both Asps went down together.", + "targetFled": "Near {WORLD} on day {DAY}, the wounded Fer-de-Lance fled the fight.", + "targetEscaped": "Near {WORLD} on day {DAY}, the Fer-de-Lance jumped clear before the kill.", + "failed": "By day {DAY}, the hunt near {WORLD} ran past its deadline." + } + } + }, + "title": "A Note With No Name", + "skeleton": "wheel-mark", + "hash": "93f69ce8", + "images": {} + } +} diff --git a/src/missions/dossiers/wheel-pilot.json b/src/missions/dossiers/wheel-pilot.json new file mode 100644 index 00000000..ac3bce64 --- /dev/null +++ b/src/missions/dossiers/wheel-pilot.json @@ -0,0 +1,49 @@ +{ + "promptVersion": 4, + "model": "claude-sonnet-5 via claude -p", + "generated": "2026-09-13", + "usage": { + "requests": 9, + "inputTokens": 75358, + "outputTokens": 107067 + }, + "dossier": { + "briefing": [ + "This makes three notes, and still no name on any of them.", + "One of ours mis-jumped clean into the dark past the last chart, where the Thargoids wait and no lane runs. Arm for the same jump, find the pod before its air runs out, and get the pilot down alive at any station. Do not sell what you find. Do not let this end in a wreck.", + "Thirty days is what the air allows. Bring them home, and you will have done something the rest of us remember, whether we ever say so. This is the last note that goes out without a name on it. — {PATRON}" + ], + "legs": { + "pod": { + "arrive": "Scoop the pod adrift in {TARGET}, land the pilot alive, and earn {PAY}", + "success": "The pilot came home alive, and {PAY} lands in the account", + "fail": "The pod is gone, and {PAY} stays uncollected" + } + }, + "lead": "A third note waits at {WORLD}, unsigned, asking the commander for a private word.", + "rumour": { + "far": "A rumour drifts a few jumps out that quiet work still waits at {WORLD}, for a pilot who asks no questions.", + "near": "One jump from {WORLD}, an unsigned line finds the commander again, short and certain: they are waiting." + }, + "news": "The word around {WORLD} denies any such group exists, yet unnamed work still finds a pilot willing enough.", + "story": { + "opening": "On day {DAY}, at {WORLD}, the commander took a third unsigned note, and swore to bring a stranger home.", + "closing": { + "complete": "By day {DAY}, the pilot stood alive on {WORLD}, and an old debt closed clean.", + "fail": "By day {DAY}, no pilot came back through {WORLD}, and the debt stayed open." + }, + "legs": { + "pod": { + "survivor:landed": "On day {DAY}, the commander landed the pod's pilot alive on {WORLD}.", + "survivor:sold": "On day {DAY}, the commander sold the survivor at {WORLD}, and broke the one rule asked of them.", + "targetDestroyed": "On day {DAY}, the pod was destroyed near {WORLD} before the commander reached it.", + "failed": "By day {DAY}, the trail from {WORLD} went cold, and the pod was never found." + } + } + }, + "title": "One of Our Own", + "skeleton": "wheel-pilot", + "hash": "2efcf4a0", + "images": {} + } +} diff --git a/src/missions/effects.ts b/src/missions/effects.ts new file mode 100644 index 00000000..a6421d7b --- /dev/null +++ b/src/missions/effects.ts @@ -0,0 +1,33 @@ +// The consequences the machine returns, and the game applies. +// +// `MissionEffect` came out of `model.ts` for the size gate (docs/TODO/219 +// M5). `model.ts` re-exports the type, so every reader keeps its import. +// The bridge (`game/mission-bridge.ts`) applies each kind, and the machine +// (`machine.ts`) and the settlement (`settlement.ts`) emit them. + +import type { Deed, DossierWord, TaggedShip, WorldChange } from './model.ts'; + +/** + * A consequence the game applies. The machine never touches the commander. + * A `say` or a `later` with an empty `text` and a `word` is a line only a + * dossier can supply. The bridge drops it when none does. + */ +export type MissionEffect = + | { kind: 'say'; text: string; command?: 'openMissions'; word?: DossierWord } + | { kind: 'later'; text: string; word?: DossierWord } + | { kind: 'pay'; tenths: number } + | { kind: 'deed'; deed: Deed } + | { kind: 'legal'; delta: number } + | { kind: 'lead'; skeleton: string; galaxy: number; world: number } + | { kind: 'worldOverride'; world: number; until: number; change: WorldChange } + | { kind: 'standingSpawn'; world: number; until: number; ships: TaggedShip[] } + /** the patron's goods go aboard: a smuggle leg starts with them */ + | { kind: 'cargo'; commodity: number; tonnes: number } + /** the patron's goods leave the hold: a smuggle leg ends with them delivered */ + | { kind: 'unload'; commodity: number; tonnes: number } + /** ships that jump in around the commander now: an ambush a leg sprang */ + | { kind: 'spawn'; ships: TaggedShip[] } + /** passengers a finished mission leaves in the crew spaces, as survivors */ + | { kind: 'survivors'; people: number } + /** a fit the settlement grants, and the game puts on the ship (docs/TODO/219 M4) */ + | { kind: 'grant'; fit: 'cloak' }; diff --git a/src/missions/hail.ts b/src/missions/hail.ts index 99e24ab1..10daf914 100644 --- a/src/missions/hail.ts +++ b/src/missions/hail.ts @@ -36,7 +36,10 @@ export function hail( if (arcs.length === 0) effects.push({ kind: 'say', text, command: 'openMissions' }); else effects.push({ kind: 'later', text }); } - const hint = dockHint(st, ctx.commander, ctx.systems, offers.length > 0, st.idleDocks); + // ONLY AN ARC'S HAIL IS A HAIL. Most worlds carry side jobs, so a count + // that counted as one silenced the patron's message on almost every dock + // (docs/TODO/213 M3). The message queues behind the count. + const hint = dockHint(st, ctx.commander, ctx.systems, arcs.length > 0, st.idleDocks); if (hint) effects.push({ kind: 'later', ...hint }); } diff --git a/src/missions/hints.ts b/src/missions/hints.ts index dffcee05..fc405ac2 100644 --- a/src/missions/hints.ts +++ b/src/missions/hints.ts @@ -6,7 +6,7 @@ // // - inside `LEAD_RUMOUR_JUMPS`, a rumour on the bulletin board and a line // on the DATA ON page; -// - inside one jump, a message from the patron when she docks; +// - inside one jump, a message from the patron when they dock; // - at the world, the offer itself, which `offers.ts` makes. // // NO MORE THAN ONE HINT PER DOCKING. A dock that makes an offer says nothing @@ -25,7 +25,8 @@ import { LEAD_NAG_DOCKS, LEAD_RUMOUR_JUMPS } from '../constants/missions.ts'; import type { StarSystem } from '../galaxy/galaxy.ts'; import { routeEstimate } from '../galaxy/route.ts'; import { dossierFor, hintSlots } from './dossiers.ts'; -import type { CommanderFacts, Dossier, DossierWord, Lead, MissionState } from './model.ts'; +import type { CommanderFacts, DossierWord, Lead, MissionState } from './model.ts'; +import type { Dossier } from './words.ts'; import { fillSlots } from './text.ts'; type Dossiers = (id: string) => Dossier | null; diff --git a/src/missions/leads.ts b/src/missions/leads.ts new file mode 100644 index 00000000..2f769032 --- /dev/null +++ b/src/missions/leads.ts @@ -0,0 +1,32 @@ +// A lead: the word that the next arc waits at a world. +// +// A mission that ends may leave one (model.ts `Outcome.lead`). This file +// saves it on the record, once, and asks the game to announce it. It came +// out of `machine.ts` for the size gate (docs/TODO/217 M1). What a dock says +// about a lead is `hints.ts`, and where it is offered is `offers.ts`. + +import type { MissionContext } from './machine.ts'; +import type { MissionEffect, MissionState } from './model.ts'; +import { startWorld } from './lookups.ts'; +import { SKELETONS, skeletonById } from './skeletons/index.ts'; + +/** + * Save a lead, once. A lead to an arc the commander holds or finished is + * dropped, as failure rule 3 asks. The effect tells the game to announce it. + * The lead is in the arc's own galaxy where its gate names one. So an arc + * that fails on a galactic jump leaves its lead behind, in the galaxy the + * next arc is offered in (docs/TODO/213 M2). + */ +export function offerLead( + st: MissionState, id: string, ctx: MissionContext, effects: MissionEffect[], +): void { + const target = skeletonById(id, ctx.skeletons ?? SKELETONS); + if (!target) return; + if (id in st.done || st.live.some((l) => l.skeleton === id)) return; + if (st.leads.some((l) => l.skeleton === id)) return; + const c = ctx.commander; + const world = startWorld(target, c); + const galaxy = target.offer.galaxy ?? c.galaxy; + st.leads.push({ skeleton: id, galaxy, world, sinceDay: c.day }); + effects.push({ kind: 'lead', skeleton: id, galaxy, world }); +} diff --git a/src/missions/lint.ts b/src/missions/lint.ts index 7ce9944c..f28f7081 100644 --- a/src/missions/lint.ts +++ b/src/missions/lint.ts @@ -13,6 +13,12 @@ // docs/TODO/192 adds two. An arc's final leg ends two to four jumps from // the lead's start world, measured on the full-tank graph. Two legs that // force two different blueprint sets at one world are a conflict. +// +// docs/TODO/213 M2 adds one. A world patron's skeleton names the galaxy it +// is offered in, because a seed slot is an index that every galaxy has. +// +// docs/TODO/213 M5 adds one more. Every trigger a verb can emit has a +// branch on the leg, or the leg lists it under `ignores`. import { ARC_HANDOVER_JUMPS } from '../constants/missions.ts'; import type { StarSystem } from '../galaxy/galaxy.ts'; @@ -20,7 +26,9 @@ import { distanceTenths } from '../galaxy/navigation.ts'; import { routeTable } from '../galaxy/route.ts'; import type { Leg, Skeleton } from './model.ts'; import { specForDesign } from '../game/ship-specs.ts'; -import { verbJob, verbModule, verbNeedsShip } from './verbs/registry.ts'; +import { jobRole, verbJob, verbModule, verbNeedsShip, verbTriggers } from './verbs/registry.ts'; +import { sameTrigger, triggerLabel } from './triggers.ts'; +import { pickByJumps } from './placement.ts'; export function lintSkeleton( s: Skeleton, all: readonly Skeleton[], systems: readonly StarSystem[], @@ -29,22 +37,55 @@ export function lintSkeleton( const ids = new Set(s.legs.map((l) => l.id)); if (s.legs.length === 0) out.push(`${s.id}: no legs`); if (ids.size !== s.legs.length) out.push(`${s.id}: a leg id repeats`); + if (s.patron.kind === 'world' && s.offer.galaxy === undefined) { + out.push(`${s.id}: a world patron needs a galaxy gate`); + } for (const leg of s.legs) { const at = `${s.id}/${leg.id}`; if (!verbModule(leg.verb.kind)) out.push(`${at}: no module for verb ${leg.verb.kind}`); if (verbNeedsShip(leg.verb)) { - const role = verbJob(leg.verb) === 'hunt' ? 'pirate' : 'trader'; + const role = jobRole(verbJob(leg.verb)); if (!specForDesign(role, leg.verb.ship)) out.push(`${at}: no ${role} row for ${leg.verb.ship}`); } + // A gang's member with no pirate row would be skipped at the arrival, + // and the leg could never end (docs/TODO/217 M1). + if (leg.verb.kind === 'hunt') { + for (const hull of leg.verb.gang ?? []) { + if (!specForDesign('pirate', hull)) out.push(`${at}: no pirate row for the gang's ${hull}`); + } + } + // A spawned ship with no row for its role is skipped at the arrival, in + // silence, and the surprise never comes (docs/TODO/214 M1). + for (const s of [...(leg.spawn ?? []), ...(leg.ambush?.ships ?? [])]) { + if (!specForDesign(jobRole(s.job), s.ship)) out.push(`${at}: no ${jobRole(s.job)} row for the spawned ${s.ship}`); + } + // A leg in witchspace has no station, so its verb must end in the sky + // (docs/TODO/219 M3). + if (leg.place.kind === 'witchspace' && !['hunt', 'recover', 'rescue', 'scan'].includes(leg.verb.kind)) { + out.push(`${at}: a ${leg.verb.kind} leg cannot end in witchspace`); + } if (!leg.next.some((b) => b.on === 'failed')) out.push(`${at}: no failed branch`); + for (const t of verbModule(leg.verb.kind) ? verbTriggers(leg.verb) : []) { + const answered = leg.next.some((b) => sameTrigger(b.on, t)) + || (leg.ignores ?? []).some((i) => sameTrigger(i, t)); + if (!answered) out.push(`${at}: no branch for ${triggerLabel(t)}, and it is not ignored`); + } for (const b of leg.next) { if (b.to !== 'complete' && b.to !== 'fail' && !ids.has(b.to)) { out.push(`${at}: branch to unknown leg ${b.to}`); } } - if (leg.place.kind === 'handover' && !all.some((t) => t.id === (leg.place as { toward: string }).toward)) { - out.push(`${at}: handover toward unknown skeleton ${leg.place.toward}`); + if (leg.place.kind === 'handover') { + const toward = all.find((t) => t.id === (leg.place as { toward: string }).toward); + if (!toward) out.push(`${at}: handover toward unknown skeleton ${leg.place.toward}`); + // Measured from every world, as a band is (docs/TODO/213 M4). A leg + // that cannot be placed starts at any station, and that is a guard. + const goal = toward ? startOf(toward) : null; + const band = leg.place; + const dry = goal === null ? null + : systems.find((from) => pickByJumps(systems, from.index, goal, band, () => 0) === null); + if (dry) out.push(`${at}: handover ${band.min}-${band.max} has no candidate from ${dry.name}`); } if (leg.place.kind === 'band') { const band = leg.place; diff --git a/src/missions/lookups.ts b/src/missions/lookups.ts index 8aa2cf18..846d2b69 100644 --- a/src/missions/lookups.ts +++ b/src/missions/lookups.ts @@ -16,12 +16,17 @@ export function legOf(skeleton: Skeleton, id: string): Leg { /** * The key that `MissionState.standing` and a dossier file use for a patron. - * A local patron is the world she stands at, so the key names that world. + * A local patron is whoever runs the station the job was taken at, so the + * key names `origin` where the caller knows it. Without one it names the + * world the commander stands at, which is right on the board. It named + * that world at every settlement until docs/TODO/213 M4, so a delivery credited the + * far end's patron. */ -export function patronId(skeleton: Skeleton, commander: CommanderFacts): string { +export function patronId(skeleton: Skeleton, commander: CommanderFacts, origin?: number): string { const p = skeleton.patron; if (p.kind === 'navy') return 'navy'; - return `world-${p.kind === 'world' ? p.seedSlot : commander.systemIndex}`; + if (p.kind === 'wheel') return 'wheel'; + return `world-${p.kind === 'world' ? p.seedSlot : (origin ?? commander.systemIndex)}`; } /** diff --git a/src/missions/machine.ts b/src/missions/machine.ts index 3e66b936..6a2f62ce 100644 --- a/src/missions/machine.ts +++ b/src/missions/machine.ts @@ -30,13 +30,14 @@ import type { } from './model.ts'; import { canAccept } from './offers.ts'; import { placeLeg } from './placement.ts'; -import { legOf, patronId, startWorld } from './lookups.ts'; -import { ARC_TOUR, SKELETONS, skeletonById } from './skeletons/index.ts'; +import { legOf } from './lookups.ts'; +import { SKELETONS, skeletonById } from './skeletons/index.ts'; import { fillSlots, legPay, lineSlots } from './text.ts'; -import { leadWorldIn } from './tour.ts'; +import { applySettlement } from './settlement.ts'; +import { offerLead } from './leads.ts'; import { sameTrigger, triggerLabel, wordKind } from './triggers.ts'; import { verbItem, verbModule, verbNeedsShip } from './verbs/registry.ts'; -import { DEADLINE_WARNING_DAYS } from '../constants/missions.ts'; +import { DEADLINE_WARNING_DAYS, WITCHSPACE_TARGET } from '../constants/missions.ts'; export interface MissionContext { commander: CommanderFacts; @@ -63,7 +64,7 @@ export function stepMissions( // A world change holds through its last day, and is gone the day after. st.changes = st.changes.filter((ch) => ch.until >= ctx.commander.day); break; - case 'galaxyChanged': leaveGalaxy(st, input.to, ctx, effects); break; + case 'galaxyChanged': leaveGalaxy(st, ctx, effects); break; default: react(st, input, ctx, effects); } if (input.kind === 'docked') hail(st, ctx, effects, st.journal.length !== journalBefore); @@ -91,7 +92,7 @@ function accept( const first = skeleton.legs[0]; const placed = placeLeg(first.place, st, c, ctx.systems, ctx.rng, id, ctx.skeletons ?? SKELETONS); if (!placed.ok) return; - st.journal.push({ skeleton: id, leg: first.id, outcome: 'accepted', day: c.day, world: c.systemIndex }); + st.journal.push({ skeleton: id, leg: first.id, outcome: 'accepted', day: c.day, world: c.systemIndex, galaxy: c.galaxy }); const live: LiveMission = { skeleton: id, leg: first.id, target: null, tag: null, progress: 0, deadlineDay: null, }; @@ -116,30 +117,26 @@ function abandon( const live = st.live.find((l) => l.skeleton === id); if (!live) return; const c = ctx.commander; - st.journal.push({ skeleton: id, leg: live.leg, outcome: 'abandoned', day: c.day, world: c.systemIndex }); + st.journal.push({ skeleton: id, leg: live.leg, outcome: 'abandoned', day: c.day, world: c.systemIndex, galaxy: c.galaxy }); finish(st, live, 'fail', ctx, effects); } /** * The commander left the galaxy. Every live mission fails by its own final - * outcome, with the departure as the reason. Every lead moves to the world - * the tour of the new galaxy gives its arc (tour.ts), from the arrival - * world. Its galaxy moves with it, so a lead never points at an index in a - * galaxy she is not in. `ctx.systems` is the galaxy she arrives in. + * outcome, with the departure as the reason. A lead stays where it was + * saved. An arc is offered in its own galaxy (`Gate.galaxy`), so the LEADS + * row says IN ANOTHER GALAXY until the commander returns. The leads used to + * move to the tour of the new galaxy. The arc they opened there placed its legs by + * seed index, which is not the tour (docs/TODO/213 M2). */ function leaveGalaxy( - st: MissionState, to: number, ctx: MissionContext, effects: MissionEffect[], + st: MissionState, ctx: MissionContext, effects: MissionEffect[], ): void { const c = ctx.commander; for (const live of [...st.live]) { - st.journal.push({ skeleton: live.skeleton, leg: live.leg, outcome: 'galaxyLeft', day: c.day, world: c.systemIndex }); + st.journal.push({ skeleton: live.skeleton, leg: live.leg, outcome: 'galaxyLeft', day: c.day, world: c.systemIndex, galaxy: c.galaxy }); finish(st, live, 'fail', ctx, effects); } - for (const lead of st.leads) { - if (!skeletonById(lead.skeleton, ctx.skeletons ?? SKELETONS)) continue; - lead.galaxy = to; - lead.world = leadWorldIn(ctx.systems, c.systemIndex, ARC_TOUR.indexOf(lead.skeleton)); - } } // --- events ----------------------------------------------------------------- @@ -163,16 +160,34 @@ function react( const leg = legOf(skeleton, live.leg); const module = verbModule(leg.verb.kind); if (!module) continue; - const reaction = module({ live, leg, commander: ctx.commander }, input); + const reaction = module({ live, leg, commander: ctx.commander, entities: st.entities }, input); if (!reaction) continue; + // A ship that ran or jumped out is gone from the record too, once a leg + // took the word (docs/TODO/217 M1). The Constrictor cannot leave, and + // its verb takes no such word, so it stays and comes back on the next + // arrival. The mark says which, so a gang hunt can read its leader's + // fate at the end. + if ((input.kind === 'fled' || input.kind === 'escaped') && input.tag in st.entities) { + st.entities[input.tag].alive = false; + st.entities[input.tag].fled = true; + } if (reaction.progress !== undefined) live.progress = reaction.progress; if (reaction.passenger && input.kind === 'scooped') { st.passengers.push({ tag: input.tag, mission: live.skeleton }); } + if (reaction.unload && leg.verb.kind === 'smuggle') { + effects.push({ kind: 'unload', commodity: leg.verb.commodity, tonnes: leg.verb.tonnes }); + } if (reaction.say !== undefined) { effects.push({ kind: 'say', text: fillSlots(reaction.say, lineSlots(ctx.systems, live.target, legPay(leg))) }); } if (reaction.trigger !== undefined) fire(st, live, reaction.trigger, ctx, effects); + // The ambush springs after the branch settles, so its line queues behind + // the settlement's own (docs/TODO/214 M2). + if (reaction.sprung && leg.ambush) { + effects.push({ kind: 'spawn', ships: leg.ambush.ships }); + effects.push({ kind: 'later', text: leg.ambush.say }); + } } // A passenger answered for is off the ship, whichever mission owned them. if (input.kind === 'survivor') st.passengers = st.passengers.filter((p) => p.tag !== input.tag); @@ -188,7 +203,8 @@ function deadlines(st: MissionState, ctx: MissionContext, effects: MissionEffect for (const live of [...st.live]) { if (live.deadlineDay === null) continue; const left = live.deadlineDay - ctx.commander.day; - const where = live.target === null ? 'ANY STATION' : ctx.systems[live.target].name.toUpperCase(); + const where = live.target === null ? 'ANY STATION' + : live.target === WITCHSPACE_TARGET ? 'WITCHSPACE' : ctx.systems[live.target].name.toUpperCase(); if (left < 0) { effects.push({ kind: 'say', text: `THE JOB AT ${where} RAN OUT OF TIME, AND IT IS LOST.` }); fire(st, live, 'deadlinePassed', ctx, effects); @@ -224,16 +240,16 @@ function takeBranch( ): void { const skeleton = skeletonOf(live.skeleton, ctx); const c = ctx.commander; - const entry = { skeleton: live.skeleton, leg: live.leg, outcome: triggerLabel(branch.on), day: c.day, world: c.systemIndex }; + const entry = { skeleton: live.skeleton, leg: live.leg, outcome: triggerLabel(branch.on), day: c.day, world: c.systemIndex, galaxy: c.galaxy }; const leg = legOf(skeleton, live.leg); const kind = wordKind(leg, branch); const word = (target: number | null): DossierWord | undefined => (kind ? { skeleton: skeleton.id, leg: leg.id, kind, slots: lineSlots(ctx.systems, target, branch.settle?.pay) } : undefined); if (branch.to === 'complete' || branch.to === 'fail') { - // The world a change lands on is where she stands, so the target is - // null. The words still name the leg's own world, or a patron's line - // read "at ANY STATION" at the end of every job (docs/TODO/203 M5). + // The world a change lands on is where the commander stands, so the + // target is null. The words still name the leg's own world, or a + // patron's line read "at ANY STATION" at the end of every job (docs/TODO/203 M5). settle(st, skeleton, branch.settle, null, ctx, effects, word(live.target), live.target); st.journal.push(entry); finish(st, live, branch.to, ctx, effects); @@ -241,10 +257,15 @@ function takeBranch( } const next = legOf(skeleton, branch.to); const placed = placeLeg(next.place, st, c, ctx.systems, ctx.rng, live.skeleton, ctx.skeletons ?? SKELETONS); - if (!placed.ok) return; - settle(st, skeleton, branch.settle, placed.target, ctx, effects, word(placed.target)); + // A LEG THAT CANNOT BE PLACED STARTS AT ANY STATION. The branch used to + // return here, before the settlement and the journal, with the target + // already dead. The kill paid nothing and the mission held its slot for + // good (docs/TODO/213 M4). The lint measures every band and every + // handover from every world, so the path is a guard rather than a rule. + const target = placed.ok ? placed.target : null; + settle(st, skeleton, branch.settle, target, ctx, effects, word(target)); st.journal.push(entry); - startLeg(st, live, skeleton, next, placed.target, ctx, effects); + startLeg(st, live, skeleton, next, target, ctx, effects); } /** @@ -290,6 +311,14 @@ function startLeg( if (verbNeedsShip(leg.verb)) { st.entities[tag] = { kind: 'ship', ship: leg.verb.ship, hull: 1, lastWorld: target ?? c.systemIndex, alive: true }; live.tag = tag; + // The gang flies with the leader, and each member is a record of its + // own under the leg's tag (docs/TODO/217 M1). So a dead member stays + // dead across an arrival, and the clean-up at the end finds it. + if (leg.verb.kind === 'hunt') { + (leg.verb.gang ?? []).forEach((ship, i) => { + st.entities[`${tag}#gang-${i + 1}`] = { kind: 'ship', ship, hull: 1, lastWorld: target ?? c.systemIndex, alive: true }; + }); + } } else if (item !== null) { st.entities[tag] = { kind: item, ship: '', hull: 1, lastWorld: target ?? c.systemIndex, alive: true }; live.tag = tag; @@ -301,9 +330,8 @@ function startLeg( } /** - * Apply a settlement, and say its word. A silent settlement with a dossier - * word still says an empty line. The bridge puts the dossier's line in its - * place, or drops it. + * Apply a settlement, and say its word (`settlement.ts`). The flags it newly + * set then fire on every live leg that names one. */ function settle( st: MissionState, skeleton: Skeleton, s: Settlement | undefined, @@ -311,30 +339,7 @@ function settle( /** the world the words name, when it is not the world a change lands on */ sayTarget: number | null = target, ): void { - const added: string[] = []; - if (s) { - if (s.pay > 0) effects.push({ kind: 'pay', tenths: s.pay }); - if (s.deed) effects.push({ kind: 'deed', deed: s.deed }); - if (s.legal) effects.push({ kind: 'legal', delta: s.legal }); - for (const f of s.setFlags ?? []) { - if (!st.flags.includes(f)) { st.flags.push(f); added.push(f); } - } - if (s.standing) { - const id = patronId(skeleton, ctx.commander); - st.standing[id] = (st.standing[id] ?? 0) + s.standing; - } - // A change to a world is the game's to keep (mission-bridge.ts), at the - // branch's world, through its last day. - const world = target ?? ctx.commander.systemIndex; - if (s.override) { - effects.push({ kind: 'worldOverride', world, until: ctx.commander.day + s.override.days, change: { override: s.override.set } }); - } - if (s.spawn) { - effects.push({ kind: 'standingSpawn', world, until: ctx.commander.day + s.spawn.days, ships: s.spawn.ships }); - } - } - const text = s?.say ? fillSlots(s.say, lineSlots(ctx.systems, sayTarget, s.pay)) : ''; - if (text || word) effects.push({ kind: 'say', text, word }); + const added = applySettlement(st, skeleton, s, target, ctx, effects, word, sayTarget); fireFlags(st, added, ctx, effects); } @@ -352,7 +357,7 @@ function finish( const o = skeleton[outcome]; settle(st, skeleton, o, null, ctx, effects); st.done[live.skeleton] = outcome; - st.journal.push({ skeleton: live.skeleton, leg: live.leg, outcome, day: c.day, world: c.systemIndex }); + st.journal.push({ skeleton: live.skeleton, leg: live.leg, outcome, day: c.day, world: c.systemIndex, galaxy: c.galaxy }); st.live = st.live.filter((l) => l !== live); for (const tag of Object.keys(st.entities)) { if (tag.startsWith(`${live.skeleton}#`)) delete st.entities[tag]; @@ -365,20 +370,3 @@ function finish( } if (o.lead) offerLead(st, o.lead, ctx, effects); } - -/** - * Save a lead, once. A lead to an arc she holds or finished is dropped, as - * failure rule 3 asks. The effect tells the game to announce it. - */ -function offerLead( - st: MissionState, id: string, ctx: MissionContext, effects: MissionEffect[], -): void { - const target = skeletonById(id, ctx.skeletons ?? SKELETONS); - if (!target) return; - if (id in st.done || st.live.some((l) => l.skeleton === id)) return; - if (st.leads.some((l) => l.skeleton === id)) return; - const c = ctx.commander; - const world = startWorld(target, c); - st.leads.push({ skeleton: id, galaxy: c.galaxy, world, sinceDay: c.day }); - effects.push({ kind: 'lead', skeleton: id, galaxy: c.galaxy, world }); -} diff --git a/src/missions/model.ts b/src/missions/model.ts index 0ba25422..daeddc0a 100644 --- a/src/missions/model.ts +++ b/src/missions/model.ts @@ -14,7 +14,8 @@ // // Nothing here runs. The types are the contract between four places. Those // are the skeletons under `skeletons/`, the verb modules, the machine, and the -// game code that sends inputs and applies effects. +// game code that sends inputs and applies effects. The shape of the words a +// model writes, a patron and a dossier, is `words.ts` (docs/TODO/213 M5). import type { BlueprintOverride } from '../game/blueprint-set.ts'; import type { ShipDesignId } from '../game/ship-identity.ts'; @@ -22,7 +23,7 @@ import type { ShipDesignId } from '../game/ship-identity.ts'; /** A mission ship names a catalogue design (`ship-identity.ts`). */ export type ShipId = ShipDesignId; -/** An item a recovery leg wants. The item table arrives with docs/TODO/190 M4. */ +/** An item a recovery leg wants, as a free name. No table of items exists. */ export type ItemId = string; /** @@ -41,8 +42,14 @@ export interface Deed { disrepute: number } export type PatronRef = | { kind: 'navy' } | { kind: 'world'; seedSlot: number } - /** whoever runs the station she stands at: a side job's patron, anywhere */ - | { kind: 'local' }; + /** whoever runs the station the commander stands at: a side job's patron, anywhere */ + | { kind: 'local' } + /** + * The Dark Wheel (docs/TODO/219): a society of pilots with no world and + * no face. Its jobs are on the board at every Anarchy and every Feudal + * world, once the commander's rating opens them. + */ + | { kind: 'wheel' }; /** * The facts about the commander that a rule can read. A projection of @@ -57,6 +64,8 @@ export interface CommanderFacts { day: number; /** tonnes per commodity index, read only: a smuggle leg asks what is aboard */ cargo: readonly number[]; + /** fuel scoops fitted: a scoop job asks before it is offered (docs/TODO/213 M2) */ + scoops: boolean; } /** A temporary change to a world that a later mission asks for. */ @@ -66,15 +75,32 @@ export interface WorldChange { override: BlueprintOverride } export interface TaggedShip { ship: ShipId; tag: string; - /** what the leg wants of it, which picks the role it flies with */ - job: 'hunt' | 'escort' | 'scan'; + /** + * What the leg wants of it, which picks the role it flies with. A + * `police` job flies a Viper as the law (docs/TODO/219 M2): a blockade at + * a smuggle's far end. + */ + job: 'hunt' | 'escort' | 'scan' | 'police' | 'thargoid'; } /** A canister or a capsule the game must spawn, tagged for a leg. */ export interface TaggedItem { tag: string; kind: 'cargo' | 'capsule' } export type Verb = - | { kind: 'hunt'; ship: ShipId; canEscape: boolean } + | { + kind: 'hunt'; ship: ShipId; canEscape: boolean; + /** + * The hulls that fly with the target (docs/TODO/217 M1). The machine + * mints a record for each under the leg's tag, so a dead member stays + * dead across an arrival. The leg ends when every ship is gone. + */ + gang?: readonly ShipId[]; + /** + * The role the target flies with, when it is not a pirate (docs/TODO/219 + * M4). A Thargoid mothership is hunted under its own roster row. + */ + job?: 'thargoid'; + } | { kind: 'deliver'; cargo?: { commodity: number; tonnes: number } } | { kind: 'recover'; item: ItemId } | { kind: 'rescue' } @@ -100,7 +126,13 @@ export type Placement = | { kind: 'band'; min: number; max: number } | { kind: 'world'; seedSlot: number } | { kind: 'entity'; tag: string } - | { kind: 'handover'; toward: string; min: number; max: number }; + | { kind: 'handover'; toward: string; min: number; max: number } + /** + * Witchspace itself (docs/TODO/219 M3): the leg's target is + * `WITCHSPACE_TARGET`, not a system, and an armed mis-jump is the way + * there. The Thargoids wait there already. + */ + | { kind: 'witchspace' }; export type Trigger = | 'success' | 'failed' | 'targetEscaped' | 'targetDestroyed' | 'targetFled' @@ -116,9 +148,15 @@ export interface Settlement { setFlags?: string[]; /** a change to the player's standing with this patron */ standing?: number; + /** + * A fit the game puts on the ship, which no shop sells (docs/TODO/219 + * M4). The Wheel's door grants the cloaking device. + */ + grant?: 'cloak'; /** * A blueprint set forced at the branch's world for `days` (docs/TODO/192 - * M3). The world is the next leg's target, or where she stands at an end. + * M3). The world is the next leg's target, or where the commander stands + * at an end. */ override?: { set: BlueprintOverride; days: number }; /** ships that wait at the branch's world for `days`, spawned on every arrival */ @@ -165,8 +203,22 @@ export interface Leg { * kill among them moves nothing. */ spawn?: TaggedShip[]; + /** + * Ships that jump in when the leg makes progress, and what the console + * says of them (docs/TODO/214 M2). A scoop springs it for a recover and a + * rescue, and the scan's end for a scan. They are untracked, as `spawn` + * is, so a kill among them settles nothing. The words never tell it. + */ + ambush?: { ships: TaggedShip[]; say: string }; /** true raises the mis-jump chance, as the 1984 courier run did */ carryingPlans?: boolean; + /** + * Triggers the verb can emit that this leg answers with nothing, on + * purpose. The lint refuses a leg that drops a trigger without saying so + * (docs/TODO/213 M5). A side hunt ignores `targetFled`, because a pirate + * cannot leave a system today, and 214 revisits that. + */ + ignores?: Trigger[]; /** the first branch whose trigger matches is the one taken */ next: Branch[]; } @@ -180,8 +232,14 @@ export interface Gate { /** skeletons that must be finished first, by either outcome */ done?: string[]; withinJumps?: number; - /** the Navy briefs in galaxy 1 only; a world patron is already in one galaxy */ + /** + * The galaxy the offer stands in. A world patron must name one, because a + * seed slot is an index and every galaxy has that index. The lint holds + * it (docs/TODO/213 M2). The Navy briefs in galaxy 1 only. + */ galaxy?: number; + /** the job needs fuel scoops fitted, so the offer waits for them */ + scoops?: boolean; } export interface Skeleton { @@ -200,61 +258,10 @@ export interface Skeleton { fail: Outcome; /** skeletons this one shuts out, for good */ excludes?: string[]; - /** how many times a side job repeats; absent means once */ + /** how many times a side job repeats; absent means without limit (failure rule 5) */ cap?: number; } -export interface Patron { - id: string; - world: number | 'navy'; - name: string; - role: string; - species: string; - voice: string; - /** image path; '' uses the world's portrait */ - portrait: string; -} - -/** - * A mission's generated words. Each field may carry the slots its comment - * names and no other, and `tools/dossier-faults.ts` holds that (docs/TODO/191). - */ -export interface Dossier { - skeleton: string; - /** the prompt hash it was written from, which covers the skeleton's shape */ - hash: string; - /** a name for the mission, with no slot */ - title: string; - /** pages the patron speaks, with {PATRON} {HERE} */ - briefing: string[]; - /** by leg: the console's word on the leg, with {TARGET} {PAY} */ - legs: Record; - /** the LEADS row, with {PATRON} {WORLD} */ - lead: string; - /** the board's rumour inside the rumour range, and the message one jump out, with {PATRON} {WORLD} */ - rumour: { far: string; near: string }; - /** the DATA ON line, with {PATRON} {WORLD} */ - news: string; - /** paths under `public/`; an absent file degrades to no image */ - images: { target?: string; place?: string }; - story: { - /** with {WORLD} {DAY} */ - opening: string; - closing: { complete: string; fail: string }; - /** by leg, then by trigger label (`triggerLabel`, machine.ts), with {WORLD} {DAY} */ - legs: Record>; - }; -} - -/** One committed dossier file, with what the run that wrote it cost. */ -export interface DossierFile { - promptVersion: number; - model: string; - generated: string; - usage: { requests: number; inputTokens: number; outputTokens: number }; - dossier: Dossier; -} - export interface LiveMission { skeleton: string; leg: string; @@ -276,6 +283,13 @@ export interface JournalEntry { outcome: string; day: number; world: number; + /** + * The galaxy `world` is an index in. Absent on a record written before + * docs/TODO/213 M4, and it reads as galaxy 1. The log named every world + * through the galaxy the commander stood in, so a jump renamed their whole + * past. + */ + galaxy?: number; } export interface EntityState { @@ -287,6 +301,12 @@ export interface EntityState { hull: number; lastWorld: number; alive: boolean; + /** + * It ran, or it jumped out, and a leg took the word (docs/TODO/217 M1). + * So it is gone from the record as a dead ship is, and the gang hunt can + * read its leader's fate at the end. + */ + fled?: boolean; } /** @@ -320,7 +340,7 @@ export interface MissionState { entities: Record; passengers: MissionPassenger[]; journal: JournalEntry[]; - /** docks since the journal last moved; the second patron message reads it */ + /** docks since a dock last moved the journal; the second patron message reads it */ idleDocks: number; /** what settlements changed about the worlds, each until a day */ changes: WorldChangeRecord[]; @@ -357,21 +377,5 @@ export type DossierWord = | { skeleton: string; leg: string; kind: 'arrive' | 'success' | 'fail'; slots: Record } | { skeleton: string; kind: 'near'; world: number }; -/** - * A consequence the game applies. The machine never touches the commander. - * A `say` or a `later` with an empty `text` and a `word` is a line only a - * dossier can supply. The bridge drops it when none does. - */ -export type MissionEffect = - | { kind: 'say'; text: string; command?: 'openMissions'; word?: DossierWord } - | { kind: 'later'; text: string; word?: DossierWord } - | { kind: 'pay'; tenths: number } - | { kind: 'deed'; deed: Deed } - | { kind: 'legal'; delta: number } - | { kind: 'lead'; skeleton: string; galaxy: number; world: number } - | { kind: 'worldOverride'; world: number; until: number; change: WorldChange } - | { kind: 'standingSpawn'; world: number; until: number; ships: TaggedShip[] } - /** the patron's goods go aboard: a smuggle leg starts with them */ - | { kind: 'cargo'; commodity: number; tonnes: number } - /** passengers a finished mission leaves in the crew spaces, as survivors */ - | { kind: 'survivors'; people: number }; +/** The consequences the game applies, in their own file (docs/TODO/219 M5). */ +export type { MissionEffect } from './effects.ts'; diff --git a/src/missions/offers.ts b/src/missions/offers.ts index 8bbc63d2..37a7e69a 100644 --- a/src/missions/offers.ts +++ b/src/missions/offers.ts @@ -1,17 +1,19 @@ -// What a station offers: which skeletons the commander can accept where she -// stands, and why the rest are shut. +// What a station offers: which skeletons the commander can accept where they +// stand, and why the rest are shut. // // A gate is the skeleton's own conditions. A LEAD opens the offer regardless // of the gate, at the lead's world (docs/TODO/190, failure rule 3). Neither // opens a slot: `MISSION_LIVE_CAP` holds, and a lead waits for one. A side -// job with a `cap` comes back after `MISSION_REOFFER_DAYS`, and an arc she -// holds or finished never comes back (failure rule 5). +// job comes back after `MISSION_REOFFER_DAYS`, until its `cap` is spent +// where it has one. An arc the commander holds or finished never comes back +// (failure rule 5). No side job had a cap, and absent read as once, so each +// was offered one time per career until docs/TODO/213 M2. // // It is read by the machine on `accept` and on `docked`, by the desk that // lists the MISSIONS screen, and by the tests. It changes nothing. -import { MISSION_LIVE_CAP, MISSION_REOFFER_DAYS } from '../constants/missions.ts'; -import type { StarSystem } from '../galaxy/galaxy.ts'; +import { MISSION_LIVE_CAP, MISSION_REOFFER_DAYS, LAWLESS_GOVERNMENTS } from '../constants/missions.ts'; +import { GOVERNMENT_NAMES, type StarSystem } from '../galaxy/galaxy.ts'; import { routeEstimate } from '../galaxy/route.ts'; import { ratingRung } from '../game/rating.ts'; import type { CommanderFacts, Gate, MissionState, Skeleton } from './model.ts'; @@ -50,6 +52,15 @@ function localJobHere(s: Skeleton, c: CommanderFacts, ctx: OfferContext): boolea return sideJobsAt(ctx.systems[c.systemIndex], ctx.skeletons ?? SKELETONS).some((j) => j.id === s.id); } +/** + * The Wheel's word reaches the lawless worlds alone (docs/TODO/219 M1): + * every Anarchy and every Feudal. Without the galaxy, every world is. + */ +function lawlessHere(s: Skeleton, c: CommanderFacts, ctx: OfferContext): boolean { + if (s.patron.kind !== 'wheel' || !ctx.systems) return true; + return LAWLESS_GOVERNMENTS.includes(GOVERNMENT_NAMES[ctx.systems[c.systemIndex].government]); +} + /** * A world patron waits at home, so her arc is offered there (docs/TODO/192 * M2), and inside `withinJumps` of there when the gate says so (M3). The @@ -72,6 +83,7 @@ function gateOpen(gate: Gate, st: MissionState, c: CommanderFacts): boolean { if (gate.minKills !== undefined && c.kills < gate.minKills) return false; if (gate.minRating !== undefined && ratingRung(c.combatScore) < gate.minRating) return false; if (gate.legalStatus === 'clean' && c.legalStatus !== 0) return false; + if (gate.scoops && !c.scoops) return false; if (gate.flags?.some((f) => !st.flags.includes(f))) return false; if (gate.notFlags?.some((f) => st.flags.includes(f))) return false; if (gate.done?.some((d) => !(d in st.done))) return false; @@ -103,12 +115,12 @@ export function leadHere(st: MissionState, id: string, c: CommanderFacts): boole } /** - * Whether the commander can accept this skeleton where she stands. + * Whether the commander can accept this skeleton where they stand. * * A LEAD OPENS THE OFFER regardless of the gate. It does not open a slot, and - * it does not restart an arc she holds or finished. A side job with a `cap` - * comes back until the cap is spent, and not before `MISSION_REOFFER_DAYS` - * from the day it last ended. + * it does not restart an arc the commander holds or finished. A side job + * comes back until its `cap` is spent, where it has one, and not before + * `MISSION_REOFFER_DAYS` from the day it last ended. */ export function canAccept(st: MissionState, id: string, ctx: OfferContext): boolean { const from = ctx.skeletons ?? SKELETONS; @@ -117,14 +129,19 @@ export function canAccept(st: MissionState, id: string, ctx: OfferContext): bool if (st.live.length >= MISSION_LIVE_CAP) return false; if (st.live.some((l) => l.skeleton === id)) return false; // An arc that ended never comes back. `done` says so even when the journal - // was not written, which a hand-built record can do. - if (s.kind !== 'side' && id in st.done) return false; + // was not written, which a hand-built record can do. The Wheel's trials + // come back after a failure, as many times as their `cap` allows, and + // never after a pass (docs/TODO/219 M1). + const again = s.kind === 'side' || s.patron.kind === 'wheel'; + if (!again && id in st.done) return false; + if (s.patron.kind === 'wheel' && st.done[id] === 'complete') return false; const ended = endings(st, id); - if (ended.count >= (s.kind === 'side' ? (s.cap ?? 1) : 1)) return false; + if (ended.count >= (again ? (s.cap ?? Infinity) : 1)) return false; if (ended.count > 0 && ctx.commander.day < ended.lastDay + MISSION_REOFFER_DAYS) return false; if (excluded(st, id, from)) return false; if (leadHere(st, id, ctx.commander)) return true; if (!nearHome(s, ctx)) return false; + if (!lawlessHere(s, ctx.commander, ctx)) return false; return localJobHere(s, ctx.commander, ctx) && gateOpen(s.offer, st, ctx.commander); } diff --git a/src/missions/patrons.ts b/src/missions/patrons.ts index 158bf600..825053e6 100644 --- a/src/missions/patrons.ts +++ b/src/missions/patrons.ts @@ -17,7 +17,8 @@ import { speciesName, type StarSystem } from '../galaxy/galaxy.ts'; import { portraitUrl } from '../ui/portrait.ts'; -import type { CommanderFacts, Patron, PatronRef } from './model.ts'; +import type { CommanderFacts, PatronRef } from './model.ts'; +import type { Patron } from './words.ts'; import galaxy1 from './patrons/galaxy-1.json' with { type: 'json' }; /** One committed patron. `system` is checked against the live galaxy, not trusted. */ @@ -54,6 +55,16 @@ export const NAVY_PATRON: Patron = { id: 'navy', world: 'navy', name: 'THE NAVY', role: 'the Navy', species: '', voice: '', portrait: '', }; +/** + * The other one (docs/TODO/219). A society of pilots that nobody admits + * exists. No world, no face, and a voice that gives nothing away. + */ +export const WHEEL_PATRON: Patron = { + id: 'wheel', world: 'wheel', name: 'THE DARK WHEEL', role: 'the Wheel', species: '', + voice: 'The Wheel writes in short lines, signs nothing, and names itself only at the end.', + portrait: '', +}; + /** * The plain title a world's patron takes when no record exists, by * government index. It is the fallback's own rule, not a copy of the @@ -78,6 +89,7 @@ export function patronFor( ref: PatronRef, facts: CommanderFacts, systems: readonly StarSystem[], origin?: number, ): Patron { if (ref.kind === 'navy') return NAVY_PATRON; + if (ref.kind === 'wheel') return WHEEL_PATRON; const world = ref.kind === 'world' ? ref.seedSlot : (origin ?? facts.systemIndex); const sys = systems[world]; const base = { diff --git a/src/missions/placement.ts b/src/missions/placement.ts index 48c6dd38..9db57ec4 100644 --- a/src/missions/placement.ts +++ b/src/missions/placement.ts @@ -5,6 +5,7 @@ // band has a candidate. A draw advances the shared world stream, so an extra // one changes every result after it. +import { WITCHSPACE_TARGET } from '../constants/missions.ts'; import type { StarSystem } from '../galaxy/galaxy.ts'; import { distanceTenths } from '../galaxy/navigation.ts'; import { routeTable } from '../galaxy/route.ts'; @@ -77,6 +78,7 @@ export function placeLeg( return target === null ? { ok: false } : { ok: true, target }; } case 'world': return { ok: true, target: place.seedSlot }; + case 'witchspace': return { ok: true, target: WITCHSPACE_TARGET }; case 'entity': { const e = state.entities[place.tag]; return e ? { ok: true, target: e.lastWorld } : { ok: false }; diff --git a/src/missions/queries.ts b/src/missions/queries.ts index 09846673..72dd2d4b 100644 --- a/src/missions/queries.ts +++ b/src/missions/queries.ts @@ -6,6 +6,7 @@ // answer reads the skeleton through the live leg, so a stage number lives // nowhere (docs/TODO/190). +import { WITCHSPACE_TARGET } from '../constants/missions.ts'; import type { BlueprintOverride } from '../game/blueprint-set.ts'; import type { StarSystem } from '../galaxy/galaxy.ts'; import { legOf } from './lookups.ts'; @@ -14,8 +15,12 @@ import { verbJob, verbNeedsShip } from './verbs/registry.ts'; import { SKELETONS, skeletonById } from './skeletons/index.ts'; import { fillSlots, legPay, lineSlots } from './text.ts'; -function liveLegs( - st: MissionState, from: readonly Skeleton[], +/** + * Every live mission with the leg it is on. The game asks it too, to know + * what a commander is here to do (docs/TODO/208 M1). + */ +export function liveLegs( + st: MissionState, from: readonly Skeleton[] = SKELETONS, ): { live: LiveMission; leg: Leg }[] { const out: { live: LiveMission; leg: Leg }[] = []; for (const live of st.live) { @@ -28,9 +33,9 @@ function liveLegs( /** * The override a live leg puts in force at `here`, or null. * - * The first live mission that names one wins. Two live legs with two - * overrides at one world is a conflict the skeleton lint does not see yet. - * The arc plan, item 192 of docs/TODO/190, decides it when a second arc exists. + * The first live mission that names one wins. Two legs of two skeletons + * that force two sets at one world are a conflict, and `lint.ts` refuses + * the pair before it ships (docs/TODO/192). */ export function missionOverride( st: MissionState, here: number, from: readonly Skeleton[] = SKELETONS, @@ -62,6 +67,12 @@ export function missionSpawns( if (live.tag === null || !verbNeedsShip(leg.verb)) continue; const e = st.entities[live.tag]; if (e && e.alive && e.kind === 'ship') out.push({ ship: e.ship, tag: live.tag, job: verbJob(leg.verb) }); + // A gang's members come from the record (docs/TODO/217 M1). So a dead + // one does not come back on the next arrival, as the untracked company + // above does. + for (const [tag, m] of Object.entries(st.entities)) { + if (tag.startsWith(`${live.tag}#gang-`) && m.alive) out.push({ ship: m.ship, tag, job: 'hunt' }); + } } // A standing spawn a settlement left here, on every arrival while it holds. for (const ch of st.changes) if (ch.world === here && ch.ships) out.push(...ch.ships); @@ -98,6 +109,20 @@ export function scanSecondsFor( return null; } +/** + * Whether the ship with `tag` is the target of a hunt it may run from + * (docs/TODO/214 M4). The world step stamps `canFlee` on it each frame, so + * a restored ship carries it again on its first frame. + */ +export function huntCanFlee( + st: MissionState, tag: string, from: readonly Skeleton[] = SKELETONS, +): boolean { + for (const { live, leg } of liveLegs(st, from)) { + if (live.tag === tag && leg.verb.kind === 'hunt') return leg.verb.canEscape; + } + return false; +} + /** The standing order for one live mission, in the game's voice. */ export function orderLine( live: LiveMission, systems: readonly StarSystem[], from: readonly Skeleton[] = SKELETONS, @@ -138,6 +163,7 @@ export function missionName( ): string { const s = skeletonById(live.skeleton, from); if (!s || s.patron.kind === 'navy') return 'NAVY MISSION'; + if (s.patron.kind === 'wheel') return 'DARK WHEEL MISSION'; const world = s.patron.kind === 'world' ? s.patron.seedSlot : acceptedAt(st, live.skeleton); return world === undefined ? 'MISSION' : `${systems[world].name.toUpperCase()} MISSION`; } @@ -145,6 +171,7 @@ export function missionName( /** Every world a live leg sends the commander to. */ export function missionDestinations(st: MissionState): ReadonlySet { const out = new Set(); - for (const l of st.live) if (l.target !== null) out.add(l.target); + // Witchspace is on no chart (docs/TODO/219 M3). + for (const l of st.live) if (l.target !== null && l.target !== WITCHSPACE_TARGET) out.add(l.target); return out; } diff --git a/src/missions/repair.ts b/src/missions/repair.ts index faecfc8e..0e3f0247 100644 --- a/src/missions/repair.ts +++ b/src/missions/repair.ts @@ -7,15 +7,22 @@ // carries a `mission` field with a stage number instead. That field is // dropped by the caller, and this installs an empty record in its place. The // save loads, and only the mission progress is lost (docs/TODO/190). +// +// A GHOST IS DROPPED (docs/TODO/213 M4). A live mission, a lead or a +// passenger can point at a skeleton or a leg the code no longer ships. Such +// a record threw on the next dock. It threw on the abandon too, so the slot +// stayed held. The loaders have no console, so the drop is silent, and the +// rest of the record stands. -import type { MissionState } from './model.ts'; +import type { MissionState, Skeleton } from './model.ts'; +import { SKELETONS, skeletonById } from './skeletons/index.ts'; import { emptyMissionState } from './state.ts'; const isRecord = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); /** The stored record when every field has its shape, else an empty one. */ -export function repairMissionState(raw: unknown): MissionState { +export function repairMissionState(raw: unknown, skeletons: readonly Skeleton[] = SKELETONS): MissionState { const empty = emptyMissionState(); if (!isRecord(raw)) return empty; const arrays = ['live', 'leads', 'flags', 'passengers', 'journal'] as const; @@ -26,5 +33,12 @@ export function repairMissionState(raw: unknown): MissionState { // Fields added after the first records were written: absent reads as none. if (typeof kept.idleDocks !== 'number') kept.idleDocks = 0; if (!Array.isArray(kept.changes)) kept.changes = []; + const known = (skeleton: string, leg?: string): boolean => { + const s = skeletonById(skeleton, skeletons); + return s !== null && (leg === undefined || s.legs.some((l) => l.id === leg)); + }; + kept.live = kept.live.filter((l) => known(l.skeleton, l.leg)); + kept.leads = kept.leads.filter((l) => known(l.skeleton)); + kept.passengers = kept.passengers.filter((p) => known(p.mission)); return kept; } diff --git a/src/missions/settlement.ts b/src/missions/settlement.ts new file mode 100644 index 00000000..26d2a9e8 --- /dev/null +++ b/src/missions/settlement.ts @@ -0,0 +1,58 @@ +// What a settlement does to the record, and what the game must do for it. +// +// A branch taken settles once (machine.ts). This file applies the +// settlement. That is the pay, the deed, the legal change, the flags and +// the standing with the patron. It is also a change to a world, a standing +// spawn, and the word the console says. It returns the flags it newly set, +// and the machine fires those on every live leg that names one. It came out of `machine.ts` for +// the size gate (docs/TODO/217 M1). +// +// PURE, as the machine is. It pushes effects, and the caller applies them. + +import type { MissionContext } from './machine.ts'; +import type { DossierWord, MissionEffect, MissionState, Settlement, Skeleton } from './model.ts'; +import { patronId } from './lookups.ts'; +import { acceptedAt } from './queries.ts'; +import { fillSlots, lineSlots } from './text.ts'; + +/** + * Apply a settlement, and say its word. A silent settlement with a dossier + * word still says an empty line. The bridge puts the dossier's line in its + * place, or drops it. + * + * @param sayTarget the world the words name, when it is not the world a + * change lands on. + * @returns the flags this settlement newly set. + */ +export function applySettlement( + st: MissionState, skeleton: Skeleton, s: Settlement | undefined, + target: number | null, ctx: MissionContext, effects: MissionEffect[], word?: DossierWord, + sayTarget: number | null = target, +): string[] { + const added: string[] = []; + if (s) { + if (s.pay > 0) effects.push({ kind: 'pay', tenths: s.pay }); + if (s.deed) effects.push({ kind: 'deed', deed: s.deed }); + if (s.legal) effects.push({ kind: 'legal', delta: s.legal }); + if (s.grant) effects.push({ kind: 'grant', fit: s.grant }); + for (const f of s.setFlags ?? []) { + if (!st.flags.includes(f)) { st.flags.push(f); added.push(f); } + } + if (s.standing) { + const id = patronId(skeleton, ctx.commander, acceptedAt(st, skeleton.id)); + st.standing[id] = (st.standing[id] ?? 0) + s.standing; + } + // A change to a world is the game's to keep (mission-bridge.ts), at the + // branch's world, through its last day. + const world = target ?? ctx.commander.systemIndex; + if (s.override) { + effects.push({ kind: 'worldOverride', world, until: ctx.commander.day + s.override.days, change: { override: s.override.set } }); + } + if (s.spawn) { + effects.push({ kind: 'standingSpawn', world, until: ctx.commander.day + s.spawn.days, ships: s.spawn.ships }); + } + } + const text = s?.say ? fillSlots(s.say, lineSlots(ctx.systems, sayTarget, s.pay)) : ''; + if (text || word) effects.push({ kind: 'say', text, word }); + return added; +} diff --git a/src/missions/skeletons/arcs/edle.ts b/src/missions/skeletons/arcs/edle.ts index b63195ab..50936852 100644 --- a/src/missions/skeletons/arcs/edle.ts +++ b/src/missions/skeletons/arcs/edle.ts @@ -15,6 +15,7 @@ import { ARC_LEG_DAYS, ARC_PAY, SIDE_JOB_RANGE } from '../../../constants/missio import { SOURCE_DESIGN } from '../../../game/ship-specs.ts'; import { shipDesignIdOf } from '../../../game/ship-identity.ts'; import type { Skeleton } from '../../model.ts'; +import { LANE_PIRATES, PAIR, wingmanOf } from '../lane.ts'; const AWAY = { kind: 'band', ...SIDE_JOB_RANGE } as const; @@ -25,10 +26,10 @@ export const ARC_EDLE: Skeleton = { patron: { kind: 'world', seedSlot: 162 }, hail: 'A COLONEL OF THE STATE GUARD AT EDLE HAS ORDERS FOR YOU', pitch: 'A MANIFEST TO CARRY, A TRANSPORTER TO GUARD, AND AN ASP TO PURGE. THE STATE PAYS.', - offer: { done: ['arc-xeer'] }, + offer: { galaxy: 1, done: ['arc-xeer'] }, legs: [ { - id: 'manifest', verb: { kind: 'deliver' }, place: AWAY, + id: 'manifest', verb: { kind: 'deliver' }, place: AWAY, spawn: [...PAIR], line: 'COLONEL: TAKE THE MANIFEST TO {TARGET}', deadlineDays: ARC_LEG_DAYS, next: [ { on: 'success', to: 'guard', settle: { pay: ARC_PAY.deliver, say: 'MANIFEST DELIVERED — {PAY}. THE TRANSPORTER LEAVES FOR {TARGET}.' } }, @@ -36,7 +37,7 @@ export const ARC_EDLE: Skeleton = { ], }, { - id: 'guard', verb: { kind: 'escort', ship: shipDesignIdOf(SOURCE_DESIGN.transporter) }, place: AWAY, + id: 'guard', verb: { kind: 'escort', ship: shipDesignIdOf(SOURCE_DESIGN.transporter) }, place: AWAY, spawn: [...LANE_PIRATES], line: 'COLONEL: SEE THE TRANSPORTER INTO STATION RANGE AT {TARGET}', deadlineDays: ARC_LEG_DAYS, next: [ { on: 'success', to: 'purge', settle: { pay: ARC_PAY.escort, say: 'TRANSPORTER SAFE — {PAY}. NOW THE ASP, NEAR {TARGET}.' } }, @@ -56,6 +57,7 @@ export const ARC_EDLE: Skeleton = { }, { id: 'purge', verb: { kind: 'hunt', ship: shipDesignIdOf(SOURCE_DESIGN.asp), canEscape: true }, place: AWAY, + spawn: [...wingmanOf(shipDesignIdOf(SOURCE_DESIGN.asp), 'purge')], line: 'COLONEL: DESTROY THE ASP — LAST SEEN AT {TARGET}', deadlineDays: ARC_LEG_DAYS, next: [ { on: 'targetDestroyed', to: 'complete', settle: { pay: ARC_PAY.hunt, say: 'ASP DESTROYED — {PAY} FROM EDLE. THE TOUR IS DONE.' } }, diff --git a/src/missions/skeletons/arcs/index.ts b/src/missions/skeletons/arcs/index.ts index d69197d5..4beb27d7 100644 --- a/src/missions/skeletons/arcs/index.ts +++ b/src/missions/skeletons/arcs/index.ts @@ -8,6 +8,10 @@ // // Each file names its theme in a comment. The words a player reads are the // dossier's (docs/TODO/191), and the lines here are the plain fallback. +// +// Every arc is gated to galaxy 1 (docs/TODO/213 M2). A world patron is a +// seed slot, and a seed slot is an index that every galaxy has. Without the +// gate, galaxy 2's Esrilees offered the governor of Lave's job. import type { Skeleton } from '../../model.ts'; import { ARC_EDLE } from './edle.ts'; diff --git a/src/missions/skeletons/arcs/lave.ts b/src/missions/skeletons/arcs/lave.ts index 5e4b85fb..dd18e7d2 100644 --- a/src/missions/skeletons/arcs/lave.ts +++ b/src/missions/skeletons/arcs/lave.ts @@ -16,6 +16,7 @@ import { ARC_HANDOVER_JUMPS, ARC_LEG_DAYS, ARC_PAY, SIDE_JOB_RANGE } from '../.. import { SOURCE_DESIGN } from '../../../game/ship-specs.ts'; import { shipDesignIdOf } from '../../../game/ship-identity.ts'; import type { Skeleton } from '../../model.ts'; +import { PAIR, wingmanOf } from '../lane.ts'; const COBRA = shipDesignIdOf(SOURCE_DESIGN.cobraMk3); @@ -26,10 +27,11 @@ export const ARC_LAVE: Skeleton = { patron: { kind: 'world', seedSlot: 7 }, hail: 'THE GOVERNOR OF LAVE HAS A JOB FOR YOU', pitch: 'THE GRUB EXPORT LEDGER WENT ADRIFT WITH A SMUGGLER. THE GOVERNOR WANTS IT BACK, AND THE SMUGGLER CAUGHT.', - offer: {}, + offer: { galaxy: 1, scoops: true }, legs: [ { id: 'ledger', verb: { kind: 'recover', item: 'grub-ledger' }, place: { kind: 'band', ...SIDE_JOB_RANGE }, + ambush: { ships: [...PAIR], say: 'THE SMUGGLER\'S FRIENDS WERE WAITING FOR THE LEDGER.' }, line: 'GOVERNOR: SCOOP THE LEDGER CANISTER ADRIFT AT {TARGET}', deadlineDays: ARC_LEG_DAYS, next: [ { on: 'success', to: 'runner', settle: { pay: ARC_PAY.recover, say: 'LEDGER ABOARD — {PAY}. NOW THE SMUGGLER.' } }, @@ -48,7 +50,7 @@ export const ARC_LAVE: Skeleton = { ], }, { - id: 'runner', verb: { kind: 'hunt', ship: COBRA, canEscape: true }, + id: 'runner', verb: { kind: 'hunt', ship: COBRA, canEscape: true }, spawn: [...wingmanOf(COBRA, 'runner')], place: { kind: 'handover', toward: 'arc-rabedira', ...ARC_HANDOVER_JUMPS }, line: 'GOVERNOR: DESTROY THE SMUGGLER\'S COBRA — LAST SEEN AT {TARGET}', deadlineDays: ARC_LEG_DAYS, next: [ diff --git a/src/missions/skeletons/arcs/rabedira.ts b/src/missions/skeletons/arcs/rabedira.ts index bb19bd26..26d3b59f 100644 --- a/src/missions/skeletons/arcs/rabedira.ts +++ b/src/missions/skeletons/arcs/rabedira.ts @@ -13,6 +13,7 @@ import { ARC_HANDOVER_JUMPS, ARC_LEG_DAYS, ARC_PAY, SIDE_JOB_RANGE } from '../.. import { SOURCE_DESIGN } from '../../../game/ship-specs.ts'; import { shipDesignIdOf } from '../../../game/ship-identity.ts'; import type { Skeleton } from '../../model.ts'; +import { LANE_PIRATES, PAIR } from '../lane.ts'; const TOWARD = { kind: 'handover', toward: 'arc-vetitice', ...ARC_HANDOVER_JUMPS } as const; @@ -23,10 +24,10 @@ export const ARC_RABEDIRA: Skeleton = { patron: { kind: 'world', seedSlot: 6 }, hail: 'AN ENVOY AT RABEDIRA ASKS FOR YOU', pitch: 'A TRUCE PAPER MUST REACH A NEIGHBOUR, AND THEN THE ENVOY MUST REACH THE MEETING ALIVE.', - offer: { done: ['arc-lave'] }, + offer: { galaxy: 1, done: ['arc-lave'] }, legs: [ { - id: 'paper', verb: { kind: 'deliver' }, place: { kind: 'band', ...SIDE_JOB_RANGE }, + id: 'paper', verb: { kind: 'deliver' }, place: { kind: 'band', ...SIDE_JOB_RANGE }, spawn: [...PAIR], line: 'ENVOY: TAKE THE TRUCE PAPER TO {TARGET}', deadlineDays: ARC_LEG_DAYS, next: [ { on: 'success', to: 'convoy', settle: { pay: ARC_PAY.deliver, say: 'PAPER DELIVERED — {PAY}. NOW THE ENVOY\'S BOA LEAVES FOR {TARGET}.' } }, @@ -34,7 +35,7 @@ export const ARC_RABEDIRA: Skeleton = { ], }, { - id: 'convoy', verb: { kind: 'escort', ship: shipDesignIdOf(SOURCE_DESIGN.boa) }, place: TOWARD, + id: 'convoy', verb: { kind: 'escort', ship: shipDesignIdOf(SOURCE_DESIGN.boa) }, place: TOWARD, spawn: [...LANE_PIRATES], line: 'ENVOY: SEE THE BOA INTO STATION RANGE AT {TARGET}', deadlineDays: ARC_LEG_DAYS, next: [ { on: 'success', to: 'complete', settle: { pay: ARC_PAY.escort, say: 'THE ENVOY IS SAFE — {PAY} FROM RABEDIRA' } }, diff --git a/src/missions/skeletons/arcs/vetitice.ts b/src/missions/skeletons/arcs/vetitice.ts index 46a26b54..77a063f9 100644 --- a/src/missions/skeletons/arcs/vetitice.ts +++ b/src/missions/skeletons/arcs/vetitice.ts @@ -13,7 +13,7 @@ import { ARC_HANDOVER_JUMPS, ARC_LEG_DAYS, ARC_PAY, SIDE_JOB_RANGE } from '../../../constants/missions.ts'; import type { Skeleton } from '../../model.ts'; -import { LANE_PIRATES } from '../lane.ts'; +import { LANE_PIRATES, LONE_KRAIT, PAIR } from '../lane.ts'; export const ARC_VETITICE: Skeleton = { id: 'arc-vetitice', @@ -22,7 +22,7 @@ export const ARC_VETITICE: Skeleton = { patron: { kind: 'world', seedSlot: 100 }, hail: 'THE PLANNING DIRECTOR AT VETITICE HAS A QUOTA TO MEET', pitch: 'PIRATES HOLD THE LANE THE COLLECTIVE\'S SHIPMENT MUST CROSS. CLEAR IT, BRING IN THE SURVEYOR, AND CARRY THE MANIFEST.', - offer: { done: ['arc-rabedira'] }, + offer: { galaxy: 1, done: ['arc-rabedira'] }, legs: [ { id: 'lane', verb: { kind: 'ambush' }, place: { kind: 'band', ...SIDE_JOB_RANGE }, spawn: [...LANE_PIRATES], @@ -34,6 +34,7 @@ export const ARC_VETITICE: Skeleton = { }, { id: 'pod', verb: { kind: 'rescue' }, place: { kind: 'band', ...SIDE_JOB_RANGE }, + ambush: { ships: [...LONE_KRAIT], say: 'SOMEBODY WANTED THE SURVEYOR.' }, line: 'DIRECTOR: SCOOP THE SURVEYOR\'S POD AT {TARGET} AND DOCK', deadlineDays: ARC_LEG_DAYS, next: [ { on: { survivor: 'landed' }, to: 'manifest', settle: { pay: ARC_PAY.rescue, say: 'SURVEYOR LANDED — {PAY}. THE MANIFEST GOES TO {TARGET}.' } }, @@ -51,7 +52,7 @@ export const ARC_VETITICE: Skeleton = { ], }, { - id: 'manifest', verb: { kind: 'deliver' }, + id: 'manifest', verb: { kind: 'deliver' }, spawn: [...PAIR], place: { kind: 'handover', toward: 'arc-xeer', ...ARC_HANDOVER_JUMPS }, line: 'DIRECTOR: DELIVER THE MANIFEST TO {TARGET}', deadlineDays: ARC_LEG_DAYS, next: [ diff --git a/src/missions/skeletons/arcs/xeer.ts b/src/missions/skeletons/arcs/xeer.ts index 1a97af80..f342eda6 100644 --- a/src/missions/skeletons/arcs/xeer.ts +++ b/src/missions/skeletons/arcs/xeer.ts @@ -13,6 +13,7 @@ import { ARC_HANDOVER_JUMPS, ARC_LEG_DAYS, ARC_PAY, SCAN_SECONDS, SIDE_JOB_RANGE import { SOURCE_DESIGN } from '../../../game/ship-specs.ts'; import { shipDesignIdOf } from '../../../game/ship-identity.ts'; import type { Skeleton } from '../../model.ts'; +import { PAIR, wingmanOf } from '../lane.ts'; const TOWARD = { kind: 'handover', toward: 'arc-edle', ...ARC_HANDOVER_JUMPS } as const; @@ -23,7 +24,7 @@ export const ARC_XEER: Skeleton = { patron: { kind: 'world', seedSlot: 150 }, hail: 'THE PORT ADMINISTRATOR AT XEER WANTS PROOF', pitch: 'AN ANACONDA IS FORGING THE HARVEST. WATCH IT, THEN CATCH THE SHIP THAT CARRIES THE PAPERS.', - offer: { done: ['arc-vetitice'] }, + offer: { galaxy: 1, done: ['arc-vetitice'] }, legs: [ { id: 'watch', verb: { kind: 'scan', ship: shipDesignIdOf(SOURCE_DESIGN.anaconda), seconds: SCAN_SECONDS }, @@ -38,6 +39,7 @@ export const ARC_XEER: Skeleton = { }, { id: 'chase', verb: { kind: 'hunt', ship: shipDesignIdOf(SOURCE_DESIGN.ferDeLance), canEscape: true }, place: TOWARD, + spawn: [...wingmanOf(shipDesignIdOf(SOURCE_DESIGN.ferDeLance), 'chase')], line: 'ADMINISTRATOR: DESTROY THE FER-DE-LANCE — LAST SEEN AT {TARGET}', deadlineDays: ARC_LEG_DAYS, next: [ { on: 'targetDestroyed', to: 'complete', settle: { pay: ARC_PAY.hunt, say: 'COURIER DESTROYED — {PAY} FROM XEER' } }, @@ -47,7 +49,7 @@ export const ARC_XEER: Skeleton = { ], }, { - id: 'trail', verb: { kind: 'deliver' }, place: TOWARD, + id: 'trail', verb: { kind: 'deliver' }, place: TOWARD, spawn: [...PAIR], line: 'ADMINISTRATOR: REPORT THE COURIER\'S TRAIL AT {TARGET}', deadlineDays: ARC_LEG_DAYS, next: [ { on: 'success', to: 'complete', settle: { pay: ARC_PAY.deliver, say: 'TRAIL REPORTED — {PAY} FROM XEER' } }, diff --git a/src/missions/skeletons/constrictor.ts b/src/missions/skeletons/constrictor.ts index 722e5c70..3c01ff5c 100644 --- a/src/missions/skeletons/constrictor.ts +++ b/src/missions/skeletons/constrictor.ts @@ -14,7 +14,7 @@ // leg it belongs to. // // The report leg has a standing order of its own. Stage 2 printed nothing, -// and a commander who took a job before she reported had no row to say so. +// and a commander who took a job before they reported had no row to say so. // Invariant 16 wants every order on a screen. import { diff --git a/src/missions/skeletons/index.ts b/src/missions/skeletons/index.ts index 57621d18..8e205fbc 100644 --- a/src/missions/skeletons/index.ts +++ b/src/missions/skeletons/index.ts @@ -8,8 +8,9 @@ import type { Skeleton } from '../model.ts'; import { ARCS } from './arcs/index.ts'; import { CONSTRICTOR } from './constrictor.ts'; import { SIDE_JOBS } from './side.ts'; +import { WHEEL } from './wheel/index.ts'; -export const SKELETONS: readonly Skeleton[] = [CONSTRICTOR, ...ARCS, ...SIDE_JOBS]; +export const SKELETONS: readonly Skeleton[] = [CONSTRICTOR, ...ARCS, ...WHEEL, ...SIDE_JOBS]; /** * The arcs of the tour, in order. The arc at index k starts at the k-th diff --git a/src/missions/skeletons/lane.ts b/src/missions/skeletons/lane.ts index 958f6945..89ade4ae 100644 --- a/src/missions/skeletons/lane.ts +++ b/src/missions/skeletons/lane.ts @@ -1,21 +1,54 @@ -// The pirates on a lane the player is sent to clear (docs/TODO/203 M2). +// The pirates a job keeps company with: on a lane, at a jump-in, and beside +// a target (docs/TODO/203 M2, docs/TODO/214 M1). // // The job Clear the Lane promises pirates on the way to the target world. So -// does the first leg of the Vetitice arc. Neither spawned any before this file, so -// the player flew an ordinary lane and could not tell what the job was about. -// Both legs name this list, so the lane is the same fight from either patron. +// does the first leg of the Vetitice arc. Neither spawned any before this file, +// so the player flew an ordinary lane and could not tell what the job was +// about. Both legs name `LANE_PIRATES`, so the lane is the same fight from +// either patron. // -// Three light hulls, flown as pirates. A Krait pair and a Mamba is a fight a -// Cobra with pulse lasers can win. It is still a fight, which a lone -// Sidewinder is not. The tags answer nothing in the machine. A lane pirate is -// scenery with a gun, and a kill among them settles no branch. +// docs/TODO/214 M1 adds the rest. Chris, 2026-09-13: *"there are pirates +// waiting for you when you jump in"*. A hunt's target flies with a wingman. +// A delivery meets a pair at the far end, because somebody wants the packet. +// A canister adrift has a Krait circling it. The words say nothing of any +// of them, and that is the surprise. A spawn list is outside the dossier +// hash, so no dossier changes. +// +// Every one of these is scenery with a gun. The tags answer nothing in the +// machine, and a kill among them settles no branch. The flight probe under +// docs/reviews/missions-2026-09-12 measures what they cost a fresh commander. import { SOURCE_DESIGN } from '../../game/ship-specs.ts'; import { shipDesignIdOf } from '../../game/ship-identity.ts'; -import type { TaggedShip } from '../model.ts'; +import type { ShipId, TaggedShip } from '../model.ts'; + +const krait = shipDesignIdOf(SOURCE_DESIGN.krait); +const mamba = shipDesignIdOf(SOURCE_DESIGN.mamba); +const sidewinder = shipDesignIdOf(SOURCE_DESIGN.sidewinder); +/** + * Three light hulls, flown as pirates. A Krait pair and a Mamba is a fight a + * Cobra with pulse lasers can win. It is still a fight, which a lone + * Sidewinder is not. + */ export const LANE_PIRATES: readonly TaggedShip[] = [ - { ship: shipDesignIdOf(SOURCE_DESIGN.krait), tag: 'lane#krait-1', job: 'hunt' }, - { ship: shipDesignIdOf(SOURCE_DESIGN.krait), tag: 'lane#krait-2', job: 'hunt' }, - { ship: shipDesignIdOf(SOURCE_DESIGN.mamba), tag: 'lane#mamba', job: 'hunt' }, + { ship: krait, tag: 'lane#krait-1', job: 'hunt' }, + { ship: krait, tag: 'lane#krait-2', job: 'hunt' }, + { ship: mamba, tag: 'lane#mamba', job: 'hunt' }, +]; + +/** Two Sidewinders at the far end of a delivery: somebody wants the packet. */ +export const PAIR: readonly TaggedShip[] = [ + { ship: sidewinder, tag: 'pair#sidewinder-1', job: 'hunt' }, + { ship: sidewinder, tag: 'pair#sidewinder-2', job: 'hunt' }, ]; + +/** One Krait circling a canister adrift. */ +export const LONE_KRAIT: readonly TaggedShip[] = [ + { ship: krait, tag: 'lone#krait', job: 'hunt' }, +]; + +/** A wingman of the target's own design, so a hunt is two of its kind. */ +export function wingmanOf(ship: ShipId, leg: string): readonly TaggedShip[] { + return [{ ship, tag: `wing#${leg}`, job: 'hunt' }]; +} diff --git a/src/missions/skeletons/side.ts b/src/missions/skeletons/side.ts index e2c6433c..1c749a61 100644 --- a/src/missions/skeletons/side.ts +++ b/src/missions/skeletons/side.ts @@ -1,37 +1,56 @@ // The eight side jobs, one per verb, so every verb the machine knows is a // job a player can take (docs/TODO/190 M4). // -// Each is a LOCAL job. The patron is whoever runs the station she stands at. +// Each is a LOCAL job. The patron is whoever runs the station the commander +// stands at. // About a third of the jobs are on any one world's board (offers.ts). Each -// has a `failed` branch, a deadline, and a fee from `SIDE_JOB_PAY`. The +// has a `failed` branch, a deadline, and a fee from `SIDE_JOB_PAY`. None +// has a cap, so each comes back a week after it ends (failure rule 5). The +// two scoops wait for fuel scoops, because a canister breaks on a hull +// without them (docs/TODO/213 M2). The // rescue is the scientist of docs/TODO/190: a pod shot before the scoop // turns the leg into a delivery at a lower fee. The words are plain, and a // dossier (item 191) replaces them on screen when one exists. import { NARCOTICS } from '../../constants/commodities.ts'; import { - RESCUE_SALVAGE_PAY, SCAN_SECONDS, SIDE_JOB_DAYS, SIDE_JOB_PAY, SIDE_JOB_RANGE, - SMUGGLE_TONNES, + GANG_BOUNTY, GANG_BROKEN_BOUNTY, GANG_HUNT_KILLS, LANE_JOB_KILLS, RESCUE_SALVAGE_PAY, SCAN_SECONDS, + SIDE_JOB_DAYS, SIDE_JOB_PAY, SIDE_JOB_RANGE, SMUGGLE_TONNES, } from '../../constants/missions.ts'; import { SOURCE_DESIGN } from '../../game/ship-specs.ts'; import { shipDesignIdOf } from '../../game/ship-identity.ts'; import type { Branch, Skeleton } from '../model.ts'; -import { LANE_PIRATES } from './lane.ts'; +import { LANE_PIRATES, LONE_KRAIT, PAIR } from './lane.ts'; const FAIL: Branch = { on: 'failed', to: 'fail' }; const LOCAL = { kind: 'side', anchor: 'local', patron: { kind: 'local' } } as const; const AWAY = { kind: 'band', ...SIDE_JOB_RANGE } as const; +/** + * The side hunt is a gang (docs/TODO/217 M1). Chris, 2026-09-13: *"rather + * than a single ship - it should be a gang of pirates"*, and *"a tough + * gang"*. A Fer-de-Lance leads it, with an Asp, a Krait and a Mamba. The + * probe chose the three (docs/TODO/217 M3). An Asp with a Cobra Mk III + * killed a stock Cobra six times in eight. This gang killed it three. + * The job ends when the lane is clear. The leader runs when it is nearly + * dead (docs/TODO/214 M4), and a gang whose leader got away pays half. + */ export const SIDE_HUNT: Skeleton = { ...LOCAL, id: 'side-hunt', hail: 'THE STATION HAS A BOUNTY POSTED', - pitch: 'A KRAIT HAS BEEN TAKING SHIPS ON THE LANE. THE STATION WANTS IT GONE.', - offer: {}, + pitch: 'A GANG HAS BEEN TAKING SHIPS ON THE LANE. THE STATION WANTS EVERY ONE OF THEM GONE.', + // The board waits for a Mostly Harmless commander (docs/TODO/217 M2). + offer: { minKills: GANG_HUNT_KILLS }, legs: [{ - id: 'hunt', verb: { kind: 'hunt', ship: shipDesignIdOf(SOURCE_DESIGN.krait), canEscape: true }, - place: AWAY, line: 'BOUNTY: DESTROY THE KRAIT — LAST SEEN AT {TARGET}', deadlineDays: SIDE_JOB_DAYS, + id: 'hunt', + verb: { + kind: 'hunt', ship: shipDesignIdOf(SOURCE_DESIGN.ferDeLance), canEscape: true, + gang: [shipDesignIdOf(SOURCE_DESIGN.asp), shipDesignIdOf(SOURCE_DESIGN.krait), shipDesignIdOf(SOURCE_DESIGN.mamba)], + }, + place: AWAY, line: 'BOUNTY: CLEAR THE GANG — LAST SEEN AT {TARGET}', deadlineDays: SIDE_JOB_DAYS, next: [ - { on: 'targetDestroyed', to: 'complete', settle: { pay: SIDE_JOB_PAY.hunt, say: 'THE KRAIT IS DESTROYED. THE STATION PAYS {PAY}.' } }, - { on: 'targetEscaped', to: 'fail' }, + { on: 'targetDestroyed', to: 'complete', settle: { pay: GANG_BOUNTY, say: 'THE GANG IS DESTROYED. THE STATION PAYS {PAY}.' } }, + { on: 'targetFled', to: 'complete', settle: { pay: GANG_BROKEN_BOUNTY, say: 'THE GANG IS BROKEN, BUT ITS LEADER RAN. THE STATION PAYS {PAY}.' } }, + { on: 'targetEscaped', to: 'complete', settle: { pay: GANG_BROKEN_BOUNTY, say: 'THE GANG IS BROKEN, BUT ITS LEADER JUMPED. THE STATION PAYS {PAY}.' } }, FAIL, ], }], @@ -44,7 +63,7 @@ export const SIDE_DELIVER: Skeleton = { pitch: 'A SEALED PACKET FOR A STATION ONE JUMP OUT. NO QUESTIONS, NO HOLD SPACE.', offer: {}, legs: [{ - id: 'run', verb: { kind: 'deliver' }, place: AWAY, + id: 'run', verb: { kind: 'deliver' }, place: AWAY, spawn: [...PAIR], line: 'DELIVERY: TAKE THE PACKET TO {TARGET}', deadlineDays: SIDE_JOB_DAYS, next: [ { on: 'success', to: 'complete', settle: { pay: SIDE_JOB_PAY.deliver, say: 'THE PACKET IS DELIVERED. THE STATION PAYS {PAY}.' } }, @@ -58,10 +77,11 @@ export const SIDE_DELIVER: Skeleton = { export const SIDE_RECOVER: Skeleton = { ...LOCAL, id: 'side-recover', hail: 'THE STATION LOST SOMETHING', pitch: 'A CANISTER WENT ADRIFT ONE JUMP OUT. SCOOP IT AND BRING IT BACK.', - offer: {}, + offer: { scoops: true }, legs: [ { - id: 'find', verb: { kind: 'recover', item: 'station-canister' }, place: AWAY, + id: 'find', verb: { kind: 'recover', item: 'station-canister' }, place: AWAY, spawn: [...LONE_KRAIT], + ambush: { ships: [...PAIR], say: 'THEY WERE WAITING FOR THE CANISTER.' }, line: 'RECOVERY: SCOOP THE CANISTER ADRIFT AT {TARGET}', deadlineDays: SIDE_JOB_DAYS, next: [ { on: 'success', to: 'home', settle: { pay: 0, say: 'THE CANISTER IS ABOARD. BRING IT BACK TO {TARGET}.' } }, @@ -85,10 +105,11 @@ export const SIDE_RECOVER: Skeleton = { export const SIDE_RESCUE: Skeleton = { ...LOCAL, id: 'side-rescue', hail: 'THE STATION HAS A PILOT ADRIFT', pitch: 'A SURVEY PILOT IS IN A POD ONE JUMP OUT. BRING HER IN ALIVE.', - offer: {}, + offer: { scoops: true }, legs: [ { id: 'pod', verb: { kind: 'rescue' }, place: AWAY, + ambush: { ships: [...LONE_KRAIT], say: 'SOMEBODY WANTED THAT POD.' }, line: 'RESCUE: SCOOP THE POD ADRIFT AT {TARGET} AND DOCK', deadlineDays: SIDE_JOB_DAYS, next: [ { on: { survivor: 'landed' }, to: 'complete', settle: { pay: SIDE_JOB_PAY.rescue, say: 'THE PILOT IS LANDED SAFE. THE STATION PAYS {PAY}.' } }, @@ -115,7 +136,9 @@ export const SIDE_RESCUE: Skeleton = { export const SIDE_AMBUSH: Skeleton = { ...LOCAL, id: 'side-ambush', hail: 'THE STATION NEEDS A LANE CLEARED', pitch: 'PIRATES HOLD THE LANE TO A NEIGHBOUR. FLY IT, FIGHT THROUGH, AND DOCK THERE.', - offer: {}, + // A pack of three on the way, so the board waits for four kills + // (docs/TODO/217 M2). + offer: { minKills: LANE_JOB_KILLS }, legs: [{ id: 'lane', verb: { kind: 'ambush' }, place: AWAY, spawn: [...LANE_PIRATES], line: 'LANE: FLY TO {TARGET} THROUGH WHATEVER WAITS, AND DOCK', deadlineDays: SIDE_JOB_DAYS, @@ -147,9 +170,11 @@ export const SIDE_SMUGGLE: Skeleton = { export const SIDE_ESCORT: Skeleton = { ...LOCAL, id: 'side-escort', hail: 'A TRADER AT THE STATION WANTS COVER', pitch: 'A PYTHON IS LEAVING FOR A NEIGHBOUR AND WANTS A GUN BESIDE HER. SEE HER INTO STATION RANGE.', - offer: {}, + // A pack of three on the way, so the board waits for four kills + // (docs/TODO/217 M2). + offer: { minKills: LANE_JOB_KILLS }, legs: [{ - id: 'cover', verb: { kind: 'escort', ship: shipDesignIdOf(SOURCE_DESIGN.python) }, place: AWAY, + id: 'cover', verb: { kind: 'escort', ship: shipDesignIdOf(SOURCE_DESIGN.python) }, place: AWAY, spawn: [...PAIR], line: 'ESCORT: SEE THE PYTHON INTO STATION RANGE AT {TARGET}', deadlineDays: SIDE_JOB_DAYS, next: [ { on: 'success', to: 'complete', settle: { pay: SIDE_JOB_PAY.escort, say: 'THE PYTHON IS SAFE IN STATION RANGE. THE STATION PAYS {PAY}.' } }, @@ -169,6 +194,7 @@ export const SIDE_SCAN: Skeleton = { legs: [{ id: 'watch', verb: { kind: 'scan', ship: shipDesignIdOf(SOURCE_DESIGN.anaconda), seconds: SCAN_SECONDS }, place: AWAY, line: 'SCAN: HOLD THE ANACONDA AT {TARGET} ON YOUR SCANNER', deadlineDays: SIDE_JOB_DAYS, + ambush: { ships: [...PAIR], say: 'THE ANACONDA\'S ESCORT SAW THE SCAN.' }, next: [ { on: 'success', to: 'complete', settle: { pay: SIDE_JOB_PAY.scan, say: 'THE SCAN IS COMPLETE. THE STATION PAYS {PAY}.' } }, { on: 'targetDestroyed', to: 'fail', settle: { pay: 0, standing: -2 } }, diff --git a/src/missions/skeletons/wheel/blockade.ts b/src/missions/skeletons/wheel/blockade.ts new file mode 100644 index 00000000..24929c1d --- /dev/null +++ b/src/missions/skeletons/wheel/blockade.ts @@ -0,0 +1,50 @@ +// The Dark Wheel's second trial: the blockade (docs/TODO/219 M2). +// +// The Wheel asks for a clean record, and then it asks the commander to keep +// it. The job is a parcel of narcotics to a world one jump out. Three +// Vipers wait at the far end. A scan on the way reads the hold and fails +// the trial. The Wheel does not use a pilot the police know. One more chance, +// as the mark gives, and then none. + +import { NARCOTICS } from '../../../constants/commodities.ts'; +import { ARC_LEG_DAYS, SIDE_JOB_RANGE, SMUGGLE_TONNES, WHEEL_PAY, WHEEL_WHISPER_RUNG } from '../../../constants/missions.ts'; +import { SOURCE_DESIGN } from '../../../game/ship-specs.ts'; +import { shipDesignIdOf } from '../../../game/ship-identity.ts'; +import type { Skeleton, TaggedShip } from '../../model.ts'; + +const AWAY = { kind: 'band', ...SIDE_JOB_RANGE } as const; + +/** Three Vipers at the far end. They are the law, and they read holds. */ +const viper = shipDesignIdOf(SOURCE_DESIGN.viper); +const BLOCKADE: readonly TaggedShip[] = [ + { ship: viper, tag: 'blockade#viper-1', job: 'police' }, + { ship: viper, tag: 'blockade#viper-2', job: 'police' }, + { ship: viper, tag: 'blockade#viper-3', job: 'police' }, +]; + +export const WHEEL_BLOCKADE: Skeleton = { + id: 'wheel-blockade', + kind: 'arc', + anchor: 'local', + patron: { kind: 'wheel' }, + hail: 'THE NOTE AGAIN. IT KNOWS WHO YOU ARE NOW.', + pitch: 'A PARCEL, ONE JUMP OUT, PAST A PATROL THAT READS HOLDS. KEEP YOUR RECORD CLEAN AND THE PARCEL ABOARD, AND YOU ARE ONE OF US.', + offer: { minRating: WHEEL_WHISPER_RUNG, flags: ['wheel.marked'], legalStatus: 'clean' }, + cap: 2, + legs: [ + { + id: 'run', + verb: { kind: 'smuggle', commodity: NARCOTICS, tonnes: SMUGGLE_TONNES }, + place: AWAY, + spawn: [...BLOCKADE], + line: 'THE WHEEL: LAND THE PARCEL AT {TARGET} WITHOUT A SCAN', + deadlineDays: ARC_LEG_DAYS, + next: [ + { on: 'success', to: 'complete', settle: { pay: WHEEL_PAY.blockade, setFlags: ['wheel.trusted'], say: 'THE PARCEL IS DOWN AND NOBODY READ YOUR HOLD. {PAY}. YOU ARE TRUSTED.' } }, + { on: 'failed', to: 'fail', settle: { pay: 0, say: 'THE HOLD WAS READ, OR THE PARCEL WAS NOT ABOARD. THE WHEEL DOES NOT USE A PILOT THE POLICE KNOW.' } }, + ], + }, + ], + complete: { pay: 0, standing: 1 }, + fail: { pay: 0, standing: -1 }, +}; diff --git a/src/missions/skeletons/wheel/door.ts b/src/missions/skeletons/wheel/door.ts new file mode 100644 index 00000000..2410bd59 --- /dev/null +++ b/src/missions/skeletons/wheel/door.ts @@ -0,0 +1,41 @@ +// The Dark Wheel's door (docs/TODO/219 M4). +// +// The last trial, at Competent. The Wheel names the place: witchspace, one +// more time. A Thargoid mothership waits there, tagged for the leg, with +// the trap's own around it. Clear it, and the door opens. The settlement +// grants the cloaking device, which no shop sells. It marks the commander +// as one of the Wheel. The words speak of Raxxla, and say no more. +// +// The mothership cannot escape, and the leg has no other way out but the +// deadline: one more chance after that, as every trial gives. + +import { ARC_LEG_DAYS, WHEEL_DOOR_RUNG } from '../../../constants/missions.ts'; +import { SOURCE_DESIGN } from '../../../game/ship-specs.ts'; +import { shipDesignIdOf } from '../../../game/ship-identity.ts'; +import type { Skeleton } from '../../model.ts'; + +export const WHEEL_DOOR: Skeleton = { + id: 'wheel-door', + kind: 'arc', + anchor: 'local', + patron: { kind: 'wheel' }, + hail: 'THE LAST NOTE. IT NAMES A DOOR.', + pitch: 'YOU HAVE DONE ENOUGH TO BE ASKED. THERE IS A DOOR WHERE NO CHART REACHES, AND SOMETHING GUARDS IT. ARM THE MIS-JUMP. CLEAR THE GUARD. SOME CALL WHAT IS BEHIND IT RAXXLA.', + offer: { minRating: WHEEL_DOOR_RUNG, flags: ['wheel.proven'] }, + cap: 2, + legs: [ + { + id: 'door', + verb: { kind: 'hunt', ship: shipDesignIdOf(SOURCE_DESIGN.thargoid), canEscape: false, job: 'thargoid' }, + place: { kind: 'witchspace' }, + line: 'THE WHEEL: CLEAR THE GUARD AT THE DOOR, IN {TARGET}', + deadlineDays: ARC_LEG_DAYS, + next: [ + { on: 'targetDestroyed', to: 'complete', settle: { pay: 0, setFlags: ['wheel.member'], grant: 'cloak', say: 'THE DOOR IS OPEN. WHAT FITS YOUR HULL NOW, NO SHOP SELLS. RAXXLA IS NOT A PLACE. IT IS THIS.' } }, + { on: 'failed', to: 'fail' }, + ], + }, + ], + complete: { pay: 0, standing: 1 }, + fail: { pay: 0, standing: -1 }, +}; diff --git a/src/missions/skeletons/wheel/index.ts b/src/missions/skeletons/wheel/index.ts new file mode 100644 index 00000000..1e2da24b --- /dev/null +++ b/src/missions/skeletons/wheel/index.ts @@ -0,0 +1,11 @@ +// The Dark Wheel's trials, in order (docs/TODO/219). Not the tour: the tour +// is five arcs at five worlds, and the Wheel has no world. Each trial waits +// for the one before it through the flag it set. + +import type { Skeleton } from '../../model.ts'; +import { WHEEL_MARK } from './mark.ts'; +import { WHEEL_BLOCKADE } from './blockade.ts'; +import { WHEEL_PILOT } from './pilot.ts'; +import { WHEEL_DOOR } from './door.ts'; + +export const WHEEL: readonly Skeleton[] = [WHEEL_MARK, WHEEL_BLOCKADE, WHEEL_PILOT, WHEEL_DOOR]; diff --git a/src/missions/skeletons/wheel/mark.ts b/src/missions/skeletons/wheel/mark.ts new file mode 100644 index 00000000..f33ddcd6 --- /dev/null +++ b/src/missions/skeletons/wheel/mark.ts @@ -0,0 +1,45 @@ +// The Dark Wheel's first trial: the mark (docs/TODO/219 M1). +// +// The whisper is the hail. Somebody watched the commander's last fight, and +// a note waits with no sender. The job is a pilot: a Fer-de-Lance that works +// the lanes with two Asps. The Wheel wants the pilot down, and the gang is +// what stands in the way. A leader that got away is a failed trial, and the +// Wheel gives one more chance (`cap`), then none. + +import { ARC_LEG_DAYS, SIDE_JOB_RANGE, WHEEL_PAY, WHEEL_WHISPER_RUNG } from '../../../constants/missions.ts'; +import { SOURCE_DESIGN } from '../../../game/ship-specs.ts'; +import { shipDesignIdOf } from '../../../game/ship-identity.ts'; +import type { Skeleton } from '../../model.ts'; + +const AWAY = { kind: 'band', ...SIDE_JOB_RANGE } as const; + +export const WHEEL_MARK: Skeleton = { + id: 'wheel-mark', + kind: 'arc', + anchor: 'local', + patron: { kind: 'wheel' }, + hail: 'A NOTE WAITS FOR YOU. NO SENDER, NO NAME.', + pitch: 'SOMEBODY WATCHED YOUR LAST FIGHT. A FER-DE-LANCE WORKS THE LANES WITH TWO ASPS. BRING ITS PILOT DOWN, AND WE WILL TALK AGAIN.', + offer: { minRating: WHEEL_WHISPER_RUNG }, + cap: 2, + legs: [ + { + id: 'mark', + verb: { + kind: 'hunt', ship: shipDesignIdOf(SOURCE_DESIGN.ferDeLance), canEscape: true, + gang: [shipDesignIdOf(SOURCE_DESIGN.asp), shipDesignIdOf(SOURCE_DESIGN.asp)], + }, + place: AWAY, + line: 'THE WHEEL: BRING DOWN THE FER-DE-LANCE AND ITS TWO ASPS NEAR {TARGET}', + deadlineDays: ARC_LEG_DAYS, + next: [ + { on: 'targetDestroyed', to: 'complete', settle: { pay: WHEEL_PAY.mark, setFlags: ['wheel.marked'], say: 'THE MARK IS DOWN. {PAY}, AND A WORD: WE WILL FIND YOU AGAIN.' } }, + { on: 'targetFled', to: 'fail', settle: { pay: 0, say: 'THE PILOT GOT AWAY. THE WHEEL WANTED THE PILOT, NOT THE GANG.' } }, + { on: 'targetEscaped', to: 'fail', settle: { pay: 0, say: 'THE PILOT JUMPED. THE WHEEL WANTED THE PILOT, NOT THE GANG.' } }, + { on: 'failed', to: 'fail' }, + ], + }, + ], + complete: { pay: 0, standing: 1 }, + fail: { pay: 0, standing: -1 }, +}; diff --git a/src/missions/skeletons/wheel/pilot.ts b/src/missions/skeletons/wheel/pilot.ts new file mode 100644 index 00000000..dd618c51 --- /dev/null +++ b/src/missions/skeletons/wheel/pilot.ts @@ -0,0 +1,37 @@ +// The Dark Wheel's third trial: the pilot (docs/TODO/219 M3). +// +// One of theirs is adrift in a pod in witchspace, where the Thargoids wait. +// The way there is an armed mis-jump. Scoop the pod among them, jump out, +// and land the pilot alive at any station. A pod shot, or a pilot sold, is a +// failed trial, and the Wheel does not sell its own. + +import { ARC_LEG_DAYS, WHEEL_PAY, WHEEL_WHISPER_RUNG } from '../../../constants/missions.ts'; +import type { Skeleton } from '../../model.ts'; + +export const WHEEL_PILOT: Skeleton = { + id: 'wheel-pilot', + kind: 'arc', + anchor: 'local', + patron: { kind: 'wheel' }, + hail: 'THE NOTE, A THIRD TIME. IT ASKS FOR ONE OF ITS OWN.', + pitch: 'ONE OF OURS IS ADRIFT IN A POD WHERE NO CHART REACHES. ARM THE MIS-JUMP, FIND THE POD AMONG THE THARGOIDS, AND BRING THE PILOT HOME ALIVE.', + offer: { minRating: WHEEL_WHISPER_RUNG, flags: ['wheel.trusted'] }, + cap: 2, + legs: [ + { + id: 'pod', + verb: { kind: 'rescue' }, + place: { kind: 'witchspace' }, + line: 'THE WHEEL: SCOOP THE POD ADRIFT IN {TARGET}, AND LAND THE PILOT ALIVE', + deadlineDays: ARC_LEG_DAYS, + next: [ + { on: { survivor: 'landed' }, to: 'complete', settle: { pay: WHEEL_PAY.pilot, setFlags: ['wheel.proven'], say: 'THE PILOT WALKS. {PAY}, AND THE WHEEL REMEMBERS.' } }, + { on: { survivor: 'sold' }, to: 'fail', settle: { pay: 0, say: 'YOU SOLD ONE OF OURS. THE WHEEL DOES NOT SELL ITS OWN.' } }, + { on: 'targetDestroyed', to: 'fail', settle: { pay: 0, say: 'THE POD IS GONE. THE WHEEL WANTED THE PILOT ALIVE.' } }, + { on: 'failed', to: 'fail' }, + ], + }, + ], + complete: { pay: 0, standing: 1 }, + fail: { pay: 0, standing: -1 }, +}; diff --git a/src/missions/story.ts b/src/missions/story.ts index 064cf434..a460996e 100644 --- a/src/missions/story.ts +++ b/src/missions/story.ts @@ -3,15 +3,18 @@ // The JOURNAL is the record of what happened: one entry per acceptance, per // branch taken, and per ending. This file turns it into pages, one per run // of a mission. A dossier's words are used where a dossier exists, and the -// skeleton's plain words where none does (docs/TODO/190 M5). The path she -// took is the story. A branch she did not take is never mentioned. +// skeleton's plain words where none does (docs/TODO/190 M5). The path the +// commander took is the story. A branch they did not take is never +// mentioned. // // It is pure, and it knows nothing about a screen. The LOG screen and the // site page (item 193 of docs/TODO/190) both render what it returns. import type { StarSystem } from '../galaxy/galaxy.ts'; +import { galaxySystems } from '../galaxy/galaxies.ts'; import { dossierFor } from './dossiers.ts'; -import type { Dossier, JournalEntry, MissionState } from './model.ts'; +import type { JournalEntry, MissionState } from './model.ts'; +import type { Dossier } from './words.ts'; import { SKELETONS, skeletonById } from './skeletons/index.ts'; import { fillSlots } from './text.ts'; @@ -21,6 +24,8 @@ export interface StoryPage { title: string; /** the world the run was accepted at */ origin: number; + /** the galaxy `origin` and `worlds` are indices in (docs/TODO/213 M4) */ + galaxy: number; /** every world the journal names on this run, in order, without repeats */ worlds: number[]; lines: string[]; @@ -40,10 +45,13 @@ function plainOutcome(outcome: string): string { const ENDINGS = new Set(['complete', 'fail', 'abandoned', 'galaxyLeft']); +/** The galaxy an entry's world is an index in: its own, or 1 for a record from before it was written. */ +const galaxyOf = (e: JournalEntry): number => e.galaxy ?? 1; + function lineFor( - e: JournalEntry, dossier: Dossier | null, systems: readonly StarSystem[], + e: JournalEntry, dossier: Dossier | null, galaxies: (galaxy: number) => readonly StarSystem[], ): string { - const slots = { WORLD: systems[e.world].name.toUpperCase(), DAY: String(e.day) }; + const slots = { WORLD: galaxies(galaxyOf(e))[e.world].name.toUpperCase(), DAY: String(e.day) }; const own = dossier?.story.legs[e.leg]?.[e.outcome]; if (own) return fillSlots(own, slots); if (e.outcome === 'accepted') { @@ -62,11 +70,17 @@ function lineFor( /** * The pages, oldest first. A run opens at its `accepted` entry and closes * at its ending. A run with no ending yet is the last page for its mission. + * + * Every world is named through the galaxy its own entry names. `systems` + * is galaxy 1's, for a caller that holds one galaxy, or a function of the + * galaxy number, which the LOG passes (docs/TODO/213 M4). */ export function storyPages( - st: MissionState, systems: readonly StarSystem[], + st: MissionState, systems: readonly StarSystem[] | ((galaxy: number) => readonly StarSystem[]), skeletons = SKELETONS, dossiers: (id: string) => Dossier | null = dossierFor, ): StoryPage[] { + const galaxies = typeof systems === 'function' ? systems + : (galaxy: number): readonly StarSystem[] => (galaxy === 1 ? systems : galaxySystems(galaxy)); const pages: StoryPage[] = []; const open = new Map(); for (const e of st.journal) { @@ -75,14 +89,14 @@ export function storyPages( const page: StoryPage = { skeleton: e.skeleton, title: dossier?.title ?? plainTitle(skeletonById(e.skeleton, skeletons)?.id ?? e.skeleton), - origin: e.world, worlds: [], lines: [], ending: null, + origin: e.world, galaxy: galaxyOf(e), worlds: [], lines: [], ending: null, }; pages.push(page); open.set(e.skeleton, page); } const page = open.get(e.skeleton); if (!page) continue; - page.lines.push(lineFor(e, dossiers(e.skeleton), systems)); + page.lines.push(lineFor(e, dossiers(e.skeleton), galaxies)); if (page.worlds[page.worlds.length - 1] !== e.world) page.worlds.push(e.world); if (e.outcome === 'complete' || e.outcome === 'fail') { page.ending = e.outcome; diff --git a/src/missions/text.ts b/src/missions/text.ts index 4ebd5e51..ca54e43a 100644 --- a/src/missions/text.ts +++ b/src/missions/text.ts @@ -4,6 +4,7 @@ // dossier's pages carry more. One filler serves both, so a slot spelt one way // in the skeleton cannot be spelt another way in the renderer. +import { WITCHSPACE_TARGET } from '../constants/missions.ts'; import type { StarSystem } from '../galaxy/galaxy.ts'; import { formatCredits } from '../game/commander.ts'; import type { Leg } from './model.ts'; @@ -18,7 +19,8 @@ export function lineSlots( systems: readonly StarSystem[], target: number | null, pay = 0, ): Record { return { - TARGET: target === null ? 'ANY STATION' : systems[target].name.toUpperCase(), + TARGET: target === null ? 'ANY STATION' + : target === WITCHSPACE_TARGET ? 'WITCHSPACE' : systems[target].name.toUpperCase(), PAY: formatCredits(pay), }; } diff --git a/src/missions/tour-html.ts b/src/missions/tour-html.ts index 973a0ae2..f2043f8e 100644 --- a/src/missions/tour-html.ts +++ b/src/missions/tour-html.ts @@ -42,7 +42,7 @@ function arcHtml(a: TourArc, n: number): string { export function tourHtml(m: TourModel): string { const arcs = m.arcs.map((a, i) => arcHtml(a, i + 1)).join('\n'); const jobs = m.sideJobs.flatMap((g) => g.jobs).map((j) => ` -
  • ${escapeHtml(j.title)} — ${escapeHtml(j.summary)}
  • `).join(''); +
  • ${escapeHtml(j.title)} — ${escapeHtml(j.summary)}${j.needs ? ` (needs ${escapeHtml(j.needs)})` : ''}
  • `).join(''); return `

    The route

    diff --git a/src/missions/tour-page.ts b/src/missions/tour-page.ts index 87d18197..fdc1f9af 100644 --- a/src/missions/tour-page.ts +++ b/src/missions/tour-page.ts @@ -5,7 +5,7 @@ // reaches no document and no window. The page's plugin (vite.config.ts) // turns it into markup. A test reads it as data. // -// It shows what the game shows a commander before she accepts: each arc's +// It shows what the game shows a commander before they accept: each arc's // title, patron, start world and briefing, and the shape of its legs. It // names which legs are recovery legs, because a failure that becomes a // branch is the tour's own point (docs/TODO/190). It does not show the @@ -20,7 +20,8 @@ import type { StarSystem } from '../galaxy/galaxy.ts'; import { routeTable } from '../galaxy/route.ts'; import { dossierFor } from './dossiers.ts'; -import type { CommanderFacts, Dossier, Patron, Skeleton, Verb } from './model.ts'; +import type { CommanderFacts, Skeleton, Verb, Gate } from './model.ts'; +import type { Dossier, Patron } from './words.ts'; import { patronFor } from './patrons.ts'; import { routeMapSvg } from './route-map.ts'; import { ARC_TOUR, SKELETONS, skeletonById } from './skeletons/index.ts'; @@ -50,7 +51,7 @@ export const JOB_SUMMARIES: Readonly> = { + 'Watch it without firing, then find and destroy the ship that carries the false papers.', 'arc-edle': 'A colonel at Edle has a manifest to move and a transporter to guard. ' + 'After that, an Asp is marked for destruction.', - 'side-hunt': 'A Krait is taking ships on the lane. Destroy it.', + 'side-hunt': 'A gang of four holds the lane, led by a Fer-de-Lance. Destroy every one of them.', 'side-deliver': 'Carry a sealed packet to a station one jump away. It takes no hold space.', 'side-recover': 'A canister went adrift one jump away. Scoop it and bring it back.', 'side-rescue': 'A survey pilot is adrift in a pod one jump away. Bring her in alive.', @@ -81,6 +82,8 @@ export interface TourJob { title: string; /** what the job is, in the site's voice */ summary: string; + /** what the board waits for before it offers the job, or null for none */ + needs: string | null; } export interface TourModel { @@ -101,6 +104,19 @@ function summaryOf(s: Skeleton): string { return JOB_SUMMARIES[s.id] ?? s.pitch; } +/** + * What a side job's board waits for, in words (docs/TODO/223 M3). A side + * job is gated by kills or by fuel scoops (docs/TODO/217 M2). A visitor who + * reads the page at zero kills would otherwise wait for a hunt that the + * board never offers. + */ +function needsOf(gate: Gate): string | null { + const parts: string[] = []; + if (gate.minKills !== undefined && gate.minKills > 0) parts.push(`${gate.minKills} kills`); + if (gate.scoops) parts.push('fuel scoops'); + return parts.length > 0 ? parts.join(' and ') : null; +} + /** * The legs that only a loss leads to. A leg is on the happy path when the * first leg is, or when a success branch of a happy leg names it. Every @@ -124,7 +140,7 @@ export function recoveryLegs(s: Skeleton): Set { } const facts = (galaxy: number, systemIndex: number): CommanderFacts => ({ - galaxy, systemIndex, kills: 0, combatScore: 0, legalStatus: 0, day: 0, cargo: [], + galaxy, systemIndex, kills: 0, combatScore: 0, legalStatus: 0, day: 0, cargo: [], scoops: false, }); function arcEntry( @@ -170,7 +186,10 @@ export function tourModel( if (s.kind !== 'side') continue; const verb = verbWord(s.legs[0].verb); const d = dossiers(s.id); - const job: TourJob = { id: s.id, title: d?.title ?? s.id.replace(/-/g, ' ').toUpperCase(), summary: summaryOf(s) }; + const job: TourJob = { + id: s.id, title: d?.title ?? s.id.replace(/-/g, ' ').toUpperCase(), + summary: summaryOf(s), needs: needsOf(s.offer), + }; byVerb.set(verb, [...(byVerb.get(verb) ?? []), job]); } const sideJobs = [...byVerb.entries()].map(([verb, jobs]) => ({ verb, jobs })); diff --git a/src/missions/tour.ts b/src/missions/tour.ts index 1cf88b33..fe8a2d9d 100644 --- a/src/missions/tour.ts +++ b/src/missions/tour.ts @@ -8,11 +8,13 @@ // world's random stream, so the tour is the same in every career. Lave's // tour is Lave's tour. // -// A galactic jump runs the same rule on the new galaxy from the arrival -// world (machine.ts). Galaxies 3, 4, 6, 7 and 8 each strand a group of -// worlds. A commander who arrives on an island gets a tour of the island. -// The bands widen when a band is empty, and a start is never a world no -// chain of jumps reaches. +// The tour is galaxy 1's. An arc is offered in the galaxy its gate names, +// and a lead keeps its galaxy across a galactic jump (docs/TODO/213 M2). A +// galactic jump used to run this rule on the new galaxy. The arc the moved +// lead opened then placed its legs by seed index, which is not a tour. The +// bands widen when a band is empty. A start is never a world no chain of +// jumps reaches. So a tour from any world of any galaxy is still well +// formed. import { TOUR_ARCS, TOUR_STEP_JUMPS } from '../constants/missions.ts'; import type { StarSystem } from '../galaxy/galaxy.ts'; @@ -49,12 +51,3 @@ export function arcStarts(systems: readonly StarSystem[], first: number, count = } return starts; } - -/** - * Where a lead to the arc at `tourIndex` points in a galaxy entered at - * `arrival`. A skeleton outside the tour waits at the arrival world. - */ -export function leadWorldIn(systems: readonly StarSystem[], arrival: number, tourIndex: number): number { - if (tourIndex < 0) return arrival; - return arcStarts(systems, arrival)[tourIndex] ?? arrival; -} diff --git a/src/missions/verbs/deliver.ts b/src/missions/verbs/deliver.ts index 049248d3..ec4c2823 100644 --- a/src/missions/verbs/deliver.ts +++ b/src/missions/verbs/deliver.ts @@ -1,8 +1,9 @@ // Deliver: dock at the leg's world. A null target means any station. // // The Constrictor's report leg and its courier leg are both this verb. The -// first has no target, and the second has one. A cargo delivery checks the -// hold too, and that arrives with docs/TODO/190 M4. +// first has no target, and the second has one. The verb carries a `cargo` +// field for a delivery that checks the hold. No shipped leg sets it, and +// this module reads none (docs/TODO/213 M5). import type { VerbModule } from './verb.ts'; diff --git a/src/missions/verbs/escort.ts b/src/missions/verbs/escort.ts index 6911aa62..9b93c3c7 100644 --- a/src/missions/verbs/escort.ts +++ b/src/missions/verbs/escort.ts @@ -3,7 +3,9 @@ // The game decides when that holds and sends `escortSafe` (world-step.ts, // under the rules docs/TODO/190 states). This leg only reads the verdict. A // ship lost on the way, to anyone, is `targetDestroyed`. One that jumps out -// is `targetEscaped`. +// is `targetEscaped`, whether it left on its own or ran from a hit. The +// charge that ran was ignored until docs/TODO/213 M1, and the commonest +// way an escort is lost was silent. import type { VerbModule } from './verb.ts'; @@ -12,6 +14,6 @@ export const escort: VerbModule = (ctx, input) => { if (!('tag' in input) || input.tag !== ctx.live.tag) return null; if (input.kind === 'escortSafe') return { trigger: 'success' }; if (input.kind === 'escortLost' || input.kind === 'destroyed') return { trigger: 'targetDestroyed' }; - if (input.kind === 'escaped') return { trigger: 'targetEscaped' }; + if (input.kind === 'escaped' || input.kind === 'fled') return { trigger: 'targetEscaped' }; return null; }; diff --git a/src/missions/verbs/hunt.ts b/src/missions/verbs/hunt.ts index 07fcf61f..ea5e7845 100644 --- a/src/missions/verbs/hunt.ts +++ b/src/missions/verbs/hunt.ts @@ -3,15 +3,69 @@ // The leg's `tag` is the ship's. A `destroyed` input with another tag is some // other ship, and it means nothing here. That one comparison is what stops a // second hunt from claiming the first hunt's kill. +// +// A HUNTED SHIP CAN DIE WITHOUT THE COMMANDER (docs/TODO/208 M3). A pirate +// takes it, or it flies into a rock. The world sends `escortLost` for a +// tagged ship wrecked with credit to nobody, and the hunt ignored it until +// now. The leg then stayed live with no ship left in the galaxy to kill. The +// ship is gone either way, so the hunt takes it as destroyed. A branch that +// paid less for another's kill would be a third outcome, and no skeleton +// has one. +// +// A SHIP THAT CANNOT ESCAPE CANNOT RUN OUT EITHER (docs/TODO/213 M5). The +// Constrictor is always where the Navy says. `fled` reached this module +// for any hunt. So the lint's table of what a hunt can emit needed the +// Constrictor to ignore a word it can never hear. +// +// A GANG IS COUNTED (docs/TODO/217 M1). A hunt whose verb names a gang ends +// when every ship is gone, and `progress` counts them. A member's tag is +// the leader's with `#gang-N` on the end (machine.ts). A member fights to +// the end, so only a kill takes one off. The leader may run, and the record +// says so once it did. So the last ship gone decides the trigger by the +// leader's fate: killed pays the gang, and a leader that ran pays half. -import type { VerbModule } from './verb.ts'; +import type { VerbContext, VerbModule, VerbReaction } from './verb.ts'; +import type { MissionInput } from '../model.ts'; export const hunt: VerbModule = (ctx, input) => { const verb = ctx.leg.verb; if (verb.kind !== 'hunt') return null; - if (!('tag' in input) || input.tag !== ctx.live.tag) return null; - if (input.kind === 'destroyed') return { trigger: 'targetDestroyed' }; - if (input.kind === 'escaped' && verb.canEscape) return { trigger: 'targetEscaped' }; + if (!('tag' in input)) return null; + if (verb.gang !== undefined && verb.gang.length > 0) return gangHunt(ctx, input, verb.gang.length, verb.canEscape); + if (input.tag !== ctx.live.tag) return null; + if (input.kind === 'destroyed' || input.kind === 'escortLost') { + return { trigger: 'targetDestroyed' }; + } + if (!verb.canEscape) return null; + if (input.kind === 'escaped') return { trigger: 'targetEscaped' }; if (input.kind === 'fled') return { trigger: 'targetFled' }; return null; }; + +/** One ship of the gang gone, and what that makes of the leg. */ +function gangHunt( + ctx: VerbContext, input: Extract, members: number, canEscape: boolean, +): VerbReaction | null { + const leader = ctx.live.tag; + if (leader === null) return null; + const isLeader = input.tag === leader; + const isMember = input.tag.startsWith(`${leader}#gang-`); + if (!isLeader && !isMember) return null; + const killed = input.kind === 'destroyed' || input.kind === 'escortLost'; + const left = input.kind === 'fled' || input.kind === 'escaped'; + if (!killed && !left) return null; + // A member fights to the end. A leader that cannot escape cannot leave. + if (left && (isMember || !canEscape)) return null; + const gone = ctx.live.progress + 1; + const remaining = members + 1 - gone; + // The leader's fate: it left now, or the record says it did. + const leaderRan = isLeader ? left : ctx.entities[leader]?.fled === true; + if (remaining > 0) { + const say = isLeader + ? `THE LEADER ${left ? 'RAN FOR IT' : 'IS DOWN'}. ${remaining} OF THE GANG LEFT.` + : `ONE OF THE GANG IS DOWN. ${remaining} LEFT.`; + return { progress: gone, say }; + } + if (!leaderRan) return { progress: gone, trigger: 'targetDestroyed' }; + return { progress: gone, trigger: input.kind === 'escaped' && isLeader ? 'targetEscaped' : 'targetFled' }; +} diff --git a/src/missions/verbs/recover.ts b/src/missions/verbs/recover.ts index ffcc5ae8..6a4a2e79 100644 --- a/src/missions/verbs/recover.ts +++ b/src/missions/verbs/recover.ts @@ -9,7 +9,7 @@ import type { VerbModule } from './verb.ts'; export const recover: VerbModule = (ctx, input) => { if (ctx.leg.verb.kind !== 'recover') return null; if (!('tag' in input) || input.tag !== ctx.live.tag) return null; - if (input.kind === 'scooped') return { trigger: 'success' }; + if (input.kind === 'scooped') return { trigger: 'success', sprung: true }; if (input.kind === 'destroyed') return { trigger: 'targetDestroyed' }; return null; }; diff --git a/src/missions/verbs/registry.ts b/src/missions/verbs/registry.ts index b8a86b2a..68b4309c 100644 --- a/src/missions/verbs/registry.ts +++ b/src/missions/verbs/registry.ts @@ -4,8 +4,14 @@ // `test/mission-skeletons.test.ts` fails on a skeleton that names one. All // eight are here since docs/TODO/190 M4. The table stays partial in type, so // a ninth verb added to the model is a lint failure until its module lands. +// +// `verbTriggers` says what each module can emit, and the lint holds every +// leg to it. A trigger a verb can emit has a branch, or the leg lists it +// under `ignores` (docs/TODO/213 M5). Three arcs carried a branch the world +// could not reach, and one side job dropped a trigger on the floor, and no +// gate saw either. -import type { Verb } from '../model.ts'; +import type { TaggedShip, Trigger, Verb } from '../model.ts'; import { ambush } from './ambush.ts'; import { deliver } from './deliver.ts'; import { escort } from './escort.ts'; @@ -31,8 +37,36 @@ export function verbNeedsShip(verb: Verb): verb is Extract): 'hunt' | 'escort' | 'scan' { - return verb.kind === 'hunt' ? 'hunt' : verb.kind === 'escort' ? 'escort' : 'scan'; +export function verbJob(verb: Extract): 'hunt' | 'escort' | 'scan' | 'thargoid' { + if (verb.kind === 'hunt') return verb.job ?? 'hunt'; + return verb.kind === 'escort' ? 'escort' : 'scan'; +} + +/** + * The roster role a job's ship is spawned and restored under. The spawn and + * the restore each mapped it alone until docs/TODO/213 M4, and the restore + * read every tagged ship as a pirate. + */ +export function jobRole(job: TaggedShip['job']): 'pirate' | 'trader' | 'police' | 'thargoid' { + if (job === 'police' || job === 'thargoid') return job; + return job === 'hunt' ? 'pirate' : 'trader'; +} + +/** + * Every trigger a verb's module can emit for a leg with this verb. The + * machine adds `failed` and `deadlinePassed` of its own, and failure rule 1 + * already holds the `failed` branch. + */ +export function verbTriggers(verb: Verb): Trigger[] { + switch (verb.kind) { + case 'hunt': return verb.canEscape + ? ['targetDestroyed', 'targetEscaped', 'targetFled'] : ['targetDestroyed']; + case 'deliver': case 'ambush': return ['success']; + case 'recover': return ['success', 'targetDestroyed']; + case 'rescue': return [{ survivor: 'landed' }, { survivor: 'sold' }, 'targetDestroyed']; + case 'smuggle': return ['success', 'failed']; + case 'escort': case 'scan': return ['success', 'targetDestroyed', 'targetEscaped']; + } } /** The item a recover or a rescue leg puts adrift, or null. */ diff --git a/src/missions/verbs/rescue.ts b/src/missions/verbs/rescue.ts index ad8e1035..36786043 100644 --- a/src/missions/verbs/rescue.ts +++ b/src/missions/verbs/rescue.ts @@ -1,9 +1,9 @@ // Rescue: scoop a tagged capsule, and bring its passenger to a station. // // The scoop is progress, not success. The passenger rides in the crew spaces -// under the pod's own tag (`MissionState.passengers`). The leg ends when she -// answers for them at a dock. That answer is `landed` for medical or a -// release, and `sold` for the Slaves row. A branch on each says what the +// under the pod's own tag (`MissionState.passengers`). The leg ends when the +// commander answers for them at a dock. That answer is `landed` for medical +// or a release, and `sold` for the Slaves row. A branch on each says what the // patron makes of it. // // A pod shot before the scoop is `targetDestroyed`. The scientist example in @@ -15,7 +15,7 @@ export const rescue: VerbModule = (ctx, input) => { if (ctx.leg.verb.kind !== 'rescue') return null; if (!('tag' in input) || input.tag !== ctx.live.tag) return null; if (input.kind === 'scooped') { - return { progress: 1, passenger: true, say: 'THE PILOT IS ABOARD. DOCK AT ANY STATION AND LAND HER.' }; + return { progress: 1, passenger: true, sprung: true, say: 'THE PILOT IS ABOARD. DOCK AT ANY STATION AND LAND THE PILOT.' }; } if (input.kind === 'destroyed' && ctx.live.progress < 1) return { trigger: 'targetDestroyed' }; if (input.kind === 'survivor') return { trigger: { survivor: input.fate } }; diff --git a/src/missions/verbs/scan.ts b/src/missions/verbs/scan.ts index 981ef136..9ab0809e 100644 --- a/src/missions/verbs/scan.ts +++ b/src/missions/verbs/scan.ts @@ -2,15 +2,17 @@ // // The game counts the seconds (world-step.ts) and sends `scanned` once they // are up. A target destroyed before then is `targetDestroyed`, and a patron -// who wanted it watched, not killed, says what that costs. +// who wanted it watched, not killed, says what that costs. A subject wrecked +// with credit to nobody is destroyed all the same. One that ran from a hit +// escaped, as much as one that jumped out (docs/TODO/213 M1). import type { VerbModule } from './verb.ts'; export const scan: VerbModule = (ctx, input) => { if (ctx.leg.verb.kind !== 'scan') return null; if (!('tag' in input) || input.tag !== ctx.live.tag) return null; - if (input.kind === 'scanned') return { trigger: 'success' }; - if (input.kind === 'destroyed') return { trigger: 'targetDestroyed' }; - if (input.kind === 'escaped') return { trigger: 'targetEscaped' }; + if (input.kind === 'scanned') return { trigger: 'success', sprung: true }; + if (input.kind === 'destroyed' || input.kind === 'escortLost') return { trigger: 'targetDestroyed' }; + if (input.kind === 'escaped' || input.kind === 'fled') return { trigger: 'targetEscaped' }; return null; }; diff --git a/src/missions/verbs/smuggle.ts b/src/missions/verbs/smuggle.ts index 344bd106..e813f93a 100644 --- a/src/missions/verbs/smuggle.ts +++ b/src/missions/verbs/smuggle.ts @@ -1,9 +1,11 @@ // Smuggle: carry the patron's goods to the leg's world, past the police. // // The goods go aboard when the leg starts (the machine's `cargo` effect). A -// dock at the target with the tonnes still aboard succeeds. A dock there -// with fewer is `failed`: the goods were sold. A police scan on the way is -// `failed` too, because the hold was read. +// dock at the target with the tonnes still aboard succeeds, and the goods +// leave the hold (the `unload` effect, docs/TODO/213 M2). They stayed aboard +// before that, so a run paid its fee and left the narcotics to sell. A dock +// there with fewer is `failed`: the goods were sold. A police scan on the +// way is `failed` too, because the hold was read. import type { VerbModule } from './verb.ts'; @@ -15,5 +17,5 @@ export const smuggle: VerbModule = (ctx, input) => { const t = ctx.live.target; if (t !== null && t !== ctx.commander.systemIndex) return null; const aboard = ctx.commander.cargo[verb.commodity] ?? 0; - return { trigger: aboard >= verb.tonnes ? 'success' : 'failed' }; + return aboard >= verb.tonnes ? { trigger: 'success', unload: true } : { trigger: 'failed' }; }; diff --git a/src/missions/verbs/verb.ts b/src/missions/verbs/verb.ts index 6c0189ba..2d2abc1e 100644 --- a/src/missions/verbs/verb.ts +++ b/src/missions/verbs/verb.ts @@ -5,12 +5,14 @@ // reports, and the machine resolves. A module never pays, never moves a leg, // and never reads the commander's purse. -import type { CommanderFacts, Leg, LiveMission, MissionInput, Trigger } from '../model.ts'; +import type { CommanderFacts, Leg, LiveMission, MissionInput, Trigger, EntityState } from '../model.ts'; export interface VerbContext { readonly live: LiveMission; readonly leg: Leg; readonly commander: CommanderFacts; + /** the tagged things on record, read only: a gang hunt reads its leader's fate */ + readonly entities: Readonly>; } /** @@ -22,6 +24,10 @@ export interface VerbReaction { progress?: number; /** the scooped pod is this mission's passenger; the machine records it */ passenger?: boolean; + /** the leg's goods are delivered, and the machine asks the game to take them off */ + unload?: boolean; + /** the step that springs the leg's ambush, where the leg has one (docs/TODO/214 M2) */ + sprung?: boolean; /** * What the console says about a step that takes no branch, with `{TARGET}` * for the leg's world (docs/TODO/203 M4). A branch speaks through its diff --git a/src/missions/words.ts b/src/missions/words.ts new file mode 100644 index 00000000..15cad251 --- /dev/null +++ b/src/missions/words.ts @@ -0,0 +1,59 @@ +// The words a model writes: a patron, and a mission's dossier. +// +// A PATRON is the person who offers a mission, by name, role and voice +// (`patrons.ts`). A DOSSIER is one mission's generated words, written +// offline and committed as JSON (`dossiers.ts`, docs/TODO/191). The machine +// never reads either, so generated text can never change what a mission +// does (docs/TODO/190). These shapes left `model.ts` when docs/TODO/213 M5 +// pushed it over the size ceiling. The rules stay there. + +export interface Patron { + id: string; + world: number | 'navy' | 'wheel'; + name: string; + role: string; + species: string; + voice: string; + /** image path; '' uses the world's portrait */ + portrait: string; +} + +/** + * A mission's generated words. Each field may carry the slots its comment + * names and no other, and `tools/dossier-faults.ts` holds that (docs/TODO/191). + */ +export interface Dossier { + skeleton: string; + /** the prompt hash it was written from, which covers the skeleton's shape */ + hash: string; + /** a name for the mission, with no slot */ + title: string; + /** pages the patron speaks, with {PATRON} {HERE} */ + briefing: string[]; + /** by leg: the console's word on the leg, with {TARGET} {PAY} */ + legs: Record; + /** the LEADS row, with {PATRON} {WORLD} */ + lead: string; + /** the board's rumour inside the rumour range, and the message one jump out, with {PATRON} {WORLD} */ + rumour: { far: string; near: string }; + /** the DATA ON line, with {PATRON} {WORLD} */ + news: string; + /** paths under `public/`; an absent file degrades to no image */ + images: { target?: string; place?: string }; + story: { + /** with {WORLD} {DAY} */ + opening: string; + closing: { complete: string; fail: string }; + /** by leg, then by trigger label (`triggerLabel`, machine.ts), with {WORLD} {DAY} */ + legs: Record>; + }; +} + +/** One committed dossier file, with what the run that wrote it cost. */ +export interface DossierFile { + promptVersion: number; + model: string; + generated: string; + usage: { requests: number; inputTokens: number; outputTokens: number }; + dossier: Dossier; +} diff --git a/src/ships/geometry.ts b/src/ships/geometry.ts index f1ab92ab..1bc3ff94 100644 --- a/src/ships/geometry.ts +++ b/src/ships/geometry.ts @@ -124,7 +124,7 @@ export const HERMIT_BEACON_ON = 0.5; /** * A bright nav beacon that marks a rock as an inhabited hermit. It is the tell - * that stops a commander from a shot at an outpost she took for a plain + * that stops a commander from a shot at an outpost they took for a plain * asteroid. A small * unlit mesh perched on the rock; `npc.ts` blinks it on the world step. Amber so * it reads against the grey rock and is not mistaken for a hostile's fire. diff --git a/src/style.css b/src/style.css index 7f171f12..8271cfe2 100644 --- a/src/style.css +++ b/src/style.css @@ -10,6 +10,9 @@ html, body { width: 100%; height: 100%; overflow: hidden; + /* No pinch and no double tap zoom on a phone (docs/TODO/220). A button + keeps its own `touch-action: none`. */ + touch-action: pan-x pan-y; background: #000; font-family: "Menlo", "Consolas", monospace; color: var(--hud-green); @@ -221,8 +224,9 @@ body.screen-open #message { left: 50%; transform: translateX(-50%); display: flex; + flex-direction: column; align-items: stretch; - gap: 10px; + gap: 8px; padding: 10px 14px; border: 1px solid var(--hud-dim); border-bottom: none; @@ -231,6 +235,8 @@ body.screen-open #message { pointer-events: none; } +/* The three panels, side by side; the gun row sits under them (docs/TODO/215 M1). */ +.hud-row { display: flex; align-items: stretch; gap: 10px; } .hud-panel { display: flex; flex-direction: column; justify-content: space-between; gap: 6px; } .hud-left, .hud-right { width: 210px; padding: 4px 0; } @@ -270,12 +276,6 @@ body.screen-open #message { #scanner { display: block; } .compass-row { display: flex; align-items: center; justify-content: space-between; } -.missiles { display: flex; gap: 4px; padding-left: 80px; } -.missiles span { - width: 12px; height: 12px; - border: 1px solid var(--hud-dim); - background: rgba(var(--hud-green-rgb), 0.5); -} #help { position: absolute; @@ -362,8 +362,6 @@ body.screen-open #message { box-shadow: 0 0 8px var(--hud-red); } -.missiles span.spent { background: transparent; } -.missiles span.locked { background: var(--hud-red); box-shadow: 0 0 8px var(--hud-red); } #lock { position: absolute; @@ -420,29 +418,171 @@ body.screen-open #message { } /* - * The key prompts, directly under the message line and quieter than it. - * - * Dim rather than amber, and smaller: a prompt is an answer offered, not an - * event announced, and it must never read as the more urgent of the two lines - * when both are up. Same centring and the same z-index band as #message, so a - * prompt raised while a screen is open is not painted underneath it. + * The course buttons (docs/TODO/205 M5): top right of the view, under the + * topbar, clear of the gunsight and of the console. They take a click and a + * tap, so each is a real target of at least 40 pixels. Under a screen the + * flight world stops, and the buttons hide with the HUD. */ -#prompts { +#courses { position: absolute; - left: 50%; bottom: 23vh; - transform: translateX(-50%); - z-index: 20; - font-size: 13px; - letter-spacing: 2px; - color: var(--hud-dim); - pointer-events: none; - white-space: nowrap; - text-align: center; + top: 44px; right: 12px; + z-index: 21; + display: flex; flex-direction: column; align-items: stretch; gap: 6px; + max-width: min(46vw, 320px); } -#prompts .prompt-key { - color: var(--hud-amber); - text-shadow: 0 0 8px rgba(var(--hud-amber-rgb), 0.7); +#courses .hud-button { + min-height: 40px; + display: flex; flex-direction: column; justify-content: center; + padding: 4px 12px; + font-size: 13px; letter-spacing: 1px; + color: var(--hud-green); + background: rgba(0, 10, 2, 0.72); + border: 1px solid rgba(var(--hud-green-rgb), 0.45); + cursor: pointer; + user-select: none; +} +#courses .hud-button:hover { background: rgba(var(--hud-green-rgb), 0.18); } +#courses .hud-button.lit { color: var(--hud-amber); border-color: rgba(var(--hud-amber-rgb), 0.55); } +#courses .hud-button.dim { opacity: 0.6; } +#courses .hud-button .note { font-size: 10px; color: var(--hud-amber); letter-spacing: 1px; } +#courses .hud-button .hint { font-size: 10px; color: var(--hud-dim); letter-spacing: 1px; } +/* An icon button sits small at the right, under the header (docs/TODO/221). */ +#courses .hud-button.icon { + align-self: flex-end; min-height: 30px; padding: 2px 12px; + font-size: 16px; letter-spacing: 2px; +} +body.screen-open #courses { visibility: hidden; } + +/* + * THE TWO ROWS (docs/TODO/215 M2). A list opens as one line of buttons that + * scrolls sideways, the full width, with a snap to each button. The course + * row sits under its header, and the target row above its header. A row that + * stacked downward covered the sky; a row that runs sideways covers one + * line of it, and only while it is open. The scrollbar is hidden, because + * the row is thumbed, and a bar under a button is a thing to miss. + */ +#course-row, #target-row { + position: absolute; + left: 0; right: 0; + z-index: 21; + display: flex; flex-direction: row; align-items: stretch; gap: 8px; + padding: 0 12px; + overflow-x: auto; overflow-y: hidden; + scroll-snap-type: x mandatory; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; } +#course-row::-webkit-scrollbar, #target-row::-webkit-scrollbar { display: none; } +#course-row { top: 100px; } +#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; + min-height: 48px; + scroll-snap-align: start; + display: flex; flex-direction: column; justify-content: center; + padding: 4px 12px; + font-size: 13px; letter-spacing: 1px; + color: var(--hud-green); + background: rgba(0, 10, 2, 0.8); + border: 1px solid rgba(var(--hud-green-rgb), 0.45); + cursor: pointer; + user-select: none; -webkit-user-select: none; +} +#course-row .hud-button:hover, #target-row .hud-button:hover { background: rgba(var(--hud-green-rgb), 0.18); } +#course-row .hud-button.lit, #target-row .hud-button.lit { color: var(--hud-amber); border-color: rgba(var(--hud-amber-rgb), 0.55); } +#course-row .hud-button.dim, #target-row .hud-button.dim { opacity: 0.6; } +#course-row .hud-button .note, #target-row .hud-button .note { font-size: 10px; color: var(--hud-amber); letter-spacing: 1px; } +#course-row .hud-button .hint, #target-row .hud-button .hint { font-size: 10px; color: var(--hud-dim); letter-spacing: 1px; } +body.screen-open #course-row, body.screen-open #target-row { visibility: hidden; } + +/* + * The target list (docs/TODO/206 M3): bottom left, above the console, where + * a thumb rests, and it opens upward. It opened over the guns on the right + * until 2026-09-13, and the two columns together covered the fight (Chris: + * "the buttons cover up the area where all the action is happening"). + */ +#targets { + position: absolute; + left: 12px; bottom: calc(var(--console-h, 180px) + 14px); + z-index: 21; + 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 + * red button on the left, where a right thumb reaches across, and it is + * held. The console takes no pointer events, so the row takes its own. + */ +#guns { + display: flex; align-items: stretch; gap: 8px; + pointer-events: auto; +} +#guns .hud-button, #targets .hud-button { + min-height: 44px; + display: flex; flex-direction: column; justify-content: center; + padding: 4px 12px; + font-size: 13px; letter-spacing: 1px; + color: var(--hud-green); + background: rgba(0, 10, 2, 0.72); + border: 1px solid rgba(var(--hud-green-rgb), 0.45); + cursor: pointer; + user-select: none; -webkit-user-select: none; + touch-action: none; +} +#guns .hud-button { flex: 1 1 0; align-items: center; text-align: center; padding: 4px 6px; } +#guns [data-hold] { flex: 1.6 1 0; } +#guns .hud-button:hover, #targets .hud-button:hover { background: rgba(var(--hud-green-rgb), 0.18); } +#guns .hud-button.lit, #targets .hud-button.lit { color: var(--hud-amber); border-color: rgba(var(--hud-amber-rgb), 0.55); } +#guns .hud-button.dim, #targets .hud-button.dim { opacity: 0.6; } +#guns .hud-button .note, #targets .hud-button .note { font-size: 10px; color: var(--hud-amber); letter-spacing: 1px; } +#guns .hud-button .hint, #targets .hud-button .hint { font-size: 10px; color: var(--hud-dim); letter-spacing: 1px; } +#guns [data-hold] { color: var(--hud-red); border-color: rgba(var(--hud-red-rgb), 0.6); } + +/* + * The roll strip of the last stretch into the slot (docs/TODO/207 M2). It is + * wide and short, and the knob follows the pointer, so a thumb can hold a + * small roll steady. `engine/strip-control.ts` moves the knob. + */ +#guns .hud-strip { + position: relative; + flex: 2 1 0; + height: 56px; + display: flex; align-items: center; justify-content: center; + border: 1px solid rgba(var(--hud-amber-rgb), 0.55); + background: rgba(var(--hud-amber-rgb), 0.08); + cursor: ew-resize; + user-select: none; -webkit-user-select: none; + touch-action: none; +} +#guns .hud-strip .knob { + position: absolute; + width: 34px; height: 34px; + border: 2px solid var(--hud-amber); + border-radius: 50%; +} +.bar .limit { + position: absolute; + top: -3px; bottom: -3px; + width: 2px; + z-index: 1; + background: var(--hud-green); + box-shadow: 0 0 6px rgba(var(--hud-green-rgb), 0.9); +} + +#guns .hud-strip .label { + font-size: 10px; letter-spacing: 1px; color: var(--hud-dim); + position: absolute; bottom: 3px; +} +body.screen-open #targets { visibility: hidden; } /* * The exercise strip — top left, under the topbar. @@ -765,7 +905,6 @@ body.screen-open #exercise { display: none; } background: var(--hud-amber); box-shadow: 0 0 8px var(--hud-amber); } -.missiles { padding-left: 8px; } #chart-canvas, #local-canvas { cursor: crosshair; } @@ -835,13 +974,22 @@ 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); } #screen .buttons { display: flex; gap: 8px; justify-content: center; margin-top: 14px; flex-wrap: wrap; } /* A button in a table row (the MISSIONS screen's ACCEPT and ABANDON) wears the same face as one in the button row, smaller. */ -#screen .buttons button, #screen table button { +#screen .buttons button, #screen table button, #screen .keys button { background: rgba(0, 30, 6, 0.9); border: 1px solid var(--hud-dim); color: var(--hud-green); @@ -854,6 +1002,18 @@ body.screen-open #exercise { display: none; } #screen .buttons button:hover, #screen table button:hover { border-color: var(--hud-green); background: rgba(var(--hud-green-rgb), 0.16); } #screen .buttons button:active, #screen table button:active { background: var(--hud-green); color: #001402; } #screen table button { padding: 3px 8px; letter-spacing: 1px; } +/* + * The key grid a name screen and a chart's search carry (docs/TODO/216 M1). + * Ten columns, so the digits are one row and the alphabet is three. A wide + * key is a word, SPACE, DEL or ENTER, and it spans two columns. The grid is + * held to 460 pixels, or a key on a desktop is a bar. A chart writes the + * grid into an empty element while its search runs (game/screens/chart.ts). + */ +#screen .keys { display: grid; grid-template-columns: repeat(10, 1fr); gap: 4px; margin: 12px auto 0; max-width: 460px; } +#screen .keys button { padding: 8px 0; letter-spacing: 0; font-size: 12px; } +#screen .keys .wide { grid-column: span 2; } +/* A chart's button row steps aside while its search runs (game/screens/chart.ts). */ +#screen .buttons.hidden { display: none; } /* The LOG screen's route map: the chart's 2:1 frame, held to a height that leaves the pages and the BACK row on the screen. */ #screen .route-map { display: block; width: 100%; max-height: 200px; margin: 10px auto 0; } @@ -861,7 +1021,6 @@ body.screen-open #exercise { display: none; } /* combat overlay: target bracket + lead reticle */ #reticle { position: absolute; inset: 0; pointer-events: none; } -.missiles span.armed { background: var(--hud-amber); box-shadow: 0 0 8px var(--hud-amber); } /* * A NARROW WINDOW: the docked screens on a phone (docs/TODO/197). @@ -904,8 +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); } - #prompts { bottom: calc(var(--console-h, 0px) + 2dvh); } + #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 @@ -940,17 +1098,36 @@ body.screen-open #exercise { display: none; } gap: 6px 10px; padding: 8px 10px; } - .hud-panel { display: contents; } - #scanner { grid-column: 1; grid-row: 1; width: 100%; height: auto; } + .hud-row, .hud-panel { display: contents; } + /* + * THE SHAPE CHRIS DREW (docs/TODO/215 M1). The scanner is two rows tall on + * the left. The compass and the letters sit above the four right gauges + * on the right. The left gauges and the shields share the rows below, and + * the guns are the last row across both columns. + */ + #scanner { grid-column: 1; grid-row: 1 / span 5; width: 100%; height: auto; } .compass-row { grid-column: 2; grid-row: 1; - flex-direction: column; - justify-content: center; + flex-direction: row; + justify-content: space-around; gap: 8px; } - .missiles { padding-left: 0; } - .hud-left .gauge { grid-column: 1; } .hud-right .gauge { grid-column: 2; } + .hud-right .gauge:nth-child(1) { grid-row: 2; } + .hud-right .gauge:nth-child(2) { grid-row: 3; } + .hud-right .gauge:nth-child(3) { grid-row: 4; } + .hud-right .gauge:nth-child(4) { grid-row: 5; } + .hud-left .gauge:nth-child(1) { grid-column: 1; grid-row: 6; } + .hud-left .gauge:nth-child(2) { grid-column: 1; grid-row: 7; } + .hud-left .gauge:nth-child(3) { grid-column: 1; grid-row: 8; } + .hud-left .gauge:nth-child(4) { grid-column: 2; grid-row: 6; } + .hud-left .gauge:nth-child(5) { grid-column: 2; grid-row: 7; } + .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. */ @@ -975,4 +1152,5 @@ body.screen-open #exercise { display: none; } #screen tr.pick td { padding-top: 15px; padding-bottom: 15px; } #screen .buttons button { padding: 15px 18px; } #screen table button { padding: 15px 12px; } + #screen .keys button { padding: 12px 0; } } diff --git a/src/ui/briefing.ts b/src/ui/briefing.ts index 1de9cd7c..e8233a13 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,47 +87,61 @@ 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. The \u25B6\u25B6 button 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', - body: `Sooner or later somebody opens fire. Your laser shoots straight - ahead: put them in the crosshair and hold the trigger — the stick and - trigger depend on your keyboard layout, and ${KEY.help} shows - yours. Lasers overheat; short bursts.

    - ${KEY.armMissile} arms a missile, which locks when a target - crosses your sights; ${KEY.fireMissile} fires it.

    - Kills raise your rating toward Elite. If the fight goes badly, run - — and if the worst happens, death puts you back at the last station you - docked at, without the flight you were on.`, + 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. The \u25CE button above it lists what is out + there, and a row sends the computer after that one.

    + 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.

    + Kills raise your rating toward Elite. If the fight goes badly, + RUN FOR IT is on the left, with the offers that fit: pay a patrol + off, or throw cargo to a pirate. Press a steering key at any time and + you fly the ship yourself. If the worst happens, + death puts you back at the last station you docked at, without the + flight you were on.`, }, { title: 'DOCKING', body: `The hard part, and everybody finds it hard at first.

    - The station rotates, and so does its docking port. An amber marker - shows where the port is, with an arrow at the edge of the screen when it - is behind you.

    - Get onto the axis straight out from the port, then roll until you match - its rotation — the opening is a letterbox and you must be the same way - up as it. Then go in slowly. The marker turns green when you are lined - up.

    - When you can afford one, buy a docking computer and press - ${KEY.dockingComputer}.`, + The station rotates, and so does its docking port. Choose + FLY TO THE STATION and the ship flies the approach for you. Near + the port it hands you the last stretch, and two things are then + yours.

    + The first is the roll: the opening is a letterbox, and you must be + the same way up as it. Drag the strip at the bottom right, or use your + roll keys. The second is the speed: hold THRUST or + BRAKE, and cross the mark on the speed bar before you go in. The + port marker turns green when you are lined up.

    + Get it wrong and you scrape the hull, bounce clear and try again. When + you can afford one, buy a docking computer and press + ${KEY.dockingComputer}: it flies the slot for you.`, }, { title: 'STAYING ALIVE', diff --git a/src/ui/chart-galactic.ts b/src/ui/chart-galactic.ts index 3755e261..5282f83e 100644 --- a/src/ui/chart-galactic.ts +++ b/src/ui/chart-galactic.ts @@ -34,6 +34,7 @@ export function renderChart(
    +
    ${chartKeyline(overlays.mode)}
    ${chartButtons()} `); diff --git a/src/ui/chart-local.ts b/src/ui/chart-local.ts index fc03eb3c..a83b57e1 100644 --- a/src/ui/chart-local.ts +++ b/src/ui/chart-local.ts @@ -3,9 +3,10 @@ // Split out of `ui/screens.ts` by docs/TODO/149 — see `chart-galactic.ts` for // why the two charts are two files. // -// Centred on where she stands rather than on the galaxy, so this is the chart -// that answers "where can I go next". It carries the market estimate beside it -// for that reason: the question a player asks of a neighbour is what it pays. +// Centred on where the commander stands rather than on the galaxy, so this is +// the chart that answers "where can I go next". It carries the market +// estimate beside it for that reason: the question a player asks of a +// neighbour is what it pays. import { type StarSystem, ECONOMY_NAMES, GOVERNMENT_NAMES, speciesName } from '../galaxy/galaxy.ts'; import { planetDescription } from '../galaxy/goatsoup.ts'; @@ -18,7 +19,7 @@ import { leadDestinations, orderDestinations } from '../game/orders.ts'; import { type MarketEstimate } from '../game/market.ts'; import { type ChartState } from '../game/chart-state.ts'; import { type ChartOverlays } from '../game/chart-overlay.ts'; -import { TENTHS_PER_CHART_UNIT, CHART_Y_SQUASH, LOCAL_SCALE, LOCAL_CANVAS } from '../constants/chart-metric.ts'; +import { TENTHS_PER_CHART_UNIT, CHART_Y_SQUASH, LOCAL_SCALE, LOCAL_CANVAS, CHART_LABEL_PX } from '../constants/chart-metric.ts'; import { maybeById, show } from './screen-shell.ts'; import { portraitUrl } from './portrait.ts'; import { nearestSystem, journey, daysTerm, contractTerm, chartKeyline, chartButtons } from './chart-readout.ts'; @@ -37,11 +38,21 @@ export function renderLocalChart(
    +
    ${chartKeyline(overlays.mode)}
    ${chartButtons()} `, true); drawLocalChart(systems, c, chart, overlays); } +/** + * Canvas pixels per CSS pixel: the canvas width over its width on the page. + * One with no width on the page, as under node, scales by one. + */ +export function labelScale(canvas: { width: number; clientWidth?: number }): number { + const shown = canvas.clientWidth ?? 0; + return shown > 0 ? canvas.width / shown : 1; +} + export function drawLocalChart( systems: StarSystem[], c: CommanderData, @@ -81,7 +92,11 @@ export function drawLocalChart( // and that reads correctly: freight on its way out of the neighbourhood. drawLanes(ctx, overlays, systems, px, py); - ctx.font = '10px Menlo, Consolas, monospace'; + // The names are drawn in CSS pixels (docs/TODO/220). So a phone that + // shows the canvas at two thirds of its width reads them at the same size + // as a desktop does. + const k = labelScale(canvas); + ctx.font = `${Math.round(CHART_LABEL_PX * k)}px Menlo, Consolas, monospace`; for (const s of systems) { const x = px(s); const y = py(s); @@ -89,13 +104,18 @@ export function drawLocalChart( const within = distanceTenths(current, s) <= c.fuel; ctx.fillStyle = within ? HUD.green : TINT.lane; ctx.beginPath(); - ctx.arc(x, y, s.index === c.systemIndex ? 3.5 : 2.5, 0, Math.PI * 2); + ctx.arc(x, y, (s.index === c.systemIndex ? 3.5 : 2.5) * k, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = within ? TINT.liftLabel : TINT.farLabel; - ctx.fillText(s.name.toUpperCase(), x + 7, y - 6); + // A name that would run off the right edge sits to the left of its dot. + // A recording canvas measures nothing, and then the name sits right. + const name = s.name.toUpperCase(); + const wide = ctx.measureText(name)?.width ?? 0; + const left = x + 7 * k + wide > w; + ctx.fillText(name, left ? x - 7 * k - wide : x + 7 * k, y - 6 * k); } - drawPriceTells(ctx, overlays.prices, systems, px, py, 8); + drawPriceTells(ctx, overlays.prices, systems, px, py, Math.round(8 * k)); // Pirate activity, as on the galactic chart. Same cull as the dots above. A // ring for a system this zoom left off the edge would land at a coordinate diff --git a/src/ui/chart-overlays.ts b/src/ui/chart-overlays.ts index 2441eefa..c12052ab 100644 --- a/src/ui/chart-overlays.ts +++ b/src/ui/chart-overlays.ts @@ -62,9 +62,10 @@ export function drawContractMarks( * A pointer over every world a saved lead names — on both charts. * * A TRIANGLE, and not a second diamond. A lead is not an order: nobody - * briefed her, and nothing is owed (docs/TODO/190 M3). The shape carries - * which fact it is, as the diamond does for an order, and the same amber - * carries the tone. It sits above the world, clear of a diamond's top point. + * briefed the commander, and nothing is owed (docs/TODO/190 M3). The shape + * carries which fact it is, as the diamond does for an order, and the same + * amber carries the tone. It sits above the world, clear of a diamond's top + * point. */ export function drawLeadMarks( ctx: CanvasRenderingContext2D, diff --git a/src/ui/chart-readout.ts b/src/ui/chart-readout.ts index 1c160e2a..05608a22 100644 --- a/src/ui/chart-readout.ts +++ b/src/ui/chart-readout.ts @@ -142,13 +142,19 @@ export function contractTerm( */ /** * The button row under either chart, for a finger (docs/TODO/198). A tap on a - * system already targets it. These are the three keys a phone has no way to - * press: D for the data screen, T for the overlay and Escape to leave. + * system already targets it. These are the five keys a phone has no way to + * press. D opens the data screen. M opens the market estimate. T cycles the + * overlay. F starts the search. Escape leaves. The search's letters are the + * key grid (`ui/key-grid.ts`). The screen paints the grid while the search + * runs, and it hides this row by its id for as long. The search reads every + * key as a letter, so a tap on DATA would type a D (docs/TODO/216 M2). */ export const chartButtons = (): string => ` -
    +
    + +
    `; export const chartKeyline = (mode: ChartOverlay): string => diff --git a/src/ui/key-grid.ts b/src/ui/key-grid.ts new file mode 100644 index 00000000..6991526c --- /dev/null +++ b/src/ui/key-grid.ts @@ -0,0 +1,37 @@ +// The key grid: the letters a finger types on a screen that takes a name. +// +// A phone has no keyboard. Three screens take a typed name, and the two +// charts find a system by typed letters. Each reads the keys through +// `Input.drainPresses`, and a tap on a `data-key` button is the same +// keystroke (invariant 13). So the grid is buttons that carry the key codes +// the screens already read, and no screen changes for it (docs/TODO/216 M1). +// +// Ten columns, so the digits are one row and the alphabet is three. The +// fourth row ends in two wide keys. The stylesheet holds the shape under +// `#screen .keys`. +// +// Two kinds. A NAME takes digits, letters, SPACE and DEL, which is the +// alphabet `normaliseSaveName` keeps. A FIND takes letters, DEL and ENTER, +// because the chart's search (game/screens/chart-search.ts) ends on Enter +// and keeps the cursor on the match. It takes no digit, because no system's +// name has one. + +/** What the grid offers: the alphabet of a name, or the letters of a search. */ +export type KeyGridKind = 'name' | 'find'; + +const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; +const digits = '1234567890'; + +/** One key. `wide` spans two columns, for a word rather than a letter. */ +const key = (code: string, label: string, wide = false): string => + ``; + +/** The grid, as markup. `show()` paints it with the rest of the screen. */ +export function keyGrid(kind: KeyGridKind): string { + const numbers = kind === 'name' ? [...digits].map((d) => key(`Digit${d}`, d)) : []; + const letters = [...alphabet].map((l) => key(`Key${l}`, l)); + const tail = kind === 'name' + ? [key('Space', 'SPACE', true), key('Backspace', 'DEL', true)] + : [key('Backspace', 'DEL', true), key('Enter', 'ENTER', true)]; + return `
    ${[...numbers, ...letters, ...tail].join('')}
    `; +} diff --git a/src/ui/key-help.ts b/src/ui/key-help.ts index 5032012c..6d5709bb 100644 --- a/src/ui/key-help.ts +++ b/src/ui/key-help.ts @@ -75,6 +75,17 @@ export function keyIfBound(mode: ControlMode, command: Command): string | null { return b && !isVirtualKey(b.key) ? keyLabel(b.key, b.shift) : null; } +/** + * The CODE bound to a command, for a button that presses it (docs/TODO/205 + * M5). The course list's chart button sends the chart key's own code, so the + * button and the key cannot part company. It answers null for an unbound + * command, as `keyIfBound` does. + */ +export function keyCodeIfBound(mode: ControlMode, command: Command): string | null { + const b = [...BINDINGS[mode], ...GLOBAL_BINDINGS].find((x) => x.command === command); + return b && !b.shift ? b.key : null; +} + /** * A code no keyboard produces: a menu row's, or a button's (docs/TODO/202). * diff --git a/src/ui/row-arrows.ts b/src/ui/row-arrows.ts new file mode 100644 index 00000000..9132eb31 --- /dev/null +++ b/src/ui/row-arrows.ts @@ -0,0 +1,14 @@ +// A value between two arrows, on a row a finger can step. +// +// The test mode and the trainer's setup each hold a list of rows. The left +// and right arrow keys change the value on the selected row. A phone has no +// arrow key. So each row carries the two as buttons, and each button +// carries the row's own index beside its key. `ScreenHost.click` selects the +// row first, and then sends the key (docs/TODO/216 M3). One home, because +// the two screens are built the same way and a third list would copy it. + +/** The value cell's contents: a left arrow, the value, a right arrow. */ +export const rowArrows = (row: number, value: string): string => + `` + + ` ${value} ` + + ``; diff --git a/src/ui/screen-host.ts b/src/ui/screen-host.ts index d6f418ab..6ec78c9a 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 = @@ -231,6 +231,12 @@ export class ScreenHost { * it take exactly the same path through the screen. `data-row` goes to * `select()`. Either way a screen implements ONE input surface. * + * A BUTTON THAT CARRIES BOTH SELECTS ITS ROW FIRST, and then sends its key + * (docs/TODO/216 M3). So an arrow on a row acts on THAT row, and not on the + * row the cursor was on. The test mode and the trainer's setup carry one on + * each row. A plain row carries no key, and a plain button carries no row, + * so neither path changes for them. + * * @returns true if the click was consumed. */ click(target: unknown, i: Input, event?: unknown): boolean { @@ -239,6 +245,13 @@ export class ScreenHost { // browser type at all. That keeps the orchestrator in the portable bucket. const el = target as HTMLElement; const e = event as MouseEvent | undefined; + const screen = this.top?.screen; + const row = el.dataset.row; + let picked = false; + if (row !== undefined && screen?.select) { + screen.select(Number(row)); + picked = true; + } const key = el.dataset.key; if (key !== undefined) { // The row's own modifier travels with the tap. A click is the same @@ -247,15 +260,7 @@ export class ScreenHost { i.injectPress(key, el.dataset.shift === '1'); return true; } - const row = el.dataset.row; - if (row !== undefined) { - const screen = this.top?.screen; - if (screen?.select) { - screen.select(Number(row)); - return true; - } - } - const screen = this.top?.screen; + if (picked) return true; if (screen?.clickAt && e) return screen.clickAt(el, e); return false; } diff --git a/src/ui/screens-career.ts b/src/ui/screens-career.ts index b87bc326..f48970a3 100644 --- a/src/ui/screens-career.ts +++ b/src/ui/screens-career.ts @@ -25,6 +25,8 @@ import { rating } from '../game/rating.ts'; import { saveLabel, type LiveRun, type LoadCost, type SaveSummary } from '../game/save-file.ts'; import { type TestModePanel } from '../game/screens/test-mode.ts'; import { show } from './screen-shell.ts'; +import { keyGrid } from './key-grid.ts'; +import { rowArrows } from './row-arrows.ts'; import { reservedNotes } from './reserved-note.ts'; /** What the commander file asks the pilot, if anything. */ @@ -147,6 +149,7 @@ export function renderSavePrompt(buffer: string, confirming: boolean): void { LETTERS AND NUMBERS · BACKSPACE · ENTER TO SAVE · ESC TO CANCEL `}
    + ${confirming ? '' : keyGrid('name')}
    ${confirming ? '' @@ -181,6 +184,11 @@ export function renderNaming(buffer: string, current = '', filedUnder = ''): voi LETTERS AND NUMBERS · BACKSPACE · ENTER TO CONFIRM · ESC TO CANCEL
    + ${keyGrid('name')} +
    + + +
    `); } /** @@ -208,6 +216,7 @@ export function renderNewCommander(buffer: string, leaving = ''): void { ESC ${leaving ? `KEEPS FLYING ${leaving}` : 'TO CANCEL'} + ${keyGrid('name')}
    @@ -290,11 +299,16 @@ export function renderTestMode(p: TestModePanel): void { // The heading is a `` with NO `data-row`, so a click on it walks up to a // table that has none either and is ignored — the row indices stay exactly // the panel's. Same construction as the trainer's setup rows. + // + // Each row carries its two arrows, with its own row index on each. So a + // tap selects the row and then steps it, and a phone can step a lever back + // (docs/TODO/216 M3). A click on the row itself only selects it. const rows = p.rows.map((r, i) => `${r.heading ? `${r.heading}` : ''} - ${r.label}${r.value} + ${r.label} + ${rowArrows(i, r.value)} `).join(''); show(`

    TEST MODE

    diff --git a/src/ui/screens-courses.ts b/src/ui/screens-courses.ts new file mode 100644 index 00000000..2a82da99 --- /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

    +
    +
    + CHOOSE WHERE TO GO +
    + +
    +
    TAP A ROW · ↑ ↓ SELECT · ENTER CHOOSE · ESC STAY DOCKED
    + `); +} diff --git a/src/ui/screens-trade.ts b/src/ui/screens-trade.ts index 2be593f1..8edad1bf 100644 --- a/src/ui/screens-trade.ts +++ b/src/ui/screens-trade.ts @@ -5,9 +5,9 @@ // what it will pay. // // `equipRows` is exported beside the painter it feeds, and that is deliberate. -// What is on the shelf, what it costs, and whether she can afford it are one -// question. `test/trade.test.ts` and `test/economy.test.ts` both ask it, and -// neither paints anything. +// What is on the shelf, what it costs, and whether the commander can afford +// it are one question. `test/trade.test.ts` and `test/economy.test.ts` both +// ask it, and neither paints anything. import { type StarSystem, type MarketEntry, COMMODITIES } from '../galaxy/galaxy.ts'; import { type CommanderData, cargoTonnes, consignedTonnes, formatCredits, cargoCapacity } from '../game/commander.ts'; @@ -39,7 +39,7 @@ export function renderMarket( // // The suffix reports the JOB and not a share of the hold, which is what // `consignedTonnes` reports. 15t of Food against a 5t consignment reads - // `15t · 5 CONSIGNED`, because 10 of those tonnes are hers. + // `15t · 5 CONSIGNED`, because 10 of those tonnes are the commander's. const consigned = (i: number): string => { const tonnes = consignedTonnes(c, i); return tonnes > 0 diff --git a/src/ui/screens-trainer.ts b/src/ui/screens-trainer.ts index 9e315f48..3637db0d 100644 --- a/src/ui/screens-trainer.ts +++ b/src/ui/screens-trainer.ts @@ -18,6 +18,7 @@ import { type CompareGroup, type SimComparePanel } from '../game/combat-sim-comp import { type SimSetupPanel, type SimSetupRow } from '../game/screens/combat-sim-setup.ts'; import { show } from './screen-shell.ts'; import { reservedNotes } from './reserved-note.ts'; +import { rowArrows } from './row-arrows.ts'; /** * One line of the setup panel, with its group heading above it if it opens one. @@ -30,7 +31,7 @@ const simSetupRow = (r: SimSetupRow, i: number, selected: number): string => `${r.heading ? `${r.heading}` : ''} - ${r.label}${r.value} + ${r.label}${rowArrows(i, r.value)} `; /** * The setup panel: a list of rows, and which one the cursor is on. @@ -59,6 +60,7 @@ export function renderCombatSimSetup(p: SimSetupPanel): void { ${reservedNotes(p.brainNote ? [p.brainNote] : [], [p.brainReserve], 'note-brain')}
    + ${p.hasReport ? '' : ''}
    @@ -215,8 +217,12 @@ export function renderCombatSimReport( ${r.warnings.map((w) => `
    ${w}
    `).join('')}
    + ${total > 1 ? ` + + ` : ''} + ${total > 1 ? `` : ''}
    @@ -293,6 +299,8 @@ export function renderCombatSimCompare(p: SimComparePanel): void { ${table(c.groups.slice(half))}
    + ${p.total > 2 ? ` + ` : ''} diff --git a/src/ui/screens.ts b/src/ui/screens.ts index 94780100..3b003aab 100644 --- a/src/ui/screens.ts +++ b/src/ui/screens.ts @@ -108,10 +108,15 @@ export function renderStatus( .map((qty, i) => (qty > 0 ? `${COMMODITIES[i].name}: ${qty}${COMMODITIES[i].unit}` : null)) .filter(Boolean) .join(' · ') || 'Empty'; - const equipmentLines = EQUIPMENT_CATALOGUE - .filter((item) => item.id !== 'missile' && equipmentOwned(item.id, c)) - .map((item) => item.name) - .join(' · ') || 'Standard fit'; + // The cloak is on no shop's list, so it is named here when it is fitted + // (docs/TODO/219 M4). + const cloak = c.equipment.cloak ? ['Cloaking Device'] : []; + const equipmentLines = [ + ...EQUIPMENT_CATALOGUE + .filter((item) => item.id !== 'missile' && equipmentOwned(item.id, c)) + .map((item) => item.name), + ...cloak, + ].join(' · ') || 'Standard fit'; show(`

    COMMANDER ${c.name}

    @@ -129,6 +134,8 @@ export function renderStatus( ${c.trumbles > 0 ? `Trumbles: ${c.trumbles}
    ` : ''} Kills: ${c.kills}
    Rating: ${rating(c.combatScore ?? c.kills).toUpperCase()} + ${c.missions.flags.includes('wheel.member') + ? '
    OF THE DARK WHEEL' : ''} ${c.tested ?? false ? '
    Test mode: used in this career' : ''} @@ -153,7 +160,7 @@ export interface OfferRow { /** * A held mission's order, with the name of who gave it, and the choices its * leg waits on. `title` and `pages` are the dossier's, as an offer's are, so - * what she agreed to stays readable after she accepts (docs/TODO/201). + * what they agreed to stays readable after they accept (docs/TODO/201). * `pages` is empty without a dossier, and `title` is then the plain name. */ export interface HeldRow extends MissionOrder { @@ -189,8 +196,9 @@ export interface MissionsView { * line still names both, and that is a different rule: it is the one surface * where dropping a kind hides it completely. * - * An offer is a row she can accept at a station. A held mission is a row she - * can abandon anywhere. `game/orders.ts` decides what a held row says. + * An offer is a row the commander can accept at a station. A held mission is + * a row they can abandon anywhere. `game/orders.ts` decides what a held row + * says. */ export function renderMissions(view: MissionsView): void { const { offers, held, leads, systems, selected, atStation } = view; @@ -208,8 +216,8 @@ export function renderMissions(view: MissionsView): void { ${offerRows} `; - // A held row reads as the offer did, and then the order: what she agreed - // to, then where she is in it (docs/TODO/201). The order is amber because + // A held row reads as the offer did, and then the order: what they agreed + // to, then where they are in it (docs/TODO/201). The order is amber because // it is the one line that changes as the legs go by. const heldRows = held.map((m, i) => ` @@ -219,7 +227,7 @@ export function renderMissions(view: MissionsView): void { // `huntWarning` is the one home of that sentence. ? `
    ${m.warning}` : ''}${m.choices.length // A CHOICE PROMPT (docs/TODO/192 M3). A leg that waits on a choice lists - // it here, one key per option, and nothing moves until she presses one. + // it here, one key per option, and nothing moves until they press one. ? `
    ${m.choices.map((id, k) => ``).join(' ')}` : ''} ${escapeHtml(m.patron.toUpperCase())} ${m.destination === null ? 'ANY STATION' : systems[m.destination].name} @@ -350,8 +358,8 @@ export function renderContracts( // A BOARD IS A STATION'S. In flight there is nothing to sign, and the offers // in `state.contractOffers` are the last station's — drawing them would show - // a pilot work she cannot take. The ACCEPTED half travels with her, because - // what she owes is true wherever she is (docs/TODO/145). + // a pilot work they cannot take. The ACCEPTED half travels with them, + // because what they owe is true wherever they are (docs/TODO/145). // A RUMOUR IS A STATION'S TOO. It is the bulletin board's word about a // lead a few jumps out (missions/hints.ts), in the amber a lead's mark // wears on the chart. diff --git a/src/viewer/main.ts b/src/viewer/main.ts index c53bbc55..2b451ff7 100644 --- a/src/viewer/main.ts +++ b/src/viewer/main.ts @@ -113,7 +113,7 @@ const tracerDir = new THREE.Vector3(); * * It exists because a pirate in an episode LAUNCHES now (docs/TODO/62). A * warhead is 250 of the commander's 765 pool points. Without this, the page - * would show a target down a third of herself to nothing at all. That is + * would show a target down a third of themselves to nothing at all. That is * exactly the kind of lie the viewer exists to stop. */ const drawnMissiles = new Set(); diff --git a/test/ai.test.ts b/test/ai.test.ts index 655bbe63..32564b96 100644 --- a/test/ai.test.ts +++ b/test/ai.test.ts @@ -53,10 +53,10 @@ const N = 60; * THE GATE USED TO BE A KILL RATE, and TODO 29 retired it. The episode's target * is the commander now — two 255-point shields and a 255-point bank, hit for * the source rule's 9 to 21 points a time — and a pirate lands about seven hits - * in forty-five seconds. So nothing kills her inside an episode and a kill rate + * in forty-five seconds. So nothing kills them inside an episode and a kill rate * is 0 for every policy including the aimbot, which measures nothing at all. * - * The share of her pools removed is the same quantity with its granularity + * The share of their pools removed is the same quantity with its granularity * back, and it separates the brains as sharply as the kill rate ever did: * measured over these 60 held-out seeds, the shipped brain takes 12.0%, an * untrained policy 1.7%, and the scripted aimbot 25.3%. @@ -82,16 +82,16 @@ const randomPirateHurt = poolShare((seed) => new Episode({ // policies left the bundle, so the competence floor guards the run the player // actually meets against a policy that has learnt nothing. check(`the scripted run hurts the commander` - + ` (${(scriptedRunHurt * 100).toFixed(1)}% of her pools)`, + + ` (${(scriptedRunHurt * 100).toFixed(1)}% of their pools)`, scriptedRunHurt >= 0.07); -check(`untrained policy barely scratches her (${(randomPirateHurt * 100).toFixed(1)}%)`, +check(`untrained policy barely scratches them (${(randomPirateHurt * 100).toFixed(1)}%)`, randomPirateHurt <= 0.05); check('the scripted run beats the untrained baseline by a factor of three', scriptedRunHurt > randomPirateHurt * 3); // There is no trained-defender 2v1 gate any more. The block that stood here // compared the shipped defence brain against a scripted trader and, in its -// final form, asserted the brain was WORSE (32.9% of her pools lost against +// final form, asserted the brain was WORSE (32.9% of their pools lost against // 23.5%) pending a v2 that never earned promotion — the v2 champion was a // pacifist (docs/TRAINING-LOG.md run 21), and on 2026-08-05 Chris discarded // the trained defence line outright. The shipped defence is hand-written code @@ -248,7 +248,7 @@ console.log('\npurity'); // the whole world step, as of the extraction out of game.ts — this is the // line that says the simulation can advance without a browser 'world-step.ts', - // "a ship fired, what happens", and which of her two shields takes it. Both + // "a ship fired, what happens", and which of their two shields takes it. Both // are rule modules the TRAINER calls, so a browser reference in either would // put the platform inside a training episode (docs/TODO/64) 'fire-resolution.ts', 'shield-face.ts', diff --git a/test/aim.test.ts b/test/aim.test.ts new file mode 100644 index 00000000..0493b408 --- /dev/null +++ b/test/aim.test.ts @@ -0,0 +1,278 @@ +// The computer lines up every pilot's ship in a fight (docs/TODO/206 M2). +// +// Chris's words of 2026-09-11: timing and tactics are the pilot's. The pilot +// owns the laser, the missiles, the E.C.M. and the choice of target. The +// computer only aims. The bought combat computer takes control of everything +// but the missiles. These fly a real headless Game. + +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 { pickTarget } from '../src/game/targets.ts'; +import type { NpcShip } from '../src/game/npc.ts'; +import { check, dismissBriefing, eq } from './harness.ts'; + +console.log('\nthe computer lines up every pilot'); + +/** A commander in open space far from the station, with an empty sky. */ +function open(seed: number): Game { + const g = withoutSaving(() => { + seedWorld(seed); + const game = new Game(() => headlessShell()); + dismissBriefing(game); + game.launch(); + return game; + }).value; + withoutSaving(() => { for (let f = 0, at = 0; f < 400; f++) g.step(1 / 60, at += 1 / 60); }); + g.state.world.clearNpcs(); + // Well clear of the station's truce, where pirates attack. + g.state.player.position.add(new THREE.Vector3(0, 40_000, 0)); + return g; +} + +function run(g: Game, frames: number): void { + withoutSaving(() => { for (let f = 0; f < frames; f++) g.step(1 / 60, 20 + f / 60); }); +} + +/** How far off the nose a ship sits, in radians. */ +function offNose(g: Game, ship: NpcShip): number { + const nose = new THREE.Vector3(0, 0, -1).applyQuaternion(g.state.player.quaternion); + return nose.angleTo(ship.object.position.clone().sub(g.state.player.position)); +} + +/** A ship placed off to one side of the commander. */ +function beside(g: Game, role: 'pirate' | 'trader', x: number, seed: number): NpcShip { + const pos = g.state.player.position.clone() + .add(new THREE.Vector3(x, 0, -1800).applyQuaternion(g.state.player.quaternion)); + return g.state.world.spawn(role, pos, seed); +} + +{ + const g = open(20_260_924); + const pirate = beside(g, 'pirate', 1400, 1); + pirate.state.speed = 0; + const before = offNose(g, pirate); + let hottest = 0; + withoutSaving(() => { + for (let f = 0; f < 150; f++) { + g.step(1 / 60, 20 + f / 60); + hottest = Math.max(hottest, g.state.sys.laserTemp); + } + }); + check('a fight starts, and the computer takes the stick of a ship with no combat computer', + g.state.session.ccEngaged); + check('...and turns the nose onto the threat', offNose(g, pirate) < before * 0.5, + `${before.toFixed(2)} to ${offNose(g, pirate).toFixed(2)} rad`); + eq('...and never fires: the trigger is the pilot\'s', hottest, 0); +} + +{ + const g = open(20_260_925); + g.state.commander.equipment.combatComputer = true; + const pirate = beside(g, 'pirate', 900, 2); + pirate.state.speed = 0; + let hottest = 0; + withoutSaving(() => { + for (let f = 0; f < 240; f++) { + g.step(1 / 60, 20 + f / 60); + hottest = Math.max(hottest, g.state.sys.laserTemp); + } + }); + check('the bought combat computer also pulls the trigger', hottest > 0, + `laser heat peaked at ${hottest.toFixed(3)}; pirate alive ${pirate.state.alive}`); +} + +{ + const g = open(20_260_926); + const pirate = beside(g, 'pirate', -1400, 3); + const trader = beside(g, 'trader', 1400, 4); + pirate.state.speed = 0; + trader.state.speed = 0; + pickTarget(g.state.world.npcs, trader); + run(g, 180); + check('with a target picked, the computer lines up on it rather than on the threat', + offNose(g, trader) < offNose(g, pirate), + `trader ${offNose(g, trader).toFixed(2)}, pirate ${offNose(g, pirate).toFixed(2)} rad`); +} + +{ + const g = open(20_260_927); + const pirate = beside(g, 'pirate', 1400, 5); + pirate.state.speed = 0; + run(g, 30); + g.input.press('ArrowUp'); + run(g, 1); + g.input.release('ArrowUp'); + check('a flight key takes the controls', !g.state.session.ccEngaged && g.state.session.handFlown); + run(g, 60); + check('...and the computer does not take them back by itself', !g.state.session.ccEngaged); + g.state.session.handFlown = false; + run(g, 1); + check('a pick hands the stick back, and the aim starts again', g.state.session.ccEngaged); +} + +// A FIGHT DROPS THE COURSE (Chris, 2026-09-12: *"it should also be disengaged +// by combat or other events"*, and *"anything that breaks the journey should +// let you reconsider what you are doing - at the moment you are stuck on a +// course"*). +// +// The aim already won the stick, because `flight.ts` asks the co-pilot before +// the course. So the course flew nothing while it waited, the button said the +// ship was heading somewhere, and the course took the ship back the moment the +// last pirate died. Now the fight ends it, and the list comes back. +{ + const g = open(20_260_928); + g.state.session.course = 'station'; + const pirate = beside(g, 'pirate', 1400, 6); + run(g, 30); + check('a fight on a course: the aim flies, and the course is dropped', + g.state.session.ccEngaged && g.state.session.course === null); + pirate.state.alive = false; + run(g, 5); + check('...and a clear area leaves the pilot to choose again, not back on it', + !g.state.session.ccEngaged && g.state.session.course === null); + + // ...and a course picked while the fight runs goes the same way. The stick is + // already the computer's, so an edge test on the fight starting would miss it. + g.state.session.course = 'station'; + beside(g, 'pirate', 1400, 6); + run(g, 30); + check('a course picked mid-fight is dropped too', g.state.session.course === null); +} + +// --- WHAT AUTHORIZES THE COMPUTER'S SHOT ------------------------------------ +// +// Two findings of the review of 2026-09-12 (`docs/COMBAT-COMPUTER-REVIEW.md`). +// Each one made the commander an Offender in its own reproduction, for a ship +// the computer never aimed at. +// +// The commander is fitted with a combat computer, because that is the fitting +// that gives the trigger away. Without it the aim is free and the trigger stays +// the pilot's, so neither fault can arise. +console.log('\nthe computer holds its shot unless the shot is its own'); +{ + // THE VIEW. `autoEngage` turns the front view on when it takes the stick. A + // pilot may look astern afterwards, and the trigger then fired the REAR + // laser at whatever was behind. + const g = open(20_260_930); + g.state.commander.equipment.combatComputer = true; + const pirate = beside(g, 'pirate', 200, 11); + pirate.state.speed = 0; + // a trader parked behind, where the rear laser points + const behind = g.state.player.position.clone() + .add(new THREE.Vector3(0, 0, 900).applyQuaternion(g.state.player.quaternion)); + const trader = g.state.world.spawn('trader', behind, 12); + trader.state.speed = 0; + g.state.commander.equipment.rearLaser = true; + + run(g, 120); + check('the computer has the stick, with a pirate ahead', g.state.session.ccEngaged); + g.state.session.view = 1; // look astern, as a command key does + const before = g.state.sys.laserTemp; + run(g, 60); + eq('looking astern, the computer fires nothing at all', + g.state.sys.laserTemp <= before, true); + check('...so the trader behind is not provoked', !trader.state.provokedByPlayer); + eq('...and the commander is still clean', g.state.commander.legalStatus, 0); +} + +{ + // THE LINE. A trader between the commander and the pirate took the shot. + const g = open(20_260_931); + g.state.commander.equipment.combatComputer = true; + const ahead = (d: number) => g.state.player.position.clone() + .add(new THREE.Vector3(0, 0, -d).applyQuaternion(g.state.player.quaternion)); + const pirate = g.state.world.spawn('pirate', ahead(2400), 13); + pirate.state.speed = 0; + run(g, 180); // let the nose come onto it + + // Both are put ON the nose, so the shot is one the computer really wants and + // the trader is squarely in the beam. Drifting geometry made an earlier + // version of this test pass for the wrong reason: the pirate slid a hair + // outside the gun cone, so nothing fired either way. + const park = (n: typeof pirate, d: number): void => { + n.object.position.copy(ahead(d)); + n.object.updateMatrixWorld(true); + }; + park(pirate, 900); + const trader = g.state.world.spawn('trader', ahead(450), 14); + trader.state.speed = 0; + park(trader, 450); + run(g, 30); + check('with a trader in the way, the computer holds its fire', + g.state.sys.laserTemp === 0, + `laser temp ${g.state.sys.laserTemp.toFixed(3)}`); + check('...so the trader is not provoked', !trader.state.provokedByPlayer); + eq('...and the commander is still clean', g.state.commander.legalStatus, 0); + + // THE CONTROL: with the way clear, the same geometry does fire. Without it, + // the two checks above would pass on a computer that never shoots at all. + trader.state.alive = false; + park(pirate, 900); + run(g, 30); + check('...and with the way clear, the same shot is taken', + g.state.sys.laserTemp > 0, `laser temp ${g.state.sys.laserTemp.toFixed(3)}`); + + // ...and the pilot's OWN pick is theirs to shoot at, whatever it is. + const picked = g.state.world.spawn('trader', ahead(450), 15); + picked.state.speed = 0; + park(picked, 450); + pickTarget(g.state.world.npcs, picked); + run(g, 60); + check('a trader the PILOT picked is shot at', picked.state.provokedByPlayer, + `provoked ${picked.state.provokedByPlayer}`); +} + +// --- IT ENGAGES ONCE, NOT EVERY FRAME -------------------------------------- +// +// Chris, 2026-09-12: *"why does it go on for so long? Several long seconds?"* +// He was describing the sound a fight makes. It was not the sound. +// +// `autoEngage` asked the CONDITION LIGHT whether there was a fight, and that +// looks `PLAYER_INTEREST_RANGE` out. The co-pilot only looks `THREAT_RANGE`. A +// pirate between the two engaged the computer, and the co-pilot refused it in +// the SAME frame. So the flag reads false at the end of every frame while the +// cue plays 60 times a second and the console holds AREA CLEAR throughout. +// +// That is why this measures the console rather than `ccEngaged`. The flag never +// survives a frame, so a fixture that sampled it saw nothing at all. The first +// version of this test did, and passed on the bug. +console.log('\nthe computer engages once, not once a frame'); +{ + const engagements = (distance: number): { engaged: number; clear: number } => { + const g = open(20_260_932); + const at = g.state.player.position.clone() + .add(new THREE.Vector3(0, 0, -distance).applyQuaternion(g.state.player.quaternion)); + const pirate = g.state.world.spawn('pirate', at, 21); + pirate.state.provoked = true; + pirate.state.provokedByPlayer = true; + pirate.state.speed = 0; + let engaged = 0; + let clear = 0; + withoutSaving(() => { + for (let f = 0; f < 300; f++) { + g.step(1 / 60, 20 + f / 60); + pirate.object.position.copy(at); // hold it still, so only the RULES move + if (g.state.session.ccEngaged) engaged += 1; + if (g.state.session.messageText === 'AREA CLEAR') clear += 1; + } + }); + return { engaged, clear }; + }; + + const close = engagements(4000); + check('a pirate inside the co-pilot\'s reach engages the computer', close.engaged === 300, + `${close.engaged} of 300 frames`); + check('...and it never gives the ship back', close.clear === 0, + `${close.clear} frames of AREA CLEAR`); + + // The band that broke it: past what the co-pilot will fly at, inside what the + // condition light calls a fight. + const band = engagements(7500); + check('a pirate the co-pilot will not fly at engages nothing', band.engaged === 0, + `${band.engaged} of 300 frames`); + check('...and the console is not told AREA CLEAR on every frame of the flight', + band.clear === 0, `${band.clear} frames of AREA CLEAR`); +} diff --git a/test/arcs.test.ts b/test/arcs.test.ts index 661ba051..cf5e2218 100644 --- a/test/arcs.test.ts +++ b/test/arcs.test.ts @@ -39,8 +39,11 @@ console.log('\nthe five arcs sit on the tour'); check('every arc has two to four legs, and mixes its verbs', ARCS.every((a) => a.legs.length >= 2 && a.legs.length <= 4 && new Set(a.legs.map((l) => l.verb.kind)).size >= 2)); - check('the first arc opens with no gate, and each later one waits for the one before', - Object.keys(ARCS[0].offer).length === 0 + // The first arc asks for the galaxy and the scoops alone (docs/TODO/213 + // M2): a scoop breaks on a hull without them. Every arc names its galaxy. + check('the first arc opens on the galaxy and the scoops, and each later one waits for the one before', + Object.keys(ARCS[0].offer).sort().join() === 'galaxy,scoops' + && ARCS.every((a) => a.offer.galaxy === 1) && ARCS.slice(1).every((a, i) => a.offer.done?.join() === ARCS[i].id)); check('every arc is in SKELETONS', ARCS.every((a) => SKELETONS.includes(a))); } @@ -78,7 +81,7 @@ function inputFor(st: MissionState, arc: Skeleton, avoid: readonly string[] | nu * would. */ function walk(arc: Skeleton, avoid: readonly string[] | null, done: string[]): { st: MissionState; legs: string[]; c: CommanderFacts } { - const c: CommanderFacts = { galaxy: 1, systemIndex: startOf(arc), kills: 0, combatScore: 0, legalStatus: 0, day: 1, cargo: [] }; + const c: CommanderFacts = { galaxy: 1, systemIndex: startOf(arc), kills: 0, combatScore: 0, legalStatus: 0, scoops: true, day: 1, cargo: [] }; const ctx = () => ({ commander: c, systems: g1, rng: () => 0.37 }); let st = emptyMissionState(); for (const d of done) st.done[d] = 'complete'; @@ -129,7 +132,7 @@ for (const [i, arc] of ARCS.entries()) { console.log('\nan arc is offered at its world only'); { - const c: CommanderFacts = { galaxy: 1, systemIndex: LAVE, kills: 0, combatScore: 0, legalStatus: 0, day: 1, cargo: [] }; + const c: CommanderFacts = { galaxy: 1, systemIndex: LAVE, kills: 0, combatScore: 0, legalStatus: 0, scoops: true, day: 1, cargo: [] }; check('the first arc is on offer at Lave from the first dock', offersFor(emptyMissionState(), { commander: c, systems: g1 }).some((s) => s.id === 'arc-lave')); check('...and not at Leesti', !offersFor(emptyMissionState(), { commander: { ...c, systemIndex: 8 }, systems: g1 }).some((s) => s.id === 'arc-lave')); diff --git a/test/arena.test.ts b/test/arena.test.ts index 4953446e..d3c88db3 100644 --- a/test/arena.test.ts +++ b/test/arena.test.ts @@ -78,7 +78,7 @@ console.log('\ncombat arena'); if (massLocked(state)) locked += 1; if (dockingOutcome(centre, state.player.quaternion, world.station, - world.stationDockZ, scratch) !== 'clear') notClear += 1; + world.stationDockZ, 0, scratch) !== 'clear') notClear += 1; } } @@ -146,7 +146,7 @@ console.log('\ncombat arena'); ships.every((n) => n.object.position.distanceTo(world.station.position) > 20_000)); check('...none of them inside the docking box', ships.every((n) => dockingOutcome(n.object.position, n.object.quaternion, - world.station, world.stationDockZ, scratch) === 'clear')); + world.station, world.stationDockZ, 0, scratch) === 'clear')); // and near enough that the fight starts: `PLAYER_INTEREST_RANGE` is where // an NPC begins to care about the player at all (npc.ts update()). It was // 9,000 written out, in a file that can import the rule. diff --git a/test/audio-fixtures.ts b/test/audio-fixtures.ts index fb2d62ab..65dac0f6 100644 --- a/test/audio-fixtures.ts +++ b/test/audio-fixtures.ts @@ -32,6 +32,14 @@ export interface Tone { frequency: number; /** every scheduled pitch, as `[hz, at]` — one entry unless the voice vibratos */ pitches: [number, number][]; + /** + * Where a GLIDE was aimed, or null for a voice that holds its pitch. + * + * Recorded from 2026-09-12, when the fight cue became a whoop. The glide IS + * that sound, and a fixture that dropped the ramp target would have passed on + * two flat tones (`test/audio.test.ts`). + */ + rampTo: number | null; /** cents away from `frequency`, as the layer asked for */ detune: number; duration: number; @@ -148,8 +156,8 @@ class FakeAudioContext { createOscillator() { const recorded: Tone = { - type: 'sine', frequency: 0, pitches: [], detune: 0, duration: 0, at: 0, - periodic: false, amp: null, + type: 'sine', frequency: 0, pitches: [], rampTo: null, detune: 0, + duration: 0, at: 0, periodic: false, amp: null, }; tones.push(recorded); const node = { @@ -159,7 +167,7 @@ class FakeAudioContext { if (!recorded.pitches.length) recorded.frequency = value; recorded.pitches.push([value, at - 10]); }, - exponentialRampToValueAtTime() {}, + exponentialRampToValueAtTime(value: number) { recorded.rampTo = value; }, }, /** * A pulse wave. The fake keeps no spectrum — what a test can ask is diff --git a/test/audio.test.ts b/test/audio.test.ts index 7b78883d..0f7712ae 100644 --- a/test/audio.test.ts +++ b/test/audio.test.ts @@ -29,7 +29,6 @@ const expected = { contractExpired: [220, 0.2], contractAccepted: [900, 0.1], dockingComputerEngaged: [700, 0.12], - combatComputerEngaged: [1000, 0.12], stationDefenceLaunched: [300, 0.18], cargoLost: [300, 0.12], equipmentDestroyed: [240, 0.2], @@ -58,6 +57,48 @@ for (const [name, [frequency, duration]] of Object.entries(expected)) { eq(`${name} keeps the standard gain`, peak(tone), 0.08); } +// THE FIGHT CUE IS A WHOOP WHOOP, and it took four goes. Chris, 2026-09-12: +// *"The alarm sound for a pirate is a bit too much"*, then *"I just want a +// couple of beeps"*, then *"It back to the horrible beep"*, then *"how about a +// whoop whoop - like a klaxon just two of them though"*. +// +// It was one 1000 Hz SQUARE beep for 0.12s. Two levers were confused for one +// through three of those attempts. The VOICE is what grated, because a square +// wave carries every odd harmonic above its pitch. The SHAPE is what makes a +// klaxon, and that is a glide rather than a note. +// +// So the claim is both, and the glide is the half a flat cue would pass +// without. `test/audio-fixtures.ts` records the ramp target from this date for +// exactly that reason. +{ + tones.length = 0; + sfx.combatComputerEngaged(); + const cue = [...tones]; + eq('the fight cue is two whoops', cue.length, 2); + check('...each one a GLIDE, which is what makes a whoop', + cue.every((t) => t.rampTo !== null && t.rampTo !== t.frequency), + cue.map((t) => `${t.frequency}->${t.rampTo}`).join(',')); + check('...rising, so it reads as an alert rather than a sigh', + cue.every((t) => (t.rampTo ?? 0) > t.frequency), + cue.map((t) => `${t.frequency}->${t.rampTo}`).join(',')); + check('...on the soft voice, not the house square that grated', + cue.every((t) => t.type === 'sine'), cue.map((t) => t.type).join(',')); + check('...at the standard gain, like every other named occasion', + cue.every((t) => peak(t) === 0.08), cue.map((t) => peak(t)).join(',')); + check('...the same glide twice, so it is one cue and not two events', + cue.length === 2 && cue[0].frequency === cue[1].frequency + && cue[0].rampTo === cue[1].rampTo, + cue.map((t) => `${t.frequency}->${t.rampTo}`).join(' then ')); + check('...the second after the first, not over it', + cue.length === 2 && cue[1].at >= cue[0].at + cue[0].duration, + cue.map((t) => `${t.at}+${t.duration}`).join(' then ')); + // A klaxon runs until somebody silences it. This one says its piece and + // stops, because the fight it announces has already started. + check('...and the whole cue is over inside a second', + cue.length === 2 && cue[1].at + cue[1].duration < 1, + `${(cue[1].at + cue[1].duration).toFixed(2)}s`); +} + // The countdown blip is the one occasion whose pitch depends on a GAME rule — // how many seconds of warning the drive gives — and audio.ts used to write that // 5 out as a digit. So the assertion is the CLAIM rather than the expression: diff --git a/test/bare-thresholds.test.ts b/test/bare-thresholds.test.ts index eec053a7..023ef3f0 100644 --- a/test/bare-thresholds.test.ts +++ b/test/bare-thresholds.test.ts @@ -48,6 +48,9 @@ const ALLOWED: Readonly> [60, "training: the speed the episode's trader target coasts below"], [300, "training: how near its waypoint that target has arrived"], ], + 'engine/phone-chrome.ts': [ + [300, "a phone browser's own double tap window, in milliseconds — the platform's, not a rule (docs/TODO/220)"], + ], 'galaxy/galaxy.ts': [ [256, 'the 1984 galaxy holds 256 systems — the source material, not a rule'], ], diff --git a/test/blueprint-override.test.ts b/test/blueprint-override.test.ts index 0908311f..11651f12 100644 --- a/test/blueprint-override.test.ts +++ b/test/blueprint-override.test.ts @@ -44,7 +44,7 @@ console.log('\nwho names a blueprint override (docs/TODO/138 M4)'); leg === null ? emptyMissionState() : constrictorAt(leg, target); eq('an idle commander raises no override', missionOverride(at(null, null), 7), null); - eq('the hunt raises the Constrictor\'s, at the system she was sent to', + eq('the hunt raises the Constrictor\'s, at the system they were sent to', missionOverride(at('hunt', 7), 7), 'constrictor'); eq('...and raises nothing at any other system', missionOverride(at('hunt', 8), 7), null); @@ -85,7 +85,7 @@ console.log('\nwho names a blueprint override (docs/TODO/138 M4)'); withoutSaving(() => g.launch()); const s = g.state; - /** Fly her there and let the arrival choose, as a jump does. */ + /** Fly the commander there and let the arrival choose, as a jump does. */ const arriveAt = ( index: number, leg: 'hunt' | 'courier' | null, target: number | null, ): string => { @@ -103,7 +103,7 @@ console.log('\nwho names a blueprint override (docs/TODO/138 M4)'); arriveAt(LOW, 'hunt', LOW), CONSTRICTOR_BLUEPRINT_SET); check('...and the world is built with it', s.world.roster === specsForSet(CONSTRICTOR_BLUEPRINT_SET)); - check('...but only at the system she was sent to', + check('...but only at the system they were sent to', ordinary(LOW).includes(arriveAt(LOW, 'hunt', HIGH))); eq('the plans pick the low-tech Thargoid set at a low-tech system', diff --git a/test/campaign.ts b/test/campaign.ts index 603abe6a..44c1deed 100644 --- a/test/campaign.ts +++ b/test/campaign.ts @@ -121,7 +121,7 @@ interface CareerResult { * * A career-long average hides the thing the tier ladder exists to produce. * "13% of receptions are gangs" is true and says nothing about whether a - * commander meets one on her second leg or her fiftieth, and a change that + * commander meets one on their second leg or their fiftieth, and a change that * moved every gang to the first third would not move that number at all. * These three bands are what TODO 29 asserts against. */ @@ -133,9 +133,9 @@ interface CareerResult { /** the worst the name ever got — decay means the final score understates it */ peakDisrepute: number; /** - * The Navy mission's signposting question (TODO 29): the leg her 16th kill - * landed on, what she was worth then, whether she had already docked at a - * TL10+ system, and whether she was carrying the only gun that kills a + * The Navy mission's signposting question (TODO 29): the leg their 16th kill + * landed on, what they were worth then, whether they had already docked at a + * TL10+ system, and whether they were carrying the only gun that kills a * Constrictor in a reasonable time. */ missionLeg: number | null; @@ -385,7 +385,7 @@ function runCareer(seed: number, systems: StarSystem[], strategy: Strategy = 'tr // purpose — to traders if (strategy === 'hunter' && k.kind !== 'bounty') continue; // ILLICIT FREIGHT IS A PRIVATEER'S WORK. A trader has a clean record to - // protect and a hold that pays without a police scan in it, so she leaves + // protect and a hold that pays without a police scan in it, so they leave // it on the board; a privateer already wants to be looked at (its cargo is // bait) and a scan is one more reason for somebody to come and try. This // is the BOT's policy, not a rule of the game — nothing in @@ -407,7 +407,7 @@ function runCareer(seed: number, systems: StarSystem[], strategy: Strategy = 'tr // after it had. With one job at a time it is 6.0 completed to 2.5 failed, // and the trader and privateer cohorts move by less than a job either // way. Nothing in `game/contracts.ts` changed; this is the simulated - // commander no longer accepting work she has no way to do. + // commander no longer accepting work they have no way to do. if (k.kind === 'bounty' && c.contracts.some((h) => h.kind === 'bounty')) continue; // a trader or a privateer takes anything going its way: freight pays, // fares pay, and either one doubles as bait @@ -968,7 +968,7 @@ function report(label: string, careers: CareerResult[], strategy: Strategy): voi assert('all three threat tiers are exercised somewhere in a career', [0, 1, 2].every((t) => bandRows.reduce((a, b) => a + b.tierSeen[t], 0) > 0)); // The ladder's whole point: a new commander meets opportunists, and the - // professionals and gangs arrive once she is worth the trouble. + // professionals and gangs arrive once they are worth the trouble. assert('threat escalates from the early band to the late one', upper(bandRows[2]) > upper(bandRows[0]), `early ${upper(bandRows[0]).toFixed(0)}% tier1+ · late ${upper(bandRows[2]).toFixed(0)}%`); @@ -981,8 +981,8 @@ function report(label: string, careers: CareerResult[], strategy: Strategy): voi `${upper(bandRows[2]).toFixed(1)}% tier1+ in the late band`); } // ...and the escalation must not outrun the upgrades. The late band throws - // twice the tier-1+ receptions and six times the gangs at her, so if the - // ladder is working she is nonetheless SAFER by then, not more at risk. + // twice the tier-1+ receptions and six times the gangs at them, so if the + // ladder is working they are nonetheless SAFER by then, not more at risk. // Measured on a 40x60 trader run: 17 deaths early, 16 middle, 0 late. assert('a commander who has upgraded is safer, despite the harder receptions', bandRows[2].deaths <= bandRows[0].deaths, diff --git a/test/chart-headless.test.ts b/test/chart-headless.test.ts index fbe8f3b7..efd4afd4 100644 --- a/test/chart-headless.test.ts +++ b/test/chart-headless.test.ts @@ -85,7 +85,7 @@ console.log('\nboth charts find a system with no document'); check(`...and F turns type-to-find on (${find || 'no error'})`, find === ''); // One letter, then a second. Two calls rather than one, because - // `typeToFind` drains a whole frame of presses at once. Leleer answers L + // `ChartSearch.type` drains a whole frame of presses at once. Leleer answers L // and Lave answers LA, so the second letter is shown to NARROW the match // rather than merely to leave it alone. const first = named('Leleer'); diff --git a/test/cloak.test.ts b/test/cloak.test.ts new file mode 100644 index 00000000..88d12f7f --- /dev/null +++ b/test/cloak.test.ts @@ -0,0 +1,79 @@ +// The cloaking device, which no shop sells (docs/TODO/219 M5). +// +// While it runs, no ship is hostile to the commander, the condition light +// stays green, the run course sees no threat, and the bank drains. A shot +// drops it, the last bank drops it, and a dock drops it. A ship without the +// fit is told so. + +import * as THREE from 'three'; +import { seedWorld } from '../src/game/rng.ts'; +import { NpcShip } from '../src/game/npc.ts'; +import { SHIPPED_BRAINS } from '../src/game/brain-names.ts'; +import { engaging, hostilesNear, hostilesOnScanner } from '../src/game/hostility.ts'; +import { withoutSaving } from '../src/game/storage.ts'; +import { keymap } from '../src/engine/keymap.ts'; +import { CLOAK_ENERGY_PER_SECOND, CLOAK_MIN_ENERGY } from '../src/constants/cloak.ts'; +import { MAX_ENERGY } from '../src/constants/pools.ts'; +import { arrived } from './course-fixtures.ts'; +import { check, eq } from './harness.ts'; + +console.log('\na cloaked commander is nobody\'s business'); +{ + seedWorld(11); + const origin = new THREE.Vector3(); + const pirate = new NpcShip('pirate', new THREE.Vector3(0, 0, -800), 3); + check('a pirate 800 out engages an uncloaked commander', engaging(pirate, origin, 0, Infinity)); + check('...and not a cloaked one', !engaging(pirate, origin, 0, Infinity, undefined, true)); + check('the condition light stays green', !hostilesNear([pirate], origin, 0, Infinity, undefined, true)); + eq('...and the run course sees no threat', hostilesOnScanner([pirate], origin, 0, Infinity, true).length, 0); + // The pirate's own frame: it hunts an uncloaked commander, and ambles past a cloaked one. + const station = new THREE.Object3D(); + const player = { position: origin, quaternion: new THREE.Quaternion(), speed: 0 } as never; + const view = (cloaked: boolean) => ({ + station, dockZ: 160, fleet: [pirate], playerLegal: 0, brains: SHIPPED_BRAINS, + missileInbound: false, playerToStation: Infinity, playerCloaked: cloaked, + }) as never; + seedWorld(11); + pirate.update(1 / 60, player, view(false)); + check('a pirate flies at an uncloaked commander', ['pursuit', 'scripted'].includes(pirate.state.flownBy), pirate.state.flownBy); + seedWorld(11); + pirate.update(1 / 60, player, view(true)); + check('...and not at a cloaked one', !['pursuit', 'scripted'].includes(pirate.state.flownBy), pirate.state.flownBy); +} + +console.log('\nthe key, the bank, the shot and the dock'); +{ + const g = arrived(20_260_980); + const s = g.state; + const said = () => s.session.messageText; + // The key, through the input, as a finger or a keyboard sends it. The + // arrival's tunnel takes the keyboard while it plays, so the test steps + // through it first. + let at = 50; + withoutSaving(() => { for (let f = 0; f < 240; f++) g.step(1 / 60, at += 1 / 60); }); + const press = (): void => { withoutSaving(() => { g.input.injectPress('KeyZ'); g.step(1 / 60, at += 1 / 60); }); }; + press(); + check('a ship without the fit is told so', /NO CLOAKING DEVICE/.test(said()) && !s.session.cloaked); + s.commander.equipment.cloak = true; + press(); + check('with the fit, the key cloaks the ship', s.session.cloaked && /CLOAKED/.test(said())); + const before = s.sys.energy; + withoutSaving(() => { for (let f = 0; f < 60; f++) g.step(1 / 60, at += 1 / 60); }); + check(`a second of cloak costs about ${CLOAK_ENERGY_PER_SECOND.toFixed(1)} points, net of recharge`, + s.sys.energy < before, `${before} -> ${s.sys.energy}`); + // The trigger drops it. + const fire = keymap().fire[0]; + withoutSaving(() => { g.input.press(fire); g.step(1 / 60, at += 1 / 60); g.input.release(fire); }); + check('a shot drops the cloak, and says why', !s.session.cloaked && /A SHOT IS A FLARE/.test(said())); + // The last bank drops it. + press(); + s.sys.energy = CLOAK_MIN_ENERGY + 0.5; + withoutSaving(() => { for (let f = 0; f < 30 && s.session.cloaked; f++) g.step(1 / 60, at += 1 / 60); }); + check('the last bank drops the cloak, and says why', !s.session.cloaked && /ENERGY LOW/.test(said())); + check('...and the bank is not spent past it', s.sys.energy > 0 && s.sys.energy <= MAX_ENERGY); + // A dock drops it. + press(); + check('cloaked again', s.session.cloaked); + withoutSaving(() => g.enterDocked()); + check('a dock drops the cloak', !s.session.cloaked); +} diff --git a/test/close-pass.test.ts b/test/close-pass.test.ts new file mode 100644 index 00000000..c2a3df15 --- /dev/null +++ b/test/close-pass.test.ts @@ -0,0 +1,95 @@ +// A neutral trader that comes close says so, one time (docs/TODO/209). +// +// Chris flew a trip to the station. He reported that a neutral trader did not +// mass lock him. The lock did fire when the torus drive ran. The drive is off +// for most of a trip, so most passes said nothing at all. +// +// A real World with ships placed by hand, as `test/targets.test.ts` does. + +import * as THREE from 'three'; +import { World } from '../src/game/world.ts'; +import { seedWorld } from '../src/game/rng.ts'; +import { generateGalaxy } from '../src/galaxy/galaxy.ts'; +import { closePassLines } from '../src/game/close-pass.ts'; +import { CLOSE_PASS_CLEAR, MASS_LOCK_SHIP, MASS_LOCK_STATION } from '../src/constants/torus.ts'; +import { check, eq } from './harness.ts'; + +console.log('\nthe close pass'); + +seedWorld(20_260_912); +const world = new World(); +world.build(generateGalaxy(1)[7]); +world.clearNpcs(); +// Far from the station, so the truce holds nobody back. +const here = world.station.position.clone().add(new THREE.Vector3(0, 0, 60_000)); +const at = (d: number) => here.clone().add(new THREE.Vector3(d, 0, 0)); +const trader = world.spawn('trader', at(MASS_LOCK_SHIP - 500), 1); +world.spawn('pirate', at(1000), 2); +const rock = world.spawn('asteroid', at(300), 3); +const far = world.spawn('trader', at(MASS_LOCK_SHIP + 500), 4); + +const lines = (toStation = 60_000): string[] => closePassLines({ + npcs: world.npcs, playerPos: here, legalStatus: 0, playerToStation: toStation, +}); + +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('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); +check('...and a trader beyond the radius waits its turn', + !first.join().includes(far.object.name.toUpperCase())); + +eq('the same trader says nothing a second time', lines().length, 0); +far.object.position.copy(at(MASS_LOCK_SHIP - 100)); +eq('...and the second trader speaks when it arrives', lines().length, 1); + +// The flag clears only when the ship opens back out, so a wobble across the +// radius does not repeat the line. +trader.object.position.copy(at(MASS_LOCK_SHIP + 100)); +eq('a ship just outside the radius stays quiet, and keeps its flag', lines().length, 0); +trader.object.position.copy(at(MASS_LOCK_SHIP - 100)); +eq('...so a step back inside says nothing', lines().length, 0); +trader.object.position.copy(at(CLOSE_PASS_CLEAR + 100)); +eq('...but a ship that really left lets go of the flag', lines().length, 0); +check('...and the flag is down', !trader.state.announcedClose); +trader.object.position.copy(at(MASS_LOCK_SHIP - 100)); +eq('...so a second approach speaks again', lines().length, 1); + +// The approach to the station is thick with traffic, and the pilot is busy. +for (const n of world.npcs) n.state.announcedClose = false; +eq('inside the station mass lock, nothing speaks', lines(MASS_LOCK_STATION - 1).length, 0); + +world.clearNpcs(); +const dead = world.spawn('trader', at(1200), 5); +dead.state.alive = false; +const docked = world.spawn('trader', at(1300), 6); +docked.state.docked = true; +eq('a wreck is not a chance to pirate, and neither is a docked ship', lines().length, 0); + +// A trader that the commander shot at is still a trader. `isHostileToPlayer` +// owns who attacks the commander, and this file restates none of that rule. +// It silences the pirate above. It says nothing about a trader. +world.clearNpcs(); +const shot = world.spawn('trader', at(1400), 7); +shot.state.provokedByPlayer = true; +eq('a trader you already shot at is a fight you started, not an offer', lines().length, 0); +world.clearNpcs(); +const picked = world.spawn('trader', at(1400), 8); +picked.state.targeted = true; +eq('...and the ship the pilot picked needs no invitation either', lines().length, 0); +world.clearNpcs(); +world.spawn('trader', at(1400), 9); +eq('...where a trader nobody touched does speak (the control)', lines().length, 1); +// The article is `shipArticle` in targets.ts, so the list and the line cannot +// disagree about one ship. A message that said "A OPHIDIAN" read as a fault. +world.clearNpcs(); +const vowel = world.spawn('trader', at(1400), 10); +vowel.object.name = 'Ophidian'; +check('a name that starts with a vowel takes AN', lines()[0].startsWith('AN OPHIDIAN')); +world.clearNpcs(); +const cons = world.spawn('trader', at(1400), 11); +cons.object.name = 'Boa'; +check('...and every other name takes A', lines()[0].startsWith('A BOA')); diff --git a/test/co-pilot-engagement.test.ts b/test/co-pilot-engagement.test.ts new file mode 100644 index 00000000..a02a3a9b --- /dev/null +++ b/test/co-pilot-engagement.test.ts @@ -0,0 +1,154 @@ +// THE ENGAGEMENT: who the co-pilot holds, and what happens at a handover. +// +// Split from `scripted-co-pilot.test.ts` on 2026-09-12, when that file passed +// the 400-line ceiling. Three files now divide the co-pilot's tests by the +// question each one asks: +// +// - `scripted-co-pilot.test.ts` — what it ASKS FOR: the trigger, the E.C.M., +// the standoff, the lead and the throttle; +// - `co-pilot-tracking.test.ts` — where the nose GOES, over time; +// - this file — WHO it holds, and what survives when the ship changes hands. +// +// Both subjects here come from the review of 2026-09-12 +// (`docs/COMBAT-COMPUTER-REVIEW.md`), and neither is visible in a demand read +// one frame at a time. + +import * as THREE from 'three'; +import { ScriptedCoPilot } from '../src/game/scripted-co-pilot.ts'; +import { freshState } from '../src/game/state.ts'; +import { newCommander } from '../src/game/commander.ts'; +import { seedWorld } from '../src/game/rng.ts'; +import { check, eq } from './harness.ts'; + +// --- ONE HANDOVER CONTRACT ------------------------------------------------ +// +// The review of 2026-09-12 found three ways a resumed co-pilot flew differently +// from a fresh one at the same geometry. The contract that answers all three: +// +// 1. THE RATES ARE THE SHIP'S. The co-pilot keeps no copy of its last ask. It +// ramps from `player.pitchRate` and `player.rollRate`, which +// `PlayerShip.update` writes from the demand it flew. So a manual override +// and a restored save both leave the ramp reading the truth. +// 2. THE ENGAGEMENT MEMORY GOES ON `reset`. The lock, the bank side and the +// under-fire timer are all the engagement's, and none of them is worth +// inheriting. +// 3. NOTHING HERE NEEDS SAVING. What survives a save is the ship's own rates, +// which `persistence.ts` already carries. +console.log('\nthe co-pilot hands the ship over on one contract'); +{ + seedWorld(4246); + const state = freshState(newCommander()); + state.world.build(state.systems[state.commander.systemIndex]); + state.world.clearNpcs(); + state.player.position.set(0, 0, 0); + state.player.quaternion.identity(); + state.player.speed = 200; + const legal = state.commander.legalStatus; + // off to one side, so there is a real bank to make + const pirate = state.world.spawn('pirate', new THREE.Vector3(900, 120, -1400), 1); + pirate.state.speed = 0; + + /** One step of a co-pilot at the fixture's geometry. */ + const ask = (cp: ScriptedCoPilot) => { + const s = cp.step(1 / 60, state.player, state.world.npcs, legal, false, null); + if (s.kind !== 'fly') throw new Error('should be flying a live threat'); + return s.demand; + }; + + // A controller that flew for a while, then had the ship taken off it. + const used = new ScriptedCoPilot(); + for (let i = 0; i < 90; i++) ask(used); + const handedBack = used.step(1 / 60, state.player, state.world.npcs, legal, true, null); + check('touching the controls hands the ship back', handedBack.kind === 'disengage'); + + // The pilot flies it somewhere, and the ship stops turning. + state.player.pitchRate = 0; + state.player.rollRate = 0; + const resumed = ask(used); + const fresh = ask(new ScriptedCoPilot()); + eq('a resumed co-pilot asks for the same roll a fresh one does', + resumed.rollRate, fresh.rollRate); + eq('...and the same pitch', resumed.pitchRate, fresh.pitchRate); + + // ...AND A RESET ONE TOO, which `steerMem.side` used to break. + // + // The geometry has to be one where the memory actually decides. A target + // BELOW commits the bank to the bottom. A target LEVEL to one side then has + // the same roll either way round, so the flip margin holds whichever side is + // committed, and a stale one shows. + const wrongWay = new ScriptedCoPilot(); + pirate.object.position.set(0, -900, -1400); + for (let i = 0; i < 60; i++) ask(wrongWay); + pirate.object.position.set(900, 0, -1400); + state.player.pitchRate = 0; + state.player.rollRate = 0; + check('a co-pilot that kept flying banks the way it committed to', + ask(wrongWay).rollRate * ask(new ScriptedCoPilot()).rollRate < 0, + 'the fixture is only a test of reset() while these disagree'); + state.player.pitchRate = 0; + state.player.rollRate = 0; + wrongWay.reset(); + eq('...and a reset one banks the way a fresh one does', + ask(wrongWay).rollRate, ask(new ScriptedCoPilot()).rollRate); + + // THE RAMP READS THE SHIP. A ship already rolling continues from that rate, + // rather than from a zero the co-pilot kept to itself. + state.player.rollRate = -1.2; + const carried = ask(new ScriptedCoPilot()).rollRate; + state.player.rollRate = 0; + const fromRest = ask(new ScriptedCoPilot()).rollRate; + check('a ship already rolling carries that rate into the ramp', + carried < fromRest - 0.5, `${carried.toFixed(3)} against ${fromRest.toFixed(3)}`); +} + +// --- THE COMMITMENT HAS TO BE GOING SOMEWHERE ------------------------------ +// +// The review of 2026-09-12 built this geometry. A held target 3,000 units away +// and 23 degrees off the nose, and a second hostile 500 units dead ahead. The +// cone alone called the far one an attack in progress and vetoed the switch, so +// ten seconds later the easy shot was still refused. `ENGAGED_PATIENCE` is the +// answer, and `npm run combat-aim` cannot see it: over 72 real fights a sweep of +// that constant from 1 second to 999 moved nothing at all. +console.log('\nthe co-pilot lets go of a kill that is going nowhere'); +{ + seedWorld(4247); + const state = freshState(newCommander()); + state.world.build(state.systems[state.commander.systemIndex]); + state.world.clearNpcs(); + state.player.position.set(0, 0, 0); + state.player.quaternion.identity(); + state.player.speed = 0; + const legal = state.commander.legalStatus; + + // 23 degrees off the nose, far out. Inside `ENGAGED_CONE`, and no shot. + const far = state.world.spawn('pirate', + new THREE.Vector3(Math.sin(0.4) * 3000, 0, -Math.cos(0.4) * 3000), 1); + far.state.speed = 0; + const cp = new ScriptedCoPilot(); + // The geometry is held still, so only the SELECTION rule can move. The ship + // flies nothing, because the question is which ship the co-pilot holds. + for (let i = 0; i < 120; i++) { + cp.step(1 / 60, state.player, state.world.npcs, legal, false, null); + } + const near = state.world.spawn('pirate', new THREE.Vector3(0, 0, -500), 2); + near.state.speed = 0; + + // Long enough to pass `ENGAGED_PATIENCE` with no shot on the far one. + let fires = false; + for (let i = 0; i < 60 * 6; i++) { + const s = cp.step(1 / 60, state.player, state.world.npcs, legal, false, null); + if (s.kind === 'fly' && s.demand.fire) fires = true; + } + check('a target it cannot shoot stops blocking the easy one', fires); + + // THE CONTROL: a commitment that IS going somewhere still holds. The near + // ship is on the gun, so the far one must not steal it back. + const steady = new ScriptedCoPilot(); + let held = 0; + for (let i = 0; i < 60 * 6; i++) { + const s = steady.step(1 / 60, state.player, state.world.npcs, legal, false, null); + if (s.kind === 'fly' && s.demand.fire) held += 1; + } + check('...and one that is landing shots keeps the lock', held > 60 * 3, + `${(held / 60).toFixed(1)}s of trigger over 6s`); +} diff --git a/test/co-pilot-tracking.test.ts b/test/co-pilot-tracking.test.ts new file mode 100644 index 00000000..f554ba82 --- /dev/null +++ b/test/co-pilot-tracking.test.ts @@ -0,0 +1,162 @@ +// TRACKING: does the co-pilot's nose stay on a target that moves? +// +// Split from `scripted-co-pilot.test.ts` on 2026-09-12, when that file passed +// the 400-line ceiling. It is a different question from the one next door. +// That file pins the co-pilot's CONTRACT: what it engages, when it asks for the +// trigger, when it answers a warhead, and when it hands back. This one flies +// the controller against prescribed target paths and measures the GEOMETRY. +// +// The review of 2026-09-12 is the reason it exists in this shape +// (`docs/COMBAT-COMPUTER-REVIEW.md`). Its own probe is the model for the four +// measures, and `npm run combat-aim` is the third check, in real fights. + +import * as THREE from 'three'; +import { ScriptedCoPilot } from '../src/game/scripted-co-pilot.ts'; +import { hitCone } from '../src/game/gunnery.ts'; +import { LASER_RANGE } from '../src/constants/player-gun.ts'; +import { freshState } from '../src/game/state.ts'; +import { newCommander } from '../src/game/commander.ts'; +import { seedWorld } from '../src/game/rng.ts'; +import { check } from './harness.ts'; + +// --- TRACKING: DOES THE NOSE STAY ON A TARGET THAT MOVES? ------------------ +// +// This replaces two weaker tests, on the review of 2026-09-12 +// (`docs/COMBAT-COMPUTER-REVIEW.md`). One accepted 20% of frames inside the gun +// cone and passed at 29%. The other measured a fixed 0.1 rad rather than the +// real cone, ran 20 seconds, and flew a target sideways while its nose pointed +// somewhere else. The co-pilot reads that nose for the target's velocity, so +// the fixture asked for a lead it then called wrong. +// +// The rules this fixture keeps: +// +// - Position, orientation and speed all come from ONE path. A fixture that +// disagrees with itself measures nothing. +// - The gun cone is `hitCone`, the gun's own. A fixed angle is not the test +// the trigger applies. +// - Four numbers, not one. A high average hides a target lost for 40 seconds, +// so the longest gap is its own limit. Acquisition and roll travel are the +// two costs a tracking gain can be paid for with. +// - Horizontal, vertical and tilted paths. A vertical orbit needs pitch +// alone and always tracked well. The coupled axes are where it broke. +console.log('\nthe co-pilot tracks a target that moves'); +{ + const DT = 1 / 60; + const UP = new THREE.Vector3(0, 1, 0); + + interface Track { + onGun: number; firstLock: number | null; longestGap: number; rollTurns: number; + } + /** + * Fly `path` for `seconds` and report the four measures. + * + * @param path writes the target's position and velocity at time t. + */ + const track = (path: (t: number, pos: THREE.Vector3, vel: THREE.Vector3) => void, + seconds: number): Track => { + seedWorld(4245); + const st = freshState(newCommander()); + st.world.build(st.systems[st.commander.systemIndex]); + st.world.clearNpcs(); + st.player.position.set(0, 0, 0); + st.player.quaternion.identity(); + st.player.speed = 200; + const pirate = st.world.spawn('pirate', new THREE.Vector3(0, 0, -1000), 1); + const cp = new ScriptedCoPilot(); + const pos = new THREE.Vector3(); + const vel = new THREE.Vector3(); + const look = new THREE.Matrix4(); + const ahead = new THREE.Vector3(); + const nose = new THREE.Vector3(); + const to = new THREE.Vector3(); + let onGun = 0; let late = 0; let rollTravel = 0; + let firstLock: number | null = null; + let gap = 0; let longestGap = 0; + for (let i = 0; i < seconds / DT; i++) { + const t = i * DT; + path(t, pos, vel); + pirate.object.position.copy(pos); + pirate.state.speed = vel.length(); + // ONE path: the nose points along the velocity the same path gives, which + // is what `velocityOf` reads back out of it. + if (pirate.state.speed > 1e-6) { + look.lookAt(pos, ahead.copy(pos).add(vel), UP); + pirate.object.quaternion.setFromRotationMatrix(look); + } + const step = cp.step(DT, st.player, st.world.npcs, st.commander.legalStatus, + false, null, Infinity, pirate); + if (step.kind !== 'fly') throw new Error('should be flying a live threat'); + st.player.update(DT, step.demand); + rollTravel += Math.abs(step.demand.rollRate) * DT; + + nose.set(0, 0, -1).applyQuaternion(st.player.quaternion); + to.copy(pirate.object.position).sub(st.player.position); + const lock = nose.angleTo(to) < hitCone(pirate.radius, to.length()) + && to.length() <= LASER_RANGE; + if (lock && firstLock === null) firstLock = t; + // the gap, and the window, both skip the first ten seconds of the swing on + if (t >= 10) { + late += 1; + if (lock) { onGun += 1; gap = 0; } else { gap += DT; longestGap = Math.max(longestGap, gap); } + } + } + return { + onGun: onGun / late, + firstLock, + longestGap, + rollTurns: rollTravel / (2 * Math.PI) / (seconds / 60), + }; + }; + + /** An orbit of a fixed centre, in one of the three planes. */ + const orbit = (radius: number, speed: number, plane: 'horizontal' | 'vertical' | 'tilted') => + (t: number, pos: THREE.Vector3, vel: THREE.Vector3): void => { + const a = (speed / radius) * t; + const x = Math.cos(a) * radius; const y = Math.sin(a) * radius; + const vx = -Math.sin(a) * speed; const vy = Math.cos(a) * speed; + if (plane === 'horizontal') { pos.set(x, 0, y); vel.set(vx, 0, vy); return; } + if (plane === 'vertical') { pos.set(0, x, y); vel.set(0, vx, vy); return; } + pos.set(x, y / Math.SQRT2, y / Math.SQRT2); + vel.set(vx, vy / Math.SQRT2, vy / Math.SQRT2); + }; + + // The limits are per case, and each one has room under the measured figure. + // The UNGATED controller is in the last column, so no limit here is vacuous: + // it fails every coupled case. See `COMBAT_ROLL_GATE`. + const CASES: { + name: string; seconds: number; minOnGun: number; maxLock: number; + maxGap: number; maxTurns: number; + path: (t: number, p: THREE.Vector3, v: THREE.Vector3) => void; + }[] = [ + { name: 'a horizontal orbit, 400 units at 300', seconds: 60, minOnGun: 0.80, + maxLock: 5, maxGap: 3, maxTurns: 6, path: orbit(400, 300, 'horizontal') }, + { name: 'a vertical orbit, 400 units at 300', seconds: 60, minOnGun: 0.95, + maxLock: 5, maxGap: 1, maxTurns: 1, path: orbit(400, 300, 'vertical') }, + { name: 'a tilted orbit, 800 units at 300', seconds: 60, minOnGun: 0.90, + maxLock: 5, maxGap: 2, maxTurns: 3, path: orbit(800, 300, 'tilted') }, + { name: 'a target crossing the front at 150', seconds: 60, minOnGun: 0.60, + maxLock: 5, maxGap: 12, maxTurns: 3, + path: (t, p, v) => { p.set(-900 + 150 * t, 0, -700); v.set(150, 0, 0); } }, + { name: 'a target weaving away', seconds: 60, minOnGun: 0.90, + maxLock: 5, maxGap: 2, maxTurns: 3, + path: (t, p, v) => { + p.set(350 * Math.sin(0.8 * t), 0, -1200 - 180 * t); + v.set(280 * Math.cos(0.8 * t), 0, -180); + } }, + { name: 'a target running straight away', seconds: 60, minOnGun: 0.99, + maxLock: 1, maxGap: 0.5, maxTurns: 0.5, + path: (t, p, v) => { p.set(0, 0, -1800 - 280 * t); v.set(0, 0, -280); } }, + ]; + + for (const c of CASES) { + const r = track(c.path, c.seconds); + const said = `${(r.onGun * 100).toFixed(0)}% on the gun, ` + + `lock ${r.firstLock === null ? 'never' : r.firstLock.toFixed(2) + 's'}, ` + + `worst gap ${r.longestGap.toFixed(2)}s, ${r.rollTurns.toFixed(1)} turns a minute`; + check(`it tracks ${c.name} (${said})`, + r.onGun >= c.minOnGun + && r.firstLock !== null && r.firstLock <= c.maxLock + && r.longestGap <= c.maxGap + && r.rollTurns <= c.maxTurns, said); + } +} diff --git a/test/combat-player.test.ts b/test/combat-player.test.ts index b8cf6843..26d86134 100644 --- a/test/combat-player.test.ts +++ b/test/combat-player.test.ts @@ -1,4 +1,4 @@ -// The commander's own trigger and her own shields. +// The commander's own trigger and their own shields. // // `game/combat-player.ts` assembles two calls out of a `GameState`: // `firePlayerLaser` pulls the trigger, and `damagePlayer` spends a hit on the diff --git a/test/combat-sim-career.test.ts b/test/combat-sim-career.test.ts index 1101c430..91e149b2 100644 --- a/test/combat-sim-career.test.ts +++ b/test/combat-sim-career.test.ts @@ -25,7 +25,7 @@ import { commanderOf, dockId, flightIds, type SaveRecord } from '../src/game/sav import { Combat } from '../src/game/combat.ts'; import { firePlayerLaser, damagePlayer } from '../src/game/combat-player.ts'; import { MAX_ENERGY } from '../src/constants/pools.ts'; -import { durability } from '../src/game/systems.ts'; +import { durability } from '../src/game/pools-left.ts'; import { CONTRABAND, CLEAN, FUGITIVE } from '../src/constants/law.ts'; import { MAX_FUEL } from '../src/constants/commander.ts'; import type { CommanderData } from '../src/game/commander.ts'; diff --git a/test/consigned-hold.test.ts b/test/consigned-hold.test.ts index a21a3ec1..0dead139 100644 --- a/test/consigned-hold.test.ts +++ b/test/consigned-hold.test.ts @@ -91,7 +91,7 @@ console.log('\nwhich tonnes are spoken for'); // Goods are fungible and the hold keeps no per-contract provenance, so the // answer is the JOB and never the pool. test/contracts.test.ts pins the // settlement half of the same rule: the station takes its five, and the ten - // she paid for stay hers. + // they paid for stay theirs. eq('a pooled hold reports the consignment, not the pool', consignedTonnes(carrying([job({ qty: 5 })], { [FOOD]: 15 }), FOOD), 5); eq('...and an empty hold still owes what was accepted', diff --git a/test/console-plate.test.ts b/test/console-plate.test.ts index 4aad8ea0..f69bbc62 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: [], + courses: [], courseRow: [], targetList: [], targetRow: [], guns: [], trial: false, messageText, messageTimer, } as unknown as Parameters[0], { diff --git a/test/constants.test.ts b/test/constants.test.ts index 91f35c36..ed6a8d99 100644 --- a/test/constants.test.ts +++ b/test/constants.test.ts @@ -120,8 +120,15 @@ const OUTSIDE: readonly Group[] = [ ], // ...and the arcs in tour order, which is a list of ids (docs/TODO/192) 'missions/skeletons/index.ts': ['SKELETONS', 'ARC_TOUR'], - // the pirates both lane legs name (docs/TODO/203 M2) - 'missions/skeletons/lane.ts': ['LANE_PIRATES'], + // the Dark Wheel's trials (docs/TODO/219) + 'missions/skeletons/wheel/mark.ts': ['WHEEL_MARK', 'AWAY'], + 'missions/skeletons/wheel/blockade.ts': ['WHEEL_BLOCKADE', 'BLOCKADE', 'AWAY'], + 'missions/skeletons/wheel/pilot.ts': ['WHEEL_PILOT'], + 'missions/skeletons/wheel/door.ts': ['WHEEL_DOOR'], + 'missions/skeletons/wheel/index.ts': ['WHEEL'], + // the pirates both lane legs name (docs/TODO/203 M2), and the company + // a job keeps at the jump-in (docs/TODO/214 M1) + 'missions/skeletons/lane.ts': ['LANE_PIRATES', 'PAIR', 'LONE_KRAIT'], 'missions/verbs/registry.ts': ['VERBS'], // the dossier table item 191 fills, and the words that end a story page 'missions/dossiers.ts': ['DOSSIERS'], @@ -130,7 +137,7 @@ const OUTSIDE: readonly Group[] = [ 'missions/story.ts': ['ENDINGS'], // the committed patron file given a name, the one patron with no world, // and the plain title a world with no record takes (docs/TODO/191 M1) - 'missions/patrons.ts': ['FILES', 'NAVY_PATRON', 'PLAIN_ROLE'], + 'missions/patrons.ts': ['FILES', 'NAVY_PATRON', 'PLAIN_ROLE', 'WHEEL_PATRON'], // the generated list of committed dossier files (docs/TODO/191 M2) 'missions/dossiers/index.ts': ['DOSSIER_FILES'], }, @@ -191,9 +198,11 @@ const OUTSIDE: readonly Group[] = [ + ' here). The conversion is done at the call site rather than by adding a' + ' second spelling of amber to the palette. Its `ZERO` scratch vector went' + ' with the docking computer\'s `lookAt` (docs/TODO/126): the autopilot builds' - + ' no orientation of its own any more, so there is nothing to look FROM', + + ' no orientation of its own any more, so there is nothing to look FROM.' + + ' `SCRAPE_SAID` is words rather than a number: what the console says' + + ' about a dock that did not take (docs/TODO/207 M3)', files: { - 'game/world-step.ts': ['WARHEAD_FLASH'], + 'game/world-step.ts': ['WARHEAD_FLASH', 'SCRAPE_SAID'], }, }, @@ -390,6 +399,35 @@ 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: words, not a number. What the console says when a course' + + ' finishes its work (docs/TODO/205 M3), beside the switch that says it.' + + ' What a course button says while it flies, and what a jump row says' + + ' when the ship cannot jump, beside the rule that raises them (M5).' + + ' KINDS is the list of courses, read off the codes that name them.' + + ' OBSTACLE_ROLES is which NPC roles a course steers round, and a role' + + ' is a word rather than a number', + files: { + 'game/flight-course.ts': ['COURSE_ENDS', 'OBSTACLE_ROLES'], + 'game/courses.ts': ['COURSE_NAMES', 'JUMP_WHY'], + 'game/course-actions.ts': ['KINDS'], + // what each role is called on the target list (docs/TODO/206 M1) + 'game/targets.ts': ['STANDING'], + // what a derelict's scan reports, read off the world's seed (docs/TODO/208 M5) + 'game/derelict.ts': ['REPORTS'], + }, + }, + { 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' @@ -439,9 +477,11 @@ 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', 'COURSE_STOP_KEY', 'COURSE_SKIP_KEY', 'COURSE_LIST_KEY', + 'TARGETS_KEY', 'TARGET_NONE_KEY', 'TARGET_ROW_PREFIX', 'ROLL_STRIP_CODE', ], 'game/screens/save-transfer.ts': ['NOT_A_SAVE', 'WRONG_VERSION', 'STORE_FULL'], 'engine/keymap.ts': ['LAYOUTS', 'STORAGE_KEY'], diff --git a/test/contract-eta.test.ts b/test/contract-eta.test.ts index c1195566..21ba6c0c 100644 --- a/test/contract-eta.test.ts +++ b/test/contract-eta.test.ts @@ -330,6 +330,6 @@ console.log('\nthe chart marks the world the Navy sent you to'); const between = standing(7, 10); between.missions = constrictorAt('report', null); eq('between the two legs nothing is marked', orderDestinations(between).size, 0); - eq('...and a system nobody sent her to has no verdict', + eq('...and a system nobody sent them to has no verdict', orderVerdict(between, inside.index, 2, g1), null); } diff --git a/test/contracts-screen.test.ts b/test/contracts-screen.test.ts index aa2d8a74..67d9c997 100644 --- a/test/contracts-screen.test.ts +++ b/test/contracts-screen.test.ts @@ -1,8 +1,8 @@ // The contracts screen, and the half of it that travels (docs/TODO/145). // // A bulletin board is a STATION's: the offers are that station's work, and -// `state.contractOffers` still holds the last one's the whole time she is in -// flight. What she SIGNED for is different — it is true wherever she is, and +// `state.contractOffers` still holds the last one's the whole time they are in +// flight. What they SIGNED for is different — it is true wherever they are, and // until 145 it could only be read at a station, which is the gap GitHub #27 // reported for the Navy mission and which contracts shared. // @@ -61,13 +61,13 @@ console.log('\nthe contracts screen, docked and in flight'); check('...and the accept control', docked.includes('ACCEPT SELECTED')); // The half that travels. - check('in flight the work she signed for is still on the screen', + check('in flight the work they signed for is still on the screen', flying.includes('ACCEPTED') && flying.includes(systems[42].name.toUpperCase()) && flying.includes(systems[11].name.toUpperCase())); // The half that does not. `state.contractOffers` is the LAST station's board, - // so drawing it in flight shows a pilot work she cannot take. + // so drawing it in flight shows a pilot work they cannot take. check('...but the board is not, because a board is a station\'s', !flying.includes('WORK ON OFFER')); check('...and neither is the control that signs for one', @@ -99,7 +99,7 @@ console.log('\n⇧C reaches it from the cockpit, and C is still the docking comp // The key, not just the button. A control that is not DRAWN but whose key still // answers is the "dead control that looks alive" failure inverted: the pilot -// cannot see it, and it spends her money anyway. So the screen refuses it. +// cannot see it, and it spends their money anyway. So the screen refuses it. console.log('\nthe accept key is refused in flight, not merely hidden'); { diff --git a/test/contracts.test.ts b/test/contracts.test.ts index 4844b90a..1c242b39 100644 --- a/test/contracts.test.ts +++ b/test/contracts.test.ts @@ -124,7 +124,7 @@ console.log('\ncontracts'); c.cargo[8] = 3; // two tonnes light at the door const ev = settleContracts(c); // EXACTLY the missing tonnes: billing the whole 5t consignment would be - // 2,340 tenths, more than she has, and would empty the account instead. + // 2,340 tenths, more than they have, and would empty the account instead. check('a short consignment is billed for the missing tonnes, not the whole job', ev[0]?.kind === 'billed' && ev[0].tonnes === 2 && ev[0].charged === 2 * 468); check('...and the money actually leaves the account', c.credits === 1000 - 936); @@ -174,7 +174,7 @@ console.log('\ncontracts'); { // Goods are fungible and the hold keeps no per-contract provenance, so a // commander who bought 10t of the same Food has covered the 5t consignment: - // the station takes its five and the ten she paid for stay hers. + // the station takes its five and the ten they paid for stay theirs. const c = cmdr(); c.contracts = [cargoRun({ commodity: 0, qty: 5 })]; c.cargo[0] = 15; @@ -259,15 +259,15 @@ console.log('\ncontracts'); { const c = cmdr({ day: 11 }); c.contracts = [passengerJob()]; - c.cargo[0] = 6; // her own goods, nothing to do with the fares + c.cargo[0] = 6; // their own goods, nothing to do with the fares const ev = settleContracts(c); check('late passengers expire, unpaid, like any other job', ev[0]?.kind === 'expired' && c.credits === 1000 && c.contracts.length === 0); - check('...and the berths go with them, leaving only her own 6t', + check('...and the berths go with them, leaving only their own 6t', cargoTonnes(c) === 6); // There is no consignment to hand back, and the HOLD IS NOT EMPTY: a job - // that reclaimed by `qty` regardless of kind would take 3t of the Food she - // bought for herself, and `commodity: 0` is Food. + // that reclaimed by `qty` regardless of kind would take 3t of the Food they + // bought for themselves, and `commodity: 0` is Food. check('...and a passenger job reclaims nothing, out of a hold with goods in it', ev[0]?.kind === 'expired' && ev[0].reclaimed === 0 && c.cargo[0] === 6); } diff --git a/test/course-arrivals.test.ts b/test/course-arrivals.test.ts new file mode 100644 index 00000000..eb186da2 --- /dev/null +++ b/test/course-arrivals.test.ts @@ -0,0 +1,162 @@ +// THE COURSES THAT FLY TO A THING AND STOP THERE. +// +// Split from `course-pilot.test.ts` on 2026-09-12, when that file passed the +// 400-line ceiling. The seam is `CoursePilot.arrive`, one function with four +// callers: the derelict, the hermit, the star and the loose cargo. Each flies +// to a standoff from something and settles at a speed. +// +// What stays next door is the STATION course, and the rules every line obeys: +// the hand-over to the pilot, the planet detour, the roll and the flight key. + +import * as THREE from 'three'; +import { CoursePilot } from '../src/game/course-pilot.ts'; +import { HERMIT_DOCK_SPEED } from '../src/constants/hermit-market.ts'; +import { COURSE_COLLECT_CAP, COURSE_DERELICT_STANDOFF } from '../src/constants/course.ts'; +import { CABIN_TEMP_FATAL } from '../src/constants/sun.ts'; +import { MAX_FUEL } from '../src/constants/commander.ts'; +import { PLAYER_FLIGHT } from '../src/constants/player-flight.ts'; +import { SCOOP_RANGE } from '../src/constants/scoop.ts'; +import { derelictReport } from '../src/game/derelict.ts'; +import { generateGalaxy } from '../src/galaxy/galaxy.ts'; +import { arrived, arrivedWith, fly, view } from './course-fixtures.ts'; +import { check, eq } from './harness.ts'; + +console.log('\nthe derelict course'); +{ + const g = arrivedWith('generation'); + g.state.session.course = 'derelict'; + fly(g, 300, () => g.state.session.course === null); + const gen = g.state.world.npcs.find((n) => n.role === 'generation')!; + const dist = g.state.player.position.distanceTo(gen.object.position); + check('the ship stops at the standoff from the generation ship', + Math.abs(dist - COURSE_DERELICT_STANDOFF) < 200, `${Math.round(dist)} units`); + check('...at the derelict\'s own drift', Math.abs(g.state.player.speed - gen.state.speed) < 10, + `${g.state.player.speed.toFixed(1)} u/s against ${gen.state.speed.toFixed(1)}`); + check('...and the course is done for the visit', g.state.session.coursesDone.includes('derelict')); + check('...and the scan says what is there, in the world\'s own words', + g.state.session.messageText.length > 20, `said: ${g.state.session.messageText}`); +} + +console.log('\nwhat a derelict\'s scan reports'); +{ + // The words come off the world's seed, so a derelict tells the same story + // on every visit, and two worlds tell different ones (docs/TODO/208 M5). + const systems = generateGalaxy(1); + const twice = [derelictReport(systems[7]), derelictReport(systems[7])]; + eq('the same world reports the same thing twice', twice[0], twice[1]); + const said = new Set(systems.map((sys) => derelictReport(sys))); + check('...and the galaxy tells more than one story', said.size > 3, `${said.size} of them`); + check('...each of them a sentence', [...said].every((line) => line.endsWith('.'))); +} + +console.log('\nthe hermit course'); +{ + const g = arrivedWith('hermit'); + g.state.session.course = 'hermit'; + fly(g, 400, () => g.state.session.hermitTrading); + check('the hermit opens his trade at the end of the course', g.state.session.hermitTrading); + check('...with the ship slow enough for the hermit\'s rule', + g.state.player.speed < HERMIT_DOCK_SPEED, `${g.state.player.speed.toFixed(1)} u/s`); + check('...and the ship never struck the rock', g.state.sys.foreShield > 0 && g.mode !== 'dead'); +} + +console.log('\nthe star course'); +{ + const g = arrived(20_260_913); + g.state.commander.equipment.scoops = true; + g.state.commander.fuel = 5; + g.state.session.course = 'skim'; + let peak = 0; + fly(g, 400, () => { + peak = Math.max(peak, g.state.sys.cabinTemp); + return g.state.session.course === null; + }); + eq('the star course fills the tank', g.state.commander.fuel, MAX_FUEL); + check('...the ship lives, and the cabin stays short of fatal', + g.mode !== 'dead' && peak < CABIN_TEMP_FATAL * 0.6, `peak ${peak.toFixed(3)}`); + check('...and the course is done for the visit', g.state.session.coursesDone.includes('skim')); +} + +// --- THE COLLECT COURSE HOLDS ONE CANISTER, AND LEADS IT ------------------- +// +// Chris, 2026-09-12: *"Collecting cargo often seems to be difficult - we miss +// it quite a lot - especially when it is moving."* +// +// Two faults, and a probe named them by flying ONE canister against five. One +// alone was never missed, at any drift. Five were missed seven times with the +// cargo at rest. So the first fault was the PICK: the course took the nearest +// every frame, and turned away from a canister it was nearly on when another +// drifted closer. The second was the AIM, which was where the canister had +// been. +// +// Both are asserted on the RULE rather than through a two-minute flight. The +// flight is what measured them, and its figures are beside +// `COURSE_COLLECT_LEAD`: over 80 runs a drift, the led course took 231 of 240 +// canisters against 191 unled. +console.log('\nthe collect course holds one canister, and leads it'); +{ + const DT = 1 / 60; + const far = new THREE.Vector3(0, 0, -50_000); + const still = (at: THREE.Vector3) => ({ at, velocity: new THREE.Vector3() }); + const ahead = new THREE.Vector3(0, 0, -600); + const aside = new THREE.Vector3(1400, 0, -600); + + // THE PICK. A canister dead ahead asks for no turn. One off to the side asks + // for a big one. So the demand says which of the two the course is flying at. + const turn = (s: { demand: { pitchRate: number; rollRate: number } | null }): number => + Math.abs(s.demand?.pitchRate ?? 0) + Math.abs(s.demand?.rollRate ?? 0); + + const fresh = new CoursePilot(); + const onAhead = turn(fresh.step(view(far, { course: 'collect', loot: [still(ahead)] }), DT)); + const onAside = turn(new CoursePilot() + .step(view(far, { course: 'collect', loot: [still(aside)] }), DT)); + check('the fixture can tell the two apart', onAside > onAhead + 0.01, + `${onAhead.toFixed(3)} ahead against ${onAside.toFixed(3)} aside`); + + const held = new CoursePilot(); + held.step(view(far, { course: 'collect', loot: [still(aside)] }), DT); + // ...and now a nearer one turns up, first in the list. + const kept = turn(held.step( + view(far, { course: 'collect', loot: [still(ahead), still(aside)] }), DT)); + check('a nearer canister does not steal one the course is already on', + kept > onAhead + 0.01, `${kept.toFixed(3)}, against ${onAhead.toFixed(3)} for the near one`); + + // ...until it is gone, and then the nearest is the next. + const moved = turn(held.step(view(far, { course: 'collect', loot: [still(ahead)] }), DT)); + check('...and when it is aboard, the course takes the next', moved <= onAhead + 0.01, + `${moved.toFixed(3)}`); + + // THE AIM. A canister dead ahead and drifting sideways is not where it was. + const drifting = { at: ahead.clone(), velocity: new THREE.Vector3(200, 0, 0) }; + const led = turn(new CoursePilot() + .step(view(far, { course: 'collect', loot: [drifting] }), DT)); + check('a drifting canister is aimed ahead of, not at', led > onAhead + 0.01, + `${led.toFixed(3)}, against ${onAhead.toFixed(3)} for the same place at rest`); +} + +// ...AND IT DOES NOT FLY AT IT TOO FAST TO CORRECT. +// +// Chris, 2026-09-12: *"collecting cargo seems to be broken sometimes. I seem to +// run at maximum speed, then slow down and then miss it and then run at maximum +// speed."* A trace showed exactly that: 400 units a second with a canister 250 +// units off, a sail past, and a loop. `arrive` picks its speed from a braking +// curve, which is right for a big target on a straight line. At 400 the ship's +// tightest turn has a radius of 276 units, and the scoop reaches 45. +// +// Holding one canister (above) made the loop permanent rather than causing it. +// The old course switched away, which looked like progress. +{ + const adrift = { at: new THREE.Vector3(0, 0, -4000), velocity: new THREE.Vector3() }; + const collect = new CoursePilot() + .step(view(new THREE.Vector3(0, 0, -50_000), { course: 'collect', loot: [adrift] }), 1 / 60); + check('a canister 4,000 units off still opens the throttle', + collect.demand?.throttle === 1); + check('...and the cap is under half what the ship can fly', + COURSE_COLLECT_CAP < PLAYER_FLIGHT.maxSpeed / 2, + `${COURSE_COLLECT_CAP} against ${PLAYER_FLIGHT.maxSpeed}`); + check('...and its turning circle is inside twice the scoop', + COURSE_COLLECT_CAP / PLAYER_FLIGHT.maxPitch < SCOOP_RANGE * 2, + `${(COURSE_COLLECT_CAP / PLAYER_FLIGHT.maxPitch).toFixed(0)} units against ${SCOOP_RANGE}`); + check('...while still fast enough to catch a canister at its top drift', + COURSE_COLLECT_CAP > 45, `${COURSE_COLLECT_CAP}`); +} diff --git a/test/course-buttons.test.ts b/test/course-buttons.test.ts new file mode 100644 index 00000000..a349b0f0 --- /dev/null +++ b/test/course-buttons.test.ts @@ -0,0 +1,162 @@ +// The course buttons over the flight view (docs/TODO/205 M5). +// +// In flight the course list is a set of buttons, not a screen, because the +// flight world stops under a screen. Each button sends its code as a menu row +// does. These press the codes in a real headless Game, as a click would. + +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 { COURSE_KEYS, COURSE_LIST_KEY, COURSE_STOP_KEY } from '../src/game/bindings.ts'; +import { courseButtonsFor, courseRowFor } from '../src/game/cockpit-buttons.ts'; +import { keyCodeIfBound } from '../src/ui/key-help.ts'; +import { check, dismissBriefing, eq } from './harness.ts'; + +console.log('\nthe course buttons in flight'); + +/** A commander at the witchpoint of a system, with no course. */ +function arrived(): Game { + const g = withoutSaving(() => { + seedWorld(20_260_917); + const game = new Game(() => headlessShell()); + dismissBriefing(game); + game.launch(); + return game; + }).value; + // The launch and the arrival each play a tunnel, and the cockpit reads only + // a few keys while one runs. So the test presses once the tunnel is over. + const settle = (): void => { + withoutSaving(() => { for (let f = 0, at = 0; f < 400; f++) g.step(1 / 60, at += 1 / 60); }); + }; + settle(); + g.arriveInSystem(); + // Only the rocks stay: they are what the mining button needs, and they + // start no fight. + const quiet = (): void => { + for (const n of g.state.world.npcs) if (n.role !== 'asteroid') n.state.alive = false; + }; + quiet(); + settle(); + quiet(); + return g; +} + +function press(g: Game, code: string): void { + g.input.injectPress(code); + withoutSaving(() => g.step(1 / 60, 1)); +} + +{ + const g = arrived(); + const panel = g.coursePanel(); + check('with no course, the list shows by itself after an arrival', + panel !== null && panel.rows !== null && panel.rows.some((c) => c.kind === 'station')); + + 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 a button that says what the ship is doing, and the fast forward icon', + courseButtonsFor(g.coursePanel()!).map((b) => b.label).join(), 'HEADING TO THE STATION,\u25B6\u25B6'); + check('...which is an icon, with no words under it', courseButtonsFor(g.coursePanel()!)[1].icon === true + && courseButtonsFor(g.coursePanel()!)[1].hint === undefined); + + // A LIT BUTTON READS AS ON, SO A TAP TURNS IT OFF (Chris, 2026-09-12: *"I + // think you should be able to click it to disengage it"*). It used to open + // the list over the running course instead. + eq('...and that button offers to stop the course', + courseButtonsFor(g.coursePanel()!)[0]?.hint, 'TAP TO STOP'); + press(g, COURSE_STOP_KEY); + eq('pressing it stops the course', g.state.session.course, null); + eq('...and the console says so', g.state.session.messageText, + 'COURSE OFF — CHOOSE WHERE TO GO'); + check('...and the list shows again by itself', (g.coursePanel()?.rows?.length ?? 0) > 0); + + g.state.commander.equipment.scoops = true; + g.state.commander.fuel = 10; + press(g, COURSE_KEYS.skim); + eq('another button picks a new course', g.state.session.course, 'skim'); + eq('...and folds the list away again', g.coursePanel()?.rows ?? null, null); +} + +{ + // The chart button opens the LOCAL chart, which shows what the tank can + // reach (Chris, 2026-09-12). + const g = arrived(); + // The list is closed by default, behind its header (docs/TODO/215 M2). + eq('the list is closed behind its header', g.hudButtons().courses[0]?.label, 'ACTIONS ▾'); + eq('...and the row is empty', g.hudButtons().courseRow.length, 0); + press(g, COURSE_LIST_KEY); + eq('the header opens it', g.hudButtons().courses[0]?.label, 'ACTIONS ▴'); + const chart = g.hudButtons().courseRow.find((b) => b.label === 'LOCAL CHART'); + eq('...and the row carries a button for the local chart', chart?.code, + keyCodeIfBound('flight', 'openLocalChart')); + press(g, COURSE_LIST_KEY); + eq('a second tap folds it', g.hudButtons().courseRow.length, 0); +} + +{ + const g = arrived(); + press(g, COURSE_KEYS.mine); + eq('a button the ship cannot fly picks nothing', g.state.session.course, null); + eq('...and the console says what the ship needs', g.state.session.messageText, + 'NEEDS A MINING LASER AND FUEL SCOOPS'); + // The row holds it, once the header is open (docs/TODO/215 M2). + const dim = courseRowFor({ ...g.coursePanel()!, open: true }, null).find((b) => b.code === COURSE_KEYS.mine); + eq('...which its button also says, under its label', dim?.note, 'NEEDS A MINING LASER AND FUEL SCOOPS'); +} + +{ + const g = arrived(); + g.state.session.paused = true; + g.input.injectPress(COURSE_KEYS.station); + withoutSaving(() => g.step(1 / 60, 1)); + eq('a paused cockpit reads no course button', g.state.session.course, null); +} + +{ + const g = withoutSaving(() => { + seedWorld(20_260_918); + const game = new Game(() => headlessShell()); + dismissBriefing(game); + return game; + }).value; + g.startExercise({ mode: 'sparring', scenario: 'single-pirate', tier: 1, seed: 1 }); + eq('an exercise shows no course buttons', g.coursePanel(), null); + g.endExercise(); +} + +// --- A FIGHT DROPS THE COURSE ---------------------------------------------- +// +// Chris, 2026-09-12: *"it should also be disengaged by combat or other +// events"*. The combat computer already won the stick, because `flight.ts` +// asks it before the course. So the course sat there flying nothing, and it +// took the ship back the moment the fight ended. The button meanwhile said the +// ship was heading somewhere. +{ + const g = arrived(); + press(g, COURSE_KEYS.station); + eq('a course is under way', g.state.session.course, 'station'); + + // a pirate close enough for the condition light to call it a fight + g.state.world.spawn('pirate', + g.state.player.position.clone().add(new THREE.Vector3(0, 0, -900)), 1); + // The console shows one line at a time, and the torus drive drops in the same + // frame, so collect what it said rather than read the last of it. + const said: string[] = []; + withoutSaving(() => { + for (let f = 0, at = 1; f < 240; f++) { + g.step(1 / 60, at += 1 / 60); + const line = g.state.session.messageText; + if (line && said.at(-1) !== line) said.push(line); + } + }); + + check('a fight takes the stick', g.state.session.ccEngaged); + eq('...and the course is dropped with it', g.state.session.course, null); + check('...and the console says why', said.includes('HOSTILE SHIP — COURSE OFF'), + said.join(' / ')); + check('...so the list shows again, rather than a course that flies nothing', + (g.coursePanel()?.rows?.length ?? 0) > 0); +} diff --git a/test/course-clearance.test.ts b/test/course-clearance.test.ts new file mode 100644 index 00000000..20981c55 --- /dev/null +++ b/test/course-clearance.test.ts @@ -0,0 +1,98 @@ +// WHERE A COURSE AIMS, so that its line is clear of what is on it. +// +// Split from `course-pilot.test.ts` on 2026-09-12, when that file passed the +// 400-line ceiling, and it follows the split of the code it tests. That file +// asks what a course DOES for one frame. This one asks where the line goes. +// +// `src/game/course-clearance.ts` is the subject. The second block is the flight +// it came from, so the pure geometry above it is held to a real approach. + +import * as THREE from 'three'; +import { Game } from '../src/game/game.ts'; +import { headlessShell } from '../src/engine/shell.ts'; +import { withoutSaving } from '../src/game/storage.ts'; +import { seedWorld } from '../src/game/rng.ts'; +import { clearOfObstacles } from '../src/game/course-clearance.ts'; +import { COURSE_OBSTACLE_CLEARANCE } from '../src/constants/course.ts'; +import { check, dismissBriefing, eq } from './harness.ts'; + +// --- A COURSE GOES ROUND WHAT IT WOULD HIT --------------------------------- +// +// Chris, 2026-09-12: *"I visited a rock hermit and then when I left clicked fly +// to the station - the computer crashed me straight into the rock hermit - we +// need to have some avoidance of obstacles"*. +// +// The line already went round the planet and, on a smuggling run, round a +// policeman. It went through everything else. +console.log('\na course goes round what it would hit'); +{ + const out = new THREE.Vector3(); + const from = new THREE.Vector3(); + const to = new THREE.Vector3(0, 0, -60_000); + const rock = (at: THREE.Vector3, radius = 120) => ({ at, radius }); + + eq('with nothing in the way, the aim is the target', + clearOfObstacles(from, to, [], out).equals(to), true); + check('a hermit ON the line moves the aim off it', + clearOfObstacles(from, to, [rock(new THREE.Vector3(0, 0, -600))], out) + .distanceTo(to) > 1, `${out.x.toFixed(0)},${out.y.toFixed(0)},${out.z.toFixed(0)}`); + check('...far enough that the new line clears its hull', + clearOfObstacles(from, to, [rock(new THREE.Vector3(0, 0, -600))], out) + .distanceTo(new THREE.Vector3(0, 0, -600)) > 120 + COURSE_OBSTACLE_CLEARANCE, + `${out.distanceTo(new THREE.Vector3(0, 0, -600)).toFixed(0)} units from its centre`); + check('a hermit well off the line is left alone', + clearOfObstacles(from, to, [rock(new THREE.Vector3(9000, 0, -600))], out).equals(to)); + // A thing AT the end of the line is the target, not an obstacle. That is what + // lets the hermit course fly to a hermit. + check('a hermit at the far end of the line is not in the way of it', + clearOfObstacles(from, to, [rock(to.clone())], out).equals(to)); + // The worst is the one with least room, not the one nearest the ship. + const near = rock(new THREE.Vector3(300, 0, -600)); + const tight = rock(new THREE.Vector3(20, 0, -4000)); + check('the tightest squeeze is the one it steers round, not the nearest', + clearOfObstacles(from, to, [near, tight], out).distanceTo(tight.at) + < clearOfObstacles(from, to, [near, tight], out).distanceTo(near.at)); +} + +// ...and the flight it came from: the station course past the hermit the +// commander just left. Without the detour this kills them outright at 399 units +// a second. +{ + const g = withoutSaving(() => { + seedWorld(20_260_933); + const game = new Game(() => headlessShell()); + dismissBriefing(game); + game.launch(); + return game; + }).value; + withoutSaving(() => { for (let f = 0, at = 0; f < 400; f++) g.step(1 / 60, at += 1 / 60); }); + g.state.world.clearNpcs(); + + // Out in the belt, with the station on the far side of the hermit, and the + // nose already down the line — a ship that has just left a hermit. + const away = new THREE.Vector3(1, 0.2, 0.3).normalize(); + g.state.player.position.copy(g.state.world.station.position).addScaledVector(away, 60_000); + const toStation = g.state.world.station.position.clone() + .sub(g.state.player.position).normalize(); + const at = g.state.player.position.clone().addScaledVector(toStation, 600); + const hermit = g.state.world.spawn('hermit', at, 3); + hermit.state.speed = 0; + g.state.player.quaternion.setFromRotationMatrix(new THREE.Matrix4() + .lookAt(g.state.player.position, g.state.world.station.position, new THREE.Vector3(0, 1, 0))); + g.state.session.course = 'station'; + + let closest = Infinity; + withoutSaving(() => { + for (let f = 0; f < 60 * 40; f++) { + g.step(1 / 60, 20 + f / 60); + hermit.object.position.copy(at); // hold it still, so only the RULES move + closest = Math.min(closest, g.state.player.position.distanceTo(at) - hermit.radius); + if (g.mode !== 'flight') break; + } + }); + check('the station course flies past the hermit rather than into it', + g.mode === 'flight', `ended as ${g.mode}`); + check('...clear of its hull by most of a turning circle', + closest > COURSE_OBSTACLE_CLEARANCE * 0.8, + `${closest.toFixed(0)} units, against a clearance of ${COURSE_OBSTACLE_CLEARANCE.toFixed(0)}`); +} diff --git a/test/course-fixtures.ts b/test/course-fixtures.ts new file mode 100644 index 00000000..c3a62bac --- /dev/null +++ b/test/course-fixtures.ts @@ -0,0 +1,90 @@ +// The fixtures the course tests share: a flat view, two arrivals and a flight. +// +// A fixture module rather than a block in one file, for the reason +// `audio-fixtures.ts` is one. `course-pilot.test.ts` and +// `course-arrivals.test.ts` split on 2026-09-12, and both need the same four. A +// second copy would be two fixtures that drift, and a fixture that disagrees +// with its twin measures nothing. + +import * as THREE from 'three'; +import { Game } from '../src/game/game.ts'; +import { headlessShell } from '../src/engine/shell.ts'; +import { withoutSaving } from '../src/game/storage.ts'; +import { seedWorld } from '../src/game/rng.ts'; +import type { CourseView } from '../src/game/course-pilot.ts'; +import { DOCK_COMPUTER_RANGE } from '../src/constants/docking-computer.ts'; +import { dismissBriefing } from './harness.ts'; + +export /** A ship at the origin, nose down −Z, and a station somewhere. */ +const view = (station: THREE.Vector3, over: Partial = {}): CourseView => ({ + course: 'station', + position: new THREE.Vector3(), + 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, + threats: [], + obstacles: [], + loot: [], + police: [], + mission: null, + dcEngaged: false, + handOverRange: DOCK_COMPUTER_RANGE, + ...over, +}); + +export /** 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; +} + +export /** 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}`); +} + +export /** 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; +} diff --git a/test/course-launch.test.ts b/test/course-launch.test.ts new file mode 100644 index 00000000..ebfea258 --- /dev/null +++ b/test/course-launch.test.ts @@ -0,0 +1,91 @@ +// 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 what to do', g.state.session.messageText, + 'CHOOSE A SYSTEM ON THE LOCAL CHART FIRST'); + + press(g, COURSE_CHART_KEY); + eq('the chart row opens the local chart, which shows what the tank reaches', + g.mode, 'local'); + 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/course-pilot.test.ts b/test/course-pilot.test.ts new file mode 100644 index 00000000..802909a0 --- /dev/null +++ b/test/course-pilot.test.ts @@ -0,0 +1,241 @@ +// 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 { withoutSaving } from '../src/game/storage.ts'; +import { CoursePilot } from '../src/game/course-pilot.ts'; +import { clearOfPlanet } from '../src/game/course-clearance.ts'; +import { COURSE_PLANET_CLEARANCE } from '../src/constants/course.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 { COURSE_DOCK_HANDOVER } from '../src/constants/course.ts'; +import { MASS_LOCK_STATION } from '../src/constants/torus.ts'; +import { keyCodeIfBound } from '../src/ui/key-help.ts'; +import { arrived, fly, view } from './course-fixtures.ts'; +import { check, eq } from './harness.ts'; + +console.log('\nthe course pilot, one frame at a time'); + + +{ + 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); + + 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: 'mine' }), 1 / 60).demand, null); +} + +console.log('\nthe station course, flown from the witchpoint'); + + +// THE COMPUTER IS A BUTTON, NOT AN AUTOMATIC (Chris, 2026-09-12: *"I have a +// docking computer - but instead of it being activated I still get the lining +// and only once that is done does the computer activate"*). Every commander +// gets the line-up, and a fitted computer flies the rest when they ask for it. +// The block below flies the same course with nobody pressing anything. +{ + const g = arrived(20_260_911); + g.state.commander.equipment.dockingComputer = true; + 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; + let pressed = false; + 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; + // The pilot presses the button the moment it is offered. + if (!pressed && g.state.session.dockTrial) { + g.input.injectPress(keyCodeIfBound('flight', 'toggleDockingComputer') ?? ''); + pressed = 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('...the button then engaged the computer, inside its range', + handOverAt > 0 && handOverAt <= DOCK_COMPUTER_RANGE + 50, `${Math.round(handOverAt)} units`); + check('...and it flew the ship in, inside three minutes', + 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('\nwith no docking computer, the course hands the slot to the pilot'); +{ + const g = arrived(20_260_935); + g.state.session.course = 'station'; + const dt = 1 / 60; + withoutSaving(() => { + for (let f = 0, at = 0; f < 240 / dt && !g.state.session.dockTrial; f++) g.step(dt, at += dt); + }); + const range = g.state.player.position.distanceTo(g.state.world.station.position); + check('the course flies the ship in and hands it over', g.state.session.dockTrial, + `${Math.round(range)} units out`); + check('...at the hand-over range', Math.abs(range - COURSE_DOCK_HANDOVER) < 200, + `${Math.round(range)} units out`); + eq('...and the course is over, because the ship is the pilot\'s now', + g.state.session.course, null); +} + +// THE SHIP MUST NOT ROLL ALL THE WAY THERE (docs/TODO/210). +// +// Chris, 2026-09-12: *"we seem to be constantly rotating when heading towards +// something"*. The steering held a cone: the nose circled the target at a +// fixed angle, and the roll never stopped. The measurement is the total roll +// over a whole trip, in full turns. It was 7.8 turns with a clear sky. +console.log('\nthe course does not roll the ship all the way there'); +{ + const g = arrived(20_260_935); + g.state.session.course = 'station'; + const dt = 1 / 60; + let rolled = 0; + withoutSaving(() => { + for (let f = 0, at = 0; f < 240 / dt && !g.state.session.dockTrial; f++) { + g.step(dt, at += dt); + rolled += Math.abs(g.state.player.rollRate) * dt; + } + }); + const turns = rolled / (2 * Math.PI); + check('the whole trip to the station costs less than one full turn of roll', + turns < 1, `${turns.toFixed(2)} turns`); + check('...and the ship still arrives', g.state.session.dockTrial); +} + +console.log('\n...and a flight key takes the ship back'); +{ + const g = arrived(20_260_912); + 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); +} + +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)); +} + + +console.log('\nthe station course goes round a planet in its way'); +{ + const g = arrived(20_260_914); + g.state.commander.equipment.dockingComputer = true; + 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; + let pressed = false; + fly(g, 400, () => { + lowest = Math.min(lowest, g.state.player.position.distanceTo(w.planetPos) - w.planetRadius); + // The line-up is the pilot's until they ask for the computer. + if (!pressed && g.state.session.dockTrial) { + g.input.injectPress(keyCodeIfBound('flight', 'toggleDockingComputer') ?? ''); + pressed = true; + } + 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('\na mission target below the clearance is refused'); +{ + // docs/TODO/213 M1: a charge that ran flew through the planet, and the + // escort course followed it. The commander crashed with full shields. + const v = view(new THREE.Vector3(0, 0, -50_000)); + const low = v.planetPos.clone().add(new THREE.Vector3(0, -(v.planetRadius + 100), 0)); + const refused = new CoursePilot().step( + view(v.stationPos, { course: 'mission', mission: { at: low, speed: 100, how: 'escort', fleeing: false } }), 1 / 60); + check('a target 100 units above the planet ends the course', refused.done); + check('...with a reason for the console', typeof refused.why === 'string' && refused.why.length > 0); + const clear = v.planetPos.clone().add(new THREE.Vector3(0, -(v.planetRadius + COURSE_PLANET_CLEARANCE * 2), 0)); + const flown = new CoursePilot().step( + view(v.stationPos, { course: 'mission', mission: { at: clear, speed: 100, how: 'escort', fleeing: false } }), 1 / 60); + check('...and one well above it is flown (the control)', !flown.done && flown.demand !== null); +} + +console.log('\na hunt is a fight until the target runs, and then it is a chase (docs/TODO/214 M4)'); +{ + const v = view(new THREE.Vector3(0, 0, -50_000)); + const ahead = new THREE.Vector3(0, 0, -6_000); + const fight = new CoursePilot().step( + view(v.stationPos, { course: 'mission', mission: { at: ahead, speed: 290, how: 'fight', fleeing: false } }), 1 / 60); + check('a target that stands and fights is left to the computer\'s aim', fight.demand === null && !fight.done); + const chase = new CoursePilot().step( + view(v.stationPos, { course: 'mission', mission: { at: ahead, speed: 290, how: 'fight', fleeing: true } }), 1 / 60); + check('a target on the run is chased at full throttle', chase.demand?.throttle === 1 && !chase.done); + check('...with the nose held on it', chase.demand !== null && chase.demand.pitchRate === 0 && chase.demand.rollRate === 0); +} + +console.log('\nnothing appears inside the planet'); +{ + // docs/TODO/205 M3 found a hermit 1,545 units inside the planet, and a + // 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/courses.test.ts b/test/courses.test.ts new file mode 100644 index 00000000..ad04e03c --- /dev/null +++ b/test/courses.test.ts @@ -0,0 +1,136 @@ +// 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(), + threat: null, + loot: 0, + ...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: { what: 'HUNT THE KRAIT', why: null } })), + ['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, + 'CHOOSE A SYSTEM ON THE LOCAL CHART FIRST'); +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, 'NOT ENOUGH FUEL TO GET THERE'); +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: { what: 'SCAN THE COBRA', why: null } }))[0], + { kind: 'mission', what: 'SCAN THE COBRA', why: null }); +eq('...and it says when the ship cannot do the job', + row(world({ mission: { what: 'RECOVER THE CARGO', why: 'NEEDS FUEL SCOOPS' } }), 'mission')?.why, + 'NEEDS FUEL SCOOPS'); +check('...no leg here, no row', !kinds(world()).includes('mission')); +check('...and a finished one leaves', !kinds(world({ mission: { what: 'X', why: null }, done: new Set(['mission']) })).includes('mission')); +check('...and no mission row at a launch', !kinds(world({ situation: 'launch', sky: [], mission: { what: 'X', why: null } })).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']); + +// --- a way out of a fight (docs/TODO/206 M5) -------------------------------- +eq('with a hostile ship on the scanner, RUN leads the list', + kinds(world({ threat: { fastest: 381, own: 400 } }))[0], 'run'); +eq('...and says how thin the margin is', row(world({ threat: { fastest: 381, own: 400 } }), 'run')?.what, + 'RUN FOR IT — YOU ARE ONLY A LITTLE FASTER'); +eq('...or that the ship is faster, where it is', row(world({ threat: { fastest: 320, own: 400 } }), 'run')?.what, + 'RUN FOR IT — YOU ARE FASTER'); +eq('...or that it is not', row(world({ threat: { fastest: 400, own: 400 } }), 'run')?.what, + 'RUN FOR IT — THEY ARE AS FAST AS YOU'); +check('with no hostile ship on the scanner, there is nothing to run from', !kinds(world()).includes('run')); + diff --git a/test/damage-paths.test.ts b/test/damage-paths.test.ts index e6d7a92a..413682b5 100644 --- a/test/damage-paths.test.ts +++ b/test/damage-paths.test.ts @@ -394,7 +394,7 @@ console.log('\nthe old scale is gone, and cannot come back'); check(`every damage call names its rule (${npcCalls} + ${playerCalls} calls)`, bad.length === 0, bad.join(' · ')); // FOUR player-facing calls, where it was five: docs/TODO/64 moved the NPC - // laser's out of the step and into `fire-resolution.ts`, where it reaches her + // laser's out of the step and into `fire-resolution.ts`, where it reaches them // through the `FireTarget` seam and arrives at `applyPlayerDamage` as the // forwarded `damage` above. The rule it names is asserted by its own source // scan in the crossfire block — an equally precise check at the new address, diff --git a/test/defence-answer.test.ts b/test/defence-answer.test.ts index 3f0d4460..0fd49d85 100644 --- a/test/defence-answer.test.ts +++ b/test/defence-answer.test.ts @@ -188,7 +188,7 @@ import { CombatComputer, freshAutopilot } from '../src/game/combat-computer.ts'; // only in brainFromFile's defaults, asserted through the loader tests.) } -// --- and now she can answer one ---------------------------------------------- +// --- and now the commander can answer one -------------------------------------- // // docs/TODO/72. Missiles became real in training (62) and the target had no // counter: no E.C.M. fitted, no output that could press one, no observation @@ -243,7 +243,7 @@ console.log('\nmissiles: the target answers one'); without.pirates.reduce((s, p) => s + p.missilesFired, 0), 1); eq('...and the same one either way', withEcm.pirates.reduce((s, p) => s + p.missilesFired, 0), 1); - // A warhead is `IMPACT.warhead` in her own pool points, and the E.C.M. is the + // A warhead is `IMPACT.warhead` in their own pool points, and the E.C.M. is the // only thing in the game that stops one. eq('a target with an E.C.M. takes a warhead\'s worth less on the same seed', without.target.damageTaken - withEcm.target.damageTaken, diff --git a/test/dock-trial.test.ts b/test/dock-trial.test.ts new file mode 100644 index 00000000..6b8c84d4 --- /dev/null +++ b/test/dock-trial.test.ts @@ -0,0 +1,369 @@ +// The pilot flies the last stretch into the slot (docs/TODO/207). +// +// Chris picked Match the spin from the four concepts on 2026-09-11. The +// computer holds the ship on the slot axis. The pilot matches the station's +// spin with the roll strip or the roll keys, and the speed with the throttle. +// The slot takes the ship only if both are right. + +import * as THREE from 'three'; +import { gunButtonsFor } from '../src/game/cockpit-buttons.ts'; +import { attachStripControl } from '../src/engine/strip-control.ts'; +import { flightDemand } from '../src/engine/flight-controls.ts'; +import { keymap } from '../src/engine/keymap.ts'; +import { dockingOutcome } from '../src/game/docking.ts'; +import { SLOT_SPEED_LIMIT } from '../src/constants/docking.ts'; +import { PLAYER_FLIGHT } from '../src/constants/player-flight.ts'; +import { Game } from '../src/game/game.ts'; +import { headlessShell } from '../src/engine/shell.ts'; +import { withoutSaving } from '../src/game/storage.ts'; +import { seedWorld } from '../src/game/rng.ts'; +import { RAILS_CONE, RAILS_STOPPED, SLOT_HALF_ACROSS } from '../src/constants/docking.ts'; +import { slotNormal } from '../src/world/slot.ts'; +import { keyCodeIfBound } from '../src/ui/key-help.ts'; +import { check, dismissBriefing, eq } from './harness.ts'; + +console.log('\nthe last stretch into the slot'); + +// --- the two controls, and nothing else -------------------------------------- +{ + const trial = gunButtonsFor({ + fireKey: 'KeyA', missiles: 3, armed: false, locked: false, armKey: 'KeyT', + launchKey: 'KeyM', disarmKey: 'KeyU', ecmKey: 'KeyE', cloakKey: null, cloaked: false, targets: null, missileInbound: false, dockKey: null, + trial: true, rails: true, accelKey: 'Space', decelKey: 'KeyX', rollStripCode: 'roll', + }); + eq('the stretch shows a strip and two held buttons, and no guns', + trial.map((b) => b.label).join(), 'DRAG TO ROLL,THRUST,BRAKE'); + // ...and before the rails take it, the pilot has nothing to do yet + // (docs/TODO/212). A strip that did nothing would be a lie. + const lining = gunButtonsFor({ + fireKey: 'KeyA', missiles: 3, armed: false, locked: false, armKey: 'KeyT', + launchKey: 'KeyM', disarmKey: 'KeyU', ecmKey: 'KeyE', cloakKey: null, cloaked: false, targets: null, missileInbound: false, dockKey: null, + trial: true, rails: false, accelKey: 'Space', decelKey: 'KeyX', rollStripCode: 'roll', + }); + eq('while the computer lines up, the pilot sees one word and no controls', + lining.map((b) => b.label).join(), 'LINING UP'); + check('the strip is dragged, not pressed', trial[0]?.strip === true); + check('...and the throttle buttons are held', trial[1]?.hold === true && trial[2]?.hold === true); +} + +// --- the strip reports how far from its middle a finger is ------------------- +{ + const listeners: Record void> = {}; + const doc = { addEventListener: (t: string, fn: (e: unknown) => void) => { listeners[t] = fn; } }; + const input = { rollStick: null as number | null }; + attachStripControl(input, doc as unknown as Document); + const knob = { offsetWidth: 34, style: { transform: '' } }; + const strip = { + getBoundingClientRect: () => ({ left: 100, width: 200 }), + querySelector: () => knob, + }; + const at = (x: number) => ({ target: { closest: () => strip }, pointerId: 1, clientX: x, preventDefault: () => {} }); + listeners.pointerdown(at(200)); + eq('a finger in the middle asks for no roll', input.rollStick, 0); + listeners.pointermove(at(300)); + eq('...at the right end, for all of it', input.rollStick, 1); + listeners.pointermove(at(150)); + eq('...and half way to the left, for half', input.rollStick, -0.5); + check('...and the knob follows it', knob.style.transform !== ''); + listeners.pointerup({ pointerId: 1 }); + eq('a finger that lifts lets the roll go', input.rollStick, null); +} + +// --- the strip rolls the ship, and a key still beats it ---------------------- +{ + const controls = { + down: new Set(), + mouseFlight: false, mouseX: 0, mouseY: 0, mouseFire: false, + rollStick: 0.5 as number | null, + held(...codes: string[]): boolean { return codes.some((c) => this.down.has(c)); }, + }; + const rates = { rollRate: 0, pitchRate: 0, speed: 0 }; + const strip = flightDemand(controls, keymap(), rates, 1 / 60); + check('the strip rolls the ship', strip.rollRate < 0); + controls.down.add(keymap().rollLeft[0]); + const key = flightDemand(controls, keymap(), rates, 1 / 60); + check('...and a roll key beats it', key.rollRate > 0); +} + +// --- the slot takes the ship only under the speed limit ---------------------- +{ + // A bare station at the origin, as test/world.test.ts measures the slot in: + // in the channel, with the wings across the slot's long axis. + const station = new THREE.Object3D(); + station.updateMatrixWorld(true); + const DOCK_Z = 160; + const scratch = { v: new THREE.Vector3(), q: new THREE.Quaternion(), r: new THREE.Vector3() }; + const quarter = new THREE.Quaternion() + .setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / 2); + const inSlot = new THREE.Vector3(0, 0, -(DOCK_Z - 20)); + const outcome = (speed: number) => + dockingOutcome(inSlot, quarter, station, DOCK_Z, speed, scratch); + eq('slow enough, the slot takes the ship', outcome(SLOT_SPEED_LIMIT - 1), 'docked'); + eq('...and one unit over, it does not', outcome(SLOT_SPEED_LIMIT + 1), 'tooFast'); + check('the limit is well under the ship\'s top speed, so it is a real choice', + SLOT_SPEED_LIMIT < PLAYER_FLIGHT.maxSpeed / 2); +} + +// --- THE MINI GAME: THE COMPUTER LINES UP, THE RAILS HOLD THE LINE ---------- +// +// Chris flew 207's stretch on a phone (2026-09-12): *"The docking does not +// seem to work at all... lining up is not actually very accurate until the +// last few moments. So we aren't actually flying straight and rolling can send +// you off away from the slot."* He asked for the shape this pins: *"we +// actually get lined up by the computer and then hand off to a 'mini' docking +// game. Something that is completely on rails."* +// +// The whole game, flown headless: the course, the computer's lining up, and +// then the rails with a pilot's hand on the strip. +console.log('\nthe docking mini game'); +{ + /** A commander at the witchpoint with the station course picked. */ + const arrive = (seed: number): Game => { + const g = withoutSaving(() => { + seedWorld(seed); + const game = new Game(() => headlessShell()); + dismissBriefing(game); + game.launch(); + game.arriveInSystem(); + return game; + }).value; + g.state.world.clearNpcs(); + g.state.session.course = 'station'; + return g; + }; + + /** + * Fly to the rails, then hold the strip as a pilot does. + * + * @param match whether the pilot matches the slot. A pilot who does nothing + * is the control: the slot is only there to meet about two turns in five. + */ + const fly = (g: Game, match: boolean, hands = true): { + rails: boolean; docked: boolean; lateral: number; handedOver: number; + scrapes: number; + } => { + const dt = 1 / 60; + const q = new THREE.Quaternion(); + const right = new THREE.Vector3(); + const thrust = keymap().accel[0] ?? ''; + const brake = keymap().decel[0] ?? ''; + let rails = false; + let lateral = Infinity; + let handedOver = -1; + let scrapes = 0; + let wasRails = false; + withoutSaving(() => { + for (let f = 0, at = 0; f < 200 / dt; f++) { + const st = g.state.world.station; + if (g.state.session.dockRails) { + if (!rails) handedOver = g.state.player.speed; + rails = true; + const local = g.state.player.position.clone(); + st.worldToLocal(local); + lateral = Math.min(lateral, Math.hypot(local.x, local.y)); + // The ship is stopped when the rails take it, so the pilot thrusts + // the whole way in, and holds the speed the slot will take. + if (!hands) { + // the control: no hand on anything + } else if (g.state.player.speed < SLOT_SPEED_LIMIT * 0.6) { + g.input.press(thrust); g.input.release(brake); + } else { + g.input.release(thrust); g.input.press(brake); + } + if (match) { + q.copy(st.quaternion).invert().multiply(g.state.player.quaternion); + right.set(1, 0, 0).applyQuaternion(q); + // The slot fits either way up, so the error wraps at a quarter + // turn rather than a half. + const err = Math.atan2(right.x, right.y); + g.input.rollStick = Math.max(-1, Math.min(1, + Math.atan2(Math.sin(2 * err), Math.cos(2 * err)) / 2 * 4)); + } + } + const held = g.state.session.dockRails; + g.step(dt, at += dt); + // The rails let go on a scrape, and only on a scrape, while in flight. + if (wasRails && !g.state.session.dockRails && g.mode === 'flight') scrapes += 1; + wasRails = held; + if (g.mode !== 'flight') break; + } + }); + return { rails, docked: g.mode === 'docked', lateral, handedOver, scrapes }; + }; + + const run = fly(arrive(20_260_951), true); + check('the rails take the ship', run.rails); + check('...with the ship stopped, so the pilot flies the whole run in', + run.handedOver <= RAILS_STOPPED, `${run.handedOver.toFixed(1)} units a second`); + check('...and a pilot who thrusts in and matches the slot docks', run.docked); + check('...having been held on the line, inside the channel', + run.lateral < SLOT_HALF_ACROSS, `${run.lateral.toFixed(0)} units off the axis`); + + check('...first time, with no scrape at all', run.scrapes === 0); + + // THE SPIN IS THE GAME, and it was not (Chris, 2026-09-12: *"I think we're + // still too easy on the docking"*). A probe of 20 approaches found that a + // pilot who never touched the roll docked every time, after one scrape. The + // slot took a roll 37 degrees out, which is 41% of every angle it presents. + // `ROLL_TOLERANCE` is now 0.24, and a bought computer keeps the old 0.65. + const lazy = fly(arrive(20_260_951), false); + check('a pilot who thrusts in but ignores the spin is bounced off', + lazy.scrapes > 0, `${lazy.scrapes} scrape(s)`); + + // The control: hands off entirely. The ship stops on the axis and stays + // there, so nobody docks by accident (docs/TODO/212). Chris asked for that + // on 2026-09-12: the pilot must thrust in. + const idle = fly(arrive(20_260_951), false, false); + check('a pilot who touches nothing waits there, and never docks', !idle.docked); +} + +// --- THE HAND-OVER WAITS FOR THE NOSE, AND THE TURN IS EASED --------------- +// +// Chris, 2026-09-12: *"When docking - we get 'The computer is lining up...' and +// it starts doing it's job, but we jump to the on rails version before it's +// actually lined up."* +// +// Two faults, one report. `railsReached` asked where the ship was and never +// which way it pointed, so the rails took it 69.7 degrees off the slot axis. +// `holdOnRails` then turned it straight inside ONE frame, although the comment +// on that file claimed both of its corrections were eased. Only the position +// was. +// +// The computer cannot make that last turn itself, and `SessionState.dockHold` +// says why. So the rails make it, eased, while the pilot still reads LINING UP. +console.log('\nthe computer finishes the line-up before the pilot gets the slot'); +{ + const g = withoutSaving(() => { + seedWorld(20_260_951); + const game = new Game(() => headlessShell()); + dismissBriefing(game); + game.launch(); + game.arriveInSystem(); + return game; + }).value; + g.state.world.clearNpcs(); + g.state.session.course = 'station'; + + const dt = 1 / 60; + const out = new THREE.Vector3(); + const fwd = new THREE.Vector3(); + const noseOff = (): number => { + slotNormal(g.state.world.station, out).multiplyScalar(-1); + return g.state.player.getForward(fwd).angleTo(out); + }; + + let atHandover = -1; + let worstJump = 0; + let noseAtStop = -1; + let stoppedFrames = 0; + withoutSaving(() => { + let last = noseOff(); + for (let f = 0, at = 0; f < 120 / dt; f++) { + const wasRails = g.state.session.dockRails; + g.step(dt, at += dt); + const now = noseOff(); + // the biggest one-frame turn of the nose, over the whole approach + if (Math.abs(now - last) > worstJump) worstJump = Math.abs(now - last); + last = now; + // the computer's last stretch: stopped, and the pilot has nothing yet + if (!g.state.session.dockRails && g.state.player.speed <= RAILS_STOPPED) { + if (noseAtStop < 0) noseAtStop = now; + stoppedFrames += 1; + } + if (!wasRails && g.state.session.dockRails) { atHandover = now; break; } + if (g.mode !== 'flight') break; + } + }); + + check('the pilot is given the slot only once the nose is on the axis', + atHandover >= 0 && atHandover < RAILS_CONE, + atHandover < 0 ? 'it never handed over at all' + : `${(atHandover * 180 / Math.PI).toFixed(1)} degrees off, against a cone of ` + + `${(RAILS_CONE * 180 / Math.PI).toFixed(1)}`); + // The old code turned the ship 69.7 degrees in the hand-over frame. + check('...and no single frame turns the nose more than 3 degrees', + worstJump < 3 * Math.PI / 180, + `worst one-frame turn ${(worstJump * 180 / Math.PI).toFixed(1)} degrees`); + // The computer flies this turn. It does not teleport it, and it does not + // hand the ship over half way round. + check('...and the computer flew it there itself, from a stop', + stoppedFrames > 0 && noseAtStop > RAILS_CONE, + `${stoppedFrames} frames of LINING UP, from ` + + `${(noseAtStop * 180 / Math.PI).toFixed(1)} degrees off`); +} + +// --- THE DOCKING COMPUTER IS A BUTTON, NOT AN AUTOMATIC -------------------- +// +// Chris, 2026-09-12: *"I have a docking computer - but instead of it being +// activated I still get the lining and only once that is done does the computer +// activate. I think we should have an options when in range 'Docking Computer' +// and if you click it activates. Otherwise we have the lining up and manual +// docking."* +// +// The course used to ask who flies the slot BEFORE the line-up, and a fitted +// computer never saw one. Then it asked AFTER, and a commander who had paid for +// the fitting still had to sit through a line-up they did not want. Now the +// question is theirs, and it is open for the whole of the stretch. +console.log('\nthe docking computer is offered, and it takes the ship when asked'); +{ + const dockKey = keyCodeIfBound('flight', 'toggleDockingComputer') ?? ''; + // THE BUTTON IS THERE THROUGHOUT, from the first frame of the line-up to the + // last of the mini game. A commander who fumbles the slot can still hand it + // over. + const source = (rails: boolean, fitted: boolean) => ({ + fireKey: 'KeyA', missiles: 0, armed: false, locked: false, armKey: 'KeyT', + launchKey: 'KeyM', disarmKey: 'KeyU', ecmKey: null, cloakKey: null, cloaked: false, targets: null, missileInbound: false, + dockKey: fitted ? dockKey : null, + trial: true, rails, accelKey: 'Space', decelKey: 'KeyX', rollStripCode: 'roll', + }); + check('while the computer lines up, a fitted one is offered', + gunButtonsFor(source(false, true)).some((b) => b.label === 'DOCKING COMPUTER')); + check('...and during the pilot\'s own run in, too', + gunButtonsFor(source(true, true)).some((b) => b.label === 'DOCKING COMPUTER')); + check('a commander with none is offered nothing', + !gunButtonsFor(source(false, false)).some((b) => b.label === 'DOCKING COMPUTER') + && !gunButtonsFor(source(true, false)).some((b) => b.label === 'DOCKING COMPUTER')); + + /** Fly the approach, pressing the button when `ask` says to. */ + const fly = (ask: boolean): { sawTrial: boolean; docked: boolean; engaged: boolean } => { + const g = withoutSaving(() => { + seedWorld(20_260_951); + const game = new Game(() => headlessShell()); + dismissBriefing(game); + game.launch(); + game.arriveInSystem(); + return game; + }).value; + g.state.commander.equipment.dockingComputer = true; + g.state.world.clearNpcs(); + g.state.session.course = 'station'; + const dt = 1 / 60; + let sawTrial = false; + let pressed = false; + let engaged = false; + withoutSaving(() => { + for (let f = 0, at = 0; f < 300 / dt; f++) { + if (g.state.session.dockTrial) sawTrial = true; + if (ask && !pressed && g.state.session.dockTrial) { + g.input.injectPress(dockKey); + pressed = true; + } + g.step(dt, at += dt); + if (g.state.session.dcEngaged) engaged = true; + if (g.mode !== 'flight') break; + } + }); + return { sawTrial, docked: g.mode === 'docked', engaged }; + }; + + const asked = fly(true); + check('a fitted computer still goes through the line-up first', asked.sawTrial); + check('...and pressing it hands the ship over', asked.engaged); + check('...and it flies the ship in from there', asked.docked); + + // NOBODY PRESSES IT, and the line-up and the mini game are what they get. + // They fly nothing here, so the slot is never taken. + const alone = fly(false); + check('a pilot who does not press it keeps the ship', !alone.engaged); + check('...and the line-up hands them the slot instead', !alone.docked); +} diff --git a/test/docking-computer.test.ts b/test/docking-computer.test.ts index dc069b51..68f31e93 100644 --- a/test/docking-computer.test.ts +++ b/test/docking-computer.test.ts @@ -28,7 +28,7 @@ import { PLAYER_FLIGHT } from '../src/constants/player-flight.ts'; import { DC_TURN_FADE_ANGLE, DC_SLOT_MARGIN, DC_ROLL_LEAD, } from '../src/constants/docking-computer.ts'; -import { ROLL_TOLERANCE } from '../src/constants/docking.ts'; +import { COMPUTER_ROLL_TOLERANCE } from '../src/constants/docking.ts'; import { STEER_SATURATION } from '../src/constants/combat-computer.ts'; import { rollErrorTo } from '../src/game/pitch-roll-steer.ts'; import { check } from './harness.ts'; @@ -128,7 +128,7 @@ console.log('\nthe docking computer near its own heading'); // the fade is the only thing holding it back and not some other clamp. check(`...and outside it spends the budget DC_SLOT_MARGIN allows (${ outside.toFixed(4)})`, - near(Math.abs(outside), ROLL_TOLERANCE * DC_SLOT_MARGIN / STEER_SATURATION, 1e-6)); + near(Math.abs(outside), COMPUTER_ROLL_TOLERANCE * DC_SLOT_MARGIN / STEER_SATURATION, 1e-6)); } // The GATE phase hands the whole axis to the turn: a slot on a spinning hull @@ -145,10 +145,10 @@ console.log('\nthe docking computer near its own heading'); const q = new THREE.Quaternion().setFromRotationMatrix( new THREE.Matrix4().lookAt(new THREE.Vector3(), plan.heading, plan.up)); q.multiply(new THREE.Quaternion() - .setFromAxisAngle(new THREE.Vector3(0, 0, 1), ROLL_TOLERANCE * 2)); + .setFromAxisAngle(new THREE.Vector3(0, 0, 1), COMPUTER_ROLL_TOLERANCE * 2)); const s = dockingSticks(q, plan, AT_REST); check(`...so with the nose on the gate heading it holds the wings still (${ - s.roll.toFixed(6)}), rolled ${(ROLL_TOLERANCE * 2).toFixed(2)} off the slot`, + s.roll.toFixed(6)}), rolled ${(COMPUTER_ROLL_TOLERANCE * 2).toFixed(2)} off the slot`, Math.abs(s.roll) < 1e-6); } } diff --git a/test/docking.test.ts b/test/docking.test.ts index bed21354..3f70f876 100644 --- a/test/docking.test.ts +++ b/test/docking.test.ts @@ -16,7 +16,7 @@ // slotMiss, hull) is test/world.test.ts's docking section; this file is about // WHERE the edges are. -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import * as THREE from 'three'; import { dockingOutcome, planDocking, makeDockPlan, type DockingOutcome, @@ -35,7 +35,7 @@ import { newCommander } from '../src/game/commander.ts'; import { seedWorld } from '../src/game/rng.ts'; import { slotNormal } from '../src/world/slot.ts'; import type { DamageSource } from '../src/game/combat.ts'; -import { check } from './harness.ts'; +import { check, eq } from './harness.ts'; /** The edge between `inside(lo)` and `!inside(hi)`, to a millionth of a unit. */ function bisect(lo: number, hi: number, inside: (x: number) => boolean): number { @@ -60,7 +60,7 @@ console.log('\ndocking thresholds'); const quarter = new THREE.Quaternion() .setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / 2); const at = (x: number, y: number, z: number, q = quarter): DockingOutcome => - dockingOutcome(new THREE.Vector3(x, y, z), q, station, DOCK_Z, scratch); + dockingOutcome(new THREE.Vector3(x, y, z), q, station, DOCK_Z, 0, scratch); const cube = bisect(DOCK_Z, DOCK_Z + 200, (x) => at(x, 0, 0) !== 'clear'); check(`the bounding cube ends at dockZ + HULL_BOX_MARGIN (${cube.toFixed(3)})`, @@ -271,6 +271,29 @@ function makeRun() { !READ_ONLY_QUATERNION.includes('rotateTowards')); } +// ...AND ONE FILE IN THE WHOLE GAME MAY MOVE THE SHIP ANOTHER WAY +// (docs/TODO/212). The rails of the docking mini game hold the ship on the +// slot axis, and that is not flying. Chris asked for exactly that. The rule +// above is about STEERING, so it stays. This says where the exception lives, +// because a reader of the rule above would otherwise believe there is none. +{ + const dir = new URL('../src/game/', import.meta.url); + const writers = readdirSync(dir) + .filter((name) => name.endsWith('.ts')) + .filter((name) => { + const src = readFileSync(new URL(name, dir), 'utf8') + .replace(/^\s*(\/\/|\*|\/\*).*$/gm, ''); + return [...src.matchAll(/player\.quaternion\s*\.\s*([a-zA-Z]+)/g)] + .some((m) => !READ_ONLY_QUATERNION.includes(m[1])); + }); + // The other three PLACE the ship rather than fly it: a new game and a + // respawn (game.ts), a restored save (persistence.ts), and the setup of one + // training exercise (combat-sim.ts). A fourth name here is a new hand on the + // ship, and it must answer for itself. + eq('dock-rails.ts is the only file in game/ that turns the ship WHILE it flies', + writers.join(', '), 'combat-sim.ts, dock-rails.ts, game.ts, persistence.ts'); +} + // --- what a fluffed slot does to you, through the same step ------------------ { diff --git a/test/dossiers.test.ts b/test/dossiers.test.ts index f35a49a9..5bcc51e3 100644 --- a/test/dossiers.test.ts +++ b/test/dossiers.test.ts @@ -18,8 +18,9 @@ import { DOSSIER_FILES } from '../src/missions/dossiers/index.ts'; import { boardRumour, dockHint, leadJumps, leadLine, worldNews } from '../src/missions/hints.ts'; import { stepMissions, type MissionContext } from '../src/missions/machine.ts'; import type { - CommanderFacts, Dossier, MissionEffect, MissionState, Skeleton, + CommanderFacts, MissionEffect, MissionState, Skeleton, } from '../src/missions/model.ts'; +import type { Dossier } from '../src/missions/words.ts'; import { patronFor } from '../src/missions/patrons.ts'; import { SKELETONS, skeletonById } from '../src/missions/skeletons/index.ts'; import { SIDE_HUNT, SIDE_RESCUE } from '../src/missions/skeletons/side.ts'; @@ -50,7 +51,7 @@ export function sampleDossier(s: Skeleton): Dossier { news: '{PATRON} is asking after a pilot here.', images: {}, story: { - opening: 'On day {DAY} she took the errand at {WORLD}.', + opening: 'On day {DAY} they took the errand at {WORLD}.', closing: { complete: 'It was done at {WORLD} on day {DAY}.', fail: 'It failed at {WORLD} on day {DAY}.' }, legs: Object.fromEntries(shape.legs.map((leg) => [leg, Object.fromEntries( shape.triggers[leg].map((t) => [t, `On day {DAY}, at {WORLD}, the leg ended by ${t.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[^a-z ]/gi, ' ').toLowerCase()}.`]), @@ -99,7 +100,7 @@ console.log('\n...and the validator refuses what the plan says it must'); check('...and so is a leg the skeleton lacks', broken((d) => { d.story.legs.extra = {}; }).some((f) => f.includes('names extra'))); check('{TARGET} in a story line is a slot the field may not carry', - broken((d) => { d.story.opening = 'She left for {TARGET} on day {DAY}.'; }) + broken((d) => { d.story.opening = 'They left for {TARGET} on day {DAY}.'; }) .some((f) => f.includes('carries {TARGET}'))); check('...and {PAY} in a briefing', broken((d) => { d.briefing = ['{PATRON} pays {PAY}.']; }).some((f) => f.includes('carries {PAY}'))); @@ -122,7 +123,7 @@ console.log('\n...and the validator refuses what the plan says it must'); check('...while a briefing may', broken((d) => { d.briefing = ['You will do, pilot.']; }).length === 0); check('a banned word is a fault', broken((d) => { d.news = 'A vibrant offer waits.'; }).some((f) => f.includes('vibrant'))); check('{DAY} needs the word day before it', - broken((d) => { d.story.opening = 'On {DAY} she took the errand at {WORLD}.'; }).some((f) => f.includes('without the word "day"'))); + broken((d) => { d.story.opening = 'On {DAY} they took the errand at {WORLD}.'; }).some((f) => f.includes('without the word "day"'))); check('a console line needs no full stop', broken((d) => { d.legs.pod.success = 'PILOT LANDED — {PAY}'; }).length === 0); check('...but an empty one is a fault', broken((d) => { d.legs.pod.success = ''; }).some((f) => f.includes('is empty'))); check('...and three sentences are too many', @@ -174,7 +175,7 @@ console.log('\n...and every reader speaks the dossier\'s words, or the skeleton\ { const LAVE = 7; const facts = (day = 0): CommanderFacts => ({ - galaxy: 1, systemIndex: LAVE, kills: 20, combatScore: 0, legalStatus: 0, day, cargo: [], + galaxy: 1, systemIndex: LAVE, kills: 20, combatScore: 0, legalStatus: 0, scoops: true, day, cargo: [], }); const ctx = (c: CommanderFacts): MissionContext => ({ commander: c, systems: g1, rng: () => 0.5 }); const full = (id: string): Dossier | null => { @@ -257,8 +258,8 @@ console.log('\n...and every reader speaks the dossier\'s words, or the skeleton\ check('...or the plain pitch', screen(none).includes(SIDE_HUNT.pitch) && !screen(none).includes('ERRAND')); // A held mission keeps what the offer showed (docs/TODO/201). The order - // line follows the pages, so the row reads as what she agreed to, then - // where she is in it. + // line follows the pages, so the row reads as what they agreed to, then + // where they are in it. const holding = (dossiers: (id: string) => Dossier | null) => captureById(() => { const commander = { ...newCommander(), systemIndex: 12, contracts: [] }; commander.missions = { 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/elite-a-live-defence.test.ts b/test/elite-a-live-defence.test.ts index 3c0420c4..9e94cc0d 100644 --- a/test/elite-a-live-defence.test.ts +++ b/test/elite-a-live-defence.test.ts @@ -34,9 +34,8 @@ import { import { npcLaserDamageToPlayer, npcLaserStrength, npcWeaponByte, } from '../src/game/gunnery.ts'; -import { - applyDamage, breachLoss, durability, freshSystems, -} from '../src/game/systems.ts'; +import { applyDamage, breachLoss, freshSystems } from '../src/game/systems.ts'; +import { durability } from '../src/game/pools-left.ts'; import { MAX_ENERGY, MAX_SHIELD } from '../src/constants/pools.ts'; import { playerPoolPoints } from '../src/game/damage-units.ts'; import { NpcShip } from '../src/game/npc.ts'; diff --git a/test/escape-pod-key.test.ts b/test/escape-pod-key.test.ts index 53b5c94c..0a498fe8 100644 --- a/test/escape-pod-key.test.ts +++ b/test/escape-pod-key.test.ts @@ -31,7 +31,7 @@ console.log('\nQ is the pod, and ⇧Q gives up the flight'); // --- what a press costs ------------------------------------------------------ -/** A commander in flight past the launch tunnel, with a store behind her. */ +/** A commander in flight past the launch tunnel, with a store behind them. */ function flying(seed: number): { g: Game; step: () => void } { seedWorld(seed); const g = new Game(() => headlessShell()); diff --git a/test/fight-buttons.test.ts b/test/fight-buttons.test.ts new file mode 100644 index 00000000..9feb61ed --- /dev/null +++ b/test/fight-buttons.test.ts @@ -0,0 +1,142 @@ +// The pilot's hands as buttons (docs/TODO/206 M3). +// +// The laser fires while its button is held. The missile arms on one press +// and fires on the next. The E.C.M. shows only when one is fitted. The target +// list opens over them, and a row picks its ship. Chris's rule of 2026-09-11: +// a new control takes a click or a tap, and needs no key. + +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 { gunButtonsFor, targetButtonsFor, targetRowFor } from '../src/game/cockpit-buttons.ts'; +import { attachHoldButtons } from '../src/engine/hold-buttons.ts'; +import { TARGET_NONE_KEY, TARGETS_KEY } from '../src/game/bindings.ts'; +import { pickedTarget } from '../src/game/targets.ts'; +import { check, dismissBriefing, eq } from './harness.ts'; + +console.log('\nthe gun row (docs/TODO/215 M1)'); + +const base = { + fireKey: 'KeyA', missiles: 3, armed: false, locked: false, + armKey: 'KeyT', disarmKey: 'KeyU', launchKey: 'KeyM', ecmKey: null, cloakKey: null, cloaked: false, targets: null, missileInbound: false, dockKey: null, + trial: false, rails: false, accelKey: 'Space', decelKey: 'KeyX', rollStripCode: 'roll', +}; +{ + 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 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 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 }); + eq('armed, the second button disarms, lit', armed[1].label + (armed[1].lit ? ' lit' : ''), 'DISARM lit'); + eq('...and it still says how many are left', armed[1].hint, '3 LEFT'); + eq('...and the fire button is live, and says it has a lock', armed[2].code + ' ' + armed[2].hint, 'KeyM LOCKED ON'); + const none = gunButtonsFor({ ...base, missiles: 0 }); + eq('with no missiles the row keeps its shape', none.length, 5); + eq('...and the arm button says why it is dim', none[1].note, 'NONE LEFT'); + eq('with no E.C.M. fitted the button says so', b[3].note, 'NOT FITTED'); + eq('with no cloak fitted the button says so too', b[4].note, 'NOT FITTED'); + const cloaked = gunButtonsFor({ ...base, cloakKey: 'KeyZ', cloaked: true }); + check('a cloak that runs is lit, and says what it costs', cloaked[4].lit === true && /DRAINS THE BANK/.test(cloaked[4].hint ?? '')); + 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', 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)); + // 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, 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); +} + +/** A commander in open space, with a trader and a pirate on the scanner. */ +function fight(): Game { + const g = withoutSaving(() => { + seedWorld(20_260_929); + const game = new Game(() => headlessShell()); + dismissBriefing(game); + game.launch(); + return game; + }).value; + withoutSaving(() => { for (let f = 0, at = 0; f < 400; f++) g.step(1 / 60, at += 1 / 60); }); + g.state.world.clearNpcs(); + g.state.player.position.add(new THREE.Vector3(0, 40_000, 0)); + const at = (x: number) => g.state.player.position.clone().add(new THREE.Vector3(x, 0, -2000)); + g.state.world.spawn('pirate', at(-1500), 1); + g.state.world.spawn('trader', at(1500), 2); + return g; +} + +function press(g: Game, code: string): void { + g.input.injectPress(code); + withoutSaving(() => g.step(1 / 60, 30)); +} + +{ + const g = fight(); + eq('the target list starts closed', g.targetPanel().open, false); + press(g, TARGETS_KEY); + eq('the TARGETS button opens it', g.targetPanel().open, true); + const trader = g.targetPanel().rows.find((r) => r.row.standing === 'TRADER')!; + g.state.session.handFlown = true; + press(g, trader.code); + eq('a row picks its ship', pickedTarget(g.state.world.npcs), trader.row.ship); + eq('...and hands the stick back to the computer', g.state.session.handFlown, false); + eq('...and closes the list', g.targetPanel().open, false); + press(g, TARGETS_KEY); + press(g, TARGET_NONE_KEY); + eq('LET THE COMPUTER CHOOSE lets go of the pick', pickedTarget(g.state.world.npcs), null); +} + +{ + // A button's code names one ship, and not a place in the list. The list + // is in order of range, and that order moves. + const g = fight(); + const before = g.targetPanel().rows.map((r) => r.code); + const [a, b] = g.targetPanel().rows.map((r) => r.row.ship); + const pa = a.object.position.clone(); + a.object.position.copy(b.object.position).multiplyScalar(1.0001); + b.object.position.copy(pa); + const after = g.targetPanel().rows; + check('two ships that swap places keep their own codes', + after.find((r) => r.row.ship === a)?.code === before[0] + && after.find((r) => r.row.ship === b)?.code === before[1]); +} + +{ + // The held button, with a document that is only the two calls it listens to. + const listeners: Record void> = {}; + const doc = { addEventListener: (type: string, fn: (e: unknown) => void) => { listeners[type] = fn; } }; + const held: string[] = []; + attachHoldButtons({ + press: (c) => held.push(`+${c}`), release: (c) => held.push(`-${c}`), + }, doc as unknown as Document); + const button = { dataset: { hold: 'KeyA' } }; + const target = { closest: () => button }; + listeners.pointerdown({ target, pointerId: 1, preventDefault: () => {} }); + listeners.pointerdown({ target, pointerId: 2, preventDefault: () => {} }); + listeners.pointerup({ pointerId: 2 }); + eq('a held button presses its key, and a second finger that lifts lets go of its own only', + held.join(' '), '+KeyA +KeyA -KeyA'); + listeners.pointercancel({ pointerId: 1 }); + eq('...and a cancelled pointer lets go too', held.at(-1), '-KeyA'); + listeners.pointerdown({ target: { closest: () => null }, pointerId: 3, preventDefault: () => {} }); + eq('a press anywhere else holds nothing', held.length, 4); +} diff --git a/test/fixtures.ts b/test/fixtures.ts index 64d3489f..ce18dbf0 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -17,7 +17,9 @@ import { makeRng } from '../src/game/rng.ts'; import { FIXED_DT } from '../src/constants/world-clock.ts'; import { CONSTRICTOR_SPEC } from '../src/game/ship-specs.ts'; import { emptyMissionState } from '../src/missions/state.ts'; -import type { MissionState } from '../src/missions/model.ts'; +import type { CommanderFacts, MissionEffect, MissionState, Skeleton } from '../src/missions/model.ts'; +import { stepMissions, type MissionContext } from '../src/missions/machine.ts'; +import { canAccept } from '../src/missions/offers.ts'; /** * Galaxy 1: the canonical universe, and the most-shared fixture in the suite. @@ -75,7 +77,7 @@ export const ecmPresser = (() => { /** * A commander's mission record with the Constrictor live at one leg. * - * Four files put her on the hunt or the courier run by hand: the chart, the + * Four files put them on the hunt or the courier run by hand: the chart, the * blueprint override, the standing orders and the trainer's career check. A * record built here has the shape the machine writes: the live leg, its tag, * the tagged entity, and the acceptance in the journal. A test that needs the @@ -93,3 +95,36 @@ export function constrictorAt( } return st; } + +// --- a commander on a side job, for the verb and the company tests ---------- +// +// They lived in test/mission-verbs.test.ts until docs/TODO/214 M3 pushed it +// over the size ceiling, and test/mission-company.test.ts needs the same +// four. A second copy would be two fixtures that drift. + +/** The facts a mission rule reads, at Lave with scoops fitted, unless `over` says otherwise. */ +export const facts = (over: Partial = {}): CommanderFacts => ({ + galaxy: 1, systemIndex: 7, kills: 0, combatScore: 0, legalStatus: 0, scoops: true, day: 0, cargo: [], ...over, +}); + +/** What a step paid, in tenths. */ +export const paid = (effects: MissionEffect[]): number => + effects.reduce((sum, e) => sum + (e.kind === 'pay' ? e.tenths : 0), 0); + +/** A world whose board carries `job`, and a context standing there. */ +export function boardFor(job: Skeleton, over: Partial = {}): MissionContext { + // A Poor commander, so a job the board gates by kills is on some board + // (docs/TODO/217 M2). A test about the gate passes its own kills. + const blooded = { kills: 16, ...over }; + const world = g1.find((s) => canAccept(emptyMissionState(), job.id, + { commander: facts({ systemIndex: s.index, ...blooded }), skeletons: [job], systems: g1 }))!; + return { commander: facts({ systemIndex: world.index, ...blooded }), systems: g1, rng: () => 0.5, skeletons: [job] }; +} + +/** The same context, moved to another world. */ +export const moved = (ctx: MissionContext, systemIndex: number, over: Partial = {}): MissionContext => + ({ ...ctx, commander: { ...ctx.commander, systemIndex, ...over } }); + +/** The record after `job` is accepted in `ctx`. */ +export const accept = (job: Skeleton, ctx: MissionContext): MissionState => + stepMissions(emptyMissionState(), { kind: 'accept', skeleton: job.id }, ctx).state; diff --git a/test/flight.test.ts b/test/flight.test.ts index c4bebaed..5ce06dc7 100644 --- a/test/flight.test.ts +++ b/test/flight.test.ts @@ -317,7 +317,10 @@ console.log('\nflight demands'); // the machinery under test, on the fixture genome — there are no shipped // defence weights since 2026-08-05 const brain = defendShaped; - const player = { position: new THREE.Vector3(), quaternion: new THREE.Quaternion(), speed: 200 }; + const player = { + position: new THREE.Vector3(), quaternion: new THREE.Quaternion(), speed: 200, + pitchRate: 0, rollRate: 0, + }; const sys = freshSystems(); const pirate = new NpcShip('pirate', new THREE.Vector3(0, 0, -900), 5); pirate.state.provoked = true; diff --git a/test/grudge-line.test.ts b/test/grudge-line.test.ts index 4d42f0aa..cbd5b0fe 100644 --- a/test/grudge-line.test.ts +++ b/test/grudge-line.test.ts @@ -4,9 +4,9 @@ // investigation. Two ships attack a commander for two different reasons, and // the game only ever explained one of them. `recordVerdict` reads // `lawTakesInterest`, so at Offender it says BOUNTY HUNTERS. The ship shooting -// at her may be a police Viper she grazed, and no line said so. +// at them may be a police Viper they grazed, and no line said so. // -// A **grudge** is that ship's private quarrel with her. `provokedByPlayer` +// A **grudge** is that ship's private quarrel with them. `provokedByPlayer` // carries it, and docs/TODO/175 M1 measured that the flag has no exit: it still // holds after 300 seconds, and a record forced back to Clean does not clear it. // @@ -58,24 +58,24 @@ console.log('\nthe roles a record cannot account for'); fleet: readonly HostileShip[], status: number, toStation = NO_TRUCE, ): readonly string[] => grudgeRolesNear(fleet, here, status, toStation); - // The case the item is named for: her record brings hunters, and the ship on - // her is a policeman she shot at. - eq('an Offender is told about the police Viper she grazed', + // The case the item is named for: their record brings hunters, and the ship on + // them is a policeman they shot at. + eq('an Offender is told about the police Viper they grazed', roles([ship('police', 100, cross)], OFFENDER).join(), 'police'); - eq('...and about a bounty hunter when she is Clean', + eq('...and about a bounty hunter when they are Clean', roles([ship('hunter', 100, cross)], CLEAN).join(), 'hunter'); - eq('...and about both, in one sentence, when both are on her', + eq('...and about both, in one sentence, when both are on them', roles([ship('police', 100, cross), ship('hunter', 200, cross)], CLEAN).join(), 'police,hunter'); - // A FUGITIVE HEARS NOTHING, and that is the design rather than a gap. Her + // A FUGITIVE HEARS NOTHING, and that is the design rather than a gap. Their // record already brings both roles, so the line beside this one is true. - eq('a Fugitive is told nothing — her record explains both', + eq('a Fugitive is told nothing — their record explains both', roles([ship('police', 100, cross), ship('hunter', 200, cross)], FUGITIVE).length, 0); // Everything `isHostileToPlayer` already refuses, refused here too. The rule // has one home, and this proves the sweep spends it rather than the flag. - eq('a Viper she never shot at is not a grudge', + eq('a Viper they never shot at is not a grudge', roles([ship('police', 100)], OFFENDER).length, 0); eq('...nor is a dead one', roles([ship('police', 100, { ...cross, alive: false })], OFFENDER).length, 0); @@ -90,11 +90,11 @@ console.log('\nthe roles a record cannot account for'); // bypassed by the same flag that puts a ship in this set (docs/TODO/158), so // the two can never disagree. Held at the port and outside it, because a // reader would otherwise expect the station to be a refuge from one. - eq('a hunter she shot at is on her at the port', + eq('a hunter they shot at is on them at the port', roles([ship('hunter', 100, cross)], CLEAN, 0).join(), 'hunter'); eq('...and outside the truce, where nothing covered it anyway', roles([ship('hunter', 100, cross)], CLEAN, STATION_TRUCE + 1).join(), 'hunter'); - eq('...while the hunter she never shot at is covered inside it', + eq('...while the hunter they never shot at is covered inside it', roles([ship('hunter', 100)], CLEAN, STATION_TRUCE - 1).length, 0); // The words, off the roles above. @@ -109,7 +109,7 @@ console.log('\nthe roles a record cannot account for'); // --- 2. ...and a pilot reads it --------------------------------------------- -console.log('\nthe console says who is on her, beside where she stands'); +console.log('\nthe console says who is on them, beside where they stand'); { /** * A commander in flight at the witchpoint with an empty sky. @@ -150,13 +150,13 @@ console.log('\nthe console says who is on her, beside where she stands'); { // ONE GRAZE DOES BOTH THINGS AT ONCE: it provokes the Viper, and it takes - // her to Offender. So the record line and the line that corrects it are + // them to Offender. So the record line and the line that corrects it are // said in the same burst, which is exactly when a pilot is confused. const { g, fly } = flying(35_000_175); const cop = target(g, 'police'); g.fireLaser(); const said = fly(SETTLE); - check(`the Viper knows it was her (${said.join(' / ')})`, + check(`the Viper knows it was them (${said.join(' / ')})`, cop.state.provokedByPlayer); eq('...and the record moved to Offender', g.state.commander.legalStatus, OFFENDER); @@ -174,8 +174,8 @@ console.log('\nthe console says who is on her, beside where she stands'); } { - // The control. A trader takes her to Offender the same way, and no law ship - // is on her. Without this, a line said on EVERY record move would pass. + // The control. A trader takes them to Offender the same way, and no law ship + // is on them. Without this, a line said on EVERY record move would pass. const { g, fly } = flying(35_000_176); target(g, 'trader'); g.fireLaser(); diff --git a/test/hud-binding.test.ts b/test/hud-binding.test.ts index 35ed9a3d..7ea58ec1 100644 --- a/test/hud-binding.test.ts +++ b/test/hud-binding.test.ts @@ -143,6 +143,7 @@ console.log('\nhud binding'); inFlight: false, witchspace: false, assist: false, + cloaked: false, ecmDetected: false, messageText: 'FRAME COMPLETE', messageTimer: 1.5, diff --git a/test/hud-model.test.ts b/test/hud-model.test.ts index 3efeb931..2bd62fcc 100644 --- a/test/hud-model.test.ts +++ b/test/hud-model.test.ts @@ -172,7 +172,7 @@ console.log('\ndocking port marker'); const aidAt = (x: number, off: number) => { const p = pose(x, off); return dockingAid( - station, DOCK_Z, p.pos, p.quat, p.forward, camera, scratch).dockAid; + station, DOCK_Z, p.pos, p.quat, p.forward, camera, scratch, ROLL_TOLERANCE).dockAid; }; const straight = aidAt(0, 0); @@ -191,7 +191,7 @@ console.log('\ndocking port marker'); // refuses. Restore `port` to `inSlot ? 'lined' : 'off'` and this goes red. const rolled = pose(0, ROLL_TOLERANCE + 0.05); const outcome = dockingOutcome( - rolled.pos, rolled.quat, station, DOCK_Z, + rolled.pos, rolled.quat, station, DOCK_Z, 0, { v: new THREE.Vector3(), q: new THREE.Quaternion(), r: new THREE.Vector3() }); const aid = aidAt(0, ROLL_TOLERANCE + 0.05); check('the pose the old marker called LINED UP is the one the slot refuses', diff --git a/test/hunt-chase.test.ts b/test/hunt-chase.test.ts new file mode 100644 index 00000000..72c47e2c --- /dev/null +++ b/test/hunt-chase.test.ts @@ -0,0 +1,80 @@ +// A hunt's target that is nearly dead runs for the edge, and the chase is the +// commander's (docs/TODO/214 M4). +// +// Two claims about the ship. `NpcShip.takeDamage` sets the run on a ship the +// world step marked, and on no other pirate, while a trader still runs on the +// first hit. And the fighter behaviour flies the run: straight at the +// waypoint, at full speed, and off the edge `TRADER_JUMP_OUT` short of it. +// +// The mission's side of it is `test/mission-courses.test.ts`, through a real +// world step: the stamp, the row's words, the line on the console, and the +// leg that fails when the ship jumps out. + +import * as THREE from 'three'; +import { NpcShip } from '../src/game/npc.ts'; +import { seedWorld } from '../src/game/rng.ts'; +import { SHIPPED_BRAINS } from '../src/game/brain-names.ts'; +import { HUNT_FLEE_FRACTION } from '../src/constants/attack-run.ts'; +import { DEEP_TRADER_RUN, TRADER_JUMP_OUT } from '../src/constants/spawn-placement.ts'; +import { check, eq } from './harness.ts'; + +const origin = new THREE.Vector3(); +const station = new THREE.Object3D(); +const player = { position: origin, quaternion: new THREE.Quaternion(), speed: 0 } as never; +const view = { + station, dockZ: 160, fleet: [], playerLegal: 0, brains: SHIPPED_BRAINS, + missileInbound: false, playerToStation: Infinity, +} as never; + +/** A pirate 2,000 units down the nose, with this much of its energy left. */ +function pirate(fraction: number, canFlee: boolean): NpcShip { + seedWorld(7); + const npc = new NpcShip('pirate', new THREE.Vector3(0, 0, -2000), 3); + npc.state.energy = Math.floor(npc.maxEnergy * fraction); + npc.state.canFlee = canFlee; + return npc; +} + +console.log('\na hunt\'s target runs when it is nearly dead, and no other pirate does'); +{ + const marked = pirate(HUNT_FLEE_FRACTION, true); + marked.takeLaserHit(1, origin.clone(), true); + check('a marked pirate hit under the fraction runs', marked.state.fleeing && marked.state.alive); + const run = marked.state.waypoint.clone().sub(marked.object.position); + check(`...straight away from the shot, ${DEEP_TRADER_RUN} out`, + run.z < 0 && Math.abs(run.length() - DEEP_TRADER_RUN) < 1, `${run.length().toFixed(0)} along z ${run.z.toFixed(0)}`); + + const whole = pirate(1, true); + whole.takeLaserHit(1, origin.clone(), true); + check('...and one hit at full energy stands and fights', !whole.state.fleeing); + + const unmarked = pirate(HUNT_FLEE_FRACTION, false); + unmarked.takeLaserHit(1, origin.clone(), true); + check('a pirate the world step never marked fights to the end', !unmarked.state.fleeing); + + seedWorld(7); + const trader = new NpcShip('trader', new THREE.Vector3(0, 0, -2000), 3); + trader.takeLaserHit(1, origin.clone(), true); + check('a trader still runs on the first hit, as before', trader.state.fleeing); + check('...and a trader\'s run sets no waypoint', trader.state.waypoint.lengthSq() === 0); +} + +console.log('\nthe fighter flies the run, and jumps out short of the waypoint'); +{ + const npc = pirate(HUNT_FLEE_FRACTION, true); + npc.takeLaserHit(1, origin.clone(), true); + const start = npc.object.position.clone(); + for (let f = 0; f < 5 * 60; f++) npc.update(1 / 60, player, view); + const moved = npc.object.position.clone().sub(start); + eq('a ship on the run reports the flight it flew', npc.state.flownBy, 'fleeing'); + check('...and five seconds on it is well down the run, away from the shot', + moved.z < -500 && moved.length() > 500, `${moved.length().toFixed(0)} units, z ${moved.z.toFixed(0)}`); + check('...at its top speed', Math.abs(npc.state.speed - npc.maxSpeed) < 1, `${npc.state.speed.toFixed(0)} of ${npc.maxSpeed}`); + check('...and it has not left yet', !npc.state.wantsDespawn); + + // The waypoint brought within reach: one more second, and the ship asks to go. + npc.state.waypoint.copy(npc.object.position).add(new THREE.Vector3(0, 0, -(TRADER_JUMP_OUT + 100))); + for (let f = 0; f < 60 && !npc.state.wantsDespawn; f++) npc.update(1 / 60, player, view); + check(`inside ${TRADER_JUMP_OUT} of the waypoint it jumps out`, npc.state.wantsDespawn); + check('...alive, so the leg reads a ship that fled and not a wreck', npc.state.alive && npc.state.fleeing); +} diff --git a/test/input.test.ts b/test/input.test.ts index 353723ad..c08e11c3 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, kept by 205 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 @@ -289,10 +302,10 @@ console.log('\none shifted tap does not arm every shifted binding in the frame') // "shift is down" set by one click would make a plain Y satisfy ⇧Y — five // tonnes over the side instead of one, from a click on a menu row. // - // `KeyZ` is bound to nothing in the cockpit, so the scan runs past it and + // `KeyX` is bound to nothing in the cockpit, so the scan runs past it and // reaches the Y pair. It stands for whatever row was actually clicked. const i = new Input(); - i.injectPress('KeyZ', true); // a shifted tap on some other control + i.injectPress('KeyX', true); // a shifted tap on some other control i.injectPress('KeyY'); // ...and a plain Y in the same frame const asked = commandsFor('flight', i); eq('a plain Y still jettisons ONE tonne', asked.join('|'), 'jettison1'); diff --git a/test/key-help.test.ts b/test/key-help.test.ts index 697d647d..1c6beaa9 100644 --- a/test/key-help.test.ts +++ b/test/key-help.test.ts @@ -292,11 +292,13 @@ console.log('\nthe briefing surfaces the whole first journey'); // with the key the table actually binds; the pages interpolate `boundKey`, // so a REBOUND key rewrites its own prose, and what this holds is that the // guidance is not REMOVED — docs/TODO/106 milestone 3. + // + // The jump and the trip in are buttons since docs/TODO/205: the launch list + // and the course buttons in flight. So the briefing names those buttons' + // words, and the two keys that used to carry the trip leave this list. const journey: [ControlMode, Command][] = [ ['docked', 'toggleHelp'], - ['flight', 'startHyperspace'], ['flight', 'toggleTorus'], - ['flight', 'armMissile'], ['flight', 'launchMissile'], - ['flight', 'fireEcm'], ['flight', 'jettison1'], + ['flight', 'jettison1'], ['flight', 'toggleDockingComputer'], ]; const text = BRIEFING.map((p) => `${p.title} ${p.body}`).join(' '); @@ -311,6 +313,15 @@ console.log('\nthe briefing surfaces the whole first journey'); unnamed.length === 0, unnamed.join(', ')); check(`every journey command is quoted with its bound key (${journey.length})`, unquoted.length === 0, unquoted.join(', ')); + // The fight is buttons too since docs/TODO/206, so the briefing names them + // rather than the missile keys and the E.C.M. key. + // 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', '\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(', ')); // The control: a bound key the briefing deliberately does not teach. check('...and the check can fail — the briefing does not quote ⇧Y', @@ -380,3 +391,9 @@ console.log('\nthe manual renders the combat ladder rather than restating it'); check('the prose still names both ends', manual.includes('Harmless') && manual.includes('E L I T E')); } + +console.log('\nthe ? guide sends a pilot to the KEYBOARD LAYOUT row, not to a key (docs/TODO/223 M3)'); +for (const file of ['play.html', 'src/engine/keymap.ts']) { + const text = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8'); + check(`${file} names the row, and no key`, text.includes('KEYBOARD LAYOUT on the station menu') && !/toggle with [A-Z] when docked/.test(text)); +} diff --git a/test/key-prose.test.ts b/test/key-prose.test.ts index ebadd1c6..e774be4c 100644 --- a/test/key-prose.test.ts +++ b/test/key-prose.test.ts @@ -6,7 +6,7 @@ // // - `NO FUEL TO JUMP — PRESS B FOR THE DISTRESS BEACON` (world-step.ts) // - `ALREADY LOCKED — U TO UNARM` (ordnance.ts) -// - `PRESS ? FOR CONTROLS — QWERTY LAYOUT (B TO SWITCH)` (game.ts) +// - `PRESS ? FOR CONTROLS — QWERTY LAYOUT (B TO SWITCH)` (game.ts, until docs/TODO/220) // // Each was a sixth help surface, free to disagree with the table the moment // anything was rebound, and nothing could see any of them. docs/TODO/128 deleted diff --git a/test/ladder-words.test.ts b/test/ladder-words.test.ts index adfa38df..aa70092b 100644 --- a/test/ladder-words.test.ts +++ b/test/ladder-words.test.ts @@ -272,6 +272,6 @@ console.log('\n...and the COMMANDER screen labels all three ladders'); // ...and the fifth meaning is still here and still correct, on the same // screen, which is what made `characterName(c.disrepute)` beside `c.name` the // sharpest case in the item. - check('...while the commander still has a name of her own', + check('...while the commander still has a name of their own', html.includes(`COMMANDER ${c.name}`)); } diff --git a/test/lawful-hit.test.ts b/test/lawful-hit.test.ts index 74c6c4af..9bcb9161 100644 --- a/test/lawful-hit.test.ts +++ b/test/lawful-hit.test.ts @@ -39,7 +39,7 @@ const SETTLE = 60 * 14; * A commander in flight with an empty sky, out of the launch tunnel and at * rest, and a way to watch the console. * - * @param atWitchpoint put her out where no fleet can launch. The station's + * @param atWitchpoint put the commander out where no fleet can launch. The station's * Vipers are a second voice, and three of the blocks below are about one voice. */ function flying(seed: number, atWitchpoint = false): { g: Game; fly: (steps: number) => string[] } { @@ -166,7 +166,7 @@ console.log('\nthe fireball takes the rest of the burst'); { // THE DEFECT, flown. A pirate 500 units off the nose and a Viper 1,400 units // directly behind it. The commander holds the trigger. Before docs/TODO/173 - // the shot 0.25 seconds after the kill reached the Viper, and it hunted her + // the shot 0.25 seconds after the kill reached the Viper, and it hunted them // for the rest of the flight. // // Out at the witchpoint, so no station fleet launches into the measurement. diff --git a/test/menu-click.test.ts b/test/menu-click.test.ts index e3598e41..a3e6e7a2 100644 --- a/test/menu-click.test.ts +++ b/test/menu-click.test.ts @@ -19,8 +19,8 @@ import { Input } from '../src/engine/input.ts'; import { commandsFor } from '../src/game/controls.ts'; -import { ScreenHost } from '../src/ui/screen-host.ts'; -import { check, eqc } from './harness.ts'; +import { ScreenHost, type Screen } from '../src/ui/screen-host.ts'; +import { check, eq, eqc } from './harness.ts'; /** An element as `ScreenHost` reads one: a `dataset` and nothing else. */ const row = (key: string, shift?: boolean): unknown => @@ -42,10 +42,10 @@ console.log('\na click on a row sends the modifier the row prints'); // The false fire, through the click path this time. One shifted click must // not arm a DIFFERENT shifted binding in the same frame. - // `KeyZ` is bound to nothing in the cockpit, so the scan runs past it and + // `KeyX` is bound to nothing in the cockpit, so the scan runs past it and // reaches the Y pair. `KeyT` would arm a missile and stop the scan first. const both = new Input(); - host.click(row('KeyZ', true), both); + host.click(row('KeyX', true), both); host.click(row('KeyY'), both); const asked = commandsFor('flight', both); check('a shifted click leaves another key unshifted', @@ -89,3 +89,33 @@ console.log('\nEnter on a row sends it too, which is the same path'); else delete globals.document; } } + +// --- a button on a row selects the row, and then sends its key ------------- +// +// The test mode and the trainer's setup carry an arrow on each row +// (docs/TODO/216 M3). The arrow must act on ITS row. So the host selects the +// row before it injects the key, and a plain row or a plain key takes the +// path it always took. + +console.log('\na click on a row\'s arrow selects that row first'); +{ + const picked: number[] = []; + const host = new ScreenHost(() => {}); + const list: Screen = { + id: 'test-mode', open: () => {}, render: () => {}, input: () => 'stay', + select: (row) => { picked.push(row); }, + }; + host.register(list); + host.open('test-mode'); + + const i = new Input(); + check('the click is consumed', host.click({ dataset: { row: '2', key: 'ArrowLeft' } }, i) === true); + eq('...it selected the row the arrow sits on', picked.join(), '2'); + check('...and then sent the arrow', i.pressed('ArrowLeft')); + + host.click({ dataset: { row: '1' } }, i); + eq('a plain row still selects', picked.join(), '2,1'); + host.click({ dataset: { key: 'ArrowRight' } }, i); + eq('...and a plain key selects nothing', picked.join(), '2,1'); + check('...while it still sends its key', i.pressed('ArrowRight')); +} diff --git a/test/mine-and-collect.test.ts b/test/mine-and-collect.test.ts new file mode 100644 index 00000000..a4a3d38e --- /dev/null +++ b/test/mine-and-collect.test.ts @@ -0,0 +1,150 @@ +// Mining and collecting (docs/TODO/206 M6). +// +// Chris's words of 2026-09-11: the same fight works on asteroids, and +// collecting needs fuel scoops. The ship flies round the cargo, and the pilot +// can stop it. So MINE THE ASTEROIDS keeps a rock picked, the computer aims, +// and the pilot fires. COLLECT EVERYTHING then flies onto each canister. + +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 { COURSE_KEYS } from '../src/game/bindings.ts'; +import { pickedTarget } from '../src/game/targets.ts'; +import { keymap } from '../src/engine/keymap.ts'; +import { check, dismissBriefing, eq } from './harness.ts'; + +console.log('\nmining and collecting'); + +/** A commander in open space with scoops, a mining laser, and an empty sky. */ +function outThere(seed: number): Game { + const g = withoutSaving(() => { + seedWorld(seed); + const game = new Game(() => headlessShell()); + dismissBriefing(game); + game.launch(); + return game; + }).value; + withoutSaving(() => { for (let f = 0, at = 0; f < 400; f++) g.step(1 / 60, at += 1 / 60); }); + g.state.world.clearNpcs(); + g.state.world.cargo.clear(); + g.state.player.position.add(new THREE.Vector3(0, 50_000, 0)); + g.state.commander.equipment.scoops = true; + g.state.commander.equipment.miningLaser = true; + return g; +} + +function run(g: Game, frames: number, hold = false): void { + const fire = keymap().fire[0]; + withoutSaving(() => { + for (let f = 0; f < frames; f++) { + if (hold) g.input.press(fire); + g.step(1 / 60, 40 + f / 60); + } + }); + g.input.release(fire); +} + +function press(g: Game, code: string): void { + g.input.injectPress(code); + withoutSaving(() => g.step(1 / 60, 39)); +} + +/** A point in front of the ship. */ +function ahead(g: Game, z: number, x = 0): THREE.Vector3 { + return g.state.player.position.clone() + .add(new THREE.Vector3(x, 0, -z).applyQuaternion(g.state.player.quaternion)); +} + +{ + const g = outThere(20_260_932); + g.state.world.cargo.spawn(ahead(g, 900), 1, [0]); + g.state.world.cargo.spawn(ahead(g, 1500, 400), 1, [0]); + const held = g.state.commander.cargo.reduce((a, b) => a + b, 0); + check('with cargo adrift and a clear scanner, the list offers to collect it', + g.coursePanel()?.rows?.some((c) => c.kind === 'collect' && c.why === null) === true); + + press(g, COURSE_KEYS.collect); + eq('the button sets the ship collecting', g.state.session.course, 'collect'); + withoutSaving(() => { + for (let f = 0; f < 60 * 120 && g.state.session.course === 'collect'; f++) { + g.step(1 / 60, 40 + f / 60); + } + }); + eq('...and every canister is aboard', g.state.world.cargo.items.length, 0); + check('...in the hold', g.state.commander.cargo.reduce((a, b) => a + b, 0) > held); + eq('...and the console says so', g.state.session.messageText, 'EVERYTHING IS ABOARD'); +} + +{ + const g = outThere(20_260_933); + g.state.commander.equipment.scoops = false; + g.state.world.cargo.spawn(ahead(g, 900), 1, [0]); + const row = g.coursePanel()?.rows?.find((c) => c.kind === 'collect'); + eq('with no fuel scoops the row says what the ship needs', row?.why, 'NEEDS FUEL SCOOPS'); +} + +{ + const g = outThere(20_260_934); + const rock = g.state.world.spawn('asteroid', ahead(g, 1200), 1); + press(g, COURSE_KEYS.mine); + run(g, 2); + eq('the mining course picks a rock as the target', pickedTarget(g.state.world.npcs), rock); + check('...and the computer takes the stick to line the ship up', g.state.session.ccEngaged); + // The ship has scoops, and it flies on through what the rock leaves. So + // the ore is counted in the hold rather than in the sky. + const said: string[] = []; + const fire = keymap().fire[0]; + withoutSaving(() => { + for (let f = 0; f < 60 * 40 && g.state.session.course === 'mine'; f++) { + g.input.press(fire); + g.step(1 / 60, 40 + f / 60); + const line = g.state.session.messageText; + if (line && said.at(-1) !== line) said.push(line); + } + }); + g.input.release(fire); + check('...and the pilot\'s trigger breaks it', !rock.state.alive); + check('...which leaves ore adrift', g.state.world.cargo.items.length > 0); + eq('...and with no rock left, the mining course ends', g.state.session.course, null); + check('...and says why', said.includes('NO ROCKS LEFT WITHIN RANGE'), said.join(' | ')); + + // ...and the ore is collected by the other course, which is the pair as a + // player flies it. + const held = g.state.commander.cargo.reduce((a, b) => a + b, 0); + press(g, COURSE_KEYS.collect); + withoutSaving(() => { + for (let f = 0; f < 60 * 120 && g.state.session.course === 'collect'; f++) { + g.step(1 / 60, 90 + f / 60); + } + }); + check('the collect course then takes the ore aboard', + g.state.commander.cargo.reduce((a, b) => a + b, 0) > held); +} + +// --- THE SHIP STOPS AT THE ROCK, IT DOES NOT DRIFT INTO IT (docs/TODO/211) -- +// +// Chris, 2026-09-12: *"when targeting asteroids or the derelict. We fly +// towards - and just keep flying towards until we hit it."* A trace of a +// picked rock 53,000 units out showed the whole fault: the ship flew in at +// 397 units a second, braked to 4.3, and then coasted the last 500 units into +// the hull over two minutes. This flies the real game and watches the gap. +console.log('\nthe ship stops short of a picked rock'); +{ + const g = outThere(20_260_941); + const rock = g.state.world.spawn('asteroid', ahead(g, 3000), 7); + press(g, COURSE_KEYS.mine); + eq('the mining course picks the rock', pickedTarget(g.state.world.npcs), rock); + run(g, 60 * 90); + const gap = g.state.player.position.distanceTo(rock.object.position) - rock.radius; + check('the ship holds off the rock rather than arrive at it', + rock.state.alive && gap > 100, `${gap.toFixed(0)} units of clear space`); + check('...and it is stopped, not still closing', + g.state.player.speed < 1, `${g.state.player.speed.toFixed(1)} units a second`); + const then = g.state.player.position.distanceTo(rock.object.position); + run(g, 60 * 60); + const now = g.state.player.position.distanceTo(rock.object.position); + check('...and a minute later it is still there', Math.abs(now - then) < 10, + `${(now - then).toFixed(1)} units in a minute`); +} diff --git a/test/missile-cap.test.ts b/test/missile-cap.test.ts index 777d051c..d6f6a262 100644 --- a/test/missile-cap.test.ts +++ b/test/missile-cap.test.ts @@ -1,7 +1,7 @@ // ONE WARHEAD IN THE AIR AT A TIME — the fairness cap, and nothing else. // // `test/missiles.test.ts` covers a missile LEAVING the rail: that both worlds -// resolve one launch identically, what it costs her, and that a pirate in a real +// resolve one launch identically, what it costs them, and that a pirate in a real // fight actually reaches for one. This file covers the rule that says it may // NOT: `WorldView.missileInbound`, read once per frame by the orchestrator and // applied by `NpcShip.chooseWeapon`. @@ -19,7 +19,7 @@ // own: moving the `missileInbound` read inside the NPC loop fails 2 here, and // arming the reload before refusing — the guard behind `npcMissileEmergency` // rather than in front of it — fails 6. Two of those show up outside this file: -// the loop mutation reddens `missiles.test.ts`'s "it kills her", which reports +// the loop mutation reddens `missiles.test.ts`'s "it kills them", which reports // the lethality and not the rule, and the arming one reddens a selection // comparison in `selection.test.ts` that turns on 0.3 points of shaped score. // diff --git a/test/missiles.test.ts b/test/missiles.test.ts index 670e1d0e..86b60542 100644 --- a/test/missiles.test.ts +++ b/test/missiles.test.ts @@ -103,12 +103,12 @@ console.log('\nmissiles: one launch rule'); // // The other half of the same shot, and the half that still has two homes: the // game bills it through `Combat.hitPlayer` and the episode through -// `TargetShip.takeDamage`. Both spend `IMPACT.warhead` in her own pool points +// `TargetShip.takeDamage`. Both spend `IMPACT.warhead` in their own pool points // on the face it came in at, and this asserts it as an equivalence rather than // as a number — a second warhead constant that merely looked plausible would // pass a check against a literal. -console.log('\nmissiles: what a warhead costs her'); +console.log('\nmissiles: what a warhead costs them'); { const warhead = playerImpactDamage(IMPACT.warhead); const reference = (fromFront: boolean) => { @@ -141,7 +141,7 @@ console.log('\nmissiles: what a warhead costs her'); // --- and it happens inside a real episode ------------------------------------ // // The acceptance test docs/TODO/62 sets, and the one that would have failed -// before it: a pirate DECIDES to launch, the warhead flies, it can kill her, and +// before it: a pirate DECIDES to launch, the warhead flies, it can kill them, and // the rail runs dry. // // THE TARGET IS THE KNIFE-FIGHTER, and that is what makes this a test rather @@ -189,7 +189,7 @@ console.log('\nmissiles: inside a training episode'); } check(`a pirate in a training episode launches (${launched} warheads over ${SEEDS} fights)`, launched > 0); - check(`...and it can kill her (${destroyed}/${SEEDS} episodes ended destroyed)`, + check(`...and it can kill them (${destroyed}/${SEEDS} episodes ended destroyed)`, destroyed > 0); check(`...and the rail runs dry: nothing fired more than it carried` + ` (${launched} of ${carried}, ${left} still racked)`, @@ -288,16 +288,16 @@ console.log('\nmissiles: in the real game, headless'); // THE SEED MOVED ON 2026-08-13 and the fixture did not, which is the case the // note below the next block describes. docs/TODO/138 M3 draws two random bits // from the seeded stream when a system is entered, so every `random()` after - // that draw shifted and 62_000_037 stopped killing her inside 400 frames. She - // is still hit; she survives. What is NOT negotiable is the replay below, and + // that draw shifted and 62_000_037 stopped killing them inside 400 frames. They + // are still hit; they survive. What is NOT negotiable is the replay below, and // it is unmoved. const a = fight(62_000_042, 4, 400); check(`a pirate in the GAME launches: ${a.carried - a.left} of ${a.carried} rounds` + ` spent, ${a.inSky} in the air at once`, a.left < a.carried && a.inSky > 0); - check(`...the warhead arrives and is worth ${IMPACT.warhead.commander} of her pools` + check(`...the warhead arrives and is worth ${IMPACT.warhead.commander} of their pools` + ` (${a.impacts} impacts)`, a.impacts > 0); - check(`...and it kills her (mode ${a.mode} inside ${(400 / 60).toFixed(1)}s)`, a.mode === 'dead'); + check(`...and it kills them (mode ${a.mode} inside ${(400 / 60).toFixed(1)}s)`, a.mode === 'dead'); // Same seed, same fight — the game side of the claim the episode makes below. // This is the half of the equivalence check that fits in a test suite: the diff --git a/test/mission-company.test.ts b/test/mission-company.test.ts new file mode 100644 index 00000000..34dc16f0 --- /dev/null +++ b/test/mission-company.test.ts @@ -0,0 +1,98 @@ +// The company a job keeps: the pirates on a lane, the ones that wait at the +// jump-in, the pair beside a charge, and the ambush a scoop springs +// (docs/TODO/203 M2, docs/TODO/214). +// +// Every one of them is scenery with a gun. The tags answer nothing in the +// machine, and a kill among them settles no branch. These blocks read what a +// leg asks the arrival and the scoop to spawn. The real-game half, where the +// ships appear in the sky, is test/mission-courses.test.ts. +// +// They left test/mission-verbs.test.ts when docs/TODO/214 M3 pushed it over +// the size ceiling. + +import { stepMissions, type MissionContext } from '../src/missions/machine.ts'; +import { missionSpawns } from '../src/missions/queries.ts'; +import type { MissionEffect } from '../src/missions/model.ts'; +import { + SIDE_AMBUSH, SIDE_DELIVER, SIDE_ESCORT, SIDE_HUNT, SIDE_RECOVER, SIDE_RESCUE, SIDE_SCAN, +} from '../src/missions/skeletons/side.ts'; +import { LANE_PIRATES } from '../src/missions/skeletons/lane.ts'; +import { ESCORT_LEASH } from '../src/constants/mission-course.ts'; +import { accept, boardFor, facts, g1, moved } from './fixtures.ts'; +import { check, eq } from './harness.ts'; + +console.log('\npirates wait at the jump-in (docs/TODO/214 M1)'); +{ + // Chris, 2026-09-13: "there are pirates waiting for you when you jump in". + const hctx = boardFor(SIDE_HUNT); + const hst = accept(SIDE_HUNT, hctx); + const at = missionSpawns(hst, hst.live[0].target as number); + eq('a side hunt spawns the leader and its gang of three (docs/TODO/217 M1)', at.length, 4); + check('...all flown as pirates', at.every((s) => s.job === 'hunt')); + check('...and only the leader answers to the leg', at.filter((s) => s.tag === hst.live[0].tag).length === 1); + const oneDown = stepMissions(hst, { kind: 'destroyed', tag: `${hst.live[0].tag}#gang-2` }, hctx).state; + eq('...and a dead member does not come back on the next arrival', + missionSpawns(oneDown, hst.live[0].target as number).length, 3); + const dctx = boardFor(SIDE_DELIVER); + const dst = accept(SIDE_DELIVER, dctx); + eq('a delivery meets a pair at the far end', missionSpawns(dst, dst.live[0].target as number).length, 2); + eq('...and nothing at the world it was taken at', missionSpawns(dst, dctx.commander.systemIndex).length, 0); + const rctx = boardFor(SIDE_RECOVER); + const rst = accept(SIDE_RECOVER, rctx); + eq('a canister adrift has one Krait circling it', missionSpawns(rst, rst.live[0].target as number).length, 1); + const sctx = boardFor(SIDE_SCAN); + const sst = accept(SIDE_SCAN, sctx); + eq('a scan meets nobody but its subject', missionSpawns(sst, sst.live[0].target as number).length, 1); +} + +console.log('\nan escort keeps company (docs/TODO/214 M3)'); +{ + const ectx = boardFor(SIDE_ESCORT); + const est = accept(SIDE_ESCORT, ectx); + const at = missionSpawns(est, est.live[0].target as number); + eq('a side escort meets the charge and a pair', at.length, 3); + eq('...one of them the charge, flown as an escort', at.filter((s) => s.job === 'escort').length, 1); + check('the leash is outside the escort standoff and inside the scanner', ESCORT_LEASH > 600 && ESCORT_LEASH < 6000); +} + +console.log('\na scoop springs an ambush, and so does the end of a scan (docs/TODO/214 M2)'); +{ + // Chris, 2026-09-13: "collecting a canister triggers an ambush". + const spawned = (effects: MissionEffect[]): number => + effects.reduce((n, e) => n + (e.kind === 'spawn' ? e.ships.length : 0), 0); + const rctx = boardFor(SIDE_RECOVER); + const rst = accept(SIDE_RECOVER, rctx); + const scooped = stepMissions(rst, { kind: 'scooped', tag: rst.live[0].tag as string }, moved(rctx, rst.live[0].target as number)); + eq('the canister scooped asks the game for the pair', spawned(scooped.effects), 2); + check('...and says so, behind the leg\'s own line', + scooped.effects.some((e) => e.kind === 'later' && e.text === SIDE_RECOVER.legs[0].ambush?.say)); + eq('...and the leg still moved on', scooped.state.live[0]?.leg, 'home'); + const pctx = boardFor(SIDE_RESCUE); + const pst = accept(SIDE_RESCUE, pctx); + eq('the pod scooped asks for the Krait', + spawned(stepMissions(pst, { kind: 'scooped', tag: pst.live[0].tag as string }, moved(pctx, pst.live[0].target as number)).effects), 1); + const sctx = boardFor(SIDE_SCAN); + const sst = accept(SIDE_SCAN, sctx); + eq('the scan done asks for the escort', spawned(stepMissions(sst, { kind: 'scanned', tag: sst.live[0].tag as string }, sctx).effects), 2); + eq('...and a subject destroyed springs nothing', + spawned(stepMissions(sst, { kind: 'destroyed', tag: sst.live[0].tag as string }, sctx).effects), 0); + const dctx = boardFor(SIDE_DELIVER); + const dst = accept(SIDE_DELIVER, dctx); + eq('a delivery has no ambush to spring', + spawned(stepMissions(dst, { kind: 'docked' }, moved(dctx, dst.live[0].target as number)).effects), 0); +} + +console.log('\nambush: the lane has pirates while the leg is live (docs/TODO/203 M2)'); +{ + // Four kills, because the board gates the lane job (docs/TODO/217 M2). + const ctx: MissionContext = { commander: facts({ kills: 16 }), systems: g1, rng: () => 0.5, skeletons: [SIDE_AMBUSH] }; + const st = accept(SIDE_AMBUSH, ctx); + const target = st.live[0].target as number; + eq('the lane job spawns the lane pirates at its world', + JSON.stringify(missionSpawns(st, target, [SIDE_AMBUSH])), JSON.stringify(LANE_PIRATES)); + check('...all flown as pirates', LANE_PIRATES.every((p) => p.job === 'hunt')); + eq('...and none anywhere else', missionSpawns(st, (target + 1) % g1.length, [SIDE_AMBUSH]).length, 0); + const done = stepMissions(stepMissions(st, { kind: 'arrived' }, { ...ctx, commander: facts({ systemIndex: target }) }).state, + { kind: 'docked' }, { ...ctx, commander: facts({ systemIndex: target }) }).state; + eq('...and they are gone once the lane is cleared', missionSpawns(done, target, [SIDE_AMBUSH]).length, 0); +} diff --git a/test/mission-courses.test.ts b/test/mission-courses.test.ts new file mode 100644 index 00000000..283a9a39 --- /dev/null +++ b/test/mission-courses.test.ts @@ -0,0 +1,335 @@ +// Every kind of mission has a way to fly it (docs/TODO/208 M1 and M2). +// +// Chris asked for this on 2026-09-11: *"the missions are becoming much more +// important - they need to feel part of the game"*. So a live job is the +// first button over the view, in its own words, and the ship flies it. +// +// These fly the real side jobs in a real headless Game. Each one is accepted +// through the mission machine, moved to the system the ship is in, and then +// flown by its button alone. The pilot's own part is the trigger, which the +// hunt holds down. + +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 { runMissions } from '../src/game/mission-bridge.ts'; +import { missionCourse } from '../src/game/mission-course.ts'; +import { clearOfPolice } from '../src/game/course-clearance.ts'; +import { SCAN_RANGE } from '../src/constants/law.ts'; +import { COURSE_ESCORT_STANDOFF, COURSE_POLICE_CLEARANCE, ESCORT_LEASH } from '../src/constants/mission-course.ts'; +import { HUNT_FLEE_FRACTION, TRADER_CALM_SECONDS } from '../src/constants/attack-run.ts'; +import { GANG_BOUNTY, GANG_BROKEN_BOUNTY } from '../src/constants/missions.ts'; +import { TRADER_JUMP_OUT } from '../src/constants/spawn-placement.ts'; +import { COURSE_KEYS } from '../src/game/bindings.ts'; +import { keymap } from '../src/engine/keymap.ts'; +import { check, dismissBriefing, eq } from './harness.ts'; + +console.log('\nthe missions, flown by their own button'); + +/** + * A commander at the witchpoint of the system its live job is in, with the + * job's target in the sky and nothing else. + */ +function onTheJob(skeleton: string, seed: number): Game { + const g = withoutSaving(() => { + seedWorld(seed); + const game = new Game(() => headlessShell()); + dismissBriefing(game); + return game; + }).value; + const c = g.state.commander; + c.equipment.scoops = true; + // Sixteen kills, so the jobs the board gates are on it (docs/TODO/217 M2). + c.kills = 16; + // A side job is on about a third of the worlds' boards (missions/offers.ts). + // So the commander stands where this one is offered. + for (let world = 0; world < 256 && c.missions.live.length === 0; world++) { + c.systemIndex = world; + withoutSaving(() => runMissions(c, { kind: 'accept', skeleton }, g.state.systems, () => 0.5)); + } + const live = c.missions.live[0]; + if (!live) throw new Error(`no world offers ${skeleton}`); + live.target = c.systemIndex; // its work is here, so the arrival spawns it + withoutSaving(() => { g.launch(); }); + withoutSaving(() => { for (let f = 0, at = 0; f < 400; f++) g.step(1 / 60, at += 1 / 60); }); + withoutSaving(() => { g.arriveInSystem(); }); + withoutSaving(() => { for (let f = 0, at = 0; f < 400; f++) g.step(1 / 60, at += 1 / 60); }); + // Only the job's own target stays: no traffic to start a fight of its own. + for (const n of g.state.world.npcs) if (n.state.missionTag === null) n.state.alive = false; + return g; +} + +/** Fly the mission button until `until`, or the seconds run out. */ +function fly(g: Game, seconds: number, until: () => boolean, trigger = false): number { + const fire = keymap().fire[0]; + let flown = 0; + withoutSaving(() => { + g.input.injectPress(COURSE_KEYS.mission); + for (let f = 0; f < seconds * 60; f++) { + if (trigger) g.input.press(fire); + g.step(1 / 60, 100 + f / 60); + flown = f / 60; + if (until() || g.mode === 'dead') break; + } + }); + g.input.release(fire); + return flown; +} + +/** The words the mission's button shows now. */ +const words = (g: Game): string | null => missionCourse( + g.state.commander.missions, g.state.commander.systemIndex, + g.state.world.npcs, g.state.world.cargo.items, g.state.world.station.position, g.state.player.position)?.what ?? null; + +{ + const g = onTheJob('side-hunt', 20_260_940); + check('a hunt names the ship on its button', words(g)?.startsWith('HUNT THE ') === true, `${words(g)}`); + // The hunt's own ship is hostile, so RUN FOR IT leads the list, as + // docs/TODO/206 M5 decided. The mission is the row under it. + check('...and the mission is on the list, under the way out', + g.coursePanel()?.rows?.some((r) => r.kind === 'mission') === true, + g.coursePanel()?.rows?.map((r) => r.kind).join() ?? 'no list'); + const took = fly(g, 240, () => g.state.commander.missions.live.length === 0 + || g.state.commander.missions.live[0]?.leg !== 'hunt', true); + check('...and the ship hunts it down, with the pilot holding the trigger', + g.state.commander.missions.live[0]?.leg !== 'hunt', `after ${took.toFixed(0)}s`); +} + +{ + const g = onTheJob('side-scan', 20_260_941); + check('a scan names the ship on its button', words(g)?.startsWith('SCAN THE ') === true, `${words(g)}`); + const took = fly(g, 300, () => g.state.commander.missions.live[0]?.leg !== 'watch'); + check('...and the ship holds it in view until the scan is done', + g.state.commander.missions.live[0]?.leg !== 'watch', `after ${took.toFixed(0)}s`); +} + +{ + const g = onTheJob('side-recover', 20_260_942); + eq('a recover says what it is', words(g), 'RECOVER THE CARGO'); + const took = fly(g, 240, () => g.state.commander.missions.live[0]?.leg !== 'fetch' + && g.state.commander.missions.live.length > 0); + check('...and the ship scoops the canister', + g.state.commander.missions.live[0]?.leg !== 'fetch', `after ${took.toFixed(0)}s`); +} + +{ + const g = onTheJob('side-rescue', 20_260_943); + eq('a rescue says what it is', words(g), 'PICK UP THE SURVIVOR'); + const took = fly(g, 240, () => (g.state.commander.missions.live[0]?.progress ?? 0) > 0); + check('...and the ship scoops the pod', + (g.state.commander.missions.live[0]?.progress ?? 0) > 0, `after ${took.toFixed(0)}s`); +} + +{ + const g = onTheJob('side-deliver', 20_260_944); + eq('a delivery needs no button of its own, because it ends at the station', words(g), null); + check('...and the station is on the list', + g.coursePanel()?.rows?.some((r) => r.kind === 'station') === true); +} + +console.log('\nthe escort course does not ram its charge, and a grazed charge goes back to work'); +{ + // docs/TODO/213 M1. The approach braked from full speed to the charge's + // own at the standoff and overshot into the hull. A ram is a hit from the + // commander, and a trader that is hit ran for the rest of its life. + const g = onTheJob('side-escort', 20_260_947); + // The charge alone: the pair that flies with it since 214 M1 would start + // a fight, and the approach is the subject. + const live = g.state.commander.missions.live[0]; + for (const n of g.state.world.npcs) if (n.state.missionTag !== live.tag) n.state.alive = false; + const charge = g.state.world.npcs.find((n) => n.state.missionTag === live.tag); + if (!charge) throw new Error('the escort spawned no charge'); + const shields = g.state.sys.foreShield; + let nearest = Infinity; + fly(g, 90, () => { + nearest = Math.min(nearest, g.state.player.position.distanceTo(charge.object.position)); + return false; + }); + check('ninety seconds beside the charge cost no shield', g.state.sys.foreShield === shields, + `${g.state.sys.foreShield} of ${shields}`); + check('...and the ship never came inside a quarter of the standoff', + nearest > COURSE_ESCORT_STANDOFF / 4, `${Math.round(nearest)} units at the nearest`); + check('...and the charge is not on the run', !charge.state.fleeing); + charge.takeLaserHit(1, g.state.player.position.clone(), false); + check('a graze sets the charge to flight', charge.state.fleeing); + fly(g, TRADER_CALM_SECONDS + 5, () => !charge.state.fleeing); + check('...and it goes back to work once the calm has passed', !charge.state.fleeing); + eq('...on its way to the station', charge.state.traderPhase, 'arriving'); +} + +console.log('\nthe charge holds for a commander who falls behind (docs/TODO/214 M3)'); +{ + const g = onTheJob('side-escort', 20_260_949); + const live = g.state.commander.missions.live[0]; + for (const n of g.state.world.npcs) if (n.state.missionTag !== live.tag) n.state.alive = false; + const charge = g.state.world.npcs.find((n) => n.state.missionTag === live.tag); + if (!charge) throw new Error('the escort spawned no charge'); + // Beyond the leash, with no course picked: the commander is not on the way. + charge.object.position.copy(g.state.player.position).add(new THREE.Vector3(0, 0, -(ESCORT_LEASH + 1000))); + const before = charge.object.position.clone(); + withoutSaving(() => { for (let f = 0; f < 10 * 60; f++) g.step(1 / 60, 100 + f / 60); }); + check('ten seconds on, the charge has held its place', + charge.object.position.distanceTo(before) < 200, `${Math.round(charge.object.position.distanceTo(before))} units`); + check('...and says so', charge.state.holding && charge.state.holdSaid); + const took = fly(g, 120, () => !charge.state.holding); + check('...until the escort course brings the commander inside the leash', !charge.state.holding, `after ${took.toFixed(0)}s`); +} + +console.log('\nthe canister scooped springs its ambush in the sky (docs/TODO/214 M2)'); +{ + const g = onTheJob('side-recover', 20_260_948); + const live = g.state.commander.missions.live[0]; + // The Krait that circles the canister stays out of it, so the scoop is the + // only thing that can spring the pair. + for (const n of g.state.world.npcs) if (n.state.missionTag !== live.tag) n.state.alive = false; + const pirates = () => g.state.world.npcs.filter((n) => n.state.alive && n.role === 'pirate').length; + eq('the sky is clear of pirates before the scoop', pirates(), 0); + // No trigger: a laser held on a canister breaks it, and the job fails. + const took = fly(g, 240, () => g.state.commander.missions.live[0]?.leg === 'home'); + eq('the ship scoops the canister', g.state.commander.missions.live[0]?.leg, 'home'); + eq('...and the pair jumps in', pirates(), 2, ); + check('...at a pirate wave\'s reach, not on top of the ship', + g.state.world.npcs.filter((n) => n.state.alive && n.role === 'pirate') + .every((n) => n.object.position.distanceTo(g.state.player.position) > 4000), `after ${took.toFixed(0)}s`); +} + +console.log('\nthe gang is in the sky at the arrival, and a dead member stays dead (docs/TODO/217 M1)'); +{ + const g = onTheJob('side-hunt', 20_260_955); + const tag = g.state.commander.missions.live[0].tag as string; + const tagged = () => g.state.world.npcs.filter((n) => n.state.alive && n.state.missionTag !== null); + eq('four tagged pirates wait at the jump-in', tagged().length, 4); + check('...one of them the leader, and three of them its gang', + tagged().filter((n) => n.state.missionTag === tag).length === 1 + && tagged().filter((n) => n.state.missionTag?.startsWith(`${tag}#gang-`)).length === 3); + const member = tagged().find((n) => n.state.missionTag !== tag); + if (!member) throw new Error('no member'); + member.state.alive = false; + withoutSaving(() => runMissions(g.state.commander, { kind: 'destroyed', tag: member.state.missionTag as string })); + // Out, and back in: the arrival spawns the gang from the record. + withoutSaving(() => { g.arriveInSystem(); }); + eq('...and the next arrival brings three', tagged().length, 3); +} + +console.log('\na hunted ship that runs has fled, not escaped'); +{ + // Before docs/TODO/208 M3 the world sent `escaped` whichever way a tagged + // ship left. A ship that ran from the commander has fled. The side hunt + // is a gang since docs/TODO/217 M1, so a leader that left leaves the gang + // live, and the record says the leader is gone and why. + const flown = (fleeing: boolean) => { + const g = onTheJob('side-hunt', 20_260_945); + const tag = g.state.commander.missions.live[0].tag as string; + const ship = g.state.world.npcs.find((n) => n.state.missionTag === tag); + if (!ship) throw new Error('the hunt spawned no ship'); + ship.state.fleeing = fleeing; + ship.state.wantsDespawn = true; + withoutSaving(() => g.step(1 / 60, 200)); + const m = g.state.commander.missions; + return { done: m.done['side-hunt'], fled: m.entities[tag]?.fled === true, alive: m.entities[tag]?.alive, row: words(g) }; + }; + const jumped = flown(false); + check('a leader that jumps out is gone from the record, and the gang hunt goes on', + jumped.done === undefined && jumped.fled && jumped.alive === false); + eq('...and the row counts the three that are left', jumped.row, 'HUNT THE GANG — 3 LEFT'); + const ran = flown(true); + check('...and one that ran is gone the same way', ran.done === undefined && ran.fled && ran.alive === false); +} + +console.log('\na smuggling run keeps wide of the police'); +{ + const from = new THREE.Vector3(0, 0, 0); + const to = new THREE.Vector3(0, 0, -20_000); + const out = new THREE.Vector3(); + const onTheLine = new THREE.Vector3(0, 0, -10_000); + clearOfPolice(from, to, [onTheLine], out); + const miss = out.distanceTo(onTheLine); + check('a policeman on the line pushes the aim wide of him', out.z !== to.z); + check('...by more than he can read a hold at', miss > SCAN_RANGE, `${Math.round(miss)} units`); + check('...and outside his warning band too', miss >= COURSE_POLICE_CLEARANCE, + `${Math.round(miss)} units`); + clearOfPolice(from, to, [new THREE.Vector3(30_000, 0, -10_000)], out); + check('a policeman well off the line changes nothing', out.equals(to)); +} + +{ + const g = onTheJob('side-smuggle', 20_260_946); + eq('a smuggling run says what it is', words(g), 'SLIP PAST THE POLICE'); + // A policeman square in the way, half way to the station. + const station = g.state.world.station.position; + const half = g.state.player.position.clone().lerp(station, 0.5); + const cop = g.state.world.spawn('police', half, 3); + let nearest = Infinity; + fly(g, 200, () => { + nearest = Math.min(nearest, g.state.player.position.distanceTo(cop.object.position)); + return g.state.player.position.distanceTo(station) < 4000; + }); + check('...and the ship goes round a policeman in the way', nearest > SCAN_RANGE, + `${Math.round(nearest)} units at the nearest`); +} + +console.log('\nthe hunt is a chase: a leader that is nearly dead runs, and a gang whose leader got away pays half (docs/TODO/214 M4, 217 M1)'); +{ + const g = onTheJob('side-hunt', 20_260_951); + const tag = g.state.commander.missions.live[0].tag as string; + // The leader alone in the sky, and its gang reported dead, so the run is + // the subject and the record agrees. + gangDown(g, tag); + const leader = g.state.world.npcs.find((n) => n.state.missionTag === tag); + if (!leader) throw new Error('the hunt spawned no leader'); + const row = () => missionCourse(g.state.commander.missions, g.state.commander.systemIndex, + g.state.world.npcs, g.state.world.cargo.items, g.state.world.station.position, g.state.player.position); + withoutSaving(() => g.step(1 / 60, 100)); + check('the world step marks the hunt\'s leader as one that may run', leader.state.canFlee); + eq('...and the course row counts the leader alone', row()?.what, 'HUNT THE GANG — 1 LEFT'); + + // Shot to under the fraction from where the commander stands. + leader.state.energy = Math.floor(leader.maxEnergy * HUNT_FLEE_FRACTION) - 1; + leader.takeLaserHit(1, g.state.player.position.clone(), true); + check('a hit that leaves it under the fraction sets it to flight', leader.state.fleeing); + eq('...and the course row says CHASE', row()?.what, 'CHASE THE LEADER — 1 LEFT'); + withoutSaving(() => g.step(1 / 60, 101)); + check('...and the console says so, once', leader.state.runSaid); + + // The edge brought within reach, so the run ends inside the test. + const away = leader.state.waypoint.clone().sub(leader.object.position).normalize(); + leader.state.waypoint.copy(leader.object.position).addScaledVector(away, TRADER_JUMP_OUT + 600); + const took = fly(g, 30, () => !g.state.world.npcs.includes(leader)); + check('the leader jumps out at the edge', !g.state.world.npcs.includes(leader), `after ${took.toFixed(1)}s`); + eq('...and the gang is broken, so the job completes', g.state.commander.missions.done['side-hunt'], 'complete'); + eq('...at half the gang bounty', g.state.commander.credits, 1000 + GANG_BROKEN_BOUNTY); +} + +console.log('\n...and a commander who chases can still make the kill'); +{ + const g = onTheJob('side-hunt', 20_260_953); + const tag = g.state.commander.missions.live[0].tag as string; + gangDown(g, tag); + const leader = g.state.world.npcs.find((n) => n.state.missionTag === tag); + if (!leader) throw new Error('the hunt spawned no leader'); + withoutSaving(() => g.step(1 / 60, 100)); + leader.state.energy = Math.floor(leader.maxEnergy * HUNT_FLEE_FRACTION) - 1; + leader.takeLaserHit(1, g.state.player.position.clone(), true); + check('the leader runs', leader.state.fleeing); + // The mission button, and the trigger held: the computer aims, the pilot fires. + const took = fly(g, 60, () => !leader.state.alive || !g.state.world.npcs.includes(leader), true); + check('the chase ends in a kill before the edge', !leader.state.alive, + `after ${took.toFixed(1)}s, alive ${leader.state.alive}, in the sky ${g.state.world.npcs.includes(leader)}`); + eq('...and the station pays the whole gang', g.state.commander.missions.done['side-hunt'], 'complete'); + check('...in full', g.state.commander.credits >= 1000 + GANG_BOUNTY, `${g.state.commander.credits} tenths`); +} + +/** + * The gang reported dead, through the machine, so the record agrees with an + * empty sky. A member set dead by hand would still count as left to do. + */ +function gangDown(g: Game, leaderTag: string): void { + for (const n of g.state.world.npcs) { + const t = n.state.missionTag; + if (t === null || t === leaderTag) continue; + n.state.alive = false; + withoutSaving(() => runMissions(g.state.commander, { kind: 'destroyed', tag: t })); + } +} diff --git a/test/mission-game.test.ts b/test/mission-game.test.ts index 6931ad31..c1d8d12d 100644 --- a/test/mission-game.test.ts +++ b/test/mission-game.test.ts @@ -12,9 +12,14 @@ import { withoutSaving, readSave, writeSave, makeRecord } from '../src/game/stor import { seedWorld } from '../src/game/rng.ts'; import { newCommander, type CommanderData } from '../src/game/commander.ts'; import { destroyShip } from '../src/game/combat-wreck.ts'; -import { runMissions } from '../src/game/mission-bridge.ts'; +import { missionShipSpec, runMissions } from '../src/game/mission-bridge.ts'; +import { repairMissionState } from '../src/missions/repair.ts'; +import { SOURCE_DESIGN, specForDesign } from '../src/game/ship-specs.ts'; +import { shipDesignIdOf } from '../src/game/ship-identity.ts'; import { parseSnapshot } from '../src/game/snapshot-parse.ts'; import { CONSTRICTOR_BLUEPRINT_SET } from '../src/constants/blueprint-set.ts'; +import { NARCOTICS } from '../src/constants/commodities.ts'; +import { SIDE_JOB_PAY, SMUGGLE_TONNES } from '../src/constants/missions.ts'; import { generateGalaxy } from '../src/galaxy/galaxy.ts'; import { routeEstimate } from '../src/galaxy/route.ts'; import { stepMissions } from '../src/missions/machine.ts'; @@ -200,13 +205,13 @@ console.log('\nthe galactic drive asks before it fails a held mission'); eq('...and no tagged entity is left behind', Object.keys(c.missions.entities).length, 0); } -console.log('\na lead crosses the galaxy with her, to a world she can reach'); +console.log('\na lead stays in the galaxy it was saved in (docs/TODO/213 M2)'); { // No shipped arc leaves a lead yet, so the pair is built here, as // test/mission-machine.test.ts builds its own. const first: Skeleton = { id: 'first', kind: 'arc', anchor: 'local', patron: { kind: 'world', seedSlot: LAVE }, - hail: 'HAIL', pitch: 'GO', offer: {}, + hail: 'HAIL', pitch: 'GO', offer: { galaxy: 1 }, legs: [{ id: 'go', verb: { kind: 'deliver' }, place: { kind: 'anywhere' }, line: 'GO', next: [{ on: 'success', to: 'complete' }, { on: 'failed', to: 'fail' }], @@ -221,28 +226,35 @@ console.log('\na lead crosses the galaxy with her, to a world she can reach'); const state: MissionState = { ...emptyMissionState(), live: [{ skeleton: 'first', leg: 'go', target: null, tag: null, progress: 0, deadlineDay: null }], - leads: [{ skeleton: 'second', galaxy: 1, world: 12, sinceDay: 0 }], }; const r = stepMissions(state, { kind: 'galaxyChanged', from: 1, to: 2 }, { - commander: { galaxy: 2, systemIndex: arrival, kills: 0, combatScore: 0, legalStatus: 0, day: 5, cargo: [] }, + commander: { galaxy: 2, systemIndex: arrival, kills: 0, combatScore: 0, legalStatus: 0, scoops: true, day: 5, cargo: [] }, systems: g2, rng: () => 0.5, skeletons: pair, }); eq('the held arc failed', r.state.done.first, 'fail'); eq('...and the lead it leaves is saved once', r.state.leads.length, 1); const lead = r.state.leads[0]; - eq('the earlier lead now names the new galaxy', lead.galaxy, 2); - check('...and a world a chain of full-tank jumps reaches from the arrival', - lead.world === arrival || routeEstimate(g2, g2[arrival], g2[lead.world]) !== null); + // The arc is offered in galaxy 1, so the lead points there, and not at + // index 12 of the galaxy they arrived in. + eq('the lead is in the galaxy the next arc is offered in', lead.galaxy, 1); + eq('...at that arc\'s own world', lead.world, 12); + eq('...and the announcement carries the same galaxy', + (r.effects.find((e) => e.kind === 'lead') as { galaxy: number } | undefined)?.galaxy, 1); + check('...and the arrival world of galaxy 2 was not made a lead', arrival !== lead.world || lead.galaxy === 1); + check('the pair is well formed (the control)', routeEstimate(g2, g2[arrival], g2[12]) !== undefined); // A save carries the pair together. An index alone names nothing across // galaxies. + // The loader drops a lead to a skeleton the game does not ship (213 M4), + // so the saved lead names a shipped arc with the same galaxy and world. seedWorld(1904); const g = withoutSaving(() => new Game(() => headlessShell())).value; dismissBriefing(g); - g.state.commander.missions = r.state; + const shipped = { ...r.state, leads: [{ ...r.state.leads[0], skeleton: 'arc-rabedira' }] }; + g.state.commander.missions = shipped; const snap = parseSnapshot(structuredClone(g.captureSnapshot())); eq('a save keeps the lead\'s galaxy and world together', - JSON.stringify(snap.commander.missions.leads), JSON.stringify(r.state.leads)); + JSON.stringify(snap.commander.missions.leads), JSON.stringify(shipped.leads)); } console.log('\na jump reaches the machine as a day and an arrival, and the lane completes (docs/TODO/203 M1)'); @@ -288,3 +300,76 @@ console.log('\na jump reaches the machine as a day and an arrival, and the lane check('...and the next dock completes the lane job through the game', c.missions.live.length === 0 && c.missions.done['side-ambush'] === 'complete'); } + +console.log('\na smuggling run leaves the goods at the far end (docs/TODO/213 M2)'); +{ + // The run paid its fee and left the narcotics aboard to sell. Through the + // real bridge: the goods go aboard at the acceptance, and off at the dock. + seedWorld(1913); + const g = withoutSaving(() => new Game(() => headlessShell())).value; + dismissBriefing(g); + const c = g.state.commander; + for (let world = 0; world < 256 && c.missions.live.length === 0; world++) { + c.systemIndex = world; + withoutSaving(() => runMissions(c, { kind: 'accept', skeleton: 'side-smuggle' }, g.state.systems, () => 0.5)); + } + eq('the patron\'s goods went aboard', c.cargo[NARCOTICS], SMUGGLE_TONNES); + const before = c.credits; + c.systemIndex = c.missions.live[0].target as number; + withoutSaving(() => runMissions(c, { kind: 'docked' }, g.state.systems)); + eq('the dock at the far end pays', c.credits - before, SIDE_JOB_PAY.smuggle); + eq('...and the goods are off the ship', c.cargo[NARCOTICS], 0); + eq('...and the job is complete', c.missions.done['side-smuggle'], 'complete'); +} + +console.log('\na save that names a ghost loads without it (docs/TODO/213 M4)'); +{ + // A live mission on a skeleton or a leg the code no longer ships threw on + // the next dock, and on the abandon that could have freed the slot. + const st: MissionState = { + ...emptyMissionState(), + live: [ + { skeleton: 'arc-ghost', leg: 'x', target: null, tag: null, progress: 0, deadlineDay: null }, + { skeleton: 'arc-lave', leg: 'old', target: null, tag: null, progress: 0, deadlineDay: null }, + { skeleton: 'arc-lave', leg: 'ledger', target: 12, tag: null, progress: 0, deadlineDay: null }, + ], + leads: [ + { skeleton: 'arc-ghost', galaxy: 1, world: 6, sinceDay: 0 }, + { skeleton: 'arc-rabedira', galaxy: 1, world: 6, sinceDay: 0 }, + ], + passengers: [{ tag: 'arc-ghost#1#x', mission: 'arc-ghost' }], + }; + const kept = repairMissionState(st); + eq('the two ghosts among the live missions are dropped', kept.live.map((l) => l.leg).join(), 'ledger'); + eq('...and the ghost lead', kept.leads.map((l) => l.skeleton).join(), 'arc-rabedira'); + eq('...and the ghost passenger', kept.passengers.length, 0); + seedWorld(1914); + const g = withoutSaving(() => new Game(() => headlessShell())).value; + dismissBriefing(g); + g.state.commander.missions = st; + const parsed = parseSnapshot(structuredClone(g.captureSnapshot())); + eq('the world loader drops them too', parsed.commander.missions.live.length, 1); + const stepped = stepMissions(parsed.commander.missions, { kind: 'docked' }, { + commander: { galaxy: 1, systemIndex: 7, kills: 0, combatScore: 0, legalStatus: 0, scoops: true, day: 0, cargo: [] }, + systems: generateGalaxy(1), rng: () => 0.5, + }); + check('...and the machine takes the record it leaves', stepped.state.live.length === 1); +} + +console.log('\na restored mission ship takes the role its leg flies it under (docs/TODO/213 M4)'); +{ + seedWorld(1915); + const g = withoutSaving(() => new Game(() => headlessShell())).value; + dismissBriefing(g); + const c = g.state.commander; + c.kills = 16; // the board gates the escort by kills since docs/TODO/217 M2 + for (let world = 0; world < 256 && c.missions.live.length === 0; world++) { + c.systemIndex = world; + withoutSaving(() => runMissions(c, { kind: 'accept', skeleton: 'side-escort' }, g.state.systems, () => 0.5)); + } + const tag = c.missions.live[0].tag as string; + const python = shipDesignIdOf(SOURCE_DESIGN.python); + eq('the Python escort restores from the trader row', missionShipSpec(c, tag), specForDesign('trader', python)); + check('...which is not the pirate row', specForDesign('pirate', python) !== specForDesign('trader', python)); + eq('a tag no entity names restores nothing', missionShipSpec(c, 'nobody'), undefined); +} diff --git a/test/mission-machine.test.ts b/test/mission-machine.test.ts index bac5367f..0e48d07b 100644 --- a/test/mission-machine.test.ts +++ b/test/mission-machine.test.ts @@ -28,7 +28,7 @@ const LAVE = 7; /** A commander at Lave with `over` on top: the facts, and nothing else. */ const facts = (over: Partial = {}): CommanderFacts => ({ - galaxy: 1, systemIndex: LAVE, kills: 0, combatScore: 0, legalStatus: 0, day: 0, cargo: [], ...over, + galaxy: 1, systemIndex: LAVE, kills: 0, combatScore: 0, legalStatus: 0, scoops: true, day: 0, cargo: [], ...over, }); /** A generator that counts its draws, so a test can say how many it made. */ diff --git a/test/mission-offers.test.ts b/test/mission-offers.test.ts index 021dd5be..faf07d07 100644 --- a/test/mission-offers.test.ts +++ b/test/mission-offers.test.ts @@ -1,20 +1,21 @@ // Offers, leads and hints: what a station offers, what it says about the // next arc, and how far the word travels (docs/TODO/190 M3). // -// `offers.ts` decides what she can accept. `hints.ts` decides the one line a +// `offers.ts` decides what the commander can accept. `hints.ts` decides the one line a // dock may say about a lead, and what the board and the DATA ON page carry. // Both are pure, and the machine asks them on `docked`. The fixture pair here // is the same shape test/mission-machine.test.ts builds, because no shipped // arc leaves a lead yet. import { canAccept, offersFor, sideJobsAt } from '../src/missions/offers.ts'; +import { generateGalaxy } from '../src/galaxy/galaxy.ts'; import { stepMissions } from '../src/missions/machine.ts'; import { boardRumour, leadJumps, leadLine, worldNews } from '../src/missions/hints.ts'; import { emptyMissionState } from '../src/missions/state.ts'; import { SIDE_JOBS } from '../src/missions/skeletons/side.ts'; import { SKELETONS } from '../src/missions/skeletons/index.ts'; import type { CommanderFacts, MissionEffect, MissionState, Skeleton } from '../src/missions/model.ts'; -import { LEAD_NAG_DOCKS, LEAD_RUMOUR_JUMPS, MISSION_REOFFER_DAYS } from '../src/constants/missions.ts'; +import { GANG_HUNT_KILLS, LANE_JOB_KILLS, LEAD_NAG_DOCKS, LEAD_RUMOUR_JUMPS, MISSION_REOFFER_DAYS } from '../src/constants/missions.ts'; import { routeEstimate } from '../src/galaxy/route.ts'; import { newCommander, type CommanderData } from '../src/game/commander.ts'; import { leadDestinations, orderVerdict, ordersSummary, standingOrders } from '../src/game/orders.ts'; @@ -31,7 +32,7 @@ import { check, eq } from './harness.ts'; const LAVE = 7; const facts = (over: Partial = {}): CommanderFacts => ({ - galaxy: 1, systemIndex: LAVE, kills: 0, combatScore: 0, legalStatus: 0, day: 0, cargo: [], ...over, + galaxy: 1, systemIndex: LAVE, kills: 0, combatScore: 0, legalStatus: 0, scoops: false, day: 0, cargo: [], ...over, }); /** An arc at Lave that leads to a second arc at `world`. */ @@ -118,8 +119,10 @@ console.log('\nhints: the word travels by distance, one line per dock'); const lead = (world: number): MissionState => ({ ...emptyMissionState(), leads: [{ skeleton: 'second', galaxy: 1, world, sinceDay: 0 }], }); + // A blooded commander with scoops, so the board's side jobs are open to + // them whatever the roster holds (docs/TODO/217 M2). const dock = (st: MissionState, world: number, skeletons: Skeleton[]) => - stepMissions(st, { kind: 'docked' }, { commander: facts({ systemIndex: world }), systems: g1, rng: () => 0.5, skeletons }); + stepMissions(st, { kind: 'docked' }, { commander: facts({ systemIndex: world, kills: 16, scoops: true }), systems: g1, rng: () => 0.5, skeletons }); // The first arc at Lave would hail on every dock; a held one keeps it quiet. const quiet = (st: MissionState): MissionState => ({ ...st, done: { first: 'complete' } }); @@ -139,7 +142,7 @@ console.log('\nhints: the word travels by distance, one line per dock'); eq('...and a dock two jumps out still says nothing', said(dock(quiet(lead(near.index)), LAVE, arcs(near.index)).effects).length, 0); const r = dock(quiet(lead(next.index)), LAVE, arcs(next.index)); - check('one jump out, the patron writes when she docks', + check('one jump out, the patron writes when they dock', said(r.effects).some((t) => t.includes('MESSAGE FROM') && t.includes(next.name.toUpperCase()))); eq('...and once only', said(r.effects).length, 1); check('...queued, so it never takes the console from an order', @@ -149,6 +152,14 @@ console.log('\nhints: the word travels by distance, one line per dock'); const both = dock(lead(next.index), LAVE, arcs(next.index)); eq('a dock that makes an offer says nothing about a lead', said(both.effects).join('|'), 'A WORD FROM THE GOVERNOR'); + // ...but a board of side jobs is not an offer of that kind. Most worlds + // carry one, and it silenced the message on almost every dock + // (docs/TODO/213 M3). + const board = dock(quiet(lead(next.index)), LAVE, [...arcs(next.index), ...SIDE_JOBS]); + check('a dock with side jobs on the board still passes the message on', + said(board.effects).some((t) => t.includes('SIDE JOB')) + && said(board.effects).some((t) => t.includes('MESSAGE FROM') && t.includes(next.name.toUpperCase())), + said(board.effects).join('|')); // The second message, after docks with no progress. let st = quiet(lead(near.index)); @@ -228,9 +239,71 @@ console.log('\nevery world has a roster of side jobs from the seed'); const st = emptyMissionState(); const off = SIDE_JOBS.find((s) => !boards[LAVE].includes(s.id))!; const where = g1.findIndex((_, i) => boards[i].includes(off.id)); + // A blooded commander with scoops, so no other gate shuts the job + // (docs/TODO/217 M2). + const able = { kills: 16, scoops: true }; check(`${off.id} is shut at Lave and open at ${g1[where].name}`, - !canAccept(st, off.id, { commander: facts({ systemIndex: LAVE }), systems: g1 }) - && canAccept(st, off.id, { commander: facts({ systemIndex: where }), systems: g1 })); + !canAccept(st, off.id, { commander: facts({ systemIndex: LAVE, ...able }), systems: g1 }) + && canAccept(st, off.id, { commander: facts({ systemIndex: where, ...able }), systems: g1 })); check('...and open everywhere with no galaxy to read a roster from', - canAccept(st, off.id, { commander: facts({ systemIndex: LAVE }) })); + canAccept(st, off.id, { commander: facts({ systemIndex: LAVE, ...able }) })); +} + +console.log('\nthe shipped side jobs come back, and an arc stays in its galaxy (docs/TODO/213 M2)'); +{ + // No side job has a cap, and absent read as once. Each was offered one + // time per career, and the boards emptied in a few sessions. + const g2 = generateGalaxy(2); + const escort = sideJobsAt(g1[LAVE], SKELETONS).find((s) => !s.offer.scoops)!; + // Sixteen kills, because the board gates some jobs by kills (docs/TODO/217 M2). + const ctxAt = (day: number) => ({ commander: facts({ day, kills: 16 }), systems: g1, rng: () => 0.5 }); + const held = stepMissions(emptyMissionState(), { kind: 'accept', skeleton: escort.id }, ctxAt(10)).state; + const ended = stepMissions(held, { kind: 'abandon', skeleton: escort.id }, ctxAt(10)).state; + eq(`${escort.id} ended on day 10`, ended.done[escort.id], 'fail'); + check('...and it is shut the next day', !canAccept(ended, escort.id, ctxAt(11))); + check(`...and open ${MISSION_REOFFER_DAYS} days on, with no cap to spend`, + canAccept(ended, escort.id, ctxAt(10 + MISSION_REOFFER_DAYS))); + check('...and open again a year on', canAccept(ended, escort.id, ctxAt(400))); + + // Galaxy 2's index 7 is Esrilees, and it offered the governor of Lave's job. + check('the Lave arc is offered at Lave, with scoops fitted', + canAccept(emptyMissionState(), 'arc-lave', { commander: facts({ scoops: true }), systems: g1 })); + check('...and not at index 7 of galaxy 2', + !canAccept(emptyMissionState(), 'arc-lave', { commander: facts({ galaxy: 2, scoops: true }), systems: g2 })); + eq('...where no arc is offered at all', + offersFor(emptyMissionState(), { commander: facts({ galaxy: 2, scoops: true }), systems: g2 }) + .filter((s) => s.kind === 'arc').length, 0); + + // A scoop breaks on a hull without fuel scoops, so the offer waits for them. + check('the Lave arc waits for the scoops', + !canAccept(emptyMissionState(), 'arc-lave', { commander: facts(), systems: g1 })); + const scoop = sideJobsAt(g1[LAVE], SKELETONS).find((s) => s.offer.scoops)!; + check(`...and so does ${scoop.id}`, + !canAccept(emptyMissionState(), scoop.id, { commander: facts(), systems: g1 }) + && canAccept(emptyMissionState(), scoop.id, { commander: facts({ scoops: true }), systems: g1 })); +} + +// --- the board gates the hard jobs (docs/TODO/217 M2) ----------------------- +// +// Chris, 2026-09-13: "should we start gating side quests by equipment/number +// of kills?" The gang hunt asks for eight, the lane job and the escort for +// four. The rest stay open, and the two scoop jobs ask for scoops as before. + +console.log('\nthe board gates the hard jobs by kills'); +{ + /** A world whose board carries the job, and what it offers a commander with these kills there. */ + const offered = (id: string, kills: number): boolean => { + const world = g1.find((s) => sideJobsAt(s, SKELETONS).some((j) => j.id === id))!; + return offersFor(emptyMissionState(), { commander: facts({ systemIndex: world.index, kills, scoops: true }), systems: g1 }) + .some((s) => s.id === id); + }; + check('a Harmless commander is not offered the gang hunt', !offered('side-hunt', 0)); + check(`...nor at ${GANG_HUNT_KILLS - 1} kills`, !offered('side-hunt', GANG_HUNT_KILLS - 1)); + check(`...and is offered it at ${GANG_HUNT_KILLS}, Mostly Harmless`, offered('side-hunt', GANG_HUNT_KILLS)); + check('the lane job waits for four kills', !offered('side-ambush', LANE_JOB_KILLS - 1) && offered('side-ambush', LANE_JOB_KILLS)); + check('...and so does the escort', !offered('side-escort', LANE_JOB_KILLS - 1) && offered('side-escort', LANE_JOB_KILLS)); + check('the delivery, the scan and the smuggle are open to a fresh commander', + ['side-deliver', 'side-scan', 'side-smuggle'].every((id) => offered(id, 0))); + check('...and so are the two scoop jobs, with scoops', ['side-recover', 'side-rescue'].every((id) => offered(id, 0))); + eq('the gang hunt\'s gate is the second rung', GANG_HUNT_KILLS, 8); } diff --git a/test/mission-pieces.test.ts b/test/mission-pieces.test.ts index ed489bcf..d70d85c6 100644 --- a/test/mission-pieces.test.ts +++ b/test/mission-pieces.test.ts @@ -24,7 +24,7 @@ import { check, eq } from './harness.ts'; const LAVE = 7; const facts = (over: Partial = {}): CommanderFacts => ({ - galaxy: 1, systemIndex: LAVE, kills: 0, combatScore: 0, legalStatus: 0, day: 10, cargo: [], ...over, + galaxy: 1, systemIndex: LAVE, kills: 0, combatScore: 0, legalStatus: 0, scoops: true, day: 10, cargo: [], ...over, }); const half = () => 0.5; @@ -61,6 +61,31 @@ console.log('\na gate by jumps: an arc is offered inside withinJumps of its worl !canAccept(empty, 'near', { commander: facts({ systemIndex: one }), skeletons: [near, home] })); } +console.log('\na leg that cannot be placed starts at any station, and the branch still settles (docs/TODO/213 M4)'); +{ + // The branch used to return before the settlement, with the target + // already dead, so the kill paid nothing and the mission held its slot. + const stuck = arc('stuck', { + legs: [ + { + id: 'one', verb: { kind: 'deliver' }, place: { kind: 'here' }, line: 'ONE', + next: [{ on: 'success', to: 'two', settle: { pay: 100 } }, { on: 'failed', to: 'fail' }], + }, + { + id: 'two', verb: { kind: 'deliver' }, place: { kind: 'entity', tag: 'nothing-by-this-name' }, line: 'TWO', + next: [{ on: 'success', to: 'complete' }, { on: 'failed', to: 'fail' }], + }, + ], + }); + const ctx: MissionContext = { commander: facts(), systems: g1, rng: half, skeletons: [stuck] }; + const held = stepMissions(emptyMissionState(), { kind: 'accept', skeleton: 'stuck' }, ctx).state; + const r = stepMissions(held, { kind: 'docked' }, ctx); + eq('the mission moved to the leg that could not be placed', r.state.live[0]?.leg, 'two'); + eq('...at any station', r.state.live[0]?.target, null); + eq('...and the branch paid', r.effects.filter((e) => e.kind === 'pay').reduce((s, e) => s + (e as { tenths: number }).tenths, 0), 100); + eq('...and the journal has the step', r.state.journal.filter((j) => j.outcome === 'success').length, 1); +} + console.log('\na flag fires the branch that names it, once'); { // `giver` sets the flag when it completes. `waiter` holds a leg that diff --git a/test/mission-skeletons.test.ts b/test/mission-skeletons.test.ts index c226604a..68e3b0de 100644 --- a/test/mission-skeletons.test.ts +++ b/test/mission-skeletons.test.ts @@ -10,6 +10,8 @@ import { SKELETONS } from '../src/missions/skeletons/index.ts'; import { lintSkeleton } from '../src/missions/lint.ts'; import type { Skeleton } from '../src/missions/model.ts'; import { routeEstimate } from '../src/galaxy/route.ts'; +import { SOURCE_DESIGN } from '../src/game/ship-specs.ts'; +import { shipDesignIdOf } from '../src/game/ship-identity.ts'; import { g1 } from './fixtures.ts'; import { check, eq } from './harness.ts'; @@ -26,7 +28,7 @@ check('the Constrictor ships', SKELETONS.some((s) => s.id === 'constrictor')); function arc(over: Partial = {}): Skeleton { return { id: 'a', kind: 'arc', anchor: 'local', patron: { kind: 'world', seedSlot: 7 }, - hail: 'HAIL', pitch: 'GO', offer: {}, + hail: 'HAIL', pitch: 'GO', offer: { galaxy: 1 }, legs: [ { id: 'one', verb: { kind: 'deliver' }, place: { kind: 'band', min: 30, max: 80 }, line: 'ONE', @@ -48,6 +50,7 @@ const b: Skeleton = { legs: [{ ...arc().legs[1], id: 'one', place: { kind: 'here' } }], }; const legs = () => arc().legs; +const cobra = shipDesignIdOf(SOURCE_DESIGN.cobraMk3); /** A world eight or more jumps from Rabedira: too far for a final leg. */ const far = g1.find((s) => (routeEstimate(g1, g1[6], s)?.jumps ?? 0) >= 8)!.index; const withOverride = (leg: Skeleton['legs'][number], set: 'constrictor' | 'thargoid', where: 'target' | 'everywhere') => @@ -93,7 +96,7 @@ const faults: [string, Skeleton, readonly Skeleton[], string][] = [ ['a final leg placed by a band of tenths', arc({ legs: [legs()[0], { ...legs()[1], place: { kind: 'band', min: 30, max: 80 } }] }), [b], 'too far from the lead'], - ['a final leg placed where she stands', + ['a final leg placed where they stand', arc({ legs: [legs()[0], { ...legs()[1], place: { kind: 'here' } }] }), [b], 'cannot be measured'], ['two arcs that force two sets everywhere', @@ -102,7 +105,36 @@ const faults: [string, Skeleton, readonly Skeleton[], string][] = [ ['a handover toward a skeleton that does not exist', arc({ legs: [{ ...legs()[0], place: { kind: 'handover', toward: 'ghost', min: 2, max: 4 } }, legs()[1]] }), [b], 'unknown skeleton ghost'], + // A seed slot is an index that every galaxy has (docs/TODO/213 M2). + ['a world patron with no galaxy gate', + arc({ offer: {} }), [b], 'galaxy gate'], + // A handover is measured from every world, as a band is (docs/TODO/213 M4). + ['a handover with no candidate from some world', + arc({ legs: [{ ...legs()[0], place: { kind: 'handover', toward: 'b', min: 30, max: 40 } }, legs()[1]] }), + [b], 'no candidate from'], + // A spawned ship needs a row for the role its job flies (docs/TODO/214 M1). + ['a spawned ship with no row for its role', + arc({ legs: [{ ...legs()[0], spawn: [{ ship: 'no-such-hull' as never, tag: 'x', job: 'hunt' }] }, legs()[1]] }), + [b], 'no pirate row for the spawned'], + ['an ambush with no row for its role (docs/TODO/214 M2)', + arc({ legs: [{ ...legs()[0], ambush: { ships: [{ ship: 'no-such-hull' as never, tag: 'x', job: 'hunt' }], say: 'X' } }, legs()[1]] }), + [b], 'no pirate row for the spawned'], + // Every trigger a verb can emit has a branch, or the leg says it ignores it + // (docs/TODO/213 M5). + ['a hunt that can escape with no branch for the escape', + arc({ legs: [{ ...legs()[0], verb: { kind: 'hunt', ship: cobra, canEscape: true }, + next: [{ on: 'targetDestroyed', to: 'two' }, { on: 'failed', to: 'fail' }] }, legs()[1]] }), + [b], 'no branch for targetEscaped'], ]; +{ + const quiet = arc({ legs: [{ ...legs()[0], verb: { kind: 'hunt', ship: cobra, canEscape: true }, + ignores: ['targetEscaped', 'targetFled'], + next: [{ on: 'targetDestroyed', to: 'two' }, { on: 'failed', to: 'fail' }] }, legs()[1]] }); + eq('...and the same leg that says it ignores them is clean', lintSkeleton(quiet, [quiet, b], g1).join('; '), ''); + const still = arc({ legs: [{ ...legs()[0], verb: { kind: 'hunt', ship: cobra, canEscape: false }, + next: [{ on: 'targetDestroyed', to: 'two' }, { on: 'failed', to: 'fail' }] }, legs()[1]] }); + eq('...and a hunt that cannot escape needs neither', lintSkeleton(still, [still, b], g1).join('; '), ''); +} for (const [name, s, others, word] of faults) { const problems = lintSkeleton(s, [s, ...others], g1); check(`the gate names ${name}`, problems.some((p) => p.includes(word)), problems.join('; ')); diff --git a/test/mission-story.test.ts b/test/mission-story.test.ts index c6e783c2..d7ae1f0e 100644 --- a/test/mission-story.test.ts +++ b/test/mission-story.test.ts @@ -1,8 +1,8 @@ // The commander's log: the journal told as a story, the route drawn, and // the LOG screen that shows both (docs/TODO/190 M5). // -// The story is the path she took. A dossier carries a line for every branch -// a leg can take, and the page carries the one she took. That is the whole +// The story is the path they took. A dossier carries a line for every branch +// a leg can take, and the page carries the one they took. That is the whole // assertion of the first block, on a dossier built here, because no dossier // ships until the pipeline plan (item 191) writes one. @@ -10,7 +10,8 @@ import { dossierFor } from '../src/missions/dossiers.ts'; import { storyPages } from '../src/missions/story.ts'; import { routeMapSvg } from '../src/missions/route-map.ts'; import { emptyMissionState } from '../src/missions/state.ts'; -import type { Dossier, MissionState } from '../src/missions/model.ts'; +import type { MissionState } from '../src/missions/model.ts'; +import type { Dossier } from '../src/missions/words.ts'; import { SIDE_RESCUE } from '../src/missions/skeletons/side.ts'; import { renderLog } from '../src/ui/screens-log.ts'; import { LogScreen } from '../src/game/screens/log.ts'; @@ -19,6 +20,7 @@ import { headlessShell } from '../src/engine/shell.ts'; import { withoutSaving } from '../src/game/storage.ts'; import { seedWorld } from '../src/game/rng.ts'; import { newCommander } from '../src/game/commander.ts'; +import { generateGalaxy } from '../src/galaxy/galaxy.ts'; import { CHART_SPAN_X, CHART_SPAN_Y } from '../src/constants/chart-metric.ts'; import { captureById } from './screen-capture.ts'; import { constrictorAt, g1 } from './fixtures.ts'; @@ -26,7 +28,7 @@ import { check, cmds, dismissBriefing, eq, eqc } from './harness.ts'; const LAVE = 7; -console.log('\nthe story tells the branch she took, and not the other one'); +console.log('\nthe story tells the branch they took, and not the other one'); { const dossier: Dossier = { skeleton: SIDE_RESCUE.id, hash: 'test', title: 'THE SURVEY PILOT', @@ -59,8 +61,8 @@ console.log('\nthe story tells the branch she took, and not the other one'); eq('...titled by the dossier', page.title, 'THE SURVEY PILOT'); const text = page.lines.join(' '); check('the opening fills its slots', text.includes('ON DAY 3 THE STATION AT LAVE ASKED')); - check('the branch she took is told', text.includes('THE POD BROKE UP OVER ' + g1[12].name.toUpperCase())); - check('...and the branch she did not take is not', !text.includes('WALKED OFF THE PAD')); + check('the branch they took is told', text.includes('THE POD BROKE UP OVER ' + g1[12].name.toUpperCase())); + check('...and the branch they did not take is not', !text.includes('WALKED OFF THE PAD')); check('the recovery leg is told', text.includes('THE SURVEY DATA REACHED LAVE ON DAY 7')); check('...and the closing', text.includes('THE JOB WAS DONE ON DAY 7')); eq('the page ends complete', page.ending, 'complete'); @@ -147,3 +149,28 @@ console.log('\nthe LOG screen, painted and opened'); g.step(1 / 60, 1 + 1 / 60); eq('...and Escape closes it', g.screens.topId, null); } + +console.log('\nthe story names a world through the galaxy its entry was written in (docs/TODO/213 M4)'); +{ + // The log named every world through the galaxy they stood in, so a galactic + // jump renamed their whole past. A journal entry carries its galaxy now, and + // one from before it was written reads as galaxy 1. + const g2 = generateGalaxy(2); + const inOne: MissionState = { + ...emptyMissionState(), + journal: [ + { skeleton: SIDE_RESCUE.id, leg: 'pod', outcome: 'accepted', day: 3, world: LAVE, galaxy: 1 }, + { skeleton: SIDE_RESCUE.id, leg: 'pod', outcome: 'fail', day: 9, world: 12, galaxy: 1 }, + { skeleton: 'constrictor', leg: 'hunt', outcome: 'accepted', day: 21, world: LAVE }, + ], + }; + const none = (): null => null; + const readIn2 = storyPages(inOne, (g) => (g === 2 ? g2 : g1), undefined, none); + check('a galaxy-1 entry read in galaxy 2 still names the galaxy-1 world', + readIn2[0].lines[0].includes(g1[LAVE].name.toUpperCase()) && !readIn2[0].lines[0].includes(g2[LAVE].name.toUpperCase())); + eq('...and the page says which galaxy its worlds are in', readIn2[0].galaxy, 1); + check('an entry with no galaxy reads as galaxy 1', readIn2[1].lines[0].includes(g1[LAVE].name.toUpperCase())); + eq('...on a page in galaxy 1', readIn2[1].galaxy, 1); + eq('an array stands for galaxy 1, as every earlier caller meant', + storyPages(inOne, g1, undefined, none)[0].lines[1], readIn2[0].lines[1]); +} diff --git a/test/mission-verbs.test.ts b/test/mission-verbs.test.ts index ebada39b..8c42473a 100644 --- a/test/mission-verbs.test.ts +++ b/test/mission-verbs.test.ts @@ -7,15 +7,14 @@ import { stepMissions } from '../src/missions/machine.ts'; import type { MissionContext } from '../src/missions/machine.ts'; -import { canAccept } from '../src/missions/offers.ts'; import { missionItems, missionSpawns, scanSecondsFor } from '../src/missions/queries.ts'; import { emptyMissionState } from '../src/missions/state.ts'; -import type { CommanderFacts, MissionEffect, MissionState, Skeleton } from '../src/missions/model.ts'; +import type { MissionEffect, MissionState, Skeleton } from '../src/missions/model.ts'; import { - SIDE_ESCORT, SIDE_HUNT, SIDE_RECOVER, SIDE_RESCUE, SIDE_SCAN, SIDE_SMUGGLE, + SIDE_DELIVER, SIDE_ESCORT, SIDE_HUNT, SIDE_RECOVER, SIDE_RESCUE, SIDE_SCAN, SIDE_SMUGGLE, } from '../src/missions/skeletons/side.ts'; import { - RESCUE_SALVAGE_PAY, SCAN_SECONDS, SIDE_JOB_PAY, SMUGGLE_TONNES, + GANG_BOUNTY, GANG_BROKEN_BOUNTY, RESCUE_SALVAGE_PAY, SCAN_SECONDS, SIDE_JOB_PAY, SMUGGLE_TONNES, } from '../src/constants/missions.ts'; import { NARCOTICS } from '../src/constants/commodities.ts'; import { DOCK_COMPUTER_RANGE } from '../src/constants/docking-computer.ts'; @@ -26,25 +25,8 @@ import { seedWorld } from '../src/game/rng.ts'; import { parseSnapshot } from '../src/game/snapshot-parse.ts'; import { g1 } from './fixtures.ts'; import { SIDE_AMBUSH } from '../src/missions/skeletons/side.ts'; -import { LANE_PIRATES } from '../src/missions/skeletons/lane.ts'; import { check, dismissBriefing, eq } from './harness.ts'; - -const facts = (over: Partial = {}): CommanderFacts => ({ - galaxy: 1, systemIndex: 7, kills: 0, combatScore: 0, legalStatus: 0, day: 0, cargo: [], ...over, -}); -const paid = (effects: MissionEffect[]): number => - effects.reduce((sum, e) => sum + (e.kind === 'pay' ? e.tenths : 0), 0); - -/** A world whose board carries `job`, and a context standing there. */ -function boardFor(job: Skeleton, over: Partial = {}): MissionContext { - const world = g1.find((s) => canAccept(emptyMissionState(), job.id, - { commander: facts({ systemIndex: s.index }), skeletons: [job], systems: g1 }))!; - return { commander: facts({ systemIndex: world.index, ...over }), systems: g1, rng: () => 0.5, skeletons: [job] }; -} -const moved = (ctx: MissionContext, systemIndex: number, over: Partial = {}): MissionContext => - ({ ...ctx, commander: { ...ctx.commander, systemIndex, ...over } }); -const accept = (job: Skeleton, ctx: MissionContext): MissionState => - stepMissions(emptyMissionState(), { kind: 'accept', skeleton: job.id }, ctx).state; +import { accept, boardFor, facts, moved, paid } from './fixtures.ts'; console.log('\nrecover: a canister adrift, scooped and brought home'); { @@ -57,7 +39,9 @@ console.log('\nrecover: a canister adrift, scooped and brought home'); eq('the job opens on the find leg', live.leg, 'find'); eq('...with a tagged canister adrift at the target', JSON.stringify(missionItems(st, target)), JSON.stringify([{ tag, kind: 'cargo' }])); - eq('...and no ship', missionSpawns(st, target).length, 0); + // ...and one Krait circling it, which answers to nothing (docs/TODO/214 M1). + eq('...and one pirate beside it that is not the leg\'s', + missionSpawns(st, target).filter((s) => s.tag !== tag).length, 1); const wrong = stepMissions(st, { kind: 'scooped', tag: 'somebody-else' }, moved(ctx, target)); eq('another canister scooped moves nothing', wrong.state.live[0].leg, 'find'); const got = stepMissions(st, { kind: 'scooped', tag }, moved(ctx, target)); @@ -145,6 +129,8 @@ console.log('\nsmuggle: the goods go aboard, and the patrol is the risk'); const hold = (tonnes: number): number[] => { const c = new Array(17).fill(0); c[NARCOTICS] = tonnes; return c; }; const landed = stepMissions(r.state, { kind: 'docked' }, moved(ctx, target, { cargo: hold(SMUGGLE_TONNES) })); eq('a dock at the target with the goods aboard pays', paid(landed.effects), SIDE_JOB_PAY.smuggle); + check('...and the goods leave the hold (docs/TODO/213 M2)', + landed.effects.some((e) => e.kind === 'unload' && e.commodity === NARCOTICS && e.tonnes === SMUGGLE_TONNES)); const light = stepMissions(r.state, { kind: 'docked' }, moved(ctx, target, { cargo: hold(SMUGGLE_TONNES - 1) })); eq('...and with a tonne sold on the way it fails', light.state.done[SIDE_SMUGGLE.id], 'fail'); const elsewhere = stepMissions(r.state, { kind: 'docked' }, moved(ctx, 7, { cargo: hold(SMUGGLE_TONNES) })); @@ -153,13 +139,27 @@ console.log('\nsmuggle: the goods go aboard, and the patrol is the risk'); eq('a police scan on the way fails it', read.state.done[SIDE_SMUGGLE.id], 'fail'); } +console.log('\na local patron\'s standing is keyed by the world the job was taken at (docs/TODO/213 M4)'); +{ + // A delivery accepted at one world and landed at another credited the far + // end's patron, because the key named the world where the branch settled. + const dctx = boardFor(SIDE_DELIVER); + const dst = accept(SIDE_DELIVER, dctx); + const origin = dctx.commander.systemIndex; + const target = dst.live[0].target as number; + check('the delivery goes to another world', target !== origin); + const done = stepMissions(dst, { kind: 'docked' }, moved(dctx, target)); + eq('the standing lands on the patron who gave the job', done.state.standing[`world-${origin}`], 1); + eq('...and none on the world it was delivered to', done.state.standing[`world-${target}`], undefined); +} + console.log('\nescort and scan, through the machine'); { const ctx = boardFor(SIDE_ESCORT); const st = accept(SIDE_ESCORT, ctx); const tag = st.live[0].tag as string; const target = st.live[0].target as number; - eq('the charge is spawned as an escort', missionSpawns(st, target)[0]?.job, 'escort'); + eq('the charge is spawned as an escort', missionSpawns(st, target).find((s) => s.tag === tag)?.job, 'escort'); const stranger = stepMissions(st, { kind: 'escortSafe', tag: 'somebody-else' }, moved(ctx, target)); eq('another ship safe pays nothing', paid(stranger.effects), 0); const safe = stepMissions(st, { kind: 'escortSafe', tag }, moved(ctx, target)); @@ -172,6 +172,10 @@ console.log('\nescort and scan, through the machine'); stepMissions(st, { kind: 'escortLost', tag }, moved(ctx, target)).state.done[SIDE_ESCORT.id], 'fail'); eq('...and so does one that jumps out', stepMissions(st, { kind: 'escaped', tag }, moved(ctx, target)).state.done[SIDE_ESCORT.id], 'fail'); + // A charge that a pirate hit runs, and jumps out while it runs. The world + // sends `fled` for that, and the verb ignored it until docs/TODO/213 M1. + eq('...and so does one that ran from a hit', + stepMissions(st, { kind: 'fled', tag }, moved(ctx, target)).state.done[SIDE_ESCORT.id], 'fail'); const sctx = boardFor(SIDE_SCAN); const sst = accept(SIDE_SCAN, sctx); @@ -183,13 +187,55 @@ console.log('\nescort and scan, through the machine'); eq('the scan pays', paid(scanned.effects), SIDE_JOB_PAY.scan); const killed = stepMissions(sst, { kind: 'destroyed', tag: stag }, sctx); eq('a subject destroyed fails it, and the patron minds', killed.state.standing[`world-${sctx.commander.systemIndex}`], -3); + const wrecked = stepMissions(sst, { kind: 'escortLost', tag: stag }, sctx); + eq('...and one wrecked by nobody is destroyed all the same', wrecked.state.standing[`world-${sctx.commander.systemIndex}`], -3); + eq('a subject that ran from a hit has escaped', + stepMissions(sst, { kind: 'fled', tag: stag }, sctx).state.done[SIDE_SCAN.id], 'fail'); + // The side hunt is a gang since docs/TODO/217 M1: a leader with three + // members, and the job ends when every one is gone. const hctx = boardFor(SIDE_HUNT); const hst = accept(SIDE_HUNT, hctx); const htag = hst.live[0].tag as string; - eq('the side hunt pays on the kill', paid(stepMissions(hst, { kind: 'destroyed', tag: htag }, hctx).effects), SIDE_JOB_PAY.hunt); - eq('...and a target that jumps out fails it', - stepMissions(hst, { kind: 'escaped', tag: htag }, hctx).state.done[SIDE_HUNT.id], 'fail'); + const members = [1, 2, 3].map((i) => `${htag}#gang-${i}`); + const said = (r: { effects: MissionEffect[] }, text: RegExp): boolean => + r.effects.some((e) => e.kind === 'say' && text.test(e.text)); + check('a gang hunt mints a record for the leader and each member', + hst.entities[htag]?.alive === true && members.every((t) => hst.entities[t]?.alive === true)); + const one = stepMissions(hst, { kind: 'destroyed', tag: members[1] }, hctx); + check('a member killed is one of the gang down, and the console counts the rest', + said(one, /^ONE OF THE GANG IS DOWN\. 3 LEFT\.$/) && paid(one.effects) === 0 && one.state.live.length === 1); + eq('...and the record says so', one.state.entities[members[1]]?.alive, false); + const two = stepMissions(one.state, { kind: 'destroyed', tag: htag }, hctx); + check('the leader killed with members left says so, and pays nothing yet', + said(two, /^THE LEADER IS DOWN\. 2 OF THE GANG LEFT\.$/) && paid(two.effects) === 0); + const three = stepMissions(two.state, { kind: 'escortLost', tag: members[0] }, hctx); + eq('a member wrecked by somebody else is down all the same', three.state.live[0].progress, 3); + const done = stepMissions(three.state, { kind: 'destroyed', tag: members[2] }, hctx); + eq('the last one gone pays the gang', paid(done.effects), GANG_BOUNTY); + eq('...and the job is complete', done.state.done[SIDE_HUNT.id], 'complete'); + check('...with the gang\'s line', said(done, /THE GANG IS DESTROYED/)); + + // The leader runs (docs/TODO/214 M4). The gang goes on, and the pay halves. + const ran = stepMissions(hst, { kind: 'fled', tag: htag }, hctx); + check('a leader that ran is gone from the record, and the record says it ran', + ran.state.entities[htag]?.alive === false && ran.state.entities[htag]?.fled === true); + check('...and the console says the gang is still there', said(ran, /^THE LEADER RAN FOR IT\. 3 OF THE GANG LEFT\.$/)); + const broken = members.reduce((r, t) => stepMissions(r.state, { kind: 'destroyed', tag: t }, hctx), ran); + eq('the gang gone after its leader ran pays half', paid(broken.effects), GANG_BROKEN_BOUNTY); + check('...and says the leader got away', said(broken, /BUT ITS LEADER RAN/)); + eq('a member never runs, so the word is ignored', + stepMissions(hst, { kind: 'fled', tag: members[0] }, hctx).state.live[0].progress, 0); + const jumped = members.reduce((r, t) => stepMissions(r.state, { kind: 'destroyed', tag: t }, hctx), + stepMissions(hst, { kind: 'escaped', tag: htag }, hctx)); + eq('a leader that jumped out pays half too', paid(jumped.effects), GANG_BROKEN_BOUNTY); + check('...and says the leader got away', said(jumped, /BUT ITS LEADER (RAN|JUMPED)/)); + // The record holds one mark for a ship that left, so the words say how + // only when the leader is the last to go. + let rest = hst; + for (const t of members) rest = stepMissions(rest, { kind: 'destroyed', tag: t }, hctx).state; + const last = stepMissions(rest, { kind: 'escaped', tag: htag }, hctx); + check('...and a leader that jumps out last says it jumped', said(last, /BUT ITS LEADER JUMPED/) && paid(last.effects) === GANG_BROKEN_BOUNTY); } console.log('\nescort, through a real world step'); @@ -226,18 +272,25 @@ console.log('\nescort, through a real world step'); step(g); eq('just outside station range nothing is paid', c.credits, before); charge.object.position.copy(station).add({ x: DOCK_COMPUTER_RANGE - 50, y: 0, z: 0 } as never); + // The commander is at the witchpoint, far from their charge (docs/TODO/214 + // M3). The fee needs them there, and the charge holds for them. + step(g); + eq('...and inside it with the commander far away, still nothing', c.credits, before); + check('...and the charge holds for them', charge.state.holding); const pirate = g.state.world.spawn('pirate', charge.object.position.clone().add({ x: 200, y: 0, z: 0 } as never), 1); + g.state.player.position.copy(charge.object.position).add({ x: 0, y: 300, z: 0 } as never); step(g); - eq('...and inside it with a pirate beside her, still nothing', c.credits, before); + check('with the commander beside it, the charge moves again', !charge.state.holding); + eq('...and inside it with a pirate beside them, still nothing', c.credits, before); const trader = g.state.world.spawn('trader', charge.object.position.clone().add({ x: -200, y: 0, z: 0 } as never), 2); g.state.world.despawn(pirate); step(g); - eq('with the pirate gone and a trader beside her, the fee lands', c.credits - before, SIDE_JOB_PAY.escort); + eq('with the pirate gone and a trader beside them, the fee lands', c.credits - before, SIDE_JOB_PAY.escort); eq('...and the job is complete', c.missions.done[SIDE_ESCORT.id], 'complete'); check('...and the charge is still in the sky to dock', g.state.world.npcs.includes(charge) && charge.state.alive); step(g); eq('a further frame pays nothing more', c.credits - before, SIDE_JOB_PAY.escort); - check('a trader beside her never blocked it', g.state.world.npcs.includes(trader)); + check('a trader beside them never blocked it', g.state.world.npcs.includes(trader)); } console.log('\nthe survivors prompt counts a passenger, and the loader keeps the tag'); @@ -272,30 +325,17 @@ console.log('\nthe survivors prompt counts a passenger, and the loader keeps the parseSnapshot(old as never).canisters.length === snap.canisters.length); } -console.log('\nambush: the lane has pirates while the leg is live (docs/TODO/203 M2)'); -{ - const ctx: MissionContext = { commander: facts(), systems: g1, rng: () => 0.5, skeletons: [SIDE_AMBUSH] }; - const st = accept(SIDE_AMBUSH, ctx); - const target = st.live[0].target as number; - eq('the lane job spawns the lane pirates at its world', - JSON.stringify(missionSpawns(st, target, [SIDE_AMBUSH])), JSON.stringify(LANE_PIRATES)); - check('...all flown as pirates', LANE_PIRATES.every((p) => p.job === 'hunt')); - eq('...and none anywhere else', missionSpawns(st, (target + 1) % g1.length, [SIDE_AMBUSH]).length, 0); - const done = stepMissions(stepMissions(st, { kind: 'arrived' }, { ...ctx, commander: facts({ systemIndex: target }) }).state, - { kind: 'docked' }, { ...ctx, commander: facts({ systemIndex: target }) }).state; - eq('...and they are gone once the lane is cleared', missionSpawns(done, target, [SIDE_AMBUSH]).length, 0); -} - console.log('\na step that takes no branch still speaks (docs/TODO/203 M4)'); { const said = (effects: MissionEffect[]): string[] => effects.flatMap((e) => (e.kind === 'say' ? [e.text] : [])); - const ctx: MissionContext = { commander: facts(), systems: g1, rng: () => 0.5, skeletons: [SIDE_RESCUE, SIDE_AMBUSH] }; + // Sixteen kills, because the board gates the lane job (docs/TODO/217 M2). + const ctx: MissionContext = { commander: facts({ kills: 16 }), systems: g1, rng: () => 0.5, skeletons: [SIDE_RESCUE, SIDE_AMBUSH] }; const st = accept(SIDE_RESCUE, ctx); const tag = st.live[0].tag as string; const target = st.live[0].target as number; const scooped = stepMissions(st, { kind: 'scooped', tag }, moved(ctx, target)); eq('the pod scooped says the pilot is aboard and what to do next', - said(scooped.effects).join('|'), 'THE PILOT IS ABOARD. DOCK AT ANY STATION AND LAND HER.'); + said(scooped.effects).join('|'), 'THE PILOT IS ABOARD. DOCK AT ANY STATION AND LAND THE PILOT.'); const lane = accept(SIDE_AMBUSH, ctx); const world = g1[lane.live[0].target as number].name.toUpperCase(); const arrived = stepMissions(lane, { kind: 'arrived' }, moved(ctx, lane.live[0].target as number)); diff --git a/test/missions.test.ts b/test/missions.test.ts index 73193194..4b7658ad 100644 --- a/test/missions.test.ts +++ b/test/missions.test.ts @@ -1,8 +1,8 @@ // The Navy's gun warning, and the thing breeding in your cabin. // // Two things that change between docks without the commander deciding -// anything. The first is what her gun is worth against the ship the Navy sent -// her after, which the briefing states in two numbers. The second is a +// anything. The first is what their gun is worth against the ship the Navy sent +// them after, which the briefing states in two numbers. The second is a // stowaway that eats the hold. They were the back half of // test/contracts.test.ts, which carried three subjects and crossed the size // ceiling when passenger work landed (docs/TODO/109). The Navy's five-stage @@ -16,7 +16,9 @@ import { newCommander } from '../src/game/commander.ts'; import type { CommanderData } from '../src/game/commander.ts'; import { stepTrumbles, trumbleMessage } from '../src/game/trumbles.ts'; import { BREED_INTERVAL, MAX_TRUMBLES } from '../src/constants/trumbles.ts'; -import { constrictorGunCheck, constrictorWarning } from '../src/game/hunt-warning.ts'; +import { constrictorGunCheck, constrictorWarning, huntWarning } from '../src/game/hunt-warning.ts'; +import { SOURCE_DESIGN, specForDesign } from '../src/game/ship-specs.ts'; +import { shipDesignIdOf } from '../src/game/ship-identity.ts'; import { check, eq } from './harness.ts'; // --- the Navy's gun warning -------------------------------------------------- @@ -27,7 +29,7 @@ console.log('\nthe Navy gun warning'); // // TODO 29's ruling on the Constrictor: the source-exact halving stays, and // what was missing was the signposting. A commander must not fly forty light - // years to discover that the upgrade she bought does nothing. + // years to discover that the upgrade they bought does nothing. { const withLaser = (laser: string) => { const c = newCommander(); @@ -43,6 +45,12 @@ console.log('\nthe Navy gun warning'); && constrictorWarning(withLaser('beam')).includes('MILITARY')); eq('a commander already carrying the right gun is told nothing', constrictorWarning(withLaser('military')), ''); + // A military laser scores more against every hull, so the warning was + // said on every hunt. Only a target that halves a hit needs the signpost + // (docs/TODO/213 M3). + const krait = specForDesign('pirate', shipDesignIdOf(SOURCE_DESIGN.krait))!; + eq('...and a Krait, which halves nothing, earns no warning at all', + huntWarning(withLaser('pulse'), krait, 'PATRON'), ''); } } diff --git a/test/music.test.ts b/test/music.test.ts index e6f3ef5b..a84e8f31 100644 --- a/test/music.test.ts +++ b/test/music.test.ts @@ -124,8 +124,8 @@ console.log('\nthe docking waltz'); // across the whole note. It must fail the check above, or the check is // measuring nothing. const asItWas: Tone = { - type: 'square', frequency: 440, pitches: [], detune: 0, duration: 0.313, - at: 0.05, periodic: false, + type: 'square', frequency: 440, pitches: [], rampTo: null, detune: 0, + duration: 0.313, at: 0.05, periodic: false, amp: { events: [[0.0001, 0.05], [0.05, 0.07], [0.0001, 0.363]] }, }; check('...and the envelope it replaced holds nothing', heldFor(asItWas) <= 0); diff --git a/test/npc-fighter.test.ts b/test/npc-fighter.test.ts index 22a7653e..dd814505 100644 --- a/test/npc-fighter.test.ts +++ b/test/npc-fighter.test.ts @@ -6,7 +6,7 @@ // police, the bounty hunter, the Thargoid and its drone. // // THE ORDER IS THE SUBJECT, and it is the one thing a reader of the file cannot -// check by eye. A ship that can reach the commander attacks HER, before it +// check by eye. A ship that can reach the commander attacks THEM, before it // looks at whatever NPC it was hunting. Reverse the two and a pirate mid-duel // with a trader ignores the commander who just arrived. // @@ -113,7 +113,7 @@ console.log('a fighter asks the commander first, then its target, then ambles'); // then for the rail. const it = ship(new THREE.Vector3()); fighterBehaviour().fly(it, DT, commander(-500), view); - check(`a fighter in reach of the commander goes for her (${it.reached.join(',')})`, + check(`a fighter in reach of the commander goes for them (${it.reached.join(',')})`, it.reached.includes('rail')); } diff --git a/test/npc-trader.test.ts b/test/npc-trader.test.ts index 979e6299..5c6c69bc 100644 --- a/test/npc-trader.test.ts +++ b/test/npc-trader.test.ts @@ -24,6 +24,7 @@ import type { BehaviourShip } from '../src/game/npc-behaviour.ts'; import { freshNpcState } from '../src/game/npc-state.ts'; import { seedWorld } from '../src/game/rng.ts'; import { SHIPPED_BRAINS } from '../src/game/brain-names.ts'; +import { TRADER_CALM_SECONDS } from '../src/constants/attack-run.ts'; import { check, eq } from './harness.ts'; // --- it flies a BehaviourShip, and it calls the working life ---------------- @@ -135,4 +136,38 @@ console.log('a trader, flown off an object literal'); eq('...and it reports no flight model', it.state.flownBy, 'none'); eq('...and it advanced too', it.advanced(), DT); } + + // 3. THE RUN ENDS (docs/TODO/213 M1). A trader that fled ran for the rest + // of its life, so an escort's charge that was grazed once never reached + // the station. The calm is seconds since the last hit, with nobody left + // hunting it. + { + const it = ship(true); + it.state.calm = TRADER_CALM_SECONDS; + traderBehaviour().fly(it, DT, commander, view); + check('a trader calm for long enough goes back to work', !it.state.fleeing); + check('...and picks a waypoint at once', it.state.waypoint.lengthSq() > 0); + const still = ship(true); + still.state.calm = TRADER_CALM_SECONDS - 1; + traderBehaviour().fly(still, DT, commander, view); + check('one that was hit more recently still runs', still.state.fleeing); + const hunted = ship(true); + hunted.state.calm = TRADER_CALM_SECONDS * 2; + hunted.nearestAttacker = () => ({} as never); + traderBehaviour().fly(hunted, DT, commander, view); + check('...and so does one that is still hunted, however calm', hunted.state.fleeing); + } + + // 4. A CHARGE THAT HOLDS FOR THE COMMANDER slows to a stop, and goes on + // when they are back (docs/TODO/214 M3). + { + const held = ship(false); + held.state.traderPhase = 'arriving'; + held.state.holding = true; + for (let f = 0; f < 60; f++) traderBehaviour().fly(held, DT, commander, view); + check('a holding trader slows to a stop', held.state.speed < 1, `${held.state.speed.toFixed(1)} u/s`); + held.state.holding = false; + for (let f = 0; f < 60; f++) traderBehaviour().fly(held, DT, commander, view); + check('...and moves again once the hold lifts', held.state.speed > 30, `${held.state.speed.toFixed(1)} u/s`); + } } diff --git a/test/patrons.test.ts b/test/patrons.test.ts index c3b596ef..825e1b47 100644 --- a/test/patrons.test.ts +++ b/test/patrons.test.ts @@ -23,7 +23,7 @@ import { check, eq } from './harness.ts'; const LAVE = 7; const facts = (systemIndex: number, galaxy = 1): CommanderFacts => ({ - galaxy, systemIndex, kills: 0, combatScore: 0, legalStatus: 0, day: 0, cargo: [], + galaxy, systemIndex, kills: 0, combatScore: 0, legalStatus: 0, scoops: true, day: 0, cargo: [], }); console.log('\na patron has a name and a face, and the Navy has neither'); @@ -50,7 +50,7 @@ console.log('\na patron has a name and a face, and the Navy has neither'); lave.name, record ? record.name : 'THE GOVERNOR OF LAVE'); const local = patronFor({ kind: 'local' }, facts(LAVE), g1); - eq('a local patron is the world she stands at', local.world, LAVE); + eq('a local patron is the world they stand at', local.world, LAVE); eq('...or the world the job was taken at, when the caller knows it', patronFor({ kind: 'local' }, facts(LAVE), g1, 12).world, 12); @@ -149,7 +149,7 @@ console.log('\n...and both screens show the patron'); taken.missions.journal.push({ skeleton: SIDE_HUNT.id, leg: 'hunt', outcome: 'accepted', day: 1, world: LAVE }); const log = captureById(() => { new LogScreen(() => ({ commander: taken, systems: g1 })).render(); }) .get('screen') ?? ''; - check('the LOG screen shows the face of the world the job was taken at, not where she reads it', + check('the LOG screen shows the face of the world the job was taken at, not where they read it', log.includes('species/007-lave.png')); check('...captioned with the patron\'s name', log.includes(`
    ${local}
    `)); } diff --git a/test/phone-page.test.ts b/test/phone-page.test.ts new file mode 100644 index 00000000..4c5c56f5 --- /dev/null +++ b/test/phone-page.test.ts @@ -0,0 +1,53 @@ +// The phone holds its zoom, hides its bar, and reads the chart +// (docs/TODO/220). +// +// Three faults from Chris's phone, and the fix for each is read here: the +// page's meta and manifest, and the chart's names drawn in CSS pixels. + +import { readFileSync } from 'node:fs'; +import { newCommander } from '../src/game/commander.ts'; +import { drawLocalChart, labelScale } from '../src/ui/chart-local.ts'; +import { CHART_LABEL_PX, LOCAL_CANVAS } from '../src/constants/chart-metric.ts'; +import { g1 } from './fixtures.ts'; +import { captureCanvas } from './screen-capture.ts'; +import { check, eq } from './harness.ts'; + +const read = (path: string): string => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'); + +console.log('\nthe page refuses a zoom, and opens as an app from the home screen'); +{ + for (const page of ['play.html', 'index.html']) { + const html = read(page); + check(`${page} tells the viewport no user scale`, + /; + eq('the manifest opens the game standalone', manifest.display, 'standalone'); + eq('...at the play page', manifest.start_url, '/play.html'); + check('the root refuses the pinch and the double tap', + /html, body \{[^}]*touch-action: pan-x pan-y;/s.test(read('src/style.css'))); + check('the entry point holds the zoom and asks for the whole screen', + /holdZoom\(\);\s*askFullscreen\(\);/.test(read('src/main.ts'))); +} + +console.log('\nthe chart draws its names in CSS pixels'); +{ + eq('a canvas shown at half its width scales by two', labelScale({ width: 560, clientWidth: 280 }), 2); + eq('...and one with no width on the page scales by one', labelScale({ width: 560, clientWidth: 0 }), 1); + eq('...as does one that reports none', labelScale({ width: 560 }), 1); + const c = newCommander(); + c.systemIndex = 7; + const chart = { cursorX: g1[7].x, cursorY: g1[7].y, targetIndex: null }; + const ops = captureCanvas(() => drawLocalChart(g1, c, chart, { + mode: 'none', lanes: [], prices: [], danger: [], marks: [], + } as never), LOCAL_CANVAS, LOCAL_CANVAS); + const names = (ops.get('local-canvas') ?? []).filter((op) => op.method === 'fillText'); + check(`the names are drawn at the constant, ${CHART_LABEL_PX}px, under no page width`, + names.length > 0 && names.every((op) => op.font.startsWith(`${CHART_LABEL_PX}px`)), + names[0]?.font ?? 'no name drawn'); +} diff --git a/test/pitch-roll-steer.test.ts b/test/pitch-roll-steer.test.ts index 2fb51867..8657a4fc 100644 --- a/test/pitch-roll-steer.test.ts +++ b/test/pitch-roll-steer.test.ts @@ -224,3 +224,28 @@ const WITHIN_A_GUN_CONE = 0.05; // ~2.9 degrees check('a target dead abeam asks for no pitch at all', pitchOnto(level, right) === 0); } + +// THE ROLL GATE: no pitch until the bank arrives (docs/TODO/210). +// +// The soft gate is the cosine of the roll error, and it leaves a little pitch +// at a wide bank. Near the nose that little is enough to hold a cone: the nose +// circles the target for ever. The course pilot passes a hard gate. Every +// combat caller passes none, and keeps the soft gate alone. +console.log('\nthe roll gate holds the pitch until the bank arrives'); +{ + const level = new THREE.Quaternion(); + // A target a little off the nose, and off to the side, so the bank is wide. + const off = new THREE.Vector3(Math.sin(0.04), 0.0005, -Math.cos(0.04)).normalize(); + const soft = bankToTurn(level, off, freshSteerMemory()); + check(`with no gate, a wide bank still asks for pitch (${soft.pitch.toFixed(5)})`, + Math.abs(soft.pitch) > 0); + const hard = bankToTurn(level, off, freshSteerMemory(), 0, 0.05); + check('with the gate, it asks for none', hard.pitch === 0); + check('...and it still asks for the same roll', Math.abs(hard.roll - soft.roll) < 1e-12); + + // Once the bank arrives, the gate lets go, and the pitch pulls the nose up. + const above = new THREE.Vector3(0, Math.sin(0.04), -Math.cos(0.04)); + const pulled = bankToTurn(level, above, freshSteerMemory(), 0, 0.05); + check(`a target above the nose, with the bank made, pitches (${pulled.pitch.toFixed(4)})`, + pulled.pitch > 0); +} diff --git a/test/run-and-offers.test.ts b/test/run-and-offers.test.ts new file mode 100644 index 00000000..e9aa495a --- /dev/null +++ b/test/run-and-offers.test.ts @@ -0,0 +1,86 @@ +// The ways out of a fight (docs/TODO/206 M5). +// +// Chris's words of 2026-09-11: a fight can be left. The run, the bribe and +// the cargo dump are all options. The run is a course. The bribe and the dump +// were offers on a line of text, and they are buttons now, with the key named +// under the words for a pilot at a keyboard. + +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 { COURSE_KEYS } from '../src/game/bindings.ts'; +import { MASS_LOCK_SHIP } from '../src/constants/torus.ts'; +import { check, dismissBriefing, eq } from './harness.ts'; + +console.log('\nthe ways out of a fight'); + +/** A wanted commander in open space, with a police Viper behind the ship. */ +function chased(seed: number): { g: Game; viper: ReturnType } { + const g = withoutSaving(() => { + seedWorld(seed); + const game = new Game(() => headlessShell()); + dismissBriefing(game); + game.launch(); + return game; + }).value; + withoutSaving(() => { for (let f = 0, at = 0; f < 400; f++) g.step(1 / 60, at += 1 / 60); }); + g.state.world.clearNpcs(); + g.state.player.position.add(new THREE.Vector3(0, 60_000, 0)); + g.state.commander.legalStatus = 2; // a fugitive: the police attack + const behind = new THREE.Vector3(0, 0, 2500).applyQuaternion(g.state.player.quaternion); + const viper = g.state.world.spawn('police', g.state.player.position.clone().add(behind), 1); + return { g, viper }; +} + +function press(g: Game, code: string): void { + g.input.injectPress(code); + withoutSaving(() => g.step(1 / 60, 30)); +} + +{ + const { g, viper } = chased(20_260_930); + withoutSaving(() => g.step(1 / 60, 29)); + const labels = g.hudButtons().courses.map((b) => b.label); + // The run stays under the header, whether the list is open or not + // (docs/TODO/215 M2): a pilot in a fight has no time for a tap. + const run = labels.find((l) => l.startsWith('RUN FOR IT')); + check('with a hostile ship on the scanner, the run is under the header', run !== undefined, labels.join(' | ')); + eq('...and says the ship outruns a police Viper', run, 'RUN FOR IT — YOU ARE FASTER'); + + press(g, COURSE_KEYS.run); + eq('the run button sets the ship running', g.state.session.course, 'run'); + check('...and the computer does not turn to fight', !g.state.session.ccEngaged); + let torus = false; + let widest = 0; + withoutSaving(() => { + for (let f = 0; f < 60 * 90 && g.state.session.course === 'run'; f++) { + g.step(1 / 60, 31 + f / 60); + if (g.state.session.torusEngaged) torus = true; + widest = Math.max(widest, g.state.player.position.distanceTo(viper.object.position)); + } + }); + check('the range opens past the mass lock', widest > MASS_LOCK_SHIP, `${Math.round(widest)} units`); + check('...where the torus drive takes the ship away', torus); + eq('...and the run ends once no hostile ship is on the scanner', g.state.session.course, null); + eq('...and the console says so', g.state.session.messageText, 'YOU GOT AWAY'); +} + +{ + const { g } = chased(20_260_931); + g.state.session.handFlown = true; // no aim, so the Viper closes as it likes + withoutSaving(() => { for (let f = 0; f < 240; f++) g.step(1 / 60, 29 + f / 60); }); + const offer = g.hudButtons().courses.find((b) => b.label.startsWith('PAY')); + check('a police ship with its guns up raises the bribe, as a button', offer !== undefined, + g.hudButtons().courses.map((b) => b.label).join(' | ')); + eq('...that sends the bribe\'s own key', offer?.code, 'KeyL'); + eq('...and names that key under the words', offer?.hint, 'OR PRESS L'); + // Every offer moves the commander's name, whether the policeman takes the + // money or turns it down (law.ts). So that is what proves the offer. + g.state.commander.credits = 1_000_000; + const before = g.state.commander.disrepute ?? 0; + press(g, offer!.code); + check('...and a press makes the offer', (g.state.commander.disrepute ?? 0) > before, + `said: ${g.state.session.messageText}`); +} diff --git a/test/run.ts b/test/run.ts index fa68c888..26f4c882 100644 --- a/test/run.ts +++ b/test/run.ts @@ -53,6 +53,7 @@ import './mission-skeletons.test.ts'; import './mission-game.test.ts'; import './mission-offers.test.ts'; import './mission-verbs.test.ts'; +import './mission-company.test.ts'; import './mission-arrival.test.ts'; import './scan-watch.test.ts'; import './mission-story.test.ts'; @@ -71,6 +72,21 @@ import './jettison.test.ts'; import './bribe.test.ts'; import './bribe-flight.test.ts'; import './prompts.test.ts'; +import './courses.test.ts'; +import './course-pilot.test.ts'; +import './course-clearance.test.ts'; +import './course-arrivals.test.ts'; +import './course-launch.test.ts'; +import './course-buttons.test.ts'; +import './skip.test.ts'; +import './targets.test.ts'; +import './close-pass.test.ts'; +import './aim.test.ts'; +import './fight-buttons.test.ts'; +import './run-and-offers.test.ts'; +import './mine-and-collect.test.ts'; +import './dock-trial.test.ts'; +import './mission-courses.test.ts'; import './world.test.ts'; import './docking.test.ts'; import './docking-computer.test.ts'; @@ -115,6 +131,8 @@ import './separation.test.ts'; import './tactics.test.ts'; import './tactic-choice.test.ts'; import './scripted-co-pilot.test.ts'; +import './co-pilot-tracking.test.ts'; +import './co-pilot-engagement.test.ts'; import './pitch-roll-steer.test.ts'; import './pursuit.test.ts'; import './human-shape.test.ts'; diff --git a/test/scan-watch.test.ts b/test/scan-watch.test.ts index 024be049..1a3fd02f 100644 --- a/test/scan-watch.test.ts +++ b/test/scan-watch.test.ts @@ -15,6 +15,8 @@ import { SOURCE_DESIGN, specForDesign } from '../src/game/ship-specs.ts'; import { shipDesignIdOf } from '../src/game/ship-identity.ts'; import { emptyMissionState } from '../src/missions/state.ts'; import { SIDE_SCAN } from '../src/missions/skeletons/side.ts'; +import { dossierFor } from '../src/missions/dossiers.ts'; +import { fillSlots } from '../src/missions/text.ts'; import { SCAN_SECONDS, SIDE_JOB_PAY } from '../src/constants/missions.ts'; import { check, consoleWatcher, dismissBriefing, eq } from './harness.ts'; @@ -64,11 +66,15 @@ console.log('\na scan counts while the subject is in view, with no missile armed said.some((t) => /^SCANNING THE ANACONDA\. \d+ OF 20 SECONDS DONE\.$/.test(t)), said.slice(0, 4).join(' / ')); check('...and the scan completes and pays, with no missile ever armed', g.state.commander.missions.live.length === 0 && g.state.commander.credits === before + SIDE_JOB_PAY.scan); - // The dossier's own line replaces the skeleton's, and it names the world - // the watch was at, not ANY STATION (docs/TODO/203 M5). + // The dossier's own line replaces the skeleton's, filled with the world + // the watch was at and never ANY STATION (docs/TODO/203 M5). The line is + // read from the dossier, because its prose changes on every generation + // (docs/TODO/218). const world = g.state.systems[g.state.commander.systemIndex].name.toUpperCase(); - check('...and the patron says so, naming the world', - said.some((t) => t === `YOU HELD THE ANACONDA AT ${world} WITHOUT FIRING, AND EARNED 250.0 CR`), said.at(-1) ?? ''); + const line = dossierFor('side-scan')?.legs.watch?.success ?? ''; + const expected = fillSlots(line, { TARGET: world, PAY: '250.0 CR' }).toUpperCase(); + check('...and the patron says so, in the dossier\'s own words', + line !== '' && said.some((t) => t === expected) && !said.some((t) => t.includes('ANY STATION')), said.at(-1) ?? ''); } console.log('\n...and not while the subject is behind the player'); diff --git a/test/screen-buttons.test.ts b/test/screen-buttons.test.ts index cbd64a1b..d3e4b43f 100644 --- a/test/screen-buttons.test.ts +++ b/test/screen-buttons.test.ts @@ -10,10 +10,17 @@ // key is what the host reads, and a label is free to change. import { renderBriefing } from '../src/ui/briefing.ts'; +import { renderNaming, renderNewCommander, renderSavePrompt } from '../src/ui/screens-career.ts'; import { ChartScreen, type ChartContext } from '../src/game/screens/chart.ts'; import { newCommander } from '../src/game/commander.ts'; import { g1 } from './fixtures.ts'; -import { capture } from './screen-capture.ts'; +import { capture, captureById } from './screen-capture.ts'; +import { Input } from '../src/engine/input.ts'; +import { renderTestMode } from '../src/ui/screens-career.ts'; +import { renderCombatSimCompare, renderCombatSimReport, renderCombatSimSetup } from '../src/ui/screens-trainer.ts'; +import { compareReports } from '../src/game/combat-sim-compare.ts'; +import { CombatSimRecorder, type CombatSimReport, type ExerciseSetup } from '../src/game/combat-sim-report.ts'; +import { NO_OPENING } from '../src/game/combat-sim-opening.ts'; import { check } from './harness.ts'; /** The smallest context a chart paints from, at Tibedied. */ @@ -54,5 +61,128 @@ console.log('\nevery docked screen ends in a button a finger can press'); check(`the ${name} chart leaves by a button`, hasButton(html, 'Escape')); check(`...and opens the data screen by a button`, hasButton(html, 'KeyD')); check(`...and cycles the overlay by a button`, hasButton(html, 'KeyT')); + check('...and opens the market estimate by a button', hasButton(html, 'KeyM')); + check('...and starts a search by a button', hasButton(html, 'KeyF')); } } + +// --- the search types on the key grid (docs/TODO/216 M2) -------------------- +// +// The grid is written into an empty element when the search starts, and +// cleared when it ends. `captureById` reads that element, as the chart +// tests read the info line. + +console.log('\nthe chart search carries its letters while it runs'); +{ + const press = (screen: ChartScreen, code: string): void => { + const i = new Input(); + i.injectPress(code); + screen.input(i); + }; + for (const id of ['chart', 'local'] as const) { + const ctx = context(); + const screen = new ChartScreen(id, () => ctx); + const keysId = id === 'chart' ? 'chart-keys' : 'local-keys'; + const name = id === 'chart' ? 'galactic' : 'short range'; + + const idle = captureById(() => screen.open()); + check(`the ${name} chart opens with no letters on it`, !(idle.get(keysId) ?? '').includes('data-key')); + + const found = captureById(() => { screen.open(); press(screen, 'KeyF'); }); + const grid = found.get(keysId) ?? ''; + check('...F puts the letters on it', hasButton(grid, 'KeyA') && hasButton(grid, 'KeyZ')); + check('...with DEL and ENTER, and no SPACE', + hasButton(grid, 'Backspace') && hasButton(grid, 'Enter') && !hasButton(grid, 'Space')); + + const ended = captureById(() => { screen.open(); press(screen, 'KeyF'); press(screen, 'Enter'); }); + check('...and ENTER takes them off again', !(ended.get(keysId) ?? '').includes('data-key')); + } +} + +// --- a typed name, by a grid of keys (docs/TODO/216 M1) --------------------- +// +// Three screens read a name through `Input.drainPresses`. A phone sends no +// letter. So each paints the key grid, whose buttons carry the codes the +// screens already read. The one with no button at all was the rename. + +console.log('\nevery screen that takes a name carries the keys to type it'); +{ + const NAME_KEYS = ['KeyA', 'KeyZ', 'Digit1', 'Digit0', 'Space', 'Backspace']; + const screens: [string, () => void][] = [ + ['the save prompt', () => renderSavePrompt('JAM', false)], + ['the rename', () => renderNaming('JAM', 'JAMESON', 'JAMESON')], + ['the new commander', () => renderNewCommander('', 'JAMESON')], + ]; + for (const [name, paint] of screens) { + const html = capture(paint); + check(`${name} carries a letter, a digit, SPACE and DEL`, + NAME_KEYS.every((k) => hasButton(html, k))); + check('...one button per key of the alphabet', (html.match(/data-key="Key[A-Z]"/g) ?? []).length === 26); + check('...and confirms by a button', hasButton(html, 'Enter')); + check('...and leaves by a button', hasButton(html, 'Escape')); + } + const asking = capture(() => renderSavePrompt('JAM', true)); + check('the save prompt that asks to replace offers Y and ESC, and no letter', + hasButton(asking, 'KeyY') && hasButton(asking, 'Escape') && !hasButton(asking, 'KeyA')); +} + +// --- the levers and the trainer step by a button (docs/TODO/216 M3) --------- +// +// Each row carries a left and a right arrow, and each arrow carries its +// row. `ScreenHost.click` selects the row first, and then sends the key +// (test/menu-click.test.ts holds that route). + +/** Does the painted screen carry an arrow button for this row? */ +const hasRowKey = (html: string, row: number, code: string): boolean => + new RegExp(`