Step 20.3: engine: Dispatch Office, modes, orders
CI / check (push) Successful in 6m50s
Details
CI / check (push) Successful in 6m50s
Details
Commands to buy the Dispatch Office, set a pilot's mode and orders, and set the spending floor; save version 5 starts every pilot on Manual (decisions 0.5.118-0.5.120). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
382bcc74f4
commit
85e8aa4495
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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<Pilot>):
|
|||
dutyLimit: 12,
|
||||
resting: false,
|
||||
restEndsAt: null,
|
||||
...pilotExtras,
|
||||
...extra,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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<Pilot, "mode" | "orders"> = {
|
||||
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> = {}): PlayerState {
|
||||
return {
|
||||
|
|
@ -60,6 +72,7 @@ export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerS
|
|||
dutyLimit: 12,
|
||||
resting: false,
|
||||
restEndsAt: null,
|
||||
...pilotExtras,
|
||||
},
|
||||
],
|
||||
bays: [],
|
||||
|
|
@ -79,6 +92,7 @@ export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerS
|
|||
activeDispatches: [],
|
||||
summaries: [],
|
||||
goals: [],
|
||||
dispatchOffice: { owned: false, spendingFloor: 500 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>): 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,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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> = {}): PlayerState {
|
||||
|
|
|
|||
|
|
@ -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> = {}): Pilot => (
|
|||
dutyLimit: 12,
|
||||
resting: false,
|
||||
restEndsAt: null,
|
||||
...pilotExtras,
|
||||
...extra,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -74,6 +74,30 @@ export const shipSchema = z.object({
|
|||
});
|
||||
export type Ship = z.infer<typeof shipSchema>;
|
||||
|
||||
/** 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<typeof pilotOrdersSchema>;
|
||||
|
||||
export const pilotModeSchema = z.enum(["manual", "independent"]);
|
||||
export type PilotMode = z.infer<typeof pilotModeSchema>;
|
||||
|
||||
/** 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<typeof dispatchOfficeSchema>;
|
||||
|
||||
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<typeof pilotSchema>;
|
||||
|
||||
|
|
@ -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<typeof playerStateSchema>;
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ describe("route catalog", () => {
|
|||
"setRefuelPolicy",
|
||||
"orderEmergencyFuel",
|
||||
"spendSkillPoint",
|
||||
"buyDispatchOffice",
|
||||
"setPilotMode",
|
||||
"setPilotOrders",
|
||||
"setSpendingFloor",
|
||||
"acknowledgeSummaries",
|
||||
"acceptDeal",
|
||||
"exportSave",
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> & {
|
||||
company: Record<string, unknown>;
|
||||
pilots: Record<string, unknown>[];
|
||||
};
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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][] = [
|
||||
|
|
|
|||
Loading…
Reference in New Issue