diff --git a/DECISIONS.md b/DECISIONS.md index 669bb6f..ae8c4ef 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. - **"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. + +## 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). diff --git a/memory-bank/change-requests/CR-009-ship-upgrades.md b/memory-bank/change-requests/CR-009-ship-upgrades.md index e77b4ad..ad66214 100644 --- a/memory-bank/change-requests/CR-009-ship-upgrades.md +++ b/memory-bank/change-requests/CR-009-ship-upgrades.md @@ -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. - **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-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 - Add a pure function that returns a ship's effective stats from its model and installed upgrade levels. diff --git a/memory-bank/change-requests/CR-021-independent-pilots.md b/memory-bank/change-requests/CR-021-independent-pilots.md index 2eb7692..91ff619 100644 --- a/memory-bank/change-requests/CR-021-independent-pilots.md +++ b/memory-bank/change-requests/CR-021-independent-pilots.md @@ -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. - **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-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 - 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. diff --git a/memory-bank/change-requests/CR-023-shop-time-rest.md b/memory-bank/change-requests/CR-023-shop-time-rest.md index 4a3efac..5a24ca4 100644 --- a/memory-bank/change-requests/CR-023-shop-time-rest.md +++ b/memory-bank/change-requests/CR-023-shop-time-rest.md @@ -52,6 +52,10 @@ Record in `DECISIONS.md` and `implementation-plan.md` §0.5 with the next free n - 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. +## 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 Rest facilities at HQs, shortening shop times, and anything else in `implementation-plan.md` §0.3. diff --git a/memory-bank/change-requests/CR-024-fleet-sleep.md b/memory-bank/change-requests/CR-024-fleet-sleep.md index ae37b43..acb6dda 100644 --- a/memory-bank/change-requests/CR-024-fleet-sleep.md +++ b/memory-bank/change-requests/CR-024-fleet-sleep.md @@ -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 - **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. - Pilot status lines show "Waiting for orders" for sleeping Independent pilots. diff --git a/memory-bank/change-requests/CR-025-short-away-summary.md b/memory-bank/change-requests/CR-025-short-away-summary.md index a29f244..833167e 100644 --- a/memory-bank/change-requests/CR-025-short-away-summary.md +++ b/memory-bank/change-requests/CR-025-short-away-summary.md @@ -82,6 +82,7 @@ Record these in `DECISIONS.md` and `implementation-plan.md` §0.5, using the nex ### 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-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. - **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. diff --git a/memory-bank/change-requests/CR-026-random-events-hull.md b/memory-bank/change-requests/CR-026-random-events-hull.md index 2f4051f..89efdcc 100644 --- a/memory-bank/change-requests/CR-026-random-events-hull.md +++ b/memory-bank/change-requests/CR-026-random-events-hull.md @@ -205,6 +205,10 @@ Removes "random events" from the deferred list (`implementation-plan.md` §0.3). | CR-023 | Event results become highlights. | | `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 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. diff --git a/memory-bank/implementation-plan.md b/memory-bank/implementation-plan.md index 2923ba7..f6fa5b6 100644 --- a/memory-bank/implementation-plan.md +++ b/memory-bank/implementation-plan.md @@ -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. 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. -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. ### 0.6 Translating change requests written for the server (CR-014 §7) diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 4adc6b3..617300b 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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.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-*`. -- [ ] 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. diff --git a/packages/engine/src/game/catchup.ts b/packages/engine/src/game/catchup.ts index 9b2dcfc..bd47c74 100644 --- a/packages/engine/src/game/catchup.ts +++ b/packages/engine/src/game/catchup.ts @@ -146,7 +146,9 @@ function decide(save: SaveDocument, pilot: SavedPilot, t: RealTime, viewed: stri } 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( { ship: { ...shipState(ship), tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice } }, diff --git a/packages/engine/test/service-routing.test.ts b/packages/engine/test/service-routing.test.ts index 747edf0..68544aa 100644 --- a/packages/engine/test/service-routing.test.ts +++ b/packages/engine/test/service-routing.test.ts @@ -92,9 +92,11 @@ describe("Independent pilots and booked service (Step 27.4, decision 0.5.154)", 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(); - 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) => { // An automatic booking (not a manual decision), and the fleet has gone quiet. 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", {}); const save = await readSave(test.app); expect(tripsOf(save).map((d) => d.record.destinationId)).toEqual(["earth"]); + expect(tripsOf(save)[0]!.record.contract).toBeNull(); expect(serviceLog(save)).toEqual(["started", "finished"]); expect(shipPa(save).locationId).toBe("earth"); }); diff --git a/packages/sim/balance/measure-service.ts b/packages/sim/balance/measure-service.ts new file mode 100644 index 0000000..67ee271 --- /dev/null +++ b/packages/sim/balance/measure-service.ts @@ -0,0 +1,24 @@ +import { simulate } from "./simulate"; + +// Away service balance by hand (Step 27.7): `tsx balance/measure-service.ts `. +// 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"}`, + ); +} diff --git a/packages/sim/balance/run.ts b/packages/sim/balance/run.ts index bd155c1..d38ef68 100644 --- a/packages/sim/balance/run.ts +++ b/packages/sim/balance/run.ts @@ -49,6 +49,11 @@ console.log( `ratio ${((away.scenarioEarnings! / active.scenarioEarnings!) * 100).toFixed(0)}% (target 40–80%), ` + `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. const absence = (hours: number) => diff --git a/packages/sim/balance/simulate.ts b/packages/sim/balance/simulate.ts index 567a17a..1c17840 100644 --- a/packages/sim/balance/simulate.ts +++ b/packages/sim/balance/simulate.ts @@ -12,7 +12,10 @@ import { content as defaultContent, type ShipModel } from "@deadhead/content"; import { affordableRefuel, applyEventOption, + autoRepairBooks, + canReachHq, hullAfterDamage, + nearestReachableHq, repairCost, repairShopMs, rollTripEvent, @@ -176,6 +179,12 @@ export interface SimulationResult { windowDefaultValue: 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 { @@ -187,6 +196,10 @@ interface SimShip extends ShipState { trips: number; /** Trips since its last event (CR-026 cooldown); absent: past the cooldown. */ 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 { @@ -446,6 +459,9 @@ export function simulate(options: SimulationOptions): SimulationResult { windowBestValue: 0, }; 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. */ 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) { let flown = record; let applied: AppliedEvent | null = null; + if (away && !pilot.isAvatar) countLowHullTrip(ship); eventStats.trips++; if (inScenario()) eventStats.windowTrips++; if (eventMode !== "off" && now >= eventsFrom) { @@ -514,7 +531,7 @@ export function simulate(options: SimulationOptions): SimulationResult { eventStats.windowBestValue += optionValue(record, bestChoice, ship); } applied = eventMode === "best" ? bestChoice : defaultChoice; - loseHull(ship, applied.hullLoss); + loseHull(ship, applied.hullLoss, record.destinationId); const extra = Math.min(applied.extraFuelFu, ship.tank.fuelFu); ship.tank = { ...ship.tank, fuelFu: ship.tank.fuelFu - extra }; flown = { @@ -530,10 +547,14 @@ export function simulate(options: SimulationOptions): SimulationResult { 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; ship.hullConditionPct = hullAfterDamage(before, points, config); + if (away) bookAutoRepair(ship, before, place); if (inScenario()) { const lost = before - ship.hullConditionPct; 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. */ function repairShips(): boolean { let acted = false; @@ -611,9 +690,9 @@ export function simulate(options: SimulationOptions): SimulationResult { const ship = ships.find((s) => s.id === flight.shipId)!; // Flying wears every hull, events or not (decision 0.5.152). const hours = tripGameHours(flight.record.departureAt, flight.record.arrivalAt, config); - loseHull(ship, hullWear(hours, config)); ship.inFlight = false; ship.locationId = r.shipLocationId; + loseHull(ship, hullWear(hours, config), ship.locationId); } if (flight.record.tutorial) { runsCompleted++; @@ -1057,12 +1136,14 @@ export function simulate(options: SimulationOptions): SimulationResult { ship, pilot: pilotState(pilot), 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, credits, spendingFloor: floor, discoveredIds: discovered, emptyPeriods: pilot.emptyPeriods ?? 0, + booking: ship.booking ?? null, }, content, ); @@ -1111,6 +1192,13 @@ export function simulate(options: SimulationOptions): SimulationResult { 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 { let acted = false; for (;;) { @@ -1120,7 +1208,7 @@ export function simulate(options: SimulationOptions): SimulationResult { !p.isAvatar && p.nextDecisionAt != null && 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]; if (!due) return acted; @@ -1158,6 +1246,7 @@ export function simulate(options: SimulationOptions): SimulationResult { let acted = false; if (tutorialActive) acted = playTutorial() || acted; else if (away) { + acted = startServices() || acted; acted = flyIndependent() || acted; } else { if (!inWindow) { @@ -1201,5 +1290,6 @@ export function simulate(options: SimulationOptions): SimulationResult { scenarioEarnings: scenario ? credits - windowCredits + (windowLoan - loanBalance) : null, payoutInFlightAtSleep, events: eventStats, + service: serviceStats, }; } diff --git a/packages/sim/test/balance.test.ts b/packages/sim/test/balance.test.ts index 3997a4f..b178609 100644 --- a/packages/sim/test/balance.test.ts +++ b/packages/sim/test/balance.test.ts @@ -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", () => { 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)", () => {