diff --git a/DECISIONS.md b/DECISIONS.md index 1bb07aa..53d9f62 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1683,3 +1683,12 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Pilots run every `validateDispatch` check** on the tank as it would be after buying, so they obey the no-strand rule, skills, Priority windows and expiry exactly as the player must. - **Contract ids compare as strings** for the last tie-break; board ids are `origin:period:n`, so on one board that is slot order. - **Deadheading home uses the same fuel rules**, then `validateReposition`. + +## Step 20.3 + +- **The Dispatch Office sits on PlayerState as `dispatchOffice { owned, spendingFloor }`**, not on `Company`: the API company stays the name, credits and loan that many screens and fixtures share; the save keeps both on `company`. +- **Migrated pilots get home Earth** (the deal's bay): a save doesn't record where a pilot was hired. New hires are homed where hired; the avatar and the veteran at Earth. +- **A pilot's home must be one of the player's bays**, since "home HQ" is a bay. +- **Switching to Manual is immediate in the save**, and that already means "when the current trip ends": an Independent pilot only decides while idle, so a trip in progress finishes either way. +- **Orders are kept even in Manual**, so switching back to Independent restores them. +- **Local checks**: `pnpm -s typecheck` printed nothing while the web package had type errors (the exit code was 2). Types are now checked with `pnpm typecheck` and an "error TS" search. diff --git a/apps/web/test/about.test.tsx b/apps/web/test/about.test.tsx index 1632667..b2191ec 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: 4"); + expect(text).toContain("Save version: 5"); 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/board-refresh.test.tsx b/apps/web/test/board-refresh.test.tsx index c0dbf8a..5201f6d 100644 --- a/apps/web/test/board-refresh.test.tsx +++ b/apps/web/test/board-refresh.test.tsx @@ -1,3 +1,4 @@ +import { pilotExtras } from "./fixtures"; import { content } from "@deadhead/content"; import type { Contract, Pilot, Ship } from "@deadhead/schema"; import { boardPeriodIndex, boardPeriodMs } from "@deadhead/sim"; @@ -40,6 +41,7 @@ const rae: Pilot = { dutyLimit: 12, resting: false, restEndsAt: null, + ...pilotExtras, }; /** Too heavy for a Courier. */ const heavyJob: Contract = { diff --git a/apps/web/test/dispatch-duty.test.tsx b/apps/web/test/dispatch-duty.test.tsx index 701cc0b..317a833 100644 --- a/apps/web/test/dispatch-duty.test.tsx +++ b/apps/web/test/dispatch-duty.test.tsx @@ -1,3 +1,4 @@ +import { pilotExtras } from "./fixtures"; import { content } from "@deadhead/content"; import type { Contract, Pilot, Ship } from "@deadhead/schema"; import { routeDistance, travelTimeGameHours } from "@deadhead/sim"; @@ -38,6 +39,7 @@ const pilot = (id: string, name: string, shipId: string, extra: Partial): dutyLimit: 12, resting: false, restEndsAt: null, + ...pilotExtras, ...extra, }); diff --git a/apps/web/test/dispatch-priority.test.tsx b/apps/web/test/dispatch-priority.test.tsx index 17c2af2..169c14c 100644 --- a/apps/web/test/dispatch-priority.test.tsx +++ b/apps/web/test/dispatch-priority.test.tsx @@ -1,3 +1,4 @@ +import { pilotExtras } from "./fixtures"; import { content } from "@deadhead/content"; import type { Contract, Pilot, Ship } from "@deadhead/schema"; import { contractTripGameHours, priorityWindow } from "@deadhead/sim"; @@ -40,6 +41,7 @@ const pilot = (id: string, name: string, shipId: string): Pilot => ({ dutyLimit: 12, resting: false, restEndsAt: null, + ...pilotExtras, }); /** Priority mail to the Moon, with the Freighter-reference window the sim would set. */ diff --git a/apps/web/test/dispatch-sheet.test.tsx b/apps/web/test/dispatch-sheet.test.tsx index 90002ea..61771e9 100644 --- a/apps/web/test/dispatch-sheet.test.tsx +++ b/apps/web/test/dispatch-sheet.test.tsx @@ -1,3 +1,4 @@ +import { pilotExtras } from "./fixtures"; import { content } from "@deadhead/content"; import type { Contract, Pilot, Ship } from "@deadhead/schema"; import { @@ -48,6 +49,7 @@ const pilot = (id: string, name: string, shipId: string, isAvatar: boolean): Pil dutyLimit: 12, resting: false, restEndsAt: null, + ...pilotExtras, }); const marsJob: Contract = { diff --git a/apps/web/test/effective-stats-ui.test.tsx b/apps/web/test/effective-stats-ui.test.tsx index 35c0c81..55d8e94 100644 --- a/apps/web/test/effective-stats-ui.test.tsx +++ b/apps/web/test/effective-stats-ui.test.tsx @@ -1,3 +1,4 @@ +import { pilotExtras } from "./fixtures"; import { content } from "@deadhead/content"; import type { Contract, Pilot, Ship } from "@deadhead/schema"; import { gameToReal, realToGame, routeDistance } from "@deadhead/sim"; @@ -38,6 +39,7 @@ const pilot: Pilot = { dutyLimit: 12, resting: false, restEndsAt: null, + ...pilotExtras, }; /** 22 t: one tonne more than a base Courier's 20, within a Cargo Bay 1 Courier's 23. */ const job: Contract = { diff --git a/apps/web/test/fixtures.ts b/apps/web/test/fixtures.ts index b3af56f..29eb992 100644 --- a/apps/web/test/fixtures.ts +++ b/apps/web/test/fixtures.ts @@ -1,5 +1,5 @@ import { content } from "@deadhead/content"; -import type { PlayerState, Ship } from "@deadhead/schema"; +import type { Pilot, PlayerState, Ship } from "@deadhead/schema"; import { effectiveStats } from "@deadhead/sim"; // Plain data builders shared by component tests and the Playwright tests (no Vitest imports). @@ -37,6 +37,18 @@ export function shipExtras( }; } +/** CR-020 fields of a Manual pilot with default orders, home at Earth. Spread into Pilot fixtures. */ +export const pilotExtras: Pick = { + mode: "manual", + orders: { + destinationIds: null, + cargoClassIds: content.cargoTypes.map((c) => c.id), + takePriority: true, + minProfit: 0, + homeLocationId: "earth", + }, +}; + /** A brand-new tutorial player, as guest sign-up returns it. */ export function samplePlayerState(overrides: Partial = {}): PlayerState { return { @@ -60,6 +72,7 @@ export function samplePlayerState(overrides: Partial = {}): PlayerS dutyLimit: 12, resting: false, restEndsAt: null, + ...pilotExtras, }, ], bays: [], @@ -79,6 +92,7 @@ export function samplePlayerState(overrides: Partial = {}): PlayerS activeDispatches: [], summaries: [], goals: [], + dispatchOffice: { owned: false, spendingFloor: 500 }, ...overrides, }; } diff --git a/apps/web/test/fuel-ui.test.tsx b/apps/web/test/fuel-ui.test.tsx index aab4f35..38c8d6e 100644 --- a/apps/web/test/fuel-ui.test.tsx +++ b/apps/web/test/fuel-ui.test.tsx @@ -1,3 +1,4 @@ +import { pilotExtras } from "./fixtures"; import { content } from "@deadhead/content"; import type { Contract, Pilot, PlayerState, Ship } from "@deadhead/schema"; import { emergencyFuel, fuelPriceAt, tripFuel } from "@deadhead/sim"; @@ -45,6 +46,7 @@ const avatar: Pilot = { dutyLimit: 12, resting: false, restEndsAt: null, + ...pilotExtras, }; const moonJob: Contract = { id: "c-moon", diff --git a/apps/web/test/pilot-share.test.tsx b/apps/web/test/pilot-share.test.tsx index dce6df7..a9972c8 100644 --- a/apps/web/test/pilot-share.test.tsx +++ b/apps/web/test/pilot-share.test.tsx @@ -1,3 +1,4 @@ +import { pilotExtras } from "./fixtures"; import { content } from "@deadhead/content"; import type { Contract, Pilot, ResolutionSummary, Ship } from "@deadhead/schema"; import { pilotShare, tripFuel } from "@deadhead/sim"; @@ -37,6 +38,7 @@ const pilot = (id: string, name: string, shipId: string, isAvatar: boolean): Pil dutyLimit: 12, resting: false, restEndsAt: null, + ...pilotExtras, }); const job: Contract = { id: "c1", diff --git a/apps/web/test/pilots-duty.test.tsx b/apps/web/test/pilots-duty.test.tsx index 10557c7..1641198 100644 --- a/apps/web/test/pilots-duty.test.tsx +++ b/apps/web/test/pilots-duty.test.tsx @@ -1,3 +1,4 @@ +import { pilotExtras } from "./fixtures"; import type { Pilot, PlayerState } from "@deadhead/schema"; import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -23,6 +24,7 @@ const pilot = (overrides: Partial): Pilot => ({ dutyLimit: 12, resting: false, restEndsAt: null, + ...pilotExtras, ...overrides, }); @@ -37,6 +39,7 @@ const state = (): PlayerState => name: "Jun Sato", resting: true, restEndsAt: NOW + 25 * 60_000, + ...pilotExtras, }), ], }); diff --git a/apps/web/test/pilots.test.tsx b/apps/web/test/pilots.test.tsx index e602b9a..8dc6d46 100644 --- a/apps/web/test/pilots.test.tsx +++ b/apps/web/test/pilots.test.tsx @@ -1,3 +1,4 @@ +import { pilotExtras } from "./fixtures"; import { content } from "@deadhead/content"; import type { Pilot, PlayerState, Ship } from "@deadhead/schema"; import { screen, waitFor, within } from "@testing-library/react"; @@ -35,6 +36,7 @@ const rae: Pilot = { dutyLimit: 12, resting: false, restEndsAt: null, + ...pilotExtras, }; function pilotsState(overrides: Partial = {}): PlayerState { diff --git a/apps/web/test/rest-summary-hq.test.tsx b/apps/web/test/rest-summary-hq.test.tsx index b231d75..01e59f2 100644 --- a/apps/web/test/rest-summary-hq.test.tsx +++ b/apps/web/test/rest-summary-hq.test.tsx @@ -1,3 +1,4 @@ +import { pilotExtras } from "./fixtures"; import type { Pilot, ResolutionSummary } from "@deadhead/schema"; import { screen, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -23,6 +24,7 @@ const pilot = (id: string, name: string, extra: Partial = {}): Pilot => ( dutyLimit: 12, resting: false, restEndsAt: null, + ...pilotExtras, ...extra, }); diff --git a/apps/web/test/veteran-ui.test.tsx b/apps/web/test/veteran-ui.test.tsx index c5c430e..545f05e 100644 --- a/apps/web/test/veteran-ui.test.tsx +++ b/apps/web/test/veteran-ui.test.tsx @@ -1,3 +1,4 @@ +import { pilotExtras } from "./fixtures"; import { content } from "@deadhead/content"; import type { Pilot } from "@deadhead/schema"; import { screen, within } from "@testing-library/react"; @@ -46,6 +47,7 @@ describe("the veteran in the UI (Step 10J.5)", () => { dutyLimit: 12, resting: false, restEndsAt: null, + ...pilotExtras, }; const base = samplePlayerState({ tutorial: null, bays: [{ id: "b", locationId: "earth" }] }); renderApp( diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index cc4eaf6..2945889 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -49,6 +49,8 @@ The `routes` catalog is the list of engine commands (CR-014). Each entry has `re | `getServerTime` | `GET /api/time` | — | `ServerTime` | no | | `createGuest` | `POST /api/guest` | — | `{ playerId, state: PlayerState }` (playerId is the save id) | no | | `getPlayerState` | `GET /api/state` | — | `PlayerState` | yes | +| `buyDispatchOffice` / `setSpendingFloor` | `POST /api/company/dispatch-office`, `/api/company/spending-floor` | `{}` / `credits` | `{ company, dispatchOffice }` (CR-020) | yes | +| `setPilotMode` / `setPilotOrders` | `POST /api/pilots/:pilotId/mode`, `/orders` | `pilotId`, `mode` / `orders` | `{ pilot }` (CR-020) | yes | | `getContractBoard` | `GET /api/locations/:locationId/board` | `locationId` | `{ contracts, periodEndsAt }` (only jobs the qualifying fleet can complete, CR-019) | yes | | `getTutorialBoard` | `GET /api/tutorial/board` | — | `{ contracts, periodEndsAt }` | yes | | `createDispatch` | `POST /api/dispatches` | `{ kind: "contract", contractId, shipId, pilotId }` / `{ kind: "tutorial", contractId }` / `{ kind: "reposition", shipId, pilotId, destinationId }` | `{ dispatch }` | yes | @@ -79,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 4; 1 → 2 added fuel tanks, CR-015; 2 → 3 put every ship on Manual refueling, CR-018; 3 → 4 dropped stored boards, CR-019), `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 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-020), `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. 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 5a6b5eb..4a43083 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -464,7 +464,7 @@ Spec: `memory-bank/change-requests/CR-020-independent-pilots.md` (received as "C - [x] **Step 20.1 — Content and config.** `config.independentPilots`: `dispatchOfficePrice` 36,000 (a Lighter), `firstLookRealMinutes` 3, `turnaroundGameMinutes` 15 (15 real seconds at ×60), `emptyPeriodsBeforeHome` 3, `maxCatchUpRealDays` 7 and `defaultSpendingFloor` 500. All must be positive, and the first-look window must be shorter than the board period. Two stale quarter-tank comments from CR-015 are gone. Tests (`packages/content/test/config.test.ts`): the defaults, with the Dispatch Office at the Lighter's price; zero and negative values rejected; a first-look window as long as the board period rejected. - [x] **Step 20.2 — Sim: eligibility and job choice.** `packages/sim/src/independent.ts`: `PilotOrders` and `defaultOrders(home)`; `pastFirstLook(now)`; `legRefuel` (exactly the fuel a leg needs beyond what's aboard, rounded up to 0.001 fu, only where fuel is sold and only if credits stay at or above the spending floor); `independentOptions` (past the first-look window, not first contacts or the job the player has open, within the orders, passing `validateDispatch` with the leg's fuel bought, making at least the minimum profit). Fuel aboard is priced at the tank's average and bought fuel at the local price, less the pilot's share, then divided by trip game hours. `compareOptions`: highest profit per hour, then the shorter trip, then the lower id. `chooseAction` returns take, deadhead home (after `emptyPeriodsBeforeHome` empty periods away, when the fuel rules allow), or wait. Tests (`packages/sim/test/independent.test.ts`): first contacts never taken; nothing in the first-look window, the same job after; the open job skipped; each order (destination, class, Priority, minimum profit at the boundary); profit per hour beats total profit (Moon over Mars); fuel and share arithmetic; tie-breaks; a pilot short of credits above the floor takes the job flyable on fuel aboard; waiting, heading home after the third empty period, and not when home is out of reach. -- [ ] Step 20.3 — Engine: Dispatch Office, modes, orders +- [x] **Step 20.3 — Engine: Dispatch Office, modes, orders.** Save version 5: pilots get `mode` (manual/independent) and `orders` (destinations or null for all, cargo classes, Priority, minimum profit, home bay); the company gets `dispatchOffice` and `npcSpendingFloor`. The 4 → 5 migration puts every pilot on Manual with default orders and home at Earth, with no office and the default floor. API: `Pilot.mode` and `Pilot.orders`; `PlayerState.dispatchOffice` holds `owned` and `spendingFloor`. Commands (`packages/engine/src/game/independent.ts`): `buyDispatchOffice` (after the tutorial, once, for its price), `setPilotMode` (Independent needs the office and is never the avatar), `setPilotOrders` (checked against locations, cargo classes and the player's bays), and `setSpendingFloor`. New error codes: `dispatch-office-required`, `dispatch-office-owned`, `avatar-not-independent`, `unknown-location`, `unknown-cargo-class`. New pilots are Manual: the avatar and the veteran are homed at Earth, hired pilots where they were hired. Tests (`packages/engine/test/independent-commands.test.ts`): Independent refused before the office, allowed after, and back to Manual; the office sells once, for its price, and not in the tutorial; the avatar is refused; orders round-trip and bad ones are refused; a hired pilot's default orders and home; the spending floor; a version-4 save imports with everyone Manual and no office. `packages/schema/test/save.test.ts`: 4 → 5 as above. Web and schema fixtures gained the new fields (`pilotExtras` in `apps/web/test/fixtures.ts`). - [ ] Step 20.4 — Engine: event-queue catch-up - [ ] Step 20.5 — Engine: live play - [ ] Step 20.6 — Client: Dispatch Office and pilot controls diff --git a/packages/engine/src/game/commands.ts b/packages/engine/src/game/commands.ts index 1ab8e78..3d2e682 100644 --- a/packages/engine/src/game/commands.ts +++ b/packages/engine/src/game/commands.ts @@ -19,6 +19,7 @@ import { unassignPilot, } from "./fleet"; import { companyView, goalStates, playerState, requirePilot, requireShip } from "./state"; +import { buyDispatchOffice, setPilotMode, setPilotOrders, setSpendingFloor } from "./independent"; /** Every command that works on an existing company's save (the server's endpoints). */ export function commandTable(): CommandTable { @@ -57,6 +58,12 @@ export function commandTable(): CommandTable { restPilot: ({ save, now }, input) => ({ pilot: restPilot(save, input.pilotId, now) }), spendSkillPoint: ({ save, now }, input) => ({ pilot: spendSkillPoint(save, input, now) }), + // Independent pilots (CR-020). + buyDispatchOffice: ({ save }) => buyDispatchOffice(save), + setPilotMode: ({ save, now }, input) => ({ pilot: setPilotMode(save, input, now) }), + setPilotOrders: ({ save, now }, input) => ({ pilot: setPilotOrders(save, input, now) }), + setSpendingFloor: ({ save }, input) => setSpendingFloor(save, input.credits), + acknowledgeSummaries: ({ save, now }, input) => ({ acknowledged: acknowledge(save, input, now), }), diff --git a/packages/engine/src/game/company.ts b/packages/engine/src/game/company.ts index 89bc7fa..8eb7216 100644 --- a/packages/engine/src/game/company.ts +++ b/packages/engine/src/game/company.ts @@ -1,4 +1,5 @@ import { content, upgradeMaxLevel } from "@deadhead/content"; +import { newPilotOrders } from "./independent"; import { CURRENT_SAVE_VERSION, type Company, type SaveDocument } from "@deadhead/schema"; import type { RealTime } from "@deadhead/sim"; import type { RandomSource } from "../core"; @@ -39,6 +40,8 @@ export function startingSave( name: companyName(worldSeed), credits: content.config.startingCredits, loanBalance: 0, + dispatchOffice: false, + npcSpendingFloor: content.config.independentPilots.defaultSpendingFloor, }, ships: [], pilots: [ @@ -56,6 +59,8 @@ export function startingSave( dutyHours: 0, restEndsAt: null, createdAt: now, + mode: "manual", + orders: newPilotOrders("earth"), }, ], bays: [], @@ -145,6 +150,8 @@ export function acceptDeal(save: SaveDocument, random: RandomSource, now: RealTi dutyHours: 0, restEndsAt: null, createdAt: now, + mode: "manual", + orders: newPilotOrders(bayLocationId), }); save.company.loanBalance = content.dealLoanAmount; Object.assign(save.player, { tutorialActive: false, loanerLocationId: null, dealAccepted: true }); diff --git a/packages/engine/src/game/fleet.ts b/packages/engine/src/game/fleet.ts index 444665e..23a5529 100644 --- a/packages/engine/src/game/fleet.ts +++ b/packages/engine/src/game/fleet.ts @@ -1,4 +1,5 @@ import { content } from "@deadhead/content"; +import { newPilotOrders } from "./independent"; import type { Bay, Pilot, SaveDocument, Ship } from "@deadhead/schema"; import { effectiveStats, @@ -111,6 +112,8 @@ export function hirePilot( dutyHours: 0, restEndsAt: null, createdAt: now, + mode: "manual", + orders: newPilotOrders(locationId), }; save.pilots.push(pilot); return toApiPilot(save, pilot, now); diff --git a/packages/engine/src/game/independent.ts b/packages/engine/src/game/independent.ts new file mode 100644 index 0000000..f10b1c5 --- /dev/null +++ b/packages/engine/src/game/independent.ts @@ -0,0 +1,107 @@ +import { content } from "@deadhead/content"; +import type { + Company, + DispatchOffice, + Pilot, + PilotMode, + PilotOrders, + SaveDocument, +} from "@deadhead/schema"; +import { defaultOrders, type RealTime } from "@deadhead/sim"; +import { fail } from "./errors"; +import { + companyView, + hasBayAt, + requirePilot, + requireTutorialOver, + spend, + toApiPilot, +} from "./state"; + +/** A new pilot's orders (CR-020 §2): anything goes, home at `homeLocationId`. */ +export function newPilotOrders(homeLocationId: string): PilotOrders { + const orders = defaultOrders(homeLocationId, content); + return { ...orders, cargoClassIds: [...orders.cargoClassIds], destinationIds: null }; +} + +export function dispatchOfficeView(save: SaveDocument): DispatchOffice { + return { owned: save.company.dispatchOffice, spendingFloor: save.company.npcSpendingFloor }; +} + +/** The one-time Dispatch Office (decision 0.5.119). */ +export function buyDispatchOffice(save: SaveDocument): { + company: Company; + dispatchOffice: DispatchOffice; +} { + requireTutorialOver(save, "buying a Dispatch Office"); + if (save.company.dispatchOffice) { + throw fail("dispatch-office-owned", "You already have a Dispatch Office."); + } + spend(save, content.config.independentPilots.dispatchOfficePrice, "a Dispatch Office"); + save.company.dispatchOffice = true; + return { company: companyView(save), dispatchOffice: dispatchOfficeView(save) }; +} + +/** + * Manual or Independent (decisions 0.5.118–0.5.120). Independent needs the + * Dispatch Office and is never the avatar. Switching to Manual mid-trip takes + * effect when the trip ends: an Independent pilot only acts when idle. + */ +export function setPilotMode( + save: SaveDocument, + input: { pilotId: string; mode: PilotMode }, + now: RealTime, +): Pilot { + const pilot = requirePilot(save, input.pilotId); + if (input.mode === "independent") { + if (pilot.isAvatar) { + throw fail( + "avatar-not-independent", + "You fly your own jobs: only hired pilots can go Independent.", + ); + } + if (!save.company.dispatchOffice) { + throw fail("dispatch-office-required", "Buy a Dispatch Office first."); + } + } + pilot.mode = input.mode; + return toApiPilot(save, pilot, now); +} + +/** A pilot's orders (decision 0.5.120), checked against content and the player's bays. */ +export function setPilotOrders( + save: SaveDocument, + input: { pilotId: string; orders: PilotOrders }, + now: RealTime, +): Pilot { + const pilot = requirePilot(save, input.pilotId); + const { orders } = input; + for (const id of orders.destinationIds ?? []) { + if (!content.location(id)) throw fail("unknown-location", `No location "${id}".`); + } + for (const id of orders.cargoClassIds) { + if (!content.cargoTypes.some((c) => c.id === id)) { + throw fail("unknown-cargo-class", `No cargo class "${id}".`); + } + } + if (!hasBayAt(save, orders.homeLocationId)) { + throw fail("no-bay-at-location", "A pilot's home must be one of your bays."); + } + pilot.orders = { + destinationIds: orders.destinationIds ? [...new Set(orders.destinationIds)] : null, + cargoClassIds: [...new Set(orders.cargoClassIds)], + takePriority: orders.takePriority, + minProfit: orders.minProfit, + homeLocationId: orders.homeLocationId, + }; + return toApiPilot(save, pilot, now); +} + +/** The credits Independent pilots never spend the company below (decision 0.5.123). */ +export function setSpendingFloor( + save: SaveDocument, + credits: number, +): { company: Company; dispatchOffice: DispatchOffice } { + save.company.npcSpendingFloor = credits; + return { company: companyView(save), dispatchOffice: dispatchOfficeView(save) }; +} diff --git a/packages/engine/src/game/state.ts b/packages/engine/src/game/state.ts index 9f1e839..e3f408c 100644 --- a/packages/engine/src/game/state.ts +++ b/packages/engine/src/game/state.ts @@ -146,6 +146,8 @@ export function toApiPilot(save: SaveDocument, pilot: SavedPilot, now: RealTime) dutyLimit: dutyLimit(pilot.skills, content.config), resting, restEndsAt: resting ? pilot.restEndsAt : null, + mode: pilot.mode, + orders: pilot.orders, }; } @@ -278,5 +280,9 @@ export function playerState(save: SaveDocument, now: RealTime): PlayerState { resolvedAt: d.resolvedAt ?? d.arrivalAt, })), goals: goalStates(save), + dispatchOffice: { + owned: save.company.dispatchOffice, + spendingFloor: save.company.npcSpendingFloor, + }, }; } diff --git a/packages/engine/test/independent-commands.test.ts b/packages/engine/test/independent-commands.test.ts new file mode 100644 index 0000000..0765bf5 --- /dev/null +++ b/packages/engine/test/independent-commands.test.ts @@ -0,0 +1,155 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { encodeSave } from "../src/index"; +import { + addPilot, + edit, + getState, + readSave, + setCredits, + signUp, + signUpOwner, + useTestApp, +} from "./support"; + +const testApp = useTestApp(); +const PRICE = content.config.independentPilots.dispatchOfficePrice; + +/** An owner with a hired pilot (unassigned, at Earth). */ +async function ownerWithPilot() { + const { app } = testApp(); + const owner = await signUpOwner(app); + await addPilot(app, { id: "p1", name: "Ada", locationId: "earth" }); + return { app, owner }; +} + +describe("Dispatch Office, pilot modes and orders (Step 20.3, decisions 0.5.118–0.5.120)", () => { + it("refuses Independent before the Dispatch Office, and allows it after", async () => { + const { app } = await ownerWithPilot(); + await expect( + app.call("setPilotMode", { pilotId: "p1", mode: "independent" }), + ).rejects.toMatchObject({ code: "dispatch-office-required" }); + + await setCredits(app, PRICE + 10); + const bought = await app.call("buyDispatchOffice", {}); + expect(bought.dispatchOffice.owned).toBe(true); + expect(bought.company.credits).toBe(10); + const { pilot } = await app.call("setPilotMode", { pilotId: "p1", mode: "independent" }); + expect(pilot.mode).toBe("independent"); + expect((await getState(app)).pilots.find((p) => p.id === "p1")!.mode).toBe("independent"); + + // And back to Manual. + expect((await app.call("setPilotMode", { pilotId: "p1", mode: "manual" })).pilot.mode).toBe( + "manual", + ); + }); + + it("sells the Dispatch Office once, for its price, and not during the tutorial", async () => { + const { app } = testApp(); + await signUp(app); + await setCredits(app, PRICE); + await expect(app.call("buyDispatchOffice", {})).rejects.toMatchObject({ + code: "tutorial-active", + }); + + const owner = await ownerWithPilot(); + await setCredits(owner.app, PRICE - 1); + await expect(owner.app.call("buyDispatchOffice", {})).rejects.toMatchObject({ + code: "insufficient-funds", + }); + await setCredits(owner.app, PRICE * 2); + await owner.app.call("buyDispatchOffice", {}); + await expect(owner.app.call("buyDispatchOffice", {})).rejects.toMatchObject({ + code: "dispatch-office-owned", + }); + expect((await getState(owner.app)).company.credits).toBe(PRICE); + }); + + it("never makes the avatar Independent", async () => { + const { app, owner } = await ownerWithPilot(); + await setCredits(app, PRICE); + await app.call("buyDispatchOffice", {}); + await expect( + app.call("setPilotMode", { pilotId: owner.avatarId, mode: "independent" }), + ).rejects.toMatchObject({ code: "avatar-not-independent" }); + }); + + it("saves and loads a pilot's orders, checking them against content and bays", async () => { + const { app } = await ownerWithPilot(); + const orders = { + destinationIds: ["moon", "mars"], + cargoClassIds: ["standard", "heavy"], + takePriority: false, + minProfit: 250, + homeLocationId: "earth", + }; + expect((await app.call("setPilotOrders", { pilotId: "p1", orders })).pilot.orders).toEqual( + orders, + ); + expect((await getState(app)).pilots.find((p) => p.id === "p1")!.orders).toEqual(orders); + expect((await readSave(app)).pilots.find((p) => p.id === "p1")!.orders).toEqual(orders); + + await expect( + app.call("setPilotOrders", { + pilotId: "p1", + orders: { ...orders, destinationIds: ["pluto"] }, + }), + ).rejects.toMatchObject({ code: "unknown-location" }); + await expect( + app.call("setPilotOrders", { pilotId: "p1", orders: { ...orders, cargoClassIds: ["gas"] } }), + ).rejects.toMatchObject({ code: "unknown-cargo-class" }); + await expect( + app.call("setPilotOrders", { pilotId: "p1", orders: { ...orders, homeLocationId: "mars" } }), + ).rejects.toMatchObject({ code: "no-bay-at-location" }); + }); + + it("starts pilots on Manual with default orders, home where they were hired", async () => { + const { app } = testApp(); + const owner = await signUpOwner(app); + await edit(app, (save) => save.bays.push({ id: "b2", locationId: "moon", createdAt: 0 })); + await setCredits(app, content.config.pilotHireCost); + const { pilot } = await app.call("hirePilot", { locationId: "moon" }); + expect(pilot.mode).toBe("manual"); + expect(pilot.orders).toEqual({ + destinationIds: null, + cargoClassIds: content.cargoTypes.map((c) => c.id), + takePriority: true, + minProfit: 0, + homeLocationId: "moon", + }); + const avatar = (await getState(app)).pilots.find((p) => p.id === owner.avatarId)!; + expect(avatar.orders.homeLocationId).toBe("earth"); + }); + + it("sets the spending floor", async () => { + const { app } = await ownerWithPilot(); + expect((await getState(app)).dispatchOffice).toEqual({ + owned: false, + spendingFloor: content.config.independentPilots.defaultSpendingFloor, + }); + const result = await app.call("setSpendingFloor", { credits: 2_000 }); + expect(result.dispatchOffice.spendingFloor).toBe(2_000); + expect((await getState(app)).dispatchOffice.spendingFloor).toBe(2_000); + }); + + it("opens a save from before CR-020 with every pilot Manual and no Dispatch Office", async () => { + const { app } = await ownerWithPilot(); + const save = await readSave(app); + const old = { + ...save, + saveVersion: 4, + company: { id: save.company.id, name: save.company.name, credits: 5, loanBalance: 0 }, + pilots: save.pilots.map(({ mode: _mode, orders: _orders, ...rest }) => rest), + }; + const device = testApp().app; + const { state } = await device.call("importSave", { code: await encodeSave(old as never) }); + expect(state.dispatchOffice).toEqual({ + owned: false, + spendingFloor: content.config.independentPilots.defaultSpendingFloor, + }); + for (const pilot of state.pilots) { + expect(pilot.mode).toBe("manual"); + expect(pilot.orders.homeLocationId).toBe("earth"); + } + }); +}); diff --git a/packages/engine/test/start.test.ts b/packages/engine/test/start.test.ts index 3a4afbf..9ef97c3 100644 --- a/packages/engine/test/start.test.ts +++ b/packages/engine/test/start.test.ts @@ -51,6 +51,14 @@ describe("company creation on the device (Step 14.8)", () => { dutyLimit: content.config.dutyLimitGameHours, resting: false, restEndsAt: null, + mode: "manual", + orders: { + destinationIds: null, + cargoClassIds: content.cargoTypes.map((c) => c.id), + takePriority: true, + minProfit: 0, + homeLocationId: "earth", + }, }, ], bays: [], @@ -64,6 +72,10 @@ describe("company creation on the device (Step 14.8)", () => { activeDispatches: [], summaries: [], goals: [], + dispatchOffice: { + owned: false, + spendingFloor: content.config.independentPilots.defaultSpendingFloor, + }, }); }); diff --git a/packages/engine/test/support.ts b/packages/engine/test/support.ts index 2e60f72..b9ec678 100644 --- a/packages/engine/test/support.ts +++ b/packages/engine/test/support.ts @@ -1,4 +1,5 @@ import { content } from "@deadhead/content"; +import { newPilotOrders } from "../src/game/independent"; import { createGuestResponseSchema, playerStateSchema, @@ -398,6 +399,8 @@ export function addPilot( dutyHours: 0, restEndsAt: null, createdAt: TEST_NOW, + mode: "manual", + orders: newPilotOrders("earth"), ...pilot, }); }); diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts index 75829b0..bc7ecb0 100644 --- a/packages/schema/src/entities.ts +++ b/packages/schema/src/entities.ts @@ -74,6 +74,30 @@ export const shipSchema = z.object({ }); export type Ship = z.infer; +/** What an Independent pilot may take (decision 0.5.120). */ +export const pilotOrdersSchema = z.object({ + /** Allowed destinations; null means every discovered location. */ + destinationIds: z.array(idSchema).nullable(), + cargoClassIds: z.array(idSchema), + takePriority: z.boolean(), + /** Credits: the least profit (payout − fuel − pilot share) a job must make. */ + minProfit: z.number().int().nonnegative(), + /** A bay location the pilot heads back to after empty boards away. */ + homeLocationId: idSchema, +}); +export type PilotOrders = z.infer; + +export const pilotModeSchema = z.enum(["manual", "independent"]); +export type PilotMode = z.infer; + +/** The company's Dispatch Office (decision 0.5.119) and the pilots' spending floor (0.5.123). */ +export const dispatchOfficeSchema = z.object({ + owned: z.boolean(), + /** Credits Independent pilots never spend the company below. */ + spendingFloor: z.number().int().nonnegative(), +}); +export type DispatchOffice = z.infer; + export const pilotSchema = z.object({ id: idSchema, name: z.string().min(1), @@ -95,6 +119,9 @@ export const pilotSchema = z.object({ /** Resting pilots cannot fly until `restEndsAt` (null when not resting). */ resting: z.boolean(), restEndsAt: timeSchema.nullable(), + /** Manual, or Independent: flies jobs on their own (CR-020). The avatar is always Manual. */ + mode: pilotModeSchema, + orders: pilotOrdersSchema, }); export type Pilot = z.infer; @@ -227,5 +254,6 @@ export const playerStateSchema = z.object({ summaries: z.array(resolutionSummarySchema), /** Onboarding goals in content order; empty during the tutorial. */ goals: z.array(goalStateSchema), + dispatchOffice: dispatchOfficeSchema, }); export type PlayerState = z.infer; diff --git a/packages/schema/src/errors.ts b/packages/schema/src/errors.ts index 614b9be..ac3c95e 100644 --- a/packages/schema/src/errors.ts +++ b/packages/schema/src/errors.ts @@ -39,6 +39,11 @@ export const SERVER_ERROR_CODES = [ "no-fuel-here", "tank-full", "emergency-fuel-unavailable", + "dispatch-office-required", + "dispatch-office-owned", + "avatar-not-independent", + "unknown-location", + "unknown-cargo-class", ] as const; export type ErrorCode = diff --git a/packages/schema/src/requests.ts b/packages/schema/src/requests.ts index ad4df43..b82d4af 100644 --- a/packages/schema/src/requests.ts +++ b/packages/schema/src/requests.ts @@ -3,8 +3,11 @@ import { baySchema, companySchema, contractSchema, + dispatchOfficeSchema, dispatchSchema, idSchema, + pilotModeSchema, + pilotOrdersSchema, pilotSchema, playerStateSchema, shipSchema, @@ -76,6 +79,15 @@ export const unassignPilotRequestSchema = z.object({ pilotId: idSchema }); export const restPilotRequestSchema = z.object({ pilotId: idSchema }); export const spendSkillPointRequestSchema = z.object({ pilotId: idSchema, skillId: idSchema }); export const pilotResponseSchema = z.object({ pilot: pilotSchema }); +/** CR-020: a hired pilot's mode and orders (decision 0.5.120). */ +export const pilotModeRequestSchema = z.object({ pilotId: idSchema, mode: pilotModeSchema }); +export const pilotOrdersRequestSchema = z.object({ pilotId: idSchema, orders: pilotOrdersSchema }); +/** CR-020: the credits Independent pilots never spend below (decision 0.5.123). */ +export const spendingFloorRequestSchema = z.object({ credits: z.number().int().nonnegative() }); +export const dispatchOfficeResponseSchema = z.object({ + company: companySchema, + dispatchOffice: dispatchOfficeSchema, +}); export const acknowledgeSummariesRequestSchema = z .object({ diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index 8b8de87..61f21ce 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -12,27 +12,31 @@ import { contractBoardResponseSchema, createDispatchRequestSchema, createGuestResponseSchema, - dispatchResponseSchema, - emptyRequestSchema, - hirePilotRequestSchema, - leaseBayRequestSchema, - pilotResponseSchema, devAddCreditsRequestSchema, - devPilotRequestSchema, - devShipRequestSchema, devAdvanceClockRequestSchema, devAdvanceClockResponseSchema, - renameCompanyRequestSchema, + devPilotRequestSchema, + devShipRequestSchema, + dispatchOfficeResponseSchema, + dispatchResponseSchema, + emergencyFuelRequestSchema, + emptyRequestSchema, exportSaveResponseSchema, + hirePilotRequestSchema, importSaveRequestSchema, + leaseBayRequestSchema, + pilotModeRequestSchema, + pilotOrdersRequestSchema, + pilotResponseSchema, + refuelPolicyRequestSchema, + refuelRequestSchema, + renameCompanyRequestSchema, repayLoanRequestSchema, + restPilotRequestSchema, shipResponseSchema, spendSkillPointRequestSchema, - restPilotRequestSchema, + spendingFloorRequestSchema, startUpgradeRequestSchema, - refuelRequestSchema, - refuelPolicyRequestSchema, - emergencyFuelRequestSchema, unassignPilotRequestSchema, } from "./requests"; @@ -171,6 +175,35 @@ export const routes = { response: pilotResponseSchema, auth: true, }, + /** CR-020: Independent pilots (decisions 0.5.119–0.5.123). */ + buyDispatchOffice: { + method: "POST", + path: "/api/company/dispatch-office", + request: emptyRequestSchema, + response: dispatchOfficeResponseSchema, + auth: true, + }, + setPilotMode: { + method: "POST", + path: "/api/pilots/:pilotId/mode", + request: pilotModeRequestSchema, + response: pilotResponseSchema, + auth: true, + }, + setPilotOrders: { + method: "POST", + path: "/api/pilots/:pilotId/orders", + request: pilotOrdersRequestSchema, + response: pilotResponseSchema, + auth: true, + }, + setSpendingFloor: { + method: "POST", + path: "/api/company/spending-floor", + request: spendingFloorRequestSchema, + response: dispatchOfficeResponseSchema, + auth: true, + }, acknowledgeSummaries: { method: "POST", path: "/api/summaries/acknowledge", diff --git a/packages/schema/src/save.ts b/packages/schema/src/save.ts index 9f65500..6834d68 100644 --- a/packages/schema/src/save.ts +++ b/packages/schema/src/save.ts @@ -8,7 +8,13 @@ import { type Resolution, } from "@deadhead/sim"; import { z } from "zod"; -import { contractSchema, idSchema, timeSchema } from "./entities"; +import { + contractSchema, + idSchema, + pilotModeSchema, + pilotOrdersSchema, + timeSchema, +} from "./entities"; /** * The save document (CR-014): everything the game database held, for the one @@ -108,6 +114,9 @@ export const savedPilotSchema = z.object({ dutyHours: z.number().nonnegative(), restEndsAt: timeSchema.nullable(), createdAt: timeSchema, + /** Manual or Independent, and orders (decision 0.5.120). */ + mode: pilotModeSchema, + orders: pilotOrdersSchema, }); /** One ship-and-pilot pair's lasting capabilities (decision 0.5.115). */ @@ -149,7 +158,7 @@ export const savedDispatchSchema = z.object({ }); /** The save version this build writes. Bump it with a new migration. */ -export const CURRENT_SAVE_VERSION = 4; +export const CURRENT_SAVE_VERSION = 5; export const saveDocumentSchema = z.object({ saveVersion: z.literal(CURRENT_SAVE_VERSION), @@ -171,6 +180,10 @@ export const saveDocumentSchema = z.object({ name: z.string().min(1), credits, loanBalance: credits.nonnegative(), + /** The Dispatch Office is bought (decision 0.5.119). */ + dispatchOffice: z.boolean(), + /** Credits Independent pilots never spend below (decision 0.5.123). */ + npcSpendingFloor: z.number().int().nonnegative(), }), ships: z.array(savedShipSchema), pilots: z.array(savedPilotSchema), @@ -265,10 +278,38 @@ function dropBoards(save: Doc): Doc { return { ...save, contractBoards: [] }; } +/** + * Version 4 → 5 (CR-020 §3): every pilot Manual with default orders (home at + * Earth, where the deal's bay is), no Dispatch Office, the default spending floor. + */ +function independentPilots(save: Doc): Doc { + const orders = { + destinationIds: null, + cargoClassIds: content.cargoTypes.map((c) => c.id), + takePriority: true, + minProfit: 0, + homeLocationId: content.config.deal.bayLocationId, + }; + return { + ...save, + company: { + ...(save.company as Doc), + dispatchOffice: false, + npcSpendingFloor: content.config.independentPilots.defaultSpendingFloor, + }, + pilots: records(save.pilots).map((pilot) => ({ + ...pilot, + mode: "manual", + orders: { ...orders, cargoClassIds: [...orders.cargoClassIds] }, + })), + }; +} + export const SAVE_MIGRATIONS: readonly SaveMigration[] = [ { from: 1, migrate: addFuelTanks }, { from: 2, migrate: manualRefueling }, { from: 3, migrate: dropBoards }, + { from: 4, migrate: independentPilots }, ]; export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string }; diff --git a/packages/schema/test/routes.test.ts b/packages/schema/test/routes.test.ts index 565a7c5..7030d4a 100644 --- a/packages/schema/test/routes.test.ts +++ b/packages/schema/test/routes.test.ts @@ -25,6 +25,10 @@ describe("route catalog", () => { "setRefuelPolicy", "orderEmergencyFuel", "spendSkillPoint", + "buyDispatchOffice", + "setPilotMode", + "setPilotOrders", + "setSpendingFloor", "acknowledgeSummaries", "acceptDeal", "exportSave", diff --git a/packages/schema/test/save-fixture.ts b/packages/schema/test/save-fixture.ts index 2c4f296..4a8b5c1 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: 4, + saveVersion: 5, saveId: "save-1", lastTimeSeen: T + 3_600_000, player: { @@ -56,7 +56,14 @@ export function fullSave(): SaveDocument { dealAccepted: true, createdAt: T, }, - company: { id: "co1", name: "Deadhead Freight", credits: 12_345, loanBalance: 60_000 }, + company: { + id: "co1", + name: "Deadhead Freight", + credits: 12_345, + loanBalance: 60_000, + dispatchOffice: false, + npcSpendingFloor: 500, + }, ships: [ { id: "s1", @@ -108,6 +115,14 @@ export function fullSave(): SaveDocument { dutyHours: 4.5, restEndsAt: null, createdAt: T, + mode: "manual", + orders: { + destinationIds: null, + cargoClassIds: ["standard", "heavy", "high-value", "dangerous"], + takePriority: true, + minProfit: 0, + homeLocationId: "earth", + }, }, { id: "veteran", @@ -123,6 +138,14 @@ export function fullSave(): SaveDocument { dutyHours: 0, restEndsAt: T + 3_000_000, createdAt: T, + mode: "manual", + orders: { + destinationIds: null, + cargoClassIds: ["standard", "heavy", "high-value", "dangerous"], + takePriority: true, + minProfit: 0, + homeLocationId: "earth", + }, }, ], bays: [{ id: "b1", locationId: "earth", createdAt: T }], diff --git a/packages/schema/test/save.test.ts b/packages/schema/test/save.test.ts index d407888..6137228 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(4); + expect(CURRENT_SAVE_VERSION).toBe(5); }); 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(4); + expect(save.saveVersion).toBe(5); // 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(4); + expect(save.saveVersion).toBe(5); 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,8 +142,41 @@ 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(4); + expect(result.save.saveVersion).toBe(5); expect(result.save.contractBoards).toEqual([]); expect({ ...result.save, contractBoards: [] }).toEqual({ ...before, contractBoards: [] }); }); }); + +describe("save migration 4 → 5: independent pilots (CR-020 §3)", () => { + it("puts every pilot on Manual with default orders, with no Dispatch Office", () => { + const before = fullSave(); + const old = JSON.parse(JSON.stringify(before)) as Record & { + company: Record; + pilots: Record[]; + }; + old.saveVersion = 4; + delete old.company.dispatchOffice; + delete old.company.npcSpendingFloor; + for (const pilot of old.pilots) { + delete pilot.mode; + delete pilot.orders; + } + const result = migrateSave(old); + if (!result.ok) throw new Error(result.error); + expect(result.save.saveVersion).toBe(5); + expect(result.save.company).toMatchObject({ dispatchOffice: false, npcSpendingFloor: 500 }); + for (const pilot of result.save.pilots) { + expect(pilot.mode).toBe("manual"); + expect(pilot.orders).toEqual({ + destinationIds: null, + cargoClassIds: ["standard", "heavy", "high-value", "dangerous"], + takePriority: true, + minProfit: 0, + homeLocationId: "earth", + }); + } + // The fixture already holds these defaults, so nothing else changes. + expect(result.save).toEqual(before); + }); +}); diff --git a/packages/schema/test/schemas.test.ts b/packages/schema/test/schemas.test.ts index 52d6509..d3f3913 100644 --- a/packages/schema/test/schemas.test.ts +++ b/packages/schema/test/schemas.test.ts @@ -89,6 +89,14 @@ const pilot = { dutyLimit: 14, resting: false, restEndsAt: null, + mode: "manual", + orders: { + destinationIds: null, + cargoClassIds: ["standard"], + takePriority: true, + minProfit: 0, + homeLocationId: "earth", + }, }; const bay = { id: "b1", locationId: "earth" }; const location = { id: "mars", discovered: false }; @@ -168,6 +176,7 @@ const playerState = { activeDispatches: [dispatch], summaries: [summary], goals: [goal], + dispatchOffice: { owned: true, spendingFloor: 500 }, }; const cases: [string, z.ZodType, unknown, unknown][] = [