Step 27.7: balance and regression
CI / check (push) Successful in 8m21s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-18 13:45:45 -04:00
parent b354e93acf
commit 1dd30ff9a8
15 changed files with 167 additions and 11 deletions

View File

@ -2034,3 +2034,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Only automatic bookings are logged as "booked"**: the player's own bookings are their decisions, already on screen. Start, finish and waiting for funds are logged for both. - **Only automatic bookings are logged as "booked"**: the player's own bookings are their decisions, already on screen. Start, finish and waiting for funds are logged for both.
- **"Service finished" sits beside the repair and upgrade lines** it covers, so the costs stay where they were (the repair line with the repair's cost, the start line with the whole charge). - **"Service finished" sits beside the repair and upgrade lines** it covers, so the costs stay where they were (the repair line with the repair's cost, the start line with the whole charge).
- **The problems are found from the state** like the others (decision 0.5.140): a booking waiting for funds, or an unstarted one whose HQ is out of reach, wherever the ship is. - **The problems are found from the state** like the others (decision 0.5.140): a booking waiting for funds, or an unstarted one whose HQ is out of reach, wherever the ship is.
## Step 27.7
- **Asleep, a service trip flies empty.** The CR lets service trips go on during fleet sleep, but taking a paid job to the HQ would earn while the fleet is quiet, breaking CR-024's week-away bound (it failed for one seed). Awake, the pilot still takes the best job that ends at the HQ.
- **"Neglected" means a second trip in a row below 25% while the repair is affordable** above the spending floor: the first such trip is usually the one to the HQ.
- **The away median drops from 60% to 49% of active play**: until now the simulator never repaired away hulls, so the old figure left out repair costs and shop time. Still inside 40–80%.
- **Amendments go on each amended CR** as "Amended by CR-027" lines (CR-023 and CR-026 get an Amendments section).

View File

@ -68,6 +68,7 @@ Record these in `DECISIONS.md` and `implementation-plan.md` §0.5.
- **Test:** All upgrades validate. Every upgrade affects a stat that exists on ShipModel. Tests for decision 0.5.49 (all three inequalities). Tests for decision 0.5.50: sampling Earth–Mars distance monthly over two game years, a maxed Courier is in range on at least one sample and out of range on at least one sample; a Courier with fuel tanks at level 2 is never in range. - **Test:** All upgrades validate. Every upgrade affects a stat that exists on ShipModel. Tests for decision 0.5.49 (all three inequalities). Tests for decision 0.5.50: sampling Earth–Mars distance monthly over two game years, a maxed Courier is in range on at least one sample and out of range on at least one sample; a Courier with fuel tanks at level 2 is never in range.
- **Amended by CR-012:** Caps follow the amended 0.5.49/0.5.50 (this plan's 0.5.67/0.5.68): three more distinctness inequalities, Courier-to-Mars measured from Earth over 16 game years; the Courier's tanks are 450 % per level (Step 10L.5). - **Amended by CR-012:** Caps follow the amended 0.5.49/0.5.50 (this plan's 0.5.67/0.5.68): three more distinctness inequalities, Courier-to-Mars measured from Earth over 16 game years; the Courier's tanks are 450 % per level (Step 10L.5).
- **Amended by CR-026:** Hull Plating is added (+1 hull rating a level, three levels, the usual costs and shop times); upgrades may add a flat amount (`flatPerLevel`) instead of a percent. - **Amended by CR-026:** Hull Plating is added (+1 hull rating a level, three levels, the usual costs and shop times); upgrades may add a flat amount (`flatPerLevel`) instead of a percent.
- **Amended by CR-027:** Upgrades (one level a visit) and repairs can be booked from anywhere for an HQ; the work starts, and is charged, when the ship is next idle there.
### Step 10F.2 — Sim: effective stats ### Step 10F.2 — Sim: effective stats
- Add a pure function that returns a ship's effective stats from its model and installed upgrade levels. - Add a pure function that returns a ship's effective stats from its model and installed upgrade levels.

View File

@ -155,6 +155,7 @@ Removes "automation / standing routes and auto-accept rules" from the deferred l
- **Test:** Component tests: the chip counts down and disappears when the window ends; the indicator appears for slow catch-ups only; the logbook lists each pilot's jobs, deadheads, rests, XP, and current location, and totals match the sum of entries. - **Test:** Component tests: the chip counts down and disappears when the window ends; the indicator appears for slow catch-ups only; the logbook lists each pilot's jobs, deadheads, rests, XP, and current location, and totals match the sum of entries.
- **Amended by CR-024:** The "while you were away" screen opens with a banner when the fleet went quiet ("Your fleet went quiet on [date, time] after [24 h] without orders.") and a Back to work button; sleeping Independent pilots read "Waiting for orders". - **Amended by CR-024:** The "while you were away" screen opens with a banner when the fleet went quiet ("Your fleet went quiet on [date, time] after [24 h] without orders.") and a Back to work button; sleeping Independent pilots read "Waiting for orders".
- **Amended by CR-025:** The per-pilot logbook moves from the pop-up to the Logbook screen, built from logbook entries and covering every pilot (Step 25.4). - **Amended by CR-025:** The per-pilot logbook moves from the pop-up to the Logbook screen, built from logbook entries and covering every pilot (Step 25.4).
- **Amended by CR-027:** A booking steers an Independent pilot's decisions: only jobs ending at the booked HQ, else a deadhead there, then waiting there for the work; out of reach, the usual choice.
### Step 19.8 — Balance ### Step 19.8 — Balance
- Extend the Phase 12 balance script with an "away" scenario: the player sets all hired pilots to Independent and closes the app for 8 real hours. - Extend the Phase 12 balance script with an "away" scenario: the player sets all hired pilots to Independent and closes the app for 8 real hours.

View File

@ -52,6 +52,10 @@ Record in `DECISIONS.md` and `implementation-plan.md` §0.5 with the next free n
- Run the full suite. - Run the full suite.
- **Test:** CI is green. Manual check: fly a pilot close to the duty limit, start an upgrade at the HQ, and confirm they're ready to fly the moment the upgrade finishes. - **Test:** CI is green. Manual check: fly a pilot close to the duty limit, start an upgrade at the HQ, and confirm they're ready to fly the moment the upgrade finishes.
## Amendments
- **Amended by CR-027:** Booked service overlaps rest the same way: a rest due on arrival or already running ends with the service, so the pilot is free at the later end.
## 5. Out of scope ## 5. Out of scope
Rest facilities at HQs, shortening shop times, and anything else in `implementation-plan.md` §0.3. Rest facilities at HQs, shortening shop times, and anything else in `implementation-plan.md` §0.3.

View File

@ -74,6 +74,7 @@ Record these in `DECISIONS.md` and `implementation-plan.md` §0.5, using the nex
### Step 22.5 — Client: return banner, status, and setting ### Step 22.5 — Client: return banner, status, and setting
- **Amended by CR-025:** The sleep banner stays at the top of the highlights pop-up, under its title. - **Amended by CR-025:** The sleep banner stays at the top of the highlights pop-up, under its title.
- **Amended by CR-027:** Service trips go on while the fleet is asleep (flying empty, so the fleet earns nothing), and `bookService`, `cancelBooking` and `setAutoRepair` are manual decisions.
- The "while you were away" screen shows the sleep banner and "Back to work" button when the fleet went quiet. HQ shows "Fleet asleep — waiting for your orders" with the same button while asleep. - The "while you were away" screen shows the sleep banner and "Back to work" button when the fleet went quiet. HQ shows "Fleet asleep — waiting for your orders" with the same button while asleep.
- Pilot status lines show "Waiting for orders" for sleeping Independent pilots. - Pilot status lines show "Waiting for orders" for sleeping Independent pilots.

View File

@ -82,6 +82,7 @@ Record these in `DECISIONS.md` and `implementation-plan.md` §0.5, using the nex
### Step 23.2 — Sim: summary builder ### Step 23.2 — Sim: summary builder
- **Amended by CR-026:** Events are logbook entries; good ones and any that hurt a hull are highlights just below level-ups, and a short absence lists its trips' events (not counted towards the 3). - **Amended by CR-026:** Events are logbook entries; good ones and any that hurt a hull are highlights just below level-ups, and a short absence lists its trips' events (not counted towards the 3).
- **Amended by CR-027:** Service is logged (automatic bookings, starts, finishes, waiting for funds), and booked service waiting for funds or out of reach is a highlight.
- A pure function that turns the entries for an absence into: duration, totals, up to 3 highlights in priority order with an overflow count, up to 5 pilot lines sorted by net profit with an overflow line, the idle-pilots line, and the "short absence" direct list when there are 3 events or fewer. - A pure function that turns the entries for an absence into: duration, totals, up to 3 highlights in priority order with an overflow count, up to 5 pilot lines sorted by net profit with an overflow line, the idle-pilots line, and the "short absence" direct list when there are 3 events or fewer.
- **Test:** With 50 pilots' worth of entries, the result has exactly 5 pilot lines and an overflow line whose totals equal the rest. Highlights follow the priority order and cap at 3 with the right overflow count. Totals equal the sum of entries. With 2 events, the result is the direct list. Net matches the delivery-summary calculation. - **Test:** With 50 pilots' worth of entries, the result has exactly 5 pilot lines and an overflow line whose totals equal the rest. Highlights follow the priority order and cap at 3 with the right overflow count. Totals equal the sum of entries. With 2 events, the result is the direct list. Net matches the delivery-summary calculation.

View File

@ -205,6 +205,10 @@ Removes "random events" from the deferred list (`implementation-plan.md` §0.3).
| CR-023 | Event results become highlights. | | CR-023 | Event results become highlights. |
| `game-design-document.md` §13 | Updated by the designer. | | `game-design-document.md` §13 | Updated by the designer. |
## Amendments
- **Amended by CR-027:** Hull damage and wear that take an Independent crew's hull below the auto-repair threshold (40% by default) book a repair at the nearest reachable HQ; Manual crews see "Repair recommended".
## 10. Out of scope ## 10. Out of scope
Losing ships or pilots, insurance, piracy, reputation effects, multi-step event chains, events at docked locations (depot shortages, price spikes), pilot traits that change defaults, and anything else in `implementation-plan.md` §0.3. Losing ships or pilots, insurance, piracy, reputation effects, multi-step event chains, events at docked locations (depot shortages, price spikes), pilot traits that change defaults, and anything else in `implementation-plan.md` §0.3.

View File

@ -213,7 +213,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
155. **Manual crews** (CR-027). A booking on a Manual pilot's ship is a reminder (tags on the ship and in the dispatch sheet); the work starts when the ship is next idle at that HQ. 155. **Manual crews** (CR-027). A booking on a Manual pilot's ship is a reminder (tags on the ship and in the dispatch sheet); the work starts when the ship is next idle at that HQ.
156. **Payment** (CR-027). Player bookings are charged whenever credits cover them; automatic ones only if credits stay at or above the NPC spending floor; a ship that can't pay waits at the HQ ("Waiting for funds"), logged and highlighted, and starts as soon as it can. 156. **Payment** (CR-027). Player bookings are charged whenever credits cover them; automatic ones only if credits stay at or above the NPC spending floor; a ship that can't pay waits at the HQ ("Waiting for funds"), logged and highlighted, and starts as soon as it can.
157. **Auto-repair** (CR-027). Per ship, on at 40% by default (10–90% in steps of 5): with an Independent crew, a hull dropping below it adds a repair booking at the nearest reachable HQ (or to the ship's booking); with a Manual crew it shows "Repair recommended" instead. 157. **Auto-repair** (CR-027). Per ship, on at 40% by default (10–90% in steps of 5): with an Independent crew, a hull dropping below it adds a repair booking at the nearest reachable HQ (or to the ship's booking); with a Manual crew it shows "Repair recommended" instead.
158. **Sleep exception** (CR-027). Service trips continue while the fleet is asleep; afterwards the pilot waits for orders. 158. **Sleep exception** (CR-027). Service trips continue while the fleet is asleep; afterwards the pilot waits for orders. Asleep, the trip flies empty (Step 27.7): a paid job to the HQ would earn while the fleet is quiet (decision 0.5.137).
159. **No double waiting** (CR-027). Rest and service run at the same time when they coincide; the pilot is free when the later ends, and a service long enough to count as rest (decision 0.5.135) resets duty and ends any remaining rest. 159. **No double waiting** (CR-027). Rest and service run at the same time when they coincide; the pilot is free when the later ends, and a service long enough to count as rest (decision 0.5.135) resets duty and ends any remaining rest.
### 0.6 Translating change requests written for the server (CR-014 §7) ### 0.6 Translating change requests written for the server (CR-014 §7)

View File

@ -575,4 +575,4 @@ Spec: `memory-bank/change-requests/CR-027-service-bookings.md` (received as "CR-
- [x] **Step 27.4 — Engine: Independent pilots and catch-up.** An Independent pilot's decision passes the ship's unstarted booking to `chooseAction`, so they take only jobs ending at the booked HQ, else deadhead there, and wait there for the work; out of reach, they work as usual. A pilot on a service trip (booked, reachable, not there yet) keeps deciding while the fleet is asleep, up to the 7-day cap; once the work starts they're back under the sleep rule. Tests (`packages/engine/test/service-routing.test.ts`): the job to the HQ over a richer one, with the work on landing; the deadhead; the service trip while asleep, and nothing after; an HQ out of reach; the same save caught up at once or an hour at a time. - [x] **Step 27.4 — Engine: Independent pilots and catch-up.** An Independent pilot's decision passes the ship's unstarted booking to `chooseAction`, so they take only jobs ending at the booked HQ, else deadhead there, and wait there for the work; out of reach, they work as usual. A pilot on a service trip (booked, reachable, not there yet) keeps deciding while the fleet is asleep, up to the 7-day cap; once the work starts they're back under the sleep rule. Tests (`packages/engine/test/service-routing.test.ts`): the job to the HQ over a richer one, with the work on landing; the deadhead; the service trip while asleep, and nothing after; an HQ out of reach; the same save caught up at once or an hour at a time.
- [x] **Step 27.5 — Client: Service section and tags.** `apps/web/src/components/Service.tsx`: the Service section in the ship's Upgrades sheet (the booking's items, HQ, estimate and status; Book service with a repair box, an upgrade picker (the next level of each) and an HQ picker defaulting to the nearest reachable (`defaultHq`, the sim's `nearestReachableHq`), with the estimate; Change and Cancel booking until the work starts; the auto-repair box and a 10–90% slider in steps of 5). `serviceTag` on the Fleet card and in the dispatch sheet ("Service booked: Engine Tuning 2 at Earth", "Repair recommended"); `serviceStatus` ("Heading to Earth for service", "In the shop at Earth (resting)", "Waiting for funds: Repair at Earth", "Can't reach Earth for service") leads an Independent pilot's status line. Tests (`apps/web/test/service.test.tsx`): booking with the estimate and the nearest HQ; a booking's items and status, Change and Cancel calling the engine; no change once started; the slider and the box; the Fleet tag; each status wording; the pilot line; the dispatch sheet tag. Screenshots: `docs/screenshots/27-5-*` (Repair recommended, the booking form, a booked ship's card and sheet; phone and desktop). - [x] **Step 27.5 — Client: Service section and tags.** `apps/web/src/components/Service.tsx`: the Service section in the ship's Upgrades sheet (the booking's items, HQ, estimate and status; Book service with a repair box, an upgrade picker (the next level of each) and an HQ picker defaulting to the nearest reachable (`defaultHq`, the sim's `nearestReachableHq`), with the estimate; Change and Cancel booking until the work starts; the auto-repair box and a 10–90% slider in steps of 5). `serviceTag` on the Fleet card and in the dispatch sheet ("Service booked: Engine Tuning 2 at Earth", "Repair recommended"); `serviceStatus` ("Heading to Earth for service", "In the shop at Earth (resting)", "Waiting for funds: Repair at Earth", "Can't reach Earth for service") leads an Independent pilot's status line. Tests (`apps/web/test/service.test.tsx`): booking with the estimate and the nearest HQ; a booking's items and status, Change and Cancel calling the engine; no change once started; the slider and the box; the Fleet tag; each status wording; the pilot line; the dispatch sheet tag. Screenshots: `docs/screenshots/27-5-*` (Repair recommended, the booking form, a booked ship's card and sheet; phone and desktop).
- [x] **Step 27.6 — Client: Logbook and summaries.** Logbook lines for `service` entries: "Iron Mule: Repair booked automatically at Earth", "service started at Earth (Repair + Engine Tuning), 3,600 cr", "service finished at Earth (…)", "waiting for funds for Repair at Earth". New away problems from the state (`awayProblems`, schema and sim `AwayProblem`): `service-waiting-funds` and `service-cant-reach`, with wording in `awayWording.highlights` ("Iron Mule is waiting for funds: Repair at Earth", "Iron Mule can't reach Jupiter Cloud Platform for its booked service"). Tests: the engine reports each problem (`packages/engine/test/service.test.ts`); the four logbook lines and both highlights render (`apps/web/test/service.test.tsx`). Screenshots: `docs/screenshots/27-6-logbook-*`. - [x] **Step 27.6 — Client: Logbook and summaries.** Logbook lines for `service` entries: "Iron Mule: Repair booked automatically at Earth", "service started at Earth (Repair + Engine Tuning), 3,600 cr", "service finished at Earth (…)", "waiting for funds for Repair at Earth". New away problems from the state (`awayProblems`, schema and sim `AwayProblem`): `service-waiting-funds` and `service-cant-reach`, with wording in `awayWording.highlights` ("Iron Mule is waiting for funds: Repair at Earth", "Iron Mule can't reach Jupiter Cloud Platform for its booked service"). Tests: the engine reports each problem (`packages/engine/test/service.test.ts`); the four logbook lines and both highlights render (`apps/web/test/service.test.tsx`). Screenshots: `docs/screenshots/27-6-logbook-*`.
- [ ] Step 27.7 — Balance and regression - [x] **Step 27.7 — Balance and regression.** The balance simulator's away scenario now runs auto-repair at 40% with events on: hull loss that crosses the threshold books a repair at the nearest reachable HQ (`bookAutoRepair`), the pilot's decisions pass the booking to `chooseAction`, a ship idle at its HQ starts the repair when credits stay above the spending floor (`startServices`), and service trips go on while asleep. Results gain `service` (bookings, services, the most trips in a row an Independent ship flew below 25% with its repair affordable); a second such trip is a `hull-neglected` violation. Measured over the 7 balance seeds, 8 hours away (`balance/measure-service.ts`): 3–4 bookings and as many repairs a run, at most 1 trip below 25%, no violations; away earns a median 49% of active play (target 40–80%; 60% before, when away hulls were never repaired). Asleep, a service trip flies empty, so the week-away bound (CR-024) still holds. Amendments (CR-027 §8) added to CR-009, CR-021, CR-023, CR-024, CR-025 and CR-026. Test: `packages/sim/test/balance.test.ts` (no ship more than one trip below 25%, bookings made and done, the away ratio and the week bound), the engine sleep test (an empty flight to the HQ with a paid job home on the board); full suite green.

View File

@ -146,7 +146,9 @@ function decide(save: SaveDocument, pilot: SavedPilot, t: RealTime, viewed: stri
} }
const random = replayRandom(pilot.id, t); const random = replayRandom(pilot.id, t);
const board = locationBoard(save, ship.locationId, random, t).contracts; // Asleep, only a service trip goes on, and it flies empty: no pay while the fleet is quiet (CR-024).
const asleep = t >= sleepsAt(save, t);
const board = asleep ? [] : locationBoard(save, ship.locationId, random, t).contracts;
const action = chooseAction( const action = chooseAction(
{ {
ship: { ...shipState(ship), tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice } }, ship: { ...shipState(ship), tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice } },

View File

@ -92,9 +92,11 @@ describe("Independent pilots and booked service (Step 27.4, decision 0.5.154)",
expect(first.record.destinationId).toBe("earth"); expect(first.record.destinationId).toBe("earth");
}); });
it("still flies to booked service while the fleet is asleep, then waits", async () => { it("still flies to booked service while the fleet is asleep, empty, then waits", async () => {
const test = await bookedPilot(); const test = await bookedPilot();
await putBoard(test.app, [], test.clock.now, "moon"); // A paid job home is on the board, but asleep the fleet earns nothing (CR-024).
const home = testContract({ id: "moon:home", originId: "moon", destinationId: "earth" });
await putBoard(test.app, [home], test.clock.now, "moon");
await edit(test.app, (save) => { await edit(test.app, (save) => {
// An automatic booking (not a manual decision), and the fleet has gone quiet. // An automatic booking (not a manual decision), and the fleet has gone quiet.
shipPa(save).booking = { shipPa(save).booking = {
@ -113,6 +115,7 @@ describe("Independent pilots and booked service (Step 27.4, decision 0.5.154)",
await test.app.call("getPlayerState", {}); await test.app.call("getPlayerState", {});
const save = await readSave(test.app); const save = await readSave(test.app);
expect(tripsOf(save).map((d) => d.record.destinationId)).toEqual(["earth"]); expect(tripsOf(save).map((d) => d.record.destinationId)).toEqual(["earth"]);
expect(tripsOf(save)[0]!.record.contract).toBeNull();
expect(serviceLog(save)).toEqual(["started", "finished"]); expect(serviceLog(save)).toEqual(["started", "finished"]);
expect(shipPa(save).locationId).toBe("earth"); expect(shipPa(save).locationId).toBe("earth");
}); });

View File

@ -0,0 +1,24 @@
import { simulate } from "./simulate";
// Away service balance by hand (Step 27.7): `tsx balance/measure-service.ts <hours away>`.
// Independent fleets with events on and auto-repair at the default threshold.
const SEEDS = [12345, 777, 4242, 99, 2026, 31337, 8];
const HOURS = Number(process.argv[2] ?? 8);
const window = (worldSeed: number, mode: "active" | "away") =>
simulate({
worldSeed,
policy: "lighter-first",
durationMs: (3 + HOURS) * 3_600_000 + 3_600_000,
scenario: { atMinute: 180, minutes: HOURS * 60, mode },
});
for (const seed of SEEDS) {
const away = window(seed, "away");
const active = window(seed, "active");
const ratio = away.scenarioEarnings! / active.scenarioEarnings!;
console.log(
`seed ${seed}: away ${away.scenarioEarnings} / active ${active.scenarioEarnings} = ${(ratio * 100).toFixed(0)}%, ` +
`bookings ${away.service.bookings}, services ${away.service.started}, ` +
`most low-hull trips ${away.service.maxLowHullTrips}, repairs ${away.events.windowRepairSpend} cr, ` +
`violations ${away.violations.map((v) => `${v.rule} ${v.detail}`).join("; ") || "none"}`,
);
}

View File

@ -49,6 +49,11 @@ console.log(
`ratio ${((away.scenarioEarnings! / active.scenarioEarnings!) * 100).toFixed(0)}% (target 40–80%), ` + `ratio ${((away.scenarioEarnings! / active.scenarioEarnings!) * 100).toFixed(0)}% (target 40–80%), ` +
`violations ${away.violations.length}`, `violations ${away.violations.length}`,
); );
console.log(
`away service (CR-027): ${away.service.bookings} auto-repair bookings, ${away.service.started} done, ` +
`most trips in a row below 25% hull ${away.service.maxLowHullTrips} (≤ 1), ` +
`repairs ${away.events.windowRepairSpend} cr`,
);
// A week away (CR-024 Step 24.7): the fleet goes quiet 24 hours into the absence. // A week away (CR-024 Step 24.7): the fleet goes quiet 24 hours into the absence.
const absence = (hours: number) => const absence = (hours: number) =>

View File

@ -12,7 +12,10 @@ import { content as defaultContent, type ShipModel } from "@deadhead/content";
import { import {
affordableRefuel, affordableRefuel,
applyEventOption, applyEventOption,
autoRepairBooks,
canReachHq,
hullAfterDamage, hullAfterDamage,
nearestReachableHq,
repairCost, repairCost,
repairShopMs, repairShopMs,
rollTripEvent, rollTripEvent,
@ -176,6 +179,12 @@ export interface SimulationResult {
windowDefaultValue: number; windowDefaultValue: number;
windowBestValue: number; windowBestValue: number;
}; };
/**
* Away service (CR-027 Step 27.7): automatic repair bookings and services
* started, and the most trips in a row an Independent ship flew below
* `needsRepairBelowPct` while the credits covered its repair.
*/
service: { bookings: number; started: number; maxLowHullTrips: number };
} }
interface SimShip extends ShipState { interface SimShip extends ShipState {
@ -187,6 +196,10 @@ interface SimShip extends ShipState {
trips: number; trips: number;
/** Trips since its last event (CR-026 cooldown); absent: past the cooldown. */ /** Trips since its last event (CR-026 cooldown); absent: past the cooldown. */
quietTrips?: number; quietTrips?: number;
/** A repair booked by auto-repair (CR-027), at this HQ. */
booking?: { hqId: string } | null;
/** Trips in a row flown below `needsRepairBelowPct` with the repair affordable. */
lowHullTrips?: number;
} }
interface SimPilot { interface SimPilot {
@ -446,6 +459,9 @@ export function simulate(options: SimulationOptions): SimulationResult {
windowBestValue: 0, windowBestValue: 0,
}; };
const inScenario = () => windowOpen; const inScenario = () => windowOpen;
const serviceStats = { bookings: 0, started: 0, maxLowHullTrips: 0 };
// Every ship on auto-repair at the default threshold (decision 0.5.157).
const autoRepair = { on: true, thresholdPct: config.autoRepair.defaultThresholdPct };
/** What an option is worth to the player: pay, less losses, hull repairs and time. */ /** What an option is worth to the player: pay, less losses, hull repairs and time. */
function optionValue(record: DispatchRecord, applied: AppliedEvent, ship: SimShip): number { function optionValue(record: DispatchRecord, applied: AppliedEvent, ship: SimShip): number {
@ -462,6 +478,7 @@ export function simulate(options: SimulationOptions): SimulationResult {
function launch(ship: SimShip, pilot: SimPilot, record: DispatchRecord) { function launch(ship: SimShip, pilot: SimPilot, record: DispatchRecord) {
let flown = record; let flown = record;
let applied: AppliedEvent | null = null; let applied: AppliedEvent | null = null;
if (away && !pilot.isAvatar) countLowHullTrip(ship);
eventStats.trips++; eventStats.trips++;
if (inScenario()) eventStats.windowTrips++; if (inScenario()) eventStats.windowTrips++;
if (eventMode !== "off" && now >= eventsFrom) { if (eventMode !== "off" && now >= eventsFrom) {
@ -514,7 +531,7 @@ export function simulate(options: SimulationOptions): SimulationResult {
eventStats.windowBestValue += optionValue(record, bestChoice, ship); eventStats.windowBestValue += optionValue(record, bestChoice, ship);
} }
applied = eventMode === "best" ? bestChoice : defaultChoice; applied = eventMode === "best" ? bestChoice : defaultChoice;
loseHull(ship, applied.hullLoss); loseHull(ship, applied.hullLoss, record.destinationId);
const extra = Math.min(applied.extraFuelFu, ship.tank.fuelFu); const extra = Math.min(applied.extraFuelFu, ship.tank.fuelFu);
ship.tank = { ...ship.tank, fuelFu: ship.tank.fuelFu - extra }; ship.tank = { ...ship.tank, fuelFu: ship.tank.fuelFu - extra };
flown = { flown = {
@ -530,10 +547,14 @@ export function simulate(options: SimulationOptions): SimulationResult {
flights.push({ record: flown, shipId: ship.id, pilotId: pilot.id, event: applied }); flights.push({ record: flown, shipId: ship.id, pilotId: pilot.id, event: applied });
} }
/** A hull hit or wear; in the scenario window, counted at what repairing it costs. */ /**
function loseHull(ship: SimShip, points: number) { * A hull hit or wear; in the scenario window, counted at what repairing it
* costs. Away, an Independent crew's ship books its repair (CR-027).
*/
function loseHull(ship: SimShip, points: number, place: string) {
const before = ship.hullConditionPct ?? 100; const before = ship.hullConditionPct ?? 100;
ship.hullConditionPct = hullAfterDamage(before, points, config); ship.hullConditionPct = hullAfterDamage(before, points, config);
if (away) bookAutoRepair(ship, before, place);
if (inScenario()) { if (inScenario()) {
const lost = before - ship.hullConditionPct; const lost = before - ship.hullConditionPct;
eventStats.windowHullCost += eventStats.windowHullCost +=
@ -541,6 +562,64 @@ export function simulate(options: SimulationOptions): SimulationResult {
} }
} }
/** Auto-repair (decision 0.5.157): at the nearest HQ reachable from where the ship lands. */
function bookAutoRepair(ship: SimShip, hullBefore: number, place: string) {
const pilot = pilots.find((p) => p.assignedShipId === ship.id);
if (!pilot || pilot.isAvatar) return;
const books = autoRepairBooks({
hullBefore,
hullAfter: ship.hullConditionPct ?? 100,
autoRepair,
pilotMode: "independent",
booking: ship.booking ? { repair: true } : null,
});
if (!books) return;
const at = { ...ship, locationId: place, inFlight: false };
ship.booking = { hqId: nearestReachableHq(at, [...bays], discovered, content) ?? home };
serviceStats.bookings++;
log(
`book repair ${ship.id} ${ship.modelId} at ${ship.booking.hqId} (${ship.hullConditionPct}%)`,
);
}
/** A booked repair starts when the ship is idle at its HQ, above the spending floor (decision 0.5.156). */
function startServices(): boolean {
let acted = false;
for (const ship of ships) {
const booking = ship.booking;
if (!booking || ship.inFlight || ship.locationId !== booking.hqId) continue;
if (ship.inShopUntil != null && ship.inShopUntil > now) continue;
const hull = ship.hullConditionPct ?? 100;
const cost = repairCost(model(ship.modelId), hull, config);
if (credits - cost < floor) continue;
credits -= cost;
eventStats.repairSpend += cost;
if (inScenario()) eventStats.windowRepairSpend += cost;
ship.inShopUntil = now + repairShopMs(hull, config);
ship.hullConditionPct = 100;
ship.booking = null;
serviceStats.started++;
log(`service ${ship.id} ${ship.modelId} from ${hull}% for ${cost} credits ${credits}`);
acted = true;
}
return acted;
}
/** A trip flown below "Needs repair" while the credits would cover the repair (Step 27.7). */
function countLowHullTrip(ship: SimShip) {
const hull = ship.hullConditionPct ?? 100;
const affordable = credits - repairCost(model(ship.modelId), hull, config) >= floor;
ship.lowHullTrips =
hull < config.hull.needsRepairBelowPct && affordable ? (ship.lowHullTrips ?? 0) + 1 : 0;
serviceStats.maxLowHullTrips = Math.max(serviceStats.maxLowHullTrips, ship.lowHullTrips);
if (ship.lowHullTrips > 1) {
violate(
"hull-neglected",
`${ship.id} ${ship.modelId} at ${hull}%, trip ${ship.lowHullTrips}`,
);
}
}
/** Hull repairs at an HQ (decision 0.5.148): below 60%, when the cash is there. */ /** Hull repairs at an HQ (decision 0.5.148): below 60%, when the cash is there. */
function repairShips(): boolean { function repairShips(): boolean {
let acted = false; let acted = false;
@ -611,9 +690,9 @@ export function simulate(options: SimulationOptions): SimulationResult {
const ship = ships.find((s) => s.id === flight.shipId)!; const ship = ships.find((s) => s.id === flight.shipId)!;
// Flying wears every hull, events or not (decision 0.5.152). // Flying wears every hull, events or not (decision 0.5.152).
const hours = tripGameHours(flight.record.departureAt, flight.record.arrivalAt, config); const hours = tripGameHours(flight.record.departureAt, flight.record.arrivalAt, config);
loseHull(ship, hullWear(hours, config));
ship.inFlight = false; ship.inFlight = false;
ship.locationId = r.shipLocationId; ship.locationId = r.shipLocationId;
loseHull(ship, hullWear(hours, config), ship.locationId);
} }
if (flight.record.tutorial) { if (flight.record.tutorial) {
runsCompleted++; runsCompleted++;
@ -1057,12 +1136,14 @@ export function simulate(options: SimulationOptions): SimulationResult {
ship, ship,
pilot: pilotState(pilot), pilot: pilotState(pilot),
orders: defaultOrders(home, content), orders: defaultOrders(home, content),
board: board(ship.locationId), // Asleep, a service trip flies empty (as the engine does).
board: now >= sleepAt ? [] : board(ship.locationId),
now, now,
credits, credits,
spendingFloor: floor, spendingFloor: floor,
discoveredIds: discovered, discoveredIds: discovered,
emptyPeriods: pilot.emptyPeriods ?? 0, emptyPeriods: pilot.emptyPeriods ?? 0,
booking: ship.booking ?? null,
}, },
content, content,
); );
@ -1111,6 +1192,13 @@ export function simulate(options: SimulationOptions): SimulationResult {
pilot.nextDecisionAt = null; pilot.nextDecisionAt = null;
} }
/** Heading for booked service (CR-027 §2): flies on while the fleet is asleep. */
function onServiceTrip(pilot: SimPilot): boolean {
const ship = ships.find((x) => x.id === pilot.assignedShipId);
if (!ship?.booking || ship.inFlight || ship.locationId === ship.booking.hqId) return false;
return canReachHq(ship, ship.booking.hqId, discovered, content);
}
function flyIndependent(): boolean { function flyIndependent(): boolean {
let acted = false; let acted = false;
for (;;) { for (;;) {
@ -1120,7 +1208,7 @@ export function simulate(options: SimulationOptions): SimulationResult {
!p.isAvatar && !p.isAvatar &&
p.nextDecisionAt != null && p.nextDecisionAt != null &&
p.nextDecisionAt <= now && p.nextDecisionAt <= now &&
p.nextDecisionAt < sleepAt, (p.nextDecisionAt < sleepAt || onServiceTrip(p)),
) )
.sort((a, b) => a.nextDecisionAt! - b.nextDecisionAt! || (a.id < b.id ? -1 : 1))[0]; .sort((a, b) => a.nextDecisionAt! - b.nextDecisionAt! || (a.id < b.id ? -1 : 1))[0];
if (!due) return acted; if (!due) return acted;
@ -1158,6 +1246,7 @@ export function simulate(options: SimulationOptions): SimulationResult {
let acted = false; let acted = false;
if (tutorialActive) acted = playTutorial() || acted; if (tutorialActive) acted = playTutorial() || acted;
else if (away) { else if (away) {
acted = startServices() || acted;
acted = flyIndependent() || acted; acted = flyIndependent() || acted;
} else { } else {
if (!inWindow) { if (!inWindow) {
@ -1201,5 +1290,6 @@ export function simulate(options: SimulationOptions): SimulationResult {
scenarioEarnings: scenario ? credits - windowCredits + (windowLoan - loanBalance) : null, scenarioEarnings: scenario ? credits - windowCredits + (windowLoan - loanBalance) : null,
payoutInFlightAtSleep, payoutInFlightAtSleep,
events: eventStats, events: eventStats,
service: serviceStats,
}; };
} }

View File

@ -143,6 +143,19 @@ describe("the away scenario (CR-021 Step 21.8)", () => {
it("never lets a pilot buy fuel below the spending floor or get stranded", () => { it("never lets a pilot buy fuel below the spending floor or get stranded", () => {
for (const run of awayRuns) expect(run.violations).toEqual([]); for (const run of awayRuns) expect(run.violations).toEqual([]);
}); });
it("keeps Independent hulls serviced with events on and auto-repair at 40% (CR-027 Step 27.7)", () => {
for (const run of awayRuns) {
// No ship flies more than one trip below 25% while its repair is affordable.
expect(run.service.maxLowHullTrips).toBeLessThanOrEqual(1);
expect(run.violations.filter((v) => v.rule === "hull-neglected")).toEqual([]);
}
// Auto-repair books repairs, and they get done.
const bookings = awayRuns.reduce((n, r) => n + r.service.bookings, 0);
const started = awayRuns.reduce((n, r) => n + r.service.started, 0);
expect(bookings).toBeGreaterThan(0);
expect(started).toBeGreaterThanOrEqual(bookings - awayRuns.length);
});
}); });
describe("a week away (CR-024 Step 24.7, plan 12.1)", () => { describe("a week away (CR-024 Step 24.7, plan 12.1)", () => {