Compare commits

...

2 Commits

Author SHA1 Message Date
fireshaper bd2a8de100 Step 27.1: content and save
CI / check (push) Has been cancelled Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:54:14 -04:00
fireshaper 49e7cca67a Record CR-027: service bookings and auto-repair (Phase 27)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:49:00 -04:00
17 changed files with 335 additions and 14 deletions

View File

@ -1989,3 +1989,14 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The refuel button is priced with the sim's own \`refuel\`**, for exactly what the button will buy: a full tank when the credits cover it, else the leg's fuel (Step 15.7's rule). When even the leg's fuel is too dear, the button is disabled and says why, instead of failing on tap. - **The refuel button is priced with the sim's own \`refuel\`**, for exactly what the button will buy: a full tank when the credits cover it, else the leg's fuel (Step 15.7's rule). When even the leg's fuel is too dear, the button is disabled and says why, instead of failing on tap.
- **Every sheet opens below the top bar**, rather than drawing a backdrop over it, so the credits and the clock stay in view and change as the player spends. The sheet's backdrop covers the rest, the bottom navigation included. The bar's height (3.5rem) is written in the sheet, since sheets render outside the layout that sets \`--header-h\`. - **Every sheet opens below the top bar**, rather than drawing a backdrop over it, so the credits and the clock stay in view and change as the player spends. The sheet's backdrop covers the rest, the bottom navigation included. The bar's height (3.5rem) is written in the sheet, since sheets render outside the layout that sets \`--header-h\`.
## CR-027 recorded (Service Bookings and Auto-Repair)
- **Numbering:** received as "CR-025 — Service Bookings and Auto-Repair" for "Phase 25"; stored as **CR-027**, built as **Phase 27** (its 25.n = 27.n), since CR-025 (Short Away Summary) and CR-026 (Random Events) exist. Its decisions CR-025.D1–D7 are **0.5.153–0.5.159**. Its references: CR-006 → CR-009, CR-011 → CR-014, CR-014 → CR-015, CR-019 → CR-021, CR-021 → CR-023, CR-022 → CR-024, CR-023 → CR-025, CR-024 → CR-026.
- **All seven decisions are recorded**: §8 says "Add CR-025.D1–D6", but §6 lists D7 (no double waiting) too.
- **No double waiting, with short repairs:** CR-023 counts the shop as rest only when it lasts a full rest; a repair can take 5 minutes, shorter than a 10-minute rest. Then the rest simply runs on to its end, which is still "the later of the two", so the CR's rule holds without changing CR-023's.
## Step 27.1
- **The booking lives on the ship** (one per ship, as the CR has one booking per ship), with its timings: `startedAt` once the work is in the shop (the booking clears at the shop end), `waitingSince` while it waits for funds at the HQ.
- **One upgrade level per booking** (`upgradeId`: the next level of that upgrade when the work starts), as the CR allows one level per visit.

View File

@ -23,7 +23,7 @@ describe("about and debug info (Step 10K.10)", () => {
expect(text).toContain("Build: test"); expect(text).toContain("Build: test");
// CR-014: the save, its format version and where it is stored; no player id. // CR-014: the save, its format version and where it is stored; no player id.
expect(text).toContain("Save: player-1"); expect(text).toContain("Save: player-1");
expect(text).toContain("Save version: 11"); expect(text).toContain("Save version: 12");
expect(text).toContain("Storage: unknown"); expect(text).toContain("Storage: unknown");
expect(text).not.toContain("Player:"); expect(text).not.toContain("Player:");
expect(text).toMatch(/Game time: \d{4}-\d{2}-\d{2}T/); expect(text).toMatch(/Game time: \d{4}-\d{2}-\d{2}T/);

View File

@ -86,7 +86,7 @@ The `routes` catalog is the list of engine commands (CR-014). Each entry has `re
## Save document (`packages/schema/src/save.ts`, CR-014) ## Save document (`packages/schema/src/save.ts`, CR-014)
One document per company on the device, validated on every load and save: `saveVersion` (currently 11; 1 → 2 added fuel tanks, CR-015; 2 → 3 put every ship on Manual refueling, CR-018; 3 → 4 dropped stored boards, CR-019; 4 → 5 added pilot `mode` and `orders` and the company's `dispatchOffice` and `npcSpendingFloor`, CR-021; 5 → 6 added pilot `nextDecisionAt` and `emptyPeriods` and dispatch `independent`, CR-021; 6 → 7 added `player.welcomeSeen`, true for existing companies, CR-022; 7 → 8 added ship `shopPilotId`, null for existing ships, CR-023; 8 → 9 added company `lastManualDecisionAt` (null until the engine's first catch-up sets it) and `sleepAfterHours`, CR-024; 9 → 10 added the `logbook`, player `awaySince`, `lastAbsence` and `readyGoalsLogged`, and ship `shopOpen`, turning resolved dispatches into entries, CR-025; 10 → 11 added ship `hullConditionPct` (100), `quietTrips` and `shopRepair`, dispatch `event` and `eventApplied` (null), company `firstEventDone` and `tripsSinceDeal`, CR-026), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan, the Dispatch Office and spending floor, and since CR-024 the last manual decision and the sleep threshold), `ships` (with `upgrades` as a level map, shop fields including `shopPilotId` (the pilot waiting out the shop as rest, CR-023), and `fuelFu`, `fuelAvgPrice`, `refuelPolicy` since CR-015), `pilots`, `bays`, `discoveredLocations`, `contractBoards` (stored boards), `dispatches` (sim record, status, resolution, acknowledged), `acceptedContracts`, `claimedGoals`, `logbook` (CR-025: entries of kind `flight` (deliveries and deadheads, with payout, pilot pay, fuel used, loan repayment and XP), `refuel` (top-up, bought, pilot or emergency), `rest`, `upgrade`, `discovery`, `level-up` and `goal-ready`, each with time, pilot and ship; ids made from what they record; kept `logbookRetentionRealDays` (7) and pruned on save). Written by `packages/engine/src/game/logbook.ts` helpers from arrivals, refuelling, rest, shop ends (a catch-up event for every `shopOpen` ship), and goals becoming ready (after every catch-up event and command). It mirrors the old server's database tables without player ids; the stored contract, dispatch record and resolution are type-checked against the sim types. `migrateSave(raw)` steps an older save through `SAVE_MIGRATIONS` (`applyMigrations`) and validates it, returning a readable error instead of throwing. One document per company on the device, validated on every load and save: `saveVersion` (currently 12; 1 → 2 added fuel tanks, CR-015; 2 → 3 put every ship on Manual refueling, CR-018; 3 → 4 dropped stored boards, CR-019; 4 → 5 added pilot `mode` and `orders` and the company's `dispatchOffice` and `npcSpendingFloor`, CR-021; 5 → 6 added pilot `nextDecisionAt` and `emptyPeriods` and dispatch `independent`, CR-021; 6 → 7 added `player.welcomeSeen`, true for existing companies, CR-022; 7 → 8 added ship `shopPilotId`, null for existing ships, CR-023; 8 → 9 added company `lastManualDecisionAt` (null until the engine's first catch-up sets it) and `sleepAfterHours`, CR-024; 9 → 10 added the `logbook`, player `awaySince`, `lastAbsence` and `readyGoalsLogged`, and ship `shopOpen`, turning resolved dispatches into entries, CR-025; 10 → 11 added ship `hullConditionPct` (100), `quietTrips` and `shopRepair`, dispatch `event` and `eventApplied` (null), company `firstEventDone` and `tripsSinceDeal`, CR-026; 11 → 12 added ship `booking` (null) and `autoRepair` (on, 40%), CR-027), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan, the Dispatch Office and spending floor, and since CR-024 the last manual decision and the sleep threshold), `ships` (with `upgrades` as a level map, shop fields including `shopPilotId` (the pilot waiting out the shop as rest, CR-023), and `fuelFu`, `fuelAvgPrice`, `refuelPolicy` since CR-015), `pilots`, `bays`, `discoveredLocations`, `contractBoards` (stored boards), `dispatches` (sim record, status, resolution, acknowledged), `acceptedContracts`, `claimedGoals`, `logbook` (CR-025: entries of kind `flight` (deliveries and deadheads, with payout, pilot pay, fuel used, loan repayment and XP), `refuel` (top-up, bought, pilot or emergency), `rest`, `upgrade`, `discovery`, `level-up` and `goal-ready`, each with time, pilot and ship; ids made from what they record; kept `logbookRetentionRealDays` (7) and pruned on save). Written by `packages/engine/src/game/logbook.ts` helpers from arrivals, refuelling, rest, shop ends (a catch-up event for every `shopOpen` ship), and goals becoming ready (after every catch-up event and command). It mirrors the old server's database tables without player ids; the stored contract, dispatch record and resolution are type-checked against the sim types. `migrateSave(raw)` steps an older save through `SAVE_MIGRATIONS` (`applyMigrations`) and validates it, returning a readable error instead of throwing.
Commands (`packages/engine/src/game/`): `GameEngine.call(name, input)` runs the route catalog's endpoints on the save, with the same schemas and error codes (`CommandError`); `getPlayerState` with no save answers `unauthorized`. Every command resolves due arrivals first and prunes on save; saves are skipped when nothing changed. `stats` keeps lifetime delivery counts for goals, so seen trips older than `savedDispatchRetentionRealHours` can be pruned. Commands (`packages/engine/src/game/`): `GameEngine.call(name, input)` runs the route catalog's endpoints on the save, with the same schemas and error codes (`CommandError`); `getPlayerState` with no save answers `unauthorized`. Every command resolves due arrivals first and prunes on save; saves are skipped when nothing changed. `stats` keeps lifetime delivery counts for goals, so seen trips older than `savedDispatchRetentionRealHours` can be pruned.

View File

@ -0,0 +1,162 @@
# Change Request CR-027 — Service Bookings and Auto-Repair
> **Numbering note (added when recorded):** this request arrived titled "CR-025 — Service Bookings and Auto-Repair" for "Phase 25". CR-025 (Short Away Summary) and CR-026 (Random Events), and Phases 25 and 26, already existed, so it is stored as **CR-027** and done as **Phase 27**: every "25.n" below means **27.n**. Its references map to this repo as: "Phase 13 and CR-001 through CR-024" = Phase 13 and **CR-001 to CR-026**; "CR-006 ship upgrades" = **CR-009**; "CR-011 local-first" = **CR-014**; "CR-014 fixed map and fuel tanks" = **CR-015**; "CR-019 independent pilots" = **CR-021**; "CR-021 shop rest" = **CR-023**; "CR-022 fleet sleep" = **CR-024**; "CR-023 away summary" = **CR-025**; "CR-024 random events" = **CR-026**. Its decisions CR-025.D1–D7 are recorded as **0.5.153–0.5.159** (D1 = 0.5.153 … D7 = 0.5.159); §8's "Add CR-025.D1–D6" is read as all seven.
>
> **On recording:** "HQ" here means one of the player's bays (an HQ location), as elsewhere; "ship detail" is the ship's Upgrades sheet (CR-026 Step 26.7).
**Project:** Deadhead: Space Freight Tycoon
**Applies to:** a build with Phase 13 and CR-001 through CR-024 complete
**Do this:** as **Phase 25**
**Related docs:** `change-request-ship-upgrades.md` (CR-006), `change-request-local-first.md` (CR-011), `change-request-fixed-map-and-fuel-tanks.md` (CR-014), `change-request-independent-pilots.md` (CR-019), `change-request-shop-rest.md` (CR-021), `change-request-fleet-sleep.md` (CR-022), `change-request-away-summary.md` (CR-023), `change-request-random-events.md` (CR-024), `DECISIONS.md`
This change request is written for the local-first engine (CR-011).
---
## 1. Why
Upgrades and repairs need the ship idle at an HQ (CR-006, CR-024), but Independent pilots (CR-019) take a new job almost as soon as they land, so their ships never sit still long enough. The only workaround is switching the pilot to Manual and flying the ship in by hand.
This change lets the player **book service** from anywhere. The pilot works their way to the chosen HQ, the work starts on arrival, and the pilot goes back to work afterward. If the pilot also needs rest, the rest and the service overlap, so the pilot never waits twice. **Auto-repair** books a repair on its own when the hull drops below a threshold: on by default at 40%, adjustable or off per ship.
## 2. Service bookings
| Rule | Detail |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Booking | From a ship's detail screen, anywhere: choose one or more service items (one repair and/or one upgrade level) and an HQ. Default HQ: the nearest one the ship can reach under the fuel rules. |
| Order of work | Repair first, then the upgrade. Shop times add up. |
| Cost estimate | Shown when booking; the actual charge is taken when the work starts. |
| Changing or canceling | Anytime before work starts. |
| Already at the HQ and idle | Work starts immediately (same as today). |
| Manual decision | Booking, changing, and canceling are manual decisions (CR-022). Automatic bookings (§3) are not. |
### Rest and service never stack
Service doesn't wait for rest, and rest doesn't wait for service. When they coincide, they run at the same time.
| Situation | What happens |
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| Pilot arrives at the booked HQ and needs mandatory rest | Service starts and the rest runs alongside it. The pilot is free when the **later** of the two ends, never the sum. |
| Pilot is already resting at the HQ when service starts (booking made, or funds arrive) | Same: they overlap; the pilot is free when the later one ends. |
| Service ends | The pilot's duty hours reset and any remaining rest ends (CR-021), since shop times are at least as long as a rest. |
| Pilot doesn't need rest | Service runs on its own; the pilot is free when it ends, with duty hours reset (CR-021). |
### What the pilot does (Independent pilots)
1. Finishes the current trip (and any mandatory rest that falls somewhere other than the booked HQ).
2. Only considers jobs whose destination is the booked HQ, scored as usual (CR-019), so the trip in still earns money. Other orders (cargo classes, minimum profit) still apply.
3. If no such job is on the board, **deadheads straight to the HQ** (fuel rules per CR-019).
4. On arrival, the service starts automatically, overlapping any rest that's due.
5. When it's done, the booking clears and the pilot resumes normal Independent work.
### Manual pilots
A booking on a Manual pilot's ship is a reminder: the tag shows on the ship and in the dispatch sheet ("Service booked at Ceres"), and the work starts automatically when the ship is next idle at that HQ, overlapping any rest that's due. The pilot doesn't route there on their own.
### Paying for it
| Booking type | When it can be charged |
| -------------------------- | -------------------------------------------------------------------------------------- |
| Booked by the player | Whenever credits cover the cost |
| Booked automatically (§3) | Only if credits stay at or above the NPC spending floor (CR-019) after paying |
If the work can't be paid for on arrival, the ship waits at the HQ with status "Waiting for funds: Repair at Ceres", the Logbook records it, and it's a "while you were away" problem highlight (CR-023). The engine retries at each board period start and whenever credits increase. The player can cancel the booking to release the ship.
### Can't reach the HQ
If the booked HQ is out of reach from where the ship is (even with refueling), the booking shows "Can't reach [HQ] from here" and the pilot keeps working normally. The booking stays and becomes active as soon as the HQ is reachable. The player can pick a different HQ.
### Fleet sleep
While the fleet is asleep (CR-022), Independent pilots with a booking still fly to the HQ and get the work done. Service trips are allowed during sleep; afterward the pilot waits for orders like the rest of the fleet.
## 3. Auto-repair
| Rule | Detail |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Setting | Per ship: **Auto-repair on/off** and a **threshold** (10% to 90%, in steps of 5). |
| Default | **On, 40%**, for every ship, new and existing. |
| When it acts | Only while the ship is crewed by an **Independent** pilot. When the hull drops below the threshold (after an event or any other damage), a repair booking is added automatically at the nearest reachable HQ, or added to an existing booking. |
| Manual crews | If the ship's pilot is Manual, crossing the threshold shows a "Repair recommended" tag instead of booking. |
| Turning it off | No new automatic bookings; an existing automatic booking stays until the player cancels it. |
## 4. UI
- **Ship detail → Service section:** current booking (items, HQ, cost estimate, status), **Book service** (item picker, HQ picker defaulting to nearest reachable), **Change**, **Cancel**, and the auto-repair toggle and threshold slider.
- **Fleet card tag:** "Service booked: Engine Tuning 2 at Ceres", or "Repair recommended" for Manual crews below the threshold.
- **Pilot status lines:** "Heading to Ceres for service", "In the shop at Ceres (resting)", "Waiting for funds: Repair at Ceres".
- **Logbook and summaries:** bookings made automatically, service started, service finished, waiting for funds.
## 5. Rules for this change
- All rules in `implementation-plan.md` §0.1 apply: steps in order, one commit per step, each step's test must pass, `packages/sim` stays pure, all numbers live in `packages/content`.
- The booked-HQ job filter and the auto-booking trigger are pure functions in `packages/sim`.
- Save migration: every ship gets auto-repair on at 40% and no booking.
- Catch-up stays deterministic: automatic bookings, service trips, and service starts happen at the same game times however catch-up is split.
- Update `architecture.md` and `progress.md` as you go. Stop for the user's review at the end of Phase 25.
## 6. Decisions
Record these in `DECISIONS.md` and `implementation-plan.md` §0.5, using the next free numbers (labeled CR-025.D1–D7 here; note the mapping).
- **CR-025.D1 Service bookings** per §2: book from anywhere; repair before upgrade; charged when work starts.
- **CR-025.D2 Independent routing:** finish the trip, take only jobs to the booked HQ, otherwise deadhead there; resume after service.
- **CR-025.D7 No double waiting:** when rest and service coincide they run at the same time; the pilot is free when the later one ends, and service ending resets duty and ends any remaining rest.
- **CR-025.D3 Manual crews:** bookings act as reminders and start automatically when the ship is idle at the HQ.
- **CR-025.D4 Payment:** player bookings charge whenever credits cover them; automatic bookings respect the NPC spending floor; unpaid ships wait at the HQ and retry.
- **CR-025.D5 Auto-repair:** per ship, default on at 40%, active only with an Independent crew; Manual crews get a "Repair recommended" tag.
- **CR-025.D6 Sleep exception:** service trips continue while the fleet is asleep.
## 7. Steps — Phase 25
### Step 25.1 — Content and save
- Config: default auto-repair threshold (40%), allowed range (10–90%, step 5). Save: per-ship booking (items, HQ, created by player or auto) and auto-repair settings. Save migration per §5.
- **Test:** Config validates. A new ship has auto-repair on at 40% and no booking. An older save migrates as described.
### Step 25.2 — Sim: routing and triggers
- Pure functions: nearest reachable HQ for a ship; whether a job's destination matches the booking; whether the pilot should deadhead to the HQ; whether an automatic repair booking should be added after a hull change.
- **Test:** With a booking at Ceres, an Independent pilot's eligible jobs all end at Ceres; with none on the board, the action is "deadhead to Ceres". A hull drop from 45% to 35% with auto-repair on at 40% and an Independent crew adds a repair booking; with a Manual crew it doesn't; with auto-repair off it doesn't. An existing upgrade booking gets the repair added rather than a second booking.
### Step 25.3 — Engine: booking commands and service start
- Commands: book service, change booking, cancel booking, set auto-repair (all manual decisions). On arrival at the booked HQ, or when a ship becomes idle there, start the work (repair, then upgrade) through the existing shop commands; charge per §2; clear the booking when done.
- **Overlap:** if the pilot is due mandatory rest on arrival, or is already resting there, the rest and the service run at the same time; the pilot is free at the later end time, and service ending resets duty and ends any remaining rest (CR-021).
- **Test:** Booking an upgrade for an idle ship already at that HQ starts it immediately. A booked ship arriving at the HQ starts service and is charged correctly. A pilot who arrives needing mandatory rest is free at the later of the rest end and the shop end, never their sum, with zero duty hours. A pilot already resting at the HQ when service starts is likewise free at the later end time. A player booking with too few credits makes the ship wait, then starts when credits rise. An automatic booking that would take credits below the spending floor waits. Canceling releases the ship.
### Step 25.4 — Engine: Independent pilots and catch-up
- Independent decision logic uses the booking filter and deadhead rule. Service trips are allowed during fleet sleep. Unreachable HQs leave the pilot working normally with the booking flagged.
- **Test:** From the same save, catching up in one go and in pieces gives identical results, including automatic bookings, service trips, and shop times. A pilot with a booking during fleet sleep still flies to the HQ and gets serviced, then waits for orders. A booking at an unreachable HQ doesn't stop the pilot from working and activates when the HQ becomes reachable.
### Step 25.5 — Client: Service section and tags
- Build the Service section, Fleet card tags, and pilot status lines from §4.
- **Test:** Component tests: booking shows the estimate and the default nearest HQ; change and cancel call the engine; the slider saves the threshold in steps of 5; tags and status lines render for each state (heading in, in the shop, waiting for funds, can't reach, repair recommended).
### Step 25.6 — Client: Logbook and summaries
- Add service entries to the Logbook and "waiting for funds" and "can't reach" as problem highlights in the "while you were away" pop-up (CR-023).
- **Test:** Component tests: entries appear for automatic bookings, service start, and service finish; the problem highlights appear when applicable.
### Step 25.7 — Balance and regression
- Run the full suite. Extend the Phase 12 balance script: Independent fleets with events on (CR-024) and auto-repair at 40%.
- **Test:** CI is green. In the balance run, no ship stays below 25% hull for more than one trip while its crew is Independent and credits allow repairs, and the CR-019 away-income target still holds. Manual check: book an upgrade for an Independent pilot's ship far from an HQ, close the app, and confirm in the Logbook that they flew to the HQ, got the upgrade, and went back to work.
## 8. Amendments
| Where | Change |
| -------- | ------------------------------------------------------------------------------------------ |
| Plan 0.5 | Add CR-025.D1–D6. |
| CR-006 | Upgrades can be booked from anywhere and start on arrival. |
| CR-019 | Independent decision logic honors bookings. |
| CR-021 | Rest and service overlap; the pilot is free at the later end time. |
| CR-022 | Service trips are allowed during fleet sleep; booking commands are manual decisions. |
| CR-023 | Service problems become highlights. |
| CR-024 | Hull damage can trigger automatic repair bookings. |
## 9. Out of scope
Mobile repair crews or field repairs, booking at locations without an HQ, booking more than one upgrade level per visit, and anything else in `implementation-plan.md` §0.3.

View File

@ -208,6 +208,13 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
150. **Radio-call notifications** (CR-026). On iOS and Android, one local notification at each radio call's fire time on trips the player sent, with the Notifications toggle. 150. **Radio-call notifications** (CR-026). On iOS and Android, one local notification at each radio call's fire time on trips the player sent, with the Notifications toggle.
151. **Independent pilots and events** (CR-026). Independent pilots' trips get the same events, radio calls and defaults; the player can answer their calls too. 151. **Independent pilots and events** (CR-026). Independent pilots' trips get the same events, radio calls and defaults; the player can answer their calls too.
152. **Hull wear** (CR-026, the user's choice in Step 26.9). Every hull loses 0.35 condition points per game hour flown, applied when a trip lands (never below 5%), so every fleet repairs now and then; with it, repairs cost 1% of the model's price per missing point (CR-026 §4 said 0.5%; tuned in Step 26.9). 152. **Hull wear** (CR-026, the user's choice in Step 26.9). Every hull loses 0.35 condition points per game hour flown, applied when a trip lands (never below 5%), so every fleet repairs now and then; with it, repairs cost 1% of the model's price per missing point (CR-026 §4 said 0.5%; tuned in Step 26.9).
153. **Service bookings** (CR-027). From a ship's detail, anywhere, the player books service at one of their HQs (the nearest reachable by default): a repair and/or one upgrade level; repair first, shop times added; an estimate when booking, charged when the work starts; changeable or cancellable until then; started at once if the ship is already idle there.
154. **Independent routing** (CR-027). An Independent pilot with a booking finishes the trip, takes only jobs ending at the booked HQ (scored and filtered as usual), otherwise deadheads there; service starts on arrival; afterwards normal work resumes. An HQ out of reach leaves the pilot working normally, the booking flagged.
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.
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)
@ -929,6 +936,12 @@ Steps 26.1–26.10 are specified in `memory-bank/change-requests/CR-026-random-e
--- ---
## Phase 27 — Service Bookings and Auto-Repair (CR-027)
Steps 27.1–27.7 are specified in `memory-bank/change-requests/CR-027-service-bookings.md` §7 (written there as 25.1–25.7). Stop for review at the end.
---
## Definition of Done (Base Game) ## Definition of Done (Base Game)
- All step tests pass in CI. - All step tests pass in CI.

View File

@ -564,3 +564,15 @@ Spec: `memory-bank/change-requests/CR-026-random-events-hull.md` (received as "C
- [x] **Job length on the board.** Location board cards show how long the job takes in game hours beside the weight and distance ("Atlas Heavy Industries · 6 t · 0.38M km · 2.2 h"), for the fastest of the player's crewed ships that could take it, preferring one at the job's origin (`jobGameHours` in `apps/web/src/game.ts`); none is shown if no ship could. The tutorial board already showed its trip time. Tests: `apps/web/test/job-hours.test.tsx`. Screenshots: `docs/screenshots/fix-job-hours-{phone,desktop}.png`. - [x] **Job length on the board.** Location board cards show how long the job takes in game hours beside the weight and distance ("Atlas Heavy Industries · 6 t · 0.38M km · 2.2 h"), for the fastest of the player's crewed ships that could take it, preferring one at the job's origin (`jobGameHours` in `apps/web/src/game.ts`); none is shown if no ship could. The tutorial board already showed its trip time. Tests: `apps/web/test/job-hours.test.tsx`. Screenshots: `docs/screenshots/fix-job-hours-{phone,desktop}.png`.
- [x] **Refuel cost, and credits always in view.** "Refuel here first" in the dispatch sheet says what it buys and costs ("Refuel here first · 36 fu for 198 cr", from `refuelQuote` in `apps/web/src/components/Fuel.tsx`, priced with the sim's `refuel` as the engine charges), and is disabled with "Not enough credits for the fuel this trip needs." when the credits can't cover the leg. Sheets now open below the top bar (`top-14` in `apps/web/src/ui/Sheet.tsx`), so the credits stay visible, and update, while one is open. Tests: `apps/web/test/fuel-ui.test.tsx`. Screenshots: `docs/screenshots/fix-refuel-cost-{phone,desktop}.png`. - [x] **Refuel cost, and credits always in view.** "Refuel here first" in the dispatch sheet says what it buys and costs ("Refuel here first · 36 fu for 198 cr", from `refuelQuote` in `apps/web/src/components/Fuel.tsx`, priced with the sim's `refuel` as the engine charges), and is disabled with "Not enough credits for the fuel this trip needs." when the credits can't cover the leg. Sheets now open below the top bar (`top-14` in `apps/web/src/ui/Sheet.tsx`), so the credits stay visible, and update, while one is open. Tests: `apps/web/test/fuel-ui.test.tsx`. Screenshots: `docs/screenshots/fix-refuel-cost-{phone,desktop}.png`.
## Phase 27 — Service Bookings and Auto-Repair (CR-027)
Spec: `memory-bank/change-requests/CR-027-service-bookings.md` (received as "CR-025 … Phase 25"). New decisions recorded as plan §0.5.153–0.5.159.
- [x] **Step 27.1 — Content and save.** Config `autoRepair` (default 40%, 10–90% in steps of 5; the default must be a step). Save version 12: each ship has `booking` (repair, upgrade, HQ, by player or auto, created, started, waiting for funds since; null when none) and `autoRepair` (on, threshold); new ships start on at 40% with no booking; the 11 → 12 migration does the same for existing ships. Tests: `packages/content/test/config.test.ts`; `packages/schema/test/save.test.ts` (11 → 12); `packages/engine/test/trip-events.test.ts` (a bought ship).
- [ ] Step 27.2 — Sim: routing and triggers
- [ ] Step 27.3 — Engine: booking commands and service start
- [ ] Step 27.4 — Engine: Independent pilots and catch-up
- [ ] Step 27.5 — Client: Service section and tags
- [ ] Step 27.6 — Client: Logbook and summaries
- [ ] Step 27.7 — Balance and regression

View File

@ -116,6 +116,7 @@ export const gameConfig: GameConfig = {
responseWindowRealMinutes: 30, responseWindowRealMinutes: 30,
minTripFraction: 0.5, minTripFraction: 0.5,
}, },
autoRepair: { defaultThresholdPct: 40, minPct: 10, maxPct: 90, stepPct: 5 },
hull: { hull: {
floorPct: 5, floorPct: 5,
ratingReductionPerPoint: 0.1, ratingReductionPerPoint: 0.1,

View File

@ -486,6 +486,13 @@ export const gameConfigSchema = z
/** A speed-up never makes a trip shorter than this share of it. */ /** A speed-up never makes a trip shorter than this share of it. */
minTripFraction: z.number().min(0).max(1), minTripFraction: z.number().min(0).max(1),
}), }),
/** Auto-repair (decision 0.5.157): the default threshold and the choices, in percent. */
autoRepair: z.object({
defaultThresholdPct: z.number().min(0).max(100),
minPct: z.number().min(0).max(100),
maxPct: z.number().min(0).max(100),
stepPct: z.number().positive(),
}),
/** Hull condition and repairs (decision 0.5.148). */ /** Hull condition and repairs (decision 0.5.148). */
hull: z.object({ hull: z.object({
floorPct: z.number().min(0).max(100), floorPct: z.number().min(0).max(100),
@ -526,6 +533,17 @@ export const gameConfigSchema = z
path: ["fleetSleep", "optionsHours"], path: ["fleetSleep", "optionsHours"],
}, },
) )
.refine(
({ autoRepair: a }) =>
a.minPct <= a.defaultThresholdPct &&
a.defaultThresholdPct <= a.maxPct &&
(a.defaultThresholdPct - a.minPct) % a.stepPct === 0 &&
(a.maxPct - a.minPct) % a.stepPct === 0,
{
message: "The auto-repair default must be one of the steps from min to max",
path: ["autoRepair", "defaultThresholdPct"],
},
)
.refine(({ events }) => events.fireFrom < events.fireTo, { .refine(({ events }) => events.fireFrom < events.fireTo, {
message: "Events fire inside the trip, early before late", message: "Events fire inside the trip, early before late",
path: ["events", "fireFrom"], path: ["events", "fireFrom"],

View File

@ -147,3 +147,23 @@ describe("fleet sleep config (Step 24.1, decision 0.5.137)", () => {
expect(withSleep({ defaultHours: 12, optionsHours: [12] })).toBe(true); expect(withSleep({ defaultHours: 12, optionsHours: [12] })).toBe(true);
}); });
}); });
describe("auto-repair config (Step 27.1, decision 0.5.157)", () => {
const withAuto = (autoRepair: typeof gameConfig.autoRepair) =>
gameConfigSchema.safeParse({ ...gameConfig, autoRepair }).success;
it("defaults to 40%, from 10% to 90% in steps of 5", () => {
expect(gameConfig.autoRepair).toEqual({
defaultThresholdPct: 40,
minPct: 10,
maxPct: 90,
stepPct: 5,
});
});
it("rejects a default off the steps or outside the range", () => {
expect(withAuto({ defaultThresholdPct: 42, minPct: 10, maxPct: 90, stepPct: 5 })).toBe(false);
expect(withAuto({ defaultThresholdPct: 95, minPct: 10, maxPct: 90, stepPct: 5 })).toBe(false);
expect(withAuto({ defaultThresholdPct: 40, minPct: 10, maxPct: 90, stepPct: 5 })).toBe(true);
});
});

View File

@ -131,6 +131,7 @@ const validGameConfig = {
responseWindowRealMinutes: 30, responseWindowRealMinutes: 30,
minTripFraction: 0.5, minTripFraction: 0.5,
}, },
autoRepair: { defaultThresholdPct: 40, minPct: 10, maxPct: 90, stepPct: 5 },
hull: { hull: {
floorPct: 5, floorPct: 5,
ratingReductionPerPoint: 0.1, ratingReductionPerPoint: 0.1,

View File

@ -140,6 +140,8 @@ export function acceptDeal(save: SaveDocument, random: RandomSource, now: RealTi
shopRepair: false, shopRepair: false,
hullConditionPct: 100, hullConditionPct: 100,
quietTrips: content.config.events.shipCooldownTrips, quietTrips: content.config.events.shipCooldownTrips,
booking: null,
autoRepair: { on: true, thresholdPct: content.config.autoRepair.defaultThresholdPct },
fromDeal: true, fromDeal: true,
createdAt: now, createdAt: now,
lastArrivalAt: now, lastArrivalAt: now,

View File

@ -82,6 +82,8 @@ export function buyShip(
shopRepair: false, shopRepair: false,
hullConditionPct: 100, hullConditionPct: 100,
quietTrips: content.config.events.shipCooldownTrips, quietTrips: content.config.events.shipCooldownTrips,
booking: null,
autoRepair: { on: true, thresholdPct: content.config.autoRepair.defaultThresholdPct },
fromDeal: false, fromDeal: false,
createdAt: now, createdAt: now,
lastArrivalAt: now, lastArrivalAt: now,

View File

@ -316,6 +316,8 @@ export function makeOwner(
shopRepair: false, shopRepair: false,
hullConditionPct: 100, hullConditionPct: 100,
quietTrips: 2, quietTrips: 2,
booking: null,
autoRepair: { on: true, thresholdPct: 40 },
// Stands in for the deal's Courier (not a Market purchase, CR-013), with a full tank. // Stands in for the deal's Courier (not a Market purchase, CR-013), with a full tank.
fromDeal: true, fromDeal: true,
createdAt: TEST_NOW, createdAt: TEST_NOW,
@ -384,6 +386,8 @@ export function addShip(
shopRepair: false, shopRepair: false,
hullConditionPct: 100, hullConditionPct: 100,
quietTrips: 2, quietTrips: 2,
booking: null,
autoRepair: { on: true, thresholdPct: 40 },
fromDeal: false, fromDeal: false,
createdAt: TEST_NOW, createdAt: TEST_NOW,
lastArrivalAt: TEST_NOW, lastArrivalAt: TEST_NOW,

View File

@ -325,3 +325,18 @@ describe("hull wear (Step 26.9, decision 0.5.152)", () => {
expect(ship.hullConditionPct).toBeLessThan(100); expect(ship.hullConditionPct).toBeLessThan(100);
}); });
}); });
describe("service settings on new ships (Step 27.1, decision 0.5.157)", () => {
it("gives a bought ship auto-repair on at 40% and no booking", async () => {
const test = newTestApp();
const owner = await signUpOwner(test.app);
await setCredits(test.app, 100_000);
const { ship } = await test.app.call("buyShip", {
modelId: "courier",
bayId: owner.earthBayId,
});
const saved = (await readSave(test.app)).ships.find((s) => s.id === ship.id)!;
expect(saved.autoRepair).toEqual({ on: true, thresholdPct: 40 });
expect(saved.booking).toBeNull();
});
});

View File

@ -105,6 +105,26 @@ export const savedShipSchema = z.object({
hullConditionPct: z.number().min(0).max(100), hullConditionPct: z.number().min(0).max(100),
/** Trips since its last event: none until the cooldown has passed (decision 0.5.145). */ /** Trips since its last event: none until the cooldown has passed (decision 0.5.145). */
quietTrips: z.number().int().nonnegative(), quietTrips: z.number().int().nonnegative(),
/**
* Service booked at an HQ (decision 0.5.153): a repair and/or one upgrade
* level, booked by the player or by auto-repair; null when none.
*/
booking: z
.object({
repair: z.boolean(),
upgradeId: idSchema.nullable(),
/** The HQ (a location with one of the player's bays). */
hqId: idSchema,
by: z.enum(["player", "auto"]),
createdAt: timeSchema,
/** The work started in the shop; the booking clears when it ends. */
startedAt: timeSchema.nullable(),
/** At the HQ but unable to pay, since then (decision 0.5.156). */
waitingSince: timeSchema.nullable(),
})
.nullable(),
/** Auto-repair (decision 0.5.157): on or off, and the hull percent that triggers it. */
autoRepair: z.object({ on: z.boolean(), thresholdPct: z.number().min(0).max(100) }),
/** Handed over in the deal rather than bought (CR-013). */ /** Handed over in the deal rather than bought (CR-013). */
fromDeal: z.boolean(), fromDeal: z.boolean(),
createdAt: timeSchema, createdAt: timeSchema,
@ -187,7 +207,7 @@ export const savedDispatchSchema = z.object({
}); });
/** The save version this build writes. Bump it with a new migration. */ /** The save version this build writes. Bump it with a new migration. */
export const CURRENT_SAVE_VERSION = 11; export const CURRENT_SAVE_VERSION = 12;
export const saveDocumentSchema = z.object({ export const saveDocumentSchema = z.object({
saveVersion: z.literal(CURRENT_SAVE_VERSION), saveVersion: z.literal(CURRENT_SAVE_VERSION),
@ -520,6 +540,19 @@ function tripEvents(save: Doc): Doc {
}; };
} }
/** Version 11 → 12 (CR-027 §5): every ship on auto-repair at the default threshold, no booking. */
function serviceBookings(save: Doc): Doc {
const thresholdPct = content.config.autoRepair.defaultThresholdPct;
return {
...save,
ships: records(save.ships).map((ship) => ({
...ship,
booking: null,
autoRepair: { on: true, thresholdPct },
})),
};
}
export const SAVE_MIGRATIONS: readonly SaveMigration[] = [ export const SAVE_MIGRATIONS: readonly SaveMigration[] = [
{ from: 1, migrate: addFuelTanks }, { from: 1, migrate: addFuelTanks },
{ from: 2, migrate: manualRefueling }, { from: 2, migrate: manualRefueling },
@ -531,6 +564,7 @@ export const SAVE_MIGRATIONS: readonly SaveMigration[] = [
{ from: 8, migrate: fleetSleep }, { from: 8, migrate: fleetSleep },
{ from: 9, migrate: logbook }, { from: 9, migrate: logbook },
{ from: 10, migrate: tripEvents }, { from: 10, migrate: tripEvents },
{ from: 11, migrate: serviceBookings },
]; ];
export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string }; export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string };

View File

@ -45,7 +45,7 @@ export function fullSave(): SaveDocument {
seed: 42, seed: 42,
}); });
return { return {
saveVersion: 11, saveVersion: 12,
saveId: "save-1", saveId: "save-1",
lastTimeSeen: T + 3_600_000, lastTimeSeen: T + 3_600_000,
player: { player: {
@ -91,6 +91,8 @@ export function fullSave(): SaveDocument {
shopRepair: false, shopRepair: false,
hullConditionPct: 100, hullConditionPct: 100,
quietTrips: 2, quietTrips: 2,
booking: null,
autoRepair: { on: true, thresholdPct: 40 },
fromDeal: true, fromDeal: true,
createdAt: T, createdAt: T,
lastArrivalAt: T + 200_000, lastArrivalAt: T + 200_000,
@ -114,6 +116,8 @@ export function fullSave(): SaveDocument {
shopRepair: false, shopRepair: false,
hullConditionPct: 100, hullConditionPct: 100,
quietTrips: 2, quietTrips: 2,
booking: null,
autoRepair: { on: true, thresholdPct: 40 },
fromDeal: true, fromDeal: true,
createdAt: T, createdAt: T,
lastArrivalAt: T, lastArrivalAt: T,

View File

@ -11,7 +11,7 @@ import { fullSave } from "./save-fixture";
describe("save document (Step 14.2)", () => { describe("save document (Step 14.2)", () => {
it("validates a fully populated save", () => { it("validates a fully populated save", () => {
expect(saveDocumentSchema.safeParse(fullSave()).success).toBe(true); expect(saveDocumentSchema.safeParse(fullSave()).success).toBe(true);
expect(CURRENT_SAVE_VERSION).toBe(11); expect(CURRENT_SAVE_VERSION).toBe(12);
}); });
it("rejects an invalid save with a readable error naming the field", () => { it("rejects an invalid save with a readable error naming the field", () => {
@ -84,7 +84,7 @@ describe("save migration 1 → 2: fuel tanks (CR-015 §5)", () => {
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
const { save } = result; const { save } = result;
const before = fullSave(); const before = fullSave();
expect(save.saveVersion).toBe(11); expect(save.saveVersion).toBe(12);
// The Courier has Extended Fuel Tanks 1 (40 fu × 4.8) and is in flight: the base // The Courier has Extended Fuel Tanks 1 (40 fu × 4.8) and is in flight: the base
// price, 5 cr/fu since CR-018 (decision 0.5.110). // price, 5 cr/fu since CR-018 (decision 0.5.110).
expect(save.ships[0]).toMatchObject({ fuelFu: 192, fuelAvgPrice: 5, refuelPolicy: "manual" }); expect(save.ships[0]).toMatchObject({ fuelFu: 192, fuelAvgPrice: 5, refuelPolicy: "manual" });
@ -116,7 +116,7 @@ describe("save migration 2 → 3: Manual refueling (CR-018 §3)", () => {
const result = migrateSave(old); const result = migrateSave(old);
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
const { save } = result; const { save } = result;
expect(save.saveVersion).toBe(11); expect(save.saveVersion).toBe(12);
expect(save.ships.map((s) => s.refuelPolicy)).toEqual(save.ships.map(() => "manual")); expect(save.ships.map((s) => s.refuelPolicy)).toEqual(save.ships.map(() => "manual"));
expect(save.ships.map((s) => [s.fuelFu, s.fuelAvgPrice])).toEqual( expect(save.ships.map((s) => [s.fuelFu, s.fuelAvgPrice])).toEqual(
before.ships.map((s) => [s.fuelFu, s.fuelAvgPrice]), before.ships.map((s) => [s.fuelFu, s.fuelAvgPrice]),
@ -142,7 +142,7 @@ describe("save migration 3 → 4: completable boards (CR-019 §3)", () => {
for (const board of old.contractBoards) delete board.fleet; for (const board of old.contractBoards) delete board.fleet;
const result = migrateSave(old); const result = migrateSave(old);
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(11); expect(result.save.saveVersion).toBe(12);
expect(result.save.contractBoards).toEqual([]); expect(result.save.contractBoards).toEqual([]);
expect({ ...result.save, contractBoards: [] }).toEqual({ ...before, contractBoards: [] }); expect({ ...result.save, contractBoards: [] }).toEqual({ ...before, contractBoards: [] });
}); });
@ -164,7 +164,7 @@ describe("save migration 4 → 5: independent pilots (CR-021 §3)", () => {
} }
const result = migrateSave(old); const result = migrateSave(old);
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(11); expect(result.save.saveVersion).toBe(12);
expect(result.save.company).toMatchObject({ dispatchOffice: false, npcSpendingFloor: 500 }); expect(result.save.company).toMatchObject({ dispatchOffice: false, npcSpendingFloor: 500 });
for (const pilot of result.save.pilots) { for (const pilot of result.save.pilots) {
expect(pilot.mode).toBe("manual"); expect(pilot.mode).toBe("manual");
@ -191,7 +191,7 @@ describe("save migration 6 → 7: welcome cards (CR-022 §3)", () => {
delete old.player.welcomeSeen; delete old.player.welcomeSeen;
const result = migrateSave(old); const result = migrateSave(old);
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(11); expect(result.save.saveVersion).toBe(12);
expect(result.save.player.welcomeSeen).toBe(true); expect(result.save.player.welcomeSeen).toBe(true);
expect(result.save).toEqual(before); expect(result.save).toEqual(before);
}); });
@ -207,7 +207,7 @@ describe("save migration 7 → 8: shop time counts as rest (CR-023)", () => {
for (const ship of old.ships) delete ship.shopPilotId; for (const ship of old.ships) delete ship.shopPilotId;
const result = migrateSave(old); const result = migrateSave(old);
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(11); expect(result.save.saveVersion).toBe(12);
// Including the ship still in the shop: its upgrade finishes without the rest. // Including the ship still in the shop: its upgrade finishes without the rest.
expect(result.save.ships.map((s) => s.shopPilotId)).toEqual(before.ships.map(() => null)); expect(result.save.ships.map((s) => s.shopPilotId)).toEqual(before.ships.map(() => null));
expect(result.save).toEqual(before); expect(result.save).toEqual(before);
@ -225,7 +225,7 @@ describe("save migration 8 → 9: fleet sleep (CR-024 §3)", () => {
delete old.company.sleepAfterHours; delete old.company.sleepAfterHours;
const result = migrateSave(old); const result = migrateSave(old);
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(11); expect(result.save.saveVersion).toBe(12);
expect(result.save.company.lastManualDecisionAt).toBeNull(); expect(result.save.company.lastManualDecisionAt).toBeNull();
expect(result.save.company.sleepAfterHours).toBe(24); expect(result.save.company.sleepAfterHours).toBe(24);
expect(result.save).toEqual(before); expect(result.save).toEqual(before);
@ -245,7 +245,7 @@ describe("save migration 9 → 10: the logbook (CR-025 §4)", () => {
for (const ship of old.ships) delete ship.shopOpen; for (const ship of old.ships) delete ship.shopOpen;
const result = migrateSave(old); const result = migrateSave(old);
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(11); expect(result.save.saveVersion).toBe(12);
// The resolved d0: its flight (payout = credits + pilot pay + loan) and its top-up. // The resolved d0: its flight (payout = credits + pilot pay + loan) and its top-up.
expect(result.save.logbook.map((e) => e.id)).toEqual(["d0:flight", "d0:top-up"]); expect(result.save.logbook.map((e) => e.id)).toEqual(["d0:flight", "d0:top-up"]);
expect(result.save.ships.map((s) => s.shopOpen)).toEqual([false, true]); expect(result.save.ships.map((s) => s.shopOpen)).toEqual([false, true]);
@ -285,7 +285,7 @@ describe("save migration 10 → 11: events and hulls (CR-026 §6)", () => {
} }
const result = migrateSave(old); const result = migrateSave(old);
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(11); expect(result.save.saveVersion).toBe(12);
expect(result.save.ships.map((s) => s.hullConditionPct)).toEqual([100, 100]); expect(result.save.ships.map((s) => s.hullConditionPct)).toEqual([100, 100]);
expect(result.save.dispatches.map((d) => d.event)).toEqual([null, null]); expect(result.save.dispatches.map((d) => d.event)).toEqual([null, null]);
// The deal is made, so the grace trips are behind it. // The deal is made, so the grace trips are behind it.
@ -293,3 +293,25 @@ describe("save migration 10 → 11: events and hulls (CR-026 §6)", () => {
expect(result.save).toEqual(before); expect(result.save).toEqual(before);
}); });
}); });
describe("save migration 11 → 12: service bookings (CR-027 §5)", () => {
it("puts every ship on auto-repair at 40% with no booking, and changes nothing else", () => {
const before = fullSave();
const old = JSON.parse(JSON.stringify(before)) as Record<string, unknown> & {
ships: Record<string, unknown>[];
};
old.saveVersion = 11;
for (const ship of old.ships) {
delete ship.booking;
delete ship.autoRepair;
}
const result = migrateSave(old);
if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(12);
expect(result.save.ships.map((s) => [s.booking, s.autoRepair])).toEqual([
[null, { on: true, thresholdPct: 40 }],
[null, { on: true, thresholdPct: 40 }],
]);
expect(result.save).toEqual(before);
});
});