From d5811bfa0e283d2f3c194714196bf3c56a5c2753 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 11 Oct 2026 18:06:21 -0400 Subject: [PATCH] Step 23.2: duty reset on upgrade completion Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 10 ++ apps/web/test/about.test.tsx | 2 +- apps/web/test/fixtures.ts | 5 +- memory-bank/architecture.md | 4 +- memory-bank/progress.md | 2 +- packages/engine/src/game/catchup.ts | 31 ++++- packages/engine/src/game/commands.ts | 3 + packages/engine/src/game/company.ts | 1 + packages/engine/src/game/fleet.ts | 27 ++++- packages/engine/src/game/state.ts | 9 +- packages/engine/test/shop-rest.test.ts | 161 +++++++++++++++++++++++++ packages/engine/test/start.test.ts | 1 + packages/engine/test/support.ts | 2 + packages/schema/src/entities.ts | 5 + packages/schema/src/save.ts | 20 ++- packages/schema/test/save-fixture.ts | 4 +- packages/schema/test/save.test.ts | 29 ++++- packages/schema/test/schemas.test.ts | 1 + 18 files changed, 295 insertions(+), 22 deletions(-) create mode 100644 packages/engine/test/shop-rest.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index f98ff76..1b08304 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1786,3 +1786,13 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` ## Step 23.1 - **The content test compares in game hours**, as the CR asks: shop time is set in real minutes and rest in game hours, so the shop time is multiplied by the time compression first. + +## Step 23.2 + +- **The pilot is recorded on the ship** (`shopPilotId`), the CR's second option. An "assigned since" time on the pilot would also need the shop's start time; the ship already knows when the shop ends. +- **Leaving the ship is the only way to lose the rest.** A pilot can fly only their assigned ship, so flying another one means being unassigned first. Leaving and coming back during the upgrade also loses it: they weren't with the ship the whole time. +- **The shop's end is a catch-up event**, not a check on read, so it happens at its own time in the queue (decision 0.5.129): an Independent pilot waiting on it decides after it, and the save is the same however the time is split. At the same moment it comes before arrivals and decisions. +- **A rest that ends with the shop brings an Independent pilot's decision forward** to the shop's end plus the usual turnaround. Without it, the pilot would sit idle until the old rest end. +- **Existing saves:** the migration gives every ship `shopPilotId: null`. Recording the current pilot instead would reset duty for long-finished upgrades too, since the migration can't tell the time. +- **"Shorter than a rest doesn't count"** is checked at the start of the upgrade (shop time against the rest length), so no pilot is recorded. +- **No sim change:** the reset is bookkeeping on the save (who was aboard, when the shop ends), so it lives in the engine beside the other catch-up events; the sim's duty and rest rules are unchanged. diff --git a/apps/web/test/about.test.tsx b/apps/web/test/about.test.tsx index 028672e..7ce67ee 100644 --- a/apps/web/test/about.test.tsx +++ b/apps/web/test/about.test.tsx @@ -23,7 +23,7 @@ describe("about and debug info (Step 10K.10)", () => { expect(text).toContain("Build: test"); // 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 version: 7"); + expect(text).toContain("Save version: 8"); expect(text).toContain("Storage: unknown"); expect(text).not.toContain("Player:"); expect(text).toMatch(/Game time: \d{4}-\d{2}-\d{2}T/); diff --git a/apps/web/test/fixtures.ts b/apps/web/test/fixtures.ts index ee03cbf..c4a3b0a 100644 --- a/apps/web/test/fixtures.ts +++ b/apps/web/test/fixtures.ts @@ -37,9 +37,10 @@ export function shipExtras( }; } -/** CR-021 fields of a Manual pilot with default orders, home at Earth. Spread into Pilot fixtures. */ -export const pilotExtras: Pick = { +/** CR-021 and CR-023 fields of a Manual pilot with default orders, home at Earth, not in the shop. Spread into Pilot fixtures. */ +export const pilotExtras: Pick = { mode: "manual", + restingInShopUntil: null, orders: { destinationIds: null, cargoClassIds: content.cargoTypes.map((c) => c.id), diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index cbecefa..7193ccd 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -26,7 +26,7 @@ Living reference for how the code is organised. Update it after any save format | `ServerTime` | `realTime`, `gameTime` | | `Company` | `id`, `name`, `credits`, `loanBalance` | | `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId`, `lastArrivalAt` (latest resolved arrival, else delivery time; picks the default Contracts location), `upgrades` (`{ upgradeId: level }`), `stats` (effective: speed, capacity, tank capacity and burn rate (CR-015), overhead, handling per tonne), `inShopUntil` (null unless in the shop now), `shop` (`{ upgradeId, level, until }` of the latest job until acknowledged; CR-009) | -| `Pilot` | `id`, `name`, `isAvatar`, `isVeteran` (the deal's veteran, CR-010), `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight), `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting; CR-004) | +| `Pilot` | `id`, `name`, `isAvatar`, `isVeteran` (the deal's veteran, CR-010), `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight), `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting; CR-004), `restingInShopUntil` (waiting with their ship in the shop, which counts as rest: when the shop time ends, else null; CR-023) | | `Bay` | `id`, `locationId` | | `LocationState` | `id`, `discovered` | | `Contract` | mirrors sim `Contract`: route, `cargoTypeId` (class), `goodId`, `shipper`, `priority`, `deliveryWindowGameHours` (Priority only, else null), `tier`, `massTonnes` (to 0.1 t under 10 t), `payout`, requirements, `offeredAt`/`expiresAt`, `firstContact`, optional `scriptedId` on a scripted tutorial contract (CR-007) | @@ -81,7 +81,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) -One document per company on the device, validated on every load and save: `saveVersion` (currently 5; 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), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan), `ships` (with `upgrades` as a level map, shop fields, and `fuelFu`, `fuelAvgPrice`, `refuelPolicy` since CR-015), `pilots`, `bays`, `discoveredLocations`, `contractBoards` (stored boards), `dispatches` (sim record, status, resolution, acknowledged), `acceptedContracts`, `claimedGoals`. 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 8; 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), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan), `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`. 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. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index d04c113..b2070e9 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -511,6 +511,6 @@ Spec: `memory-bank/change-requests/CR-022-welcome-screens.md` (received as "CR-0 Spec: `memory-bank/change-requests/CR-023-shop-time-rest.md` (received as "CR-021 … Phase 21"). New decision recorded as plan §0.5.135. - [x] **Step 23.1 — Content test.** Every upgrade level's shop time, in game hours (real minutes × time compression), is at least `restGameHours`: today 10, 20 and 30 game hours against a 10-hour rest. Test: `packages/content/test/upgrades.test.ts`. -- [ ] Step 23.2 — Sim and engine: duty reset on upgrade completion +- [x] **Step 23.2 — Engine: duty reset on upgrade completion.** `startUpgrade` records the ship's pilot as `shopPilotId` (save version 8; the 7 → 8 migration sets it null, so an upgrade already under way finishes without the rest), but only when the shop time covers a full rest. `unassignPilot` clears it: a pilot can only fly another ship after leaving this one, so "assigned the whole time" and "didn't fly" are the same check. The shop's end is a new event in the catch-up queue (before arrivals and decisions at the same moment): `finishShop` sets the pilot's duty to zero, ends a rest that would run later, and brings an Independent pilot's next decision forward to the turnaround after it. The dev "finish shop" does the same at once. `Pilot.restingInShopUntil` tells the client. Tests (`packages/engine/test/shop-rest.test.ts`): a pilot near the limit is at zero duty and flies the moment the upgrade ends; one who left and flew another ship keeps their duty; one who left and came back doesn't get the reset; a longer rest ends with the shop and a shorter one runs its course; an Independent pilot comes out rested and decides after the turnaround; catch-up at once equals 30-second steps. `packages/schema/test/save.test.ts`: the 7 → 8 migration. - [ ] Step 23.3 — Client: status line - [ ] Step 23.4 — Regression diff --git a/packages/engine/src/game/catchup.ts b/packages/engine/src/game/catchup.ts index f22b41b..a4374eb 100644 --- a/packages/engine/src/game/catchup.ts +++ b/packages/engine/src/game/catchup.ts @@ -12,7 +12,7 @@ import { import type { RandomSource } from "../core"; import { locationBoard } from "./boards"; import { dispatchContract, dispatchReposition } from "./dispatch"; -import { refuelShip } from "./fleet"; +import { finishShop, refuelShip } from "./fleet"; import { scheduleDecision } from "./independent"; import { resolveArrivals } from "./resolve"; import { discoveredIds, pilotState, shipState } from "./state"; @@ -77,6 +77,18 @@ function nextDecision(save: SaveDocument, limit: RealTime): SavedPilot | null { return best; } +/** The earliest shop end by `limit` with a pilot waiting it out (decision 0.5.135). */ +function nextShopEnd(save: SaveDocument, limit: RealTime): SaveDocument["ships"][number] | null { + let best: SaveDocument["ships"][number] | null = null; + for (const ship of save.ships) { + const t = ship.inShopUntil; + if (ship.shopPilotId === null || t === null || t > limit) continue; + if (!best || t < best.inShopUntil! || (t === best.inShopUntil && ship.id < best.id)) + best = ship; + } + return best; +} + function nextArrival(save: SaveDocument, limit: RealTime): RealTime | null { let earliest: RealTime | null = null; for (const d of save.dispatches) { @@ -163,9 +175,9 @@ function decide(save: SaveDocument, pilot: SavedPilot, t: RealTime, viewed: stri } /** - * Replays time up to `until` as an event queue (decision 0.5.129): arrivals - * and Independent pilots' decision points in time order; at the same moment - * arrivals first, then pilots by id. Each arrival resolves at its own time, so + * Replays time up to `until` as an event queue (decision 0.5.129): shop ends + * (decision 0.5.135), arrivals and Independent pilots' decision points in time + * order; at the same moment shop ends first, then arrivals, then pilots by id. Each arrival resolves at its own time, so * the result doesn't depend on how the time is split. */ export function catchUp( @@ -179,9 +191,20 @@ export function catchUp( let decisions = 0; let reached = Number.NEGATIVE_INFINITY; for (let events = 0; events < maxEvents; events++) { + const shop = nextShopEnd(save, until); + const shopAt = shop?.inShopUntil ?? null; const arrival = nextArrival(save, until); const pilot = nextDecision(save, decideLimit); const decisionAt = pilot?.nextDecisionAt ?? null; + if ( + shopAt !== null && + (arrival === null || shopAt <= arrival) && + (decisionAt === null || shopAt <= decisionAt) + ) { + finishShop(save, shop!); + reached = shopAt; + continue; + } if (arrival === null && decisionAt === null) { // Pilots who would have decided after the cap pick up from `until`. if (decideLimit < until) { diff --git a/packages/engine/src/game/commands.ts b/packages/engine/src/game/commands.ts index 042c349..f4a9b8b 100644 --- a/packages/engine/src/game/commands.ts +++ b/packages/engine/src/game/commands.ts @@ -8,6 +8,7 @@ import { acknowledge, assignPilot, buyShip, + finishShop, hirePilot, leaseBay, restPilot, @@ -115,6 +116,8 @@ export function commandTable(): CommandTable { case "finish-shop": // Done now: the "out of the shop" note still shows, as for a finished job. if (ship.inShopUntil !== null && ship.inShopUntil > now) ship.inShopUntil = now; + // Its pilot comes out rested at once (decision 0.5.135). + if (ship.shopPilotId !== null) finishShop(save, ship); break; case "move": if (ship.status !== "idle") diff --git a/packages/engine/src/game/company.ts b/packages/engine/src/game/company.ts index c99410a..b72e330 100644 --- a/packages/engine/src/game/company.ts +++ b/packages/engine/src/game/company.ts @@ -126,6 +126,7 @@ export function acceptDeal(save: SaveDocument, random: RandomSource, now: RealTi inShopUntil: null, shopUpgradeId: null, shopLevel: null, + shopPilotId: null, fromDeal: true, createdAt: now, lastArrivalAt: now, diff --git a/packages/engine/src/game/fleet.ts b/packages/engine/src/game/fleet.ts index fdccc12..7761130 100644 --- a/packages/engine/src/game/fleet.ts +++ b/packages/engine/src/game/fleet.ts @@ -1,5 +1,5 @@ import { content } from "@deadhead/content"; -import { newPilotOrders } from "./independent"; +import { newPilotOrders, scheduleDecision } from "./independent"; import type { Bay, Pilot, SaveDocument, Ship } from "@deadhead/schema"; import { effectiveStats, @@ -75,6 +75,7 @@ export function buyShip( inShopUntil: null, shopUpgradeId: null, shopLevel: null, + shopPilotId: null, fromDeal: false, createdAt: now, lastArrivalAt: now, @@ -164,9 +165,29 @@ export function unassignPilot(save: SaveDocument, pilotId: string, now: RealTime throw fail("no-bay-at-location", "Pilots can only leave their ship at one of your bays."); } Object.assign(pilot, { assignedShipId: null, locationId: ship.locationId }); + // Leaving the ship gives up the shop rest (decision 0.5.135). + if (ship.shopPilotId === pilot.id) ship.shopPilotId = null; return toApiPilot(save, pilot, now); } +/** + * The shop time ends (decision 0.5.135): the pilot who stayed with the ship + * the whole time comes out rested. Duty goes to zero, and a rest that would + * end later ends now. An Independent pilot who was waiting for that rest + * decides after the usual turnaround. + */ +export function finishShop(save: SaveDocument, ship: SavedShip): void { + const at = ship.inShopUntil; + const pilot = save.pilots.find((p) => p.id === ship.shopPilotId); + ship.shopPilotId = null; + if (!pilot || at === null || pilot.assignedShipId !== ship.id) return; + pilot.dutyHours = 0; + if (pilot.restEndsAt !== null && pilot.restEndsAt > at) { + pilot.restEndsAt = at; + if (pilot.nextDecisionAt !== null) scheduleDecision(pilot, at); + } +} + /** Rests an idle pilot now, at any duty level (CR-004): duty resets and they can't fly until it ends. */ export function restPilot(save: SaveDocument, pilotId: string, now: RealTime): Pilot { requireTutorialOver(save, "resting a pilot"); @@ -226,10 +247,14 @@ export function startUpgrade( if (!check.ok) throw fail(check.reason, UPGRADE_MESSAGES[check.reason]); save.company.credits -= check.cost; ship.upgrades = { ...ship.upgrades, [upgrade.id]: check.level }; + // The pilot aboard waits it out as rest, if it lasts a full rest (decision 0.5.135). + const pilot = save.pilots.find((p) => p.assignedShipId === ship.id); + const coversRest = check.shopUntil - now >= restEndsAt(now, content.config) - now; Object.assign(ship, { inShopUntil: check.shopUntil, shopUpgradeId: upgrade.id, shopLevel: check.level, + shopPilotId: pilot && coversRest ? pilot.id : null, }); return toApiShip(save, ship, now); } diff --git a/packages/engine/src/game/state.ts b/packages/engine/src/game/state.ts index 9eb108a..360ba66 100644 --- a/packages/engine/src/game/state.ts +++ b/packages/engine/src/game/state.ts @@ -126,9 +126,10 @@ export function toApiDispatch(dispatch: SavedDispatch): Dispatch { export function toApiPilot(save: SaveDocument, pilot: SavedPilot, now: RealTime): Pilot { const resting = isResting(pilot.restEndsAt, now); const level = pilotLevel(pilot.xp, content.config); - const shipLocation = pilot.assignedShipId - ? (save.ships.find((s) => s.id === pilot.assignedShipId)?.locationId ?? null) - : null; + const ship = pilot.assignedShipId + ? save.ships.find((s) => s.id === pilot.assignedShipId) + : undefined; + const shipLocation = ship?.locationId ?? null; return { id: pilot.id, name: pilot.name, @@ -146,6 +147,8 @@ export function toApiPilot(save: SaveDocument, pilot: SavedPilot, now: RealTime) dutyLimit: dutyLimit(pilot.skills, content.config), resting, restEndsAt: resting ? pilot.restEndsAt : null, + restingInShopUntil: + ship && ship.shopPilotId === pilot.id && isInShop(ship, now) ? ship.inShopUntil : null, mode: pilot.mode, orders: pilot.orders, }; diff --git a/packages/engine/test/shop-rest.test.ts b/packages/engine/test/shop-rest.test.ts new file mode 100644 index 0000000..d3aa3d0 --- /dev/null +++ b/packages/engine/test/shop-rest.test.ts @@ -0,0 +1,161 @@ +import { content } from "@deadhead/content"; +import { MS_PER_MINUTE, upgradeShopMs } from "@deadhead/sim"; +import { describe, expect, it } from "vitest"; +import { turnaroundMs } from "../src/game/independent"; +import { + addPilot, + addShip, + edit, + newTestApp, + readSave, + setCredits, + signUpOwner, + type TestApp, +} from "./support"; + +const SHOP_MS = upgradeShopMs(1, content.config); +const LIMIT = content.config.dutyLimitGameHours; +const pilotIn = async (test: TestApp, id: string) => + (await test.app.call("getPlayerState", {})).pilots.find((p) => p.id === id)!; + +/** An owner with a Courier at Earth crewed by `p1`, one trip short of the duty limit. */ +async function tiredPilot(test: TestApp) { + await signUpOwner(test.app); + await setCredits(test.app, 100_000); + await addShip(test.app, { id: "f1", modelId: "courier", name: "Wren" }); + await addPilot(test.app, { id: "p1", name: "Ada", assignedShipId: "f1", dutyHours: LIMIT - 0.5 }); +} + +const upgrade = (test: TestApp) => + test.app.call("startUpgrade", { shipId: "f1", upgradeId: "engines" }); + +describe("shop time counts as rest (Step 23.2, decision 0.5.135)", () => { + it("brings a pilot near the duty limit out of the shop with no duty, ready to fly", async () => { + const test = newTestApp(); + await tiredPilot(test); + const startedAt = test.clock.now; + await upgrade(test); + expect((await pilotIn(test, "p1")).restingInShopUntil).toBe(startedAt + SHOP_MS); + + test.clock.now = startedAt + SHOP_MS; + const pilot = await pilotIn(test, "p1"); + expect(pilot.dutyHours).toBe(0); + expect(pilot.resting).toBe(false); + expect(pilot.restingInShopUntil).toBeNull(); + const { dispatch } = await test.app.call("createDispatch", { + kind: "reposition", + shipId: "f1", + pilotId: "p1", + destinationId: "moon", + }); + expect(dispatch.status).toBe("in-flight"); + }); + + it("keeps the duty of a pilot who left the ship and flew another one", async () => { + const test = newTestApp(); + await tiredPilot(test); + await addShip(test.app, { id: "f2", modelId: "courier", name: "Kite" }); + // Fresh enough that the Moon trip adds duty without forcing a rest. + await edit(test.app, (save) => { + save.pilots.find((p) => p.id === "p1")!.dutyHours = 2; + }); + const startedAt = test.clock.now; + await upgrade(test); + await test.app.call("unassignPilot", { pilotId: "p1" }); + expect((await pilotIn(test, "p1")).restingInShopUntil).toBeNull(); + await test.app.call("assignPilot", { pilotId: "p1", shipId: "f2" }); + await test.app.call("createDispatch", { + kind: "reposition", + shipId: "f2", + pilotId: "p1", + destinationId: "moon", + }); + + test.clock.now = startedAt + SHOP_MS + 60 * MS_PER_MINUTE; + const pilot = await pilotIn(test, "p1"); + // At the Moon with the trip added to their duty, not reset by the shop. + expect(pilot.status).toBe("idle"); + expect(pilot.dutyHours).toBeGreaterThan(2); + expect((await readSave(test.app)).ships.find((s) => s.id === "f1")!.shopPilotId).toBeNull(); + }); + + it("doesn't reset a pilot who left the ship and came back during the upgrade", async () => { + const test = newTestApp(); + await tiredPilot(test); + const startedAt = test.clock.now; + await upgrade(test); + await test.app.call("unassignPilot", { pilotId: "p1" }); + await test.app.call("assignPilot", { pilotId: "p1", shipId: "f1" }); + test.clock.now = startedAt + SHOP_MS; + expect((await pilotIn(test, "p1")).dutyHours).toBe(LIMIT - 0.5); + }); + + it("frees a pilot whose rest would have ended after the upgrade, and leaves a shorter rest alone", async () => { + const test = newTestApp(); + await tiredPilot(test); + const startedAt = test.clock.now; + await addShip(test.app, { id: "f2", modelId: "courier", name: "Kite" }); + await addPilot(test.app, { id: "p2", name: "Bo", assignedShipId: "f2" }); + await edit(test.app, (save) => { + save.pilots.find((p) => p.id === "p1")!.restEndsAt = startedAt + 3 * SHOP_MS; + save.pilots.find((p) => p.id === "p2")!.restEndsAt = startedAt + SHOP_MS / 2; + }); + await upgrade(test); + await test.app.call("startUpgrade", { shipId: "f2", upgradeId: "engines" }); + + // The short rest runs its course alongside the upgrade. + test.clock.now = startedAt + SHOP_MS / 2; + expect((await pilotIn(test, "p2")).resting).toBe(false); + expect((await pilotIn(test, "p1")).resting).toBe(true); + + test.clock.now = startedAt + SHOP_MS; + const p1 = await pilotIn(test, "p1"); + expect(p1.resting).toBe(false); + expect(p1.dutyHours).toBe(0); + }); + + it("brings an Independent pilot out rested, flying after the turnaround", async () => { + const test = newTestApp(); + await tiredPilot(test); + await test.app.call("buyDispatchOffice", {}); + await edit(test.app, (save) => { + save.pilots.find((p) => p.id === "p1")!.restEndsAt = test.clock.now + 3 * SHOP_MS; + }); + const startedAt = test.clock.now; + await upgrade(test); + await test.app.call("setPilotMode", { pilotId: "p1", mode: "independent" }); + + test.clock.now = startedAt + SHOP_MS; + await test.app.call("getPlayerState", {}); + const save = await readSave(test.app); + const p1 = save.pilots.find((p) => p.id === "p1")!; + expect(p1.dutyHours).toBe(0); + expect(p1.restEndsAt).toBe(startedAt + SHOP_MS); + expect(p1.nextDecisionAt).toBe(startedAt + SHOP_MS + turnaroundMs()); + }); + + it("gives the same save caught up at once as in live play", async () => { + const once = newTestApp(); + await tiredPilot(once); + await once.app.call("buyDispatchOffice", {}); + await upgrade(once); + await once.app.call("setPilotMode", { pilotId: "p1", mode: "independent" }); + const { code } = await once.app.call("exportSave", {}); + const live = newTestApp(); + live.clock.now = once.clock.now; + await live.app.call("importSave", { code }); + + const end = once.clock.now + 3 * 60 * MS_PER_MINUTE; + once.clock.now = end; + await once.app.call("getPlayerState", {}); + while (live.clock.now < end) { + live.clock.now = Math.min(end, live.clock.now + 30_000); + await live.app.call("getPlayerState", {}); + } + + const a = await readSave(once.app); + const b = await readSave(live.app); + expect(a.dispatches.some((d) => d.independent)).toBe(true); + expect({ ...b, lastTimeSeen: 0 }).toEqual({ ...a, lastTimeSeen: 0 }); + }); +}); diff --git a/packages/engine/test/start.test.ts b/packages/engine/test/start.test.ts index fd6b6b9..e3ddfda 100644 --- a/packages/engine/test/start.test.ts +++ b/packages/engine/test/start.test.ts @@ -51,6 +51,7 @@ describe("company creation on the device (Step 14.8)", () => { dutyLimit: content.config.dutyLimitGameHours, resting: false, restEndsAt: null, + restingInShopUntil: null, mode: "manual", orders: { destinationIds: null, diff --git a/packages/engine/test/support.ts b/packages/engine/test/support.ts index 71c2b96..5c15d54 100644 --- a/packages/engine/test/support.ts +++ b/packages/engine/test/support.ts @@ -309,6 +309,7 @@ export function makeOwner( inShopUntil: null, shopUpgradeId: null, shopLevel: null, + shopPilotId: null, // Stands in for the deal's Courier (not a Market purchase, CR-013), with a full tank. fromDeal: true, createdAt: TEST_NOW, @@ -372,6 +373,7 @@ export function addShip( inShopUntil: null, shopUpgradeId: null, shopLevel: null, + shopPilotId: null, fromDeal: false, createdAt: TEST_NOW, lastArrivalAt: TEST_NOW, diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts index 61d2d92..a9e83e6 100644 --- a/packages/schema/src/entities.ts +++ b/packages/schema/src/entities.ts @@ -119,6 +119,11 @@ export const pilotSchema = z.object({ /** Resting pilots cannot fly until `restEndsAt` (null when not resting). */ resting: z.boolean(), restEndsAt: timeSchema.nullable(), + /** + * Waiting with their ship in the shop, which counts as rest (decision + * 0.5.135): when the shop time ends; null otherwise. + */ + restingInShopUntil: timeSchema.nullable(), /** Manual, or Independent: flies jobs on their own (CR-021). The avatar is always Manual. */ mode: pilotModeSchema, orders: pilotOrdersSchema, diff --git a/packages/schema/src/save.ts b/packages/schema/src/save.ts index cd2ca05..fcfb436 100644 --- a/packages/schema/src/save.ts +++ b/packages/schema/src/save.ts @@ -86,6 +86,12 @@ export const savedShipSchema = z.object({ /** The latest shop job, kept until the player has seen it. */ shopUpgradeId: idSchema.nullable(), shopLevel: z.number().int().positive().nullable(), + /** + * The pilot aboard when the upgrade started, while they stay with the ship: + * they come out rested (decision 0.5.135). Cleared when they leave it, and + * when the shop time ends. + */ + shopPilotId: idSchema.nullable(), /** Handed over in the deal rather than bought (CR-013). */ fromDeal: z.boolean(), createdAt: timeSchema, @@ -164,7 +170,7 @@ export const savedDispatchSchema = z.object({ }); /** The save version this build writes. Bump it with a new migration. */ -export const CURRENT_SAVE_VERSION = 7; +export const CURRENT_SAVE_VERSION = 8; export const saveDocumentSchema = z.object({ saveVersion: z.literal(CURRENT_SAVE_VERSION), @@ -337,6 +343,17 @@ function welcomeSeen(save: Doc): Doc { return { ...save, player: { ...(save.player as Doc), welcomeSeen: true } }; } +/** + * Version 7 → 8 (CR-023): no ship has a pilot waiting out the shop. An upgrade + * already under way finishes as before, without the rest. + */ +function shopPilot(save: Doc): Doc { + return { + ...save, + ships: records(save.ships).map((ship) => ({ ...ship, shopPilotId: null })), + }; +} + export const SAVE_MIGRATIONS: readonly SaveMigration[] = [ { from: 1, migrate: addFuelTanks }, { from: 2, migrate: manualRefueling }, @@ -344,6 +361,7 @@ export const SAVE_MIGRATIONS: readonly SaveMigration[] = [ { from: 4, migrate: independentPilots }, { from: 5, migrate: catchUpState }, { from: 6, migrate: welcomeSeen }, + { from: 7, migrate: shopPilot }, ]; export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string }; diff --git a/packages/schema/test/save-fixture.ts b/packages/schema/test/save-fixture.ts index 346eb4d..42358fc 100644 --- a/packages/schema/test/save-fixture.ts +++ b/packages/schema/test/save-fixture.ts @@ -45,7 +45,7 @@ export function fullSave(): SaveDocument { seed: 42, }); return { - saveVersion: 7, + saveVersion: 8, saveId: "save-1", lastTimeSeen: T + 3_600_000, player: { @@ -76,6 +76,7 @@ export function fullSave(): SaveDocument { inShopUntil: null, shopUpgradeId: null, shopLevel: null, + shopPilotId: null, fromDeal: true, createdAt: T, lastArrivalAt: T + 200_000, @@ -93,6 +94,7 @@ export function fullSave(): SaveDocument { inShopUntil: T + 7_200_000, shopUpgradeId: "cargo-bay", shopLevel: 1, + shopPilotId: null, fromDeal: true, createdAt: T, lastArrivalAt: T, diff --git a/packages/schema/test/save.test.ts b/packages/schema/test/save.test.ts index 08e35b7..57a7469 100644 --- a/packages/schema/test/save.test.ts +++ b/packages/schema/test/save.test.ts @@ -11,7 +11,7 @@ import { fullSave } from "./save-fixture"; describe("save document (Step 14.2)", () => { it("validates a fully populated save", () => { expect(saveDocumentSchema.safeParse(fullSave()).success).toBe(true); - expect(CURRENT_SAVE_VERSION).toBe(7); + expect(CURRENT_SAVE_VERSION).toBe(8); }); 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); const { save } = result; const before = fullSave(); - expect(save.saveVersion).toBe(7); + expect(save.saveVersion).toBe(8); // 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). 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); if (!result.ok) throw new Error(result.error); const { save } = result; - expect(save.saveVersion).toBe(7); + expect(save.saveVersion).toBe(8); expect(save.ships.map((s) => s.refuelPolicy)).toEqual(save.ships.map(() => "manual")); expect(save.ships.map((s) => [s.fuelFu, s.fuelAvgPrice])).toEqual( 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; const result = migrateSave(old); if (!result.ok) throw new Error(result.error); - expect(result.save.saveVersion).toBe(7); + expect(result.save.saveVersion).toBe(8); expect(result.save.contractBoards).toEqual([]); 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); if (!result.ok) throw new Error(result.error); - expect(result.save.saveVersion).toBe(7); + expect(result.save.saveVersion).toBe(8); expect(result.save.company).toMatchObject({ dispatchOffice: false, npcSpendingFloor: 500 }); for (const pilot of result.save.pilots) { expect(pilot.mode).toBe("manual"); @@ -191,8 +191,25 @@ describe("save migration 6 → 7: welcome cards (CR-022 §3)", () => { delete old.player.welcomeSeen; const result = migrateSave(old); if (!result.ok) throw new Error(result.error); - expect(result.save.saveVersion).toBe(7); + expect(result.save.saveVersion).toBe(8); expect(result.save.player.welcomeSeen).toBe(true); expect(result.save).toEqual(before); }); }); + +describe("save migration 7 → 8: shop time counts as rest (CR-023)", () => { + it("gives no ship a pilot waiting out the shop, and changes nothing else", () => { + const before = fullSave(); + const old = JSON.parse(JSON.stringify(before)) as Record & { + ships: Record[]; + }; + old.saveVersion = 7; + for (const ship of old.ships) delete ship.shopPilotId; + const result = migrateSave(old); + if (!result.ok) throw new Error(result.error); + expect(result.save.saveVersion).toBe(8); + // 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).toEqual(before); + }); +}); diff --git a/packages/schema/test/schemas.test.ts b/packages/schema/test/schemas.test.ts index 3eb0f72..64ad63e 100644 --- a/packages/schema/test/schemas.test.ts +++ b/packages/schema/test/schemas.test.ts @@ -89,6 +89,7 @@ const pilot = { dutyLimit: 14, resting: false, restEndsAt: null, + restingInShopUntil: null, mode: "manual", orders: { destinationIds: null,