Step 23.2: duty reset on upgrade completion
CI / check (push) Has been cancelled Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-11 18:06:21 -04:00
parent be0322df67
commit d5811bfa0e
18 changed files with 295 additions and 22 deletions

View File

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

View File

@ -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/);

View File

@ -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<Pilot, "mode" | "orders"> = {
/** 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<Pilot, "mode" | "orders" | "restingInShopUntil"> = {
mode: "manual",
restingInShopUntil: null,
orders: {
destinationIds: null,
cargoClassIds: content.cargoTypes.map((c) => c.id),

View File

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

View File

@ -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

View File

@ -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) {

View File

@ -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")

View File

@ -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,

View File

@ -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);
}

View File

@ -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,
};

View File

@ -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 });
});
});

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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 };

View File

@ -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,

View File

@ -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<string, unknown> & {
ships: Record<string, unknown>[];
};
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);
});
});

View File

@ -89,6 +89,7 @@ const pilot = {
dutyLimit: 14,
resting: false,
restEndsAt: null,
restingInShopUntil: null,
mode: "manual",
orders: {
destinationIds: null,