Step 23.2: duty reset on upgrade completion
CI / check (push) Has been cancelled
Details
CI / check (push) Has been cancelled
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
be0322df67
commit
d5811bfa0e
10
DECISIONS.md
10
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.
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ const pilot = {
|
|||
dutyLimit: 14,
|
||||
resting: false,
|
||||
restEndsAt: null,
|
||||
restingInShopUntil: null,
|
||||
mode: "manual",
|
||||
orders: {
|
||||
destinationIds: null,
|
||||
|
|
|
|||
Loading…
Reference in New Issue