Renumber Independent Pilots to CR-021, Phase 21
CI / check (push) Successful in 6m32s Details

CR-020 and Phase 20 went to the Bridge dashboard, recorded first in
another session. Independent Pilots is CR-021 (decisions 0.5.122-0.5.130);
its Steps 20.1-20.5 commits are Steps 21.1-21.5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 23:36:46 -04:00
parent 42a7be028e
commit 32603d5d7e
27 changed files with 108 additions and 103 deletions

View File

@ -1665,17 +1665,17 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The Dashboard drops its Credits and Game date tiles** (user request): both are always in the header's chips. Ships idle and Ships in flight stay, side by side.
## CR-020 recorded (Independent Pilots)
## CR-021 recorded (Independent Pilots)
- **Numbering:** received as "CR-019 — Independent Pilots" for "Phase 19"; stored as **CR-020**, built as **Phase 20** (its 19.n = 20.n). Its decisions CR-019.D1–D9 are **0.5.118–0.5.126**. Its references: CR-005 → CR-007, CR-007 → CR-010, CR-011 → CR-014 (its 10K.6 → Step 14.6), CR-014 → CR-015, CR-016 → CR-017, CR-017 → CR-018, CR-018 → CR-019; CR-001 and CR-004 are unchanged.
- **Numbering:** received as "CR-019 — Independent Pilots" for "Phase 19"; stored as **CR-021**, built as **Phase 21** (its 19.n = 21.n). Its decisions CR-019.D1–D9 are **0.5.126–0.5.130**. Its references: CR-005 → CR-007, CR-007 → CR-010, CR-011 → CR-014 (its 10K.6 → Step 14.6), CR-014 → CR-015, CR-016 → CR-017, CR-017 → CR-018, CR-018 → CR-019; CR-001 and CR-004 are unchanged.
- **Plan 0.3:** automation/standing routes is struck from the deferred list; standing orders, pilot traits, decision events and a hiring market stay deferred.
## Step 20.1
## Step 21.1
- **The settings live in one `independentPilots` group** in GameConfig.
- **The Dispatch Office costs exactly a Lighter (36,000 cr)**, the CR's "start around the price of a Lighter", until Step 20.8's balance pass says otherwise.
- **The Dispatch Office costs exactly a Lighter (36,000 cr)**, the CR's "start around the price of a Lighter", until Step 21.8's balance pass says otherwise.
## Step 20.2
## Step 21.2
- **"Stay above the floor" is `credits − fuel cost ≥ floor`**: a purchase may leave exactly the floor.
- **A pilot buys exactly the leg's shortfall**, rounded up to 0.001 fu so the tank never ends a hair short. Where the origin sells no fuel, only jobs flyable on what's aboard count.
@ -1684,7 +1684,7 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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
## Step 21.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.
@ -1693,7 +1693,7 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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.
## Step 20.4
## Step 21.4
- **Decision points are stored, not derived**: each pilot keeps `nextDecisionAt`. A landing sets it to a turnaround after the arrival, or after the rest it triggers; setting a pilot Independent sets it to a turnaround from then. Rest, the shop and the first-look window each push it later; a wait sets it to the next board period's first-look end. Shop endings and rest endings need no events of their own: the decision point checks them.
- **Board periods matter only to waiting pilots**, so they appear as those pilots' next decision points rather than as separate events.
@ -1704,7 +1704,12 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **A refused pilot action**, which shouldn't happen because the sim chose by the same rules, leaves the pilot waiting for the next board period instead of stopping the replay.
- **Chunks are 500 events**, each its own save, so a week's catch-up never holds the screen for long.
## Step 20.5
## Step 21.5
- **"Viewed" means the dispatch sheet is open**: it's the contract's detail view. Browsing the board isn't viewing one contract.
- **The open contract is held in memory only** (`GameEngine.setViewedContract`). It never reaches the save, and catch-up after a reload has none, as the CR's "live play only" says.
## CR-021 renumbered (was CR-020)
- **Two sessions recorded a CR-020 the same evening**: the Bridge dashboard and HQs tab (recorded 22:19, built as Phase 20, decisions 0.5.118–0.5.121), and Independent Pilots (recorded 22:59 in another session working in the same checkout). The first one keeps CR-020. Independent Pilots becomes **CR-021 / Phase 21**, decisions **0.5.122–0.5.130**. Its first five steps had already been committed and pushed as "Step 20.1"–"Step 20.5"; they are Steps 21.1–21.5 in the plan, progress and here, and the code and test comments now say CR-021. History isn't rewritten.
- **"The HQ screen" in CR-021 is the Bridge** (the old HQ tab, now the Dashboard). The new HQs tab is information only (decision 0.5.121), so the Dispatch Office is bought on the Bridge.

View File

@ -23,7 +23,7 @@ export interface ApiClient {
notificationPrompt?(): Promise<void>;
/**
* The contract the player has open in the dispatch sheet, or null (decision
* 0.5.122): Independent pilots leave it alone. Missing means no pilots to tell.
* 0.5.126): Independent pilots leave it alone. Missing means no pilots to tell.
*/
setViewedContract?(contractId: string | null): void;
}

View File

@ -294,7 +294,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
/** Choose a ship and pilot for a contract, see the numbers, and confirm (Step 9.5). */
export function DispatchSheet({ contract, onClose }: { contract: Contract; onClose: () => void }) {
const api = useApi();
// While it's open, Independent pilots leave this contract to the player (decision 0.5.122).
// While it's open, Independent pilots leave this contract to the player (decision 0.5.126).
useEffect(() => {
api.setViewedContract?.(contract.id);
return () => api.setViewedContract?.(null);

View File

@ -37,7 +37,7 @@ export function shipExtras(
};
}
/** CR-020 fields of a Manual pilot with default orders, home at Earth. Spread into Pilot fixtures. */
/** CR-021 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: {

View File

@ -24,7 +24,7 @@ const job: Contract = {
firstContact: false,
};
describe("the open contract (Step 20.5, decision 0.5.122)", () => {
describe("the open contract (Step 21.5, decision 0.5.126)", () => {
it("tells the engine which contract the dispatch sheet has open, and when it closes", async () => {
const setViewedContract = vi.fn();
const api = Object.assign(

View File

@ -49,8 +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 |
| `buyDispatchOffice` / `setSpendingFloor` | `POST /api/company/dispatch-office`, `/api/company/spending-floor` | `{}` / `credits` | `{ company, dispatchOffice }` (CR-021) | yes |
| `setPilotMode` / `setPilotOrders` | `POST /api/pilots/:pilotId/mode`, `/orders` | `pilotId`, `mode` / `orders` | `{ pilot }` (CR-021) | 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 |
@ -81,7 +81,7 @@ The `routes` catalog is the list of engine commands (CR-014). Each entry has `re
## Save document (`packages/schema/src/save.ts`, CR-014)
One document per company on the device, validated on every load and save: `saveVersion` (currently 5; 1 → 2 added fuel tanks, CR-015; 2 → 3 put every ship on Manual refueling, CR-018; 3 → 4 dropped stored boards, CR-019; 4 → 5 added pilot `mode` and `orders` and the company's `dispatchOffice` and `npcSpendingFloor`, CR-020; 5 → 6 added pilot `nextDecisionAt` and `emptyPeriods` and dispatch `independent`, 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.
One document per company on the device, validated on every load and save: `saveVersion` (currently 5; 1 → 2 added fuel tanks, CR-015; 2 → 3 put every ship on Manual refueling, CR-018; 3 → 4 dropped stored boards, CR-019; 4 → 5 added pilot `mode` and `orders` and the company's `dispatchOffice` and `npcSpendingFloor`, CR-021; 5 → 6 added pilot `nextDecisionAt` and `emptyPeriods` and dispatch `independent`, CR-021), `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.
@ -139,6 +139,6 @@ Since CR-014 the client runs the game itself: `src/platform/game.ts` (storage, d
On the device the client keeps the save (IndexedDB, or files on native) and, in local storage, device preferences, seen board ids and, in developer builds, the developer clock offset. The game works the same with or without a network.
### Independent pilots and catch-up (CR-020)
### Independent pilots and catch-up (CR-021)
`packages/engine/src/game/catchup.ts`: `catchUp(save, until, { decideUntil, maxEvents, viewedContractId })` replays time as an event queue. It takes the next in-flight arrival or the next Independent pilot's `nextDecisionAt`, in time order: arrivals first at the same moment, then pilots by id. Each arrival resolves at its own time. A decision point checks rest, the shop and the first-look window (each pushes `nextDecisionAt` later), then asks the sim's `chooseAction` about the pilot's board. It acts through the normal `refuelShip`, `dispatchContract` and `dispatchReposition`, with ids made from the pilot and the moment (`ind-…`), so the same time always gives the same save. Waiting schedules the next board period's first-look end and counts empty periods away from home. A trip's landing schedules the next decision a turnaround after the arrival, or after the rest it triggers. Decisions stop `maxCatchUpRealDays` after the save's `lastTimeSeen` (`decideUntil`), and later ones resume from `until`. Every command runs `catchUp` to its `now` before it acts. `GameEngine.resolveDue(since, { chunkEvents, onProgress })` runs the loop's pass in chunks (500 events, one save each), with `onProgress(0…1)` in between. `GameEngine.setViewedContract(id)` tells pilots which contract the player has open. Dispatches an Independent pilot chose are flagged `independent`, and no notification is scheduled for them.

View File

@ -1,6 +1,6 @@
# Change Request CR-020 — Independent Pilots
# Change Request CR-021 — Independent Pilots
> **Numbering note (added when recorded):** this request arrived titled "CR-019 — Independent Pilots" for "Phase 19". CR-019 (completable boards) and Phase 19 already existed, so it is stored as **CR-020** and done as **Phase 20**: every "19.n" below means **20.n**. Its references map to this repo as: "Phase 13 and CR-001 through CR-018" = Phase 13 and **CR-001 to CR-019**; "CR-001" (goals, Step 10A.5) and "CR-004" (pilot rest) are the same numbers; "CR-005 cargo goods" = **CR-007**; "CR-007 veteran pilot" (profit-share pay) = **CR-010**; "CR-011 local-first" = **CR-014**, and its "10K.6" resolution loop is **Step 14.6**; "CR-014 fixed map and fuel tanks" = **CR-015**; "CR-016" (the Pilots tab badge) = **CR-017**; "CR-017 fuel tuning" = **CR-018**; "CR-018 completable jobs" = **CR-019**. Its new decisions CR-019.D1–D9 are recorded as **0.5.118 Owner-operator, 0.5.119 Dispatch Office, 0.5.120 Modes and orders, 0.5.121 Job choice, 0.5.122 Player priority, 0.5.123 Fuel for pilots, 0.5.124 Idle behavior, 0.5.125 Deterministic catch-up, 0.5.126 No independent-trip notifications**.
> **Numbering note (added when recorded):** this request arrived titled "CR-019 — Independent Pilots" for "Phase 19". CR-019 (completable boards) and Phase 19 already existed, and CR-020 / Phase 20 went to the Bridge dashboard and HQs tab (recorded first, in another session), so it is stored as **CR-021** and done as **Phase 21**: every "19.n" below means **21.n**. (It was first recorded here as CR-020; its first five steps were committed as "Step 20.1"–"Step 20.5" before the clash was noticed, and are Steps 21.1–21.5.) Its references map to this repo as: "Phase 13 and CR-001 through CR-018" = Phase 13 and **CR-001 to CR-019**; "CR-001" (goals, Step 10A.5) and "CR-004" (pilot rest) are the same numbers; "CR-005 cargo goods" = **CR-007**; "CR-007 veteran pilot" (profit-share pay) = **CR-010**; "CR-011 local-first" = **CR-014**, and its "10K.6" resolution loop is **Step 14.6**; "CR-014 fixed map and fuel tanks" = **CR-015**; "CR-016" (the Pilots tab badge) = **CR-017**; "CR-017 fuel tuning" = **CR-018**; "CR-018 completable jobs" = **CR-019**. Its new decisions CR-019.D1–D9 are recorded as **0.5.122 Owner-operator, 0.5.123 Dispatch Office, 0.5.124 Modes and orders, 0.5.125 Job choice, 0.5.126 Player priority, 0.5.127 Fuel for pilots, 0.5.128 Idle behavior, 0.5.129 Deterministic catch-up, 0.5.130 No independent-trip notifications** (0.5.118–0.5.121 are the Bridge CR-020's).
>
> **On recording:** "HQ" means a player bay (plan 0.5 bays); the HQ screen is the app's HQ tab. "Game minutes" convert to real time by `timeCompression` (60 in this build), so the default 15-game-minute turnaround is 15 real seconds.

View File

@ -36,7 +36,7 @@ This plan covers the **base game only**: the core loop of taking contracts, disp
### 0.3 Explicitly deferred (do NOT build)
Random events, reputation, fuel price drift and depot shortages *(CR-015: fixed per-location prices are in, decision 0.5.96)*, fuel storage at HQ bays, throttle setting, multi-leg routes, recurring bay rent, pilot salaries over time, pilot poaching, ~~automation/standing routes~~ *(CR-020: Independent pilots are in; standing orders, pilot traits, decision events and a hiring market stay deferred)*, interstellar, push notifications, Capacitor builds, Hardened landing class, surface Io and Venus's surface *(CR-016 added Venus Cloud City and Io Sulfur Works, which need no Hardened ships)*, asteroid-belt hazards, mining claims and belt events *(CR-016's belt is visual only)*, Fragile/Urgent/Fuel-efficiency skills, social features, monetization, account linking (email, Apple, Google sign-in) *(CR-011)*.
Random events, reputation, fuel price drift and depot shortages *(CR-015: fixed per-location prices are in, decision 0.5.96)*, fuel storage at HQ bays, throttle setting, multi-leg routes, recurring bay rent, pilot salaries over time, pilot poaching, ~~automation/standing routes~~ *(CR-021: Independent pilots are in; standing orders, pilot traits, decision events and a hiring market stay deferred)*, interstellar, push notifications, Capacitor builds, Hardened landing class, surface Io and Venus's surface *(CR-016 added Venus Cloud City and Io Sulfur Works, which need no Hardened ships)*, asteroid-belt hazards, mining claims and belt events *(CR-016's belt is visual only)*, Fragile/Urgent/Fuel-efficiency skills, social features, monetization, account linking (email, Apple, Google sign-in) *(CR-011)*.
### 0.4 Glossary
@ -173,19 +173,19 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
115. **Qualifying fleet** (CR-019). Owned ships at the location, or in flight there and arriving before the board period ends, each with its assigned pilot, or with any unassigned pilot at the location when the player has a bay there; the whole fleet when no pair qualifies.
116. **Permanent checks only** (CR-019). Completable means passing cargo fit (effective capacity), landing, range (full-tank reach, or the fuel on board where the origin sells none), the no-strand rule, the Priority window and the cargo and Long Haul skills. Rest, the shop, fuel on board and credits don't count.
117. **Mid-period changes** (CR-019). A stored board keeps the fleet snapshot it was generated with; reads hide jobs the current qualifying fleet can no longer complete; new capability shows at the next board period. Replaces 0.5.53's weight warning.
118. **Owner-operator** (CR-020). The avatar is never autonomous. Hired pilots never buy, upgrade or spend skill points; they do earn XP.
119. **Dispatch Office** (CR-020). A one-time company purchase, required before any pilot can be Independent.
120. **Modes and orders** (CR-020). A hired pilot is Manual (default) or Independent; Independent pilots follow per-pilot orders: allowed destinations, allowed cargo classes, Priority jobs or not, minimum profit per job, and a home HQ (the bay where they were hired, or Earth).
121. **Job choice** (CR-020). An Independent pilot takes the eligible job on the board where they are with the highest profit per game hour, (payout − fuel cost − pilot share) ÷ trip time; ties go to the shorter trip, then the lower contract id.
122. **Player priority** (CR-020). Pilots don't take jobs in the first-look window (default 3 real minutes into each board period), first-contact jobs, or the job the player has open.
123. **Fuel for pilots** (CR-020). A pilot buys only the fuel the chosen leg needs, and only while company credits stay above the NPC spending floor (default 500 cr); never emergency fuel.
124. **Idle behavior** (CR-020). With nothing to take, a pilot waits for the next board period; after 3 empty periods in a row away from their home HQ they deadhead home when they can.
125. **Deterministic catch-up** (CR-020). On open or resume the engine replays events since the last saved time in time order (ties by pilot id), at most 7 days; the result doesn't depend on how the time is split.
126. **No independent-trip notifications** (CR-020). Independent trips are decided on the device as time is replayed, so none are scheduled as notifications.
118. **Bridge** (CR-020). The first tab (`/`) is the Dashboard, labelled "Bridge"; it replaces the HQ screen and keeps its company stats, ships on jobs, rest, shop, goals, loan and tutorial panels.
119. **Your ship** (CR-020). The Dashboard leads with the avatar's assigned ship (the loaner during the tutorial): status, trip and cargo, fuel with the low-fuel warning, and the avatar's duty or rest.
120. **Alerts** (CR-020). An Alerts panel, empty for now, where in-flight alerts and news will appear.
121. **HQs tab** (CR-020). A new tab lists the player's HQs with information only (location, fuel price, ships and unassigned pilots there, what an HQ allows); leasing stays on Places.
122. **Owner-operator** (CR-021). The avatar is never autonomous. Hired pilots never buy, upgrade or spend skill points; they do earn XP.
123. **Dispatch Office** (CR-021). A one-time company purchase, required before any pilot can be Independent.
124. **Modes and orders** (CR-021). A hired pilot is Manual (default) or Independent; Independent pilots follow per-pilot orders: allowed destinations, allowed cargo classes, Priority jobs or not, minimum profit per job, and a home HQ (the bay where they were hired, or Earth).
125. **Job choice** (CR-021). An Independent pilot takes the eligible job on the board where they are with the highest profit per game hour, (payout − fuel cost − pilot share) ÷ trip time; ties go to the shorter trip, then the lower contract id.
126. **Player priority** (CR-021). Pilots don't take jobs in the first-look window (default 3 real minutes into each board period), first-contact jobs, or the job the player has open.
127. **Fuel for pilots** (CR-021). A pilot buys only the fuel the chosen leg needs, and only while company credits stay above the NPC spending floor (default 500 cr); never emergency fuel.
128. **Idle behavior** (CR-021). With nothing to take, a pilot waits for the next board period; after 3 empty periods in a row away from their home HQ they deadhead home when they can.
129. **Deterministic catch-up** (CR-021). On open or resume the engine replays events since the last saved time in time order (ties by pilot id), at most 7 days; the result doesn't depend on how the time is split.
130. **No independent-trip notifications** (CR-021). Independent trips are decided on the device as time is replayed, so none are scheduled as notifications.
### 0.6 Translating change requests written for the server (CR-014 §7)
@ -866,9 +866,9 @@ Steps 20.1–20.4 are specified in `memory-bank/change-requests/CR-020-bridge-da
---
## Phase 20 — Independent Pilots (CR-020)
## Phase 21 — Independent Pilots (CR-021)
Steps 20.1–20.9 are specified in `memory-bank/change-requests/CR-020-independent-pilots.md` §5 (written there as 19.1–19.9), with its §6 amendments. Stop for review at the end.
Steps 21.1–21.9 are specified in `memory-bank/change-requests/CR-021-independent-pilots.md` §5 (written there as 19.1–19.9; Steps 21.1–21.5 were committed as "Step 20.1"–"Step 20.5"), with its §6 amendments. Stop for review at the end.
---

View File

@ -458,16 +458,16 @@ Spec: `memory-bank/change-requests/CR-020-bridge-dashboard.md`. New decisions re
**Phase 20 is complete; stopped for review.**
## Phase 20 — Independent Pilots (CR-020)
## Phase 21 — Independent Pilots (CR-021)
Spec: `memory-bank/change-requests/CR-020-independent-pilots.md` (received as "CR-019 … Phase 19"). New decisions recorded as plan §0.5.118–0.5.126.
Spec: `memory-bank/change-requests/CR-021-independent-pilots.md` (received as "CR-019 … Phase 19"; first recorded here as CR-021 until the clash with the Bridge CR-021 was noticed). New decisions recorded as plan §0.5.126–0.5.130. Steps 21.1–21.5 were committed as "Step 21.1"–"Step 21.5".
- [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.
- [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`).
- [x] **Step 20.4 — Engine: event-queue catch-up.** `catchUp` in `packages/engine/src/game/catchup.ts` (see architecture.md) replaces the plain arrival pass in every command, in company creation and import, and in the resolution loop. The loop's `resolveDue` now works in chunks with a progress callback and reports pilot decisions so the screen refreshes. Save version 6: pilots' `nextDecisionAt` and `emptyPeriods`, and dispatches' `independent` flag. Setting a pilot Independent schedules their first decision a turnaround later; setting them Manual clears it. Arrivals now resolve at their arrival time, not when the app noticed them. Independent trips are left out of notifications. Tests (`packages/engine/test/catch-up.test.ts`): two Independent pilots caught up 8 hours at once, and one hour at a time on a copy, give identical saves (over 20 trips); two pilots deciding together split the best two contracts by id; a pilot at the duty limit rests on landing and flies again only after the rest plus the turnaround; 10 days away stops decisions at 7 days, with work right up to the cap, and resumes from now; pilots' trips are never first contacts, never inside the first-look window, never after expiry, never the same contract twice; chunked catch-up reports rising progress to 1 and ends at the same save; no notification for a pilot's own trip.
- [x] **Step 20.5 — Engine: live play.** While the app is open, the resolution loop's pass is the catch-up of Step 20.4, so pilots decide every few seconds. `ApiClient.setViewedContract` (the engine client calls `GameEngine.setViewedContract`) is set by the dispatch sheet while it's open and cleared when it closes; catch-up passes it to the sim, which skips that contract. Tests (`packages/engine/test/live-play.test.ts`): with the best contract open a pilot takes the next one; after it closes, another pilot takes it; the loop's tick makes an Independent pilot's decision and refreshes the screen. `apps/web/test/viewed-contract.test.tsx`: opening the dispatch sheet reports its contract, and Cancel clears it.
- [ ] Step 20.6 — Client: Dispatch Office and pilot controls
- [ ] Step 20.7 — Client: board and logbook
- [ ] Step 20.8 — Balance
- [ ] Step 20.9 — Plan amendments and regression
- [x] **Step 21.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 21.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.
- [x] **Step 21.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`).
- [x] **Step 21.4 — Engine: event-queue catch-up.** `catchUp` in `packages/engine/src/game/catchup.ts` (see architecture.md) replaces the plain arrival pass in every command, in company creation and import, and in the resolution loop. The loop's `resolveDue` now works in chunks with a progress callback and reports pilot decisions so the screen refreshes. Save version 6: pilots' `nextDecisionAt` and `emptyPeriods`, and dispatches' `independent` flag. Setting a pilot Independent schedules their first decision a turnaround later; setting them Manual clears it. Arrivals now resolve at their arrival time, not when the app noticed them. Independent trips are left out of notifications. Tests (`packages/engine/test/catch-up.test.ts`): two Independent pilots caught up 8 hours at once, and one hour at a time on a copy, give identical saves (over 20 trips); two pilots deciding together split the best two contracts by id; a pilot at the duty limit rests on landing and flies again only after the rest plus the turnaround; 10 days away stops decisions at 7 days, with work right up to the cap, and resumes from now; pilots' trips are never first contacts, never inside the first-look window, never after expiry, never the same contract twice; chunked catch-up reports rising progress to 1 and ends at the same save; no notification for a pilot's own trip.
- [x] **Step 21.5 — Engine: live play.** While the app is open, the resolution loop's pass is the catch-up of Step 21.4, so pilots decide every few seconds. `ApiClient.setViewedContract` (the engine client calls `GameEngine.setViewedContract`) is set by the dispatch sheet while it's open and cleared when it closes; catch-up passes it to the sim, which skips that contract. Tests (`packages/engine/test/live-play.test.ts`): with the best contract open a pilot takes the next one; after it closes, another pilot takes it; the loop's tick makes an Independent pilot's decision and refreshes the screen. `apps/web/test/viewed-contract.test.tsx`: opening the dispatch sheet reports its contract, and Cancel clears it.
- [ ] Step 21.6 — Client: Dispatch Office and pilot controls
- [ ] Step 21.7 — Client: board and logbook
- [ ] Step 21.8 — Balance
- [ ] Step 21.9 — Plan amendments and regression

View File

@ -86,7 +86,7 @@ export const gameConfig: GameConfig = {
pilotHireCost: 2000,
// A quarter of each contract's profit (payout − fuel) goes to a hired pilot (CR-010).
pilotProfitShare: 0.25,
// Independent pilots (CR-020): the Dispatch Office costs about a Lighter; the
// Independent pilots (CR-021): the Dispatch Office costs about a Lighter; the
// player has the first 3 minutes of every 10-minute board; pilots turn round
// in 15 game minutes (15 real seconds) and head home after 3 empty boards.
independentPilots: {

View File

@ -329,19 +329,19 @@ export const gameConfigSchema = z
pilotHireCost: nonNegativeInt,
/** Hired pilots' share of profit (payout − fuel) per contract delivery (decision 0.5.12, CR-010). */
pilotProfitShare: fraction,
/** Independent pilots (CR-020, decisions 0.5.119–0.5.125). */
/** Independent pilots (CR-021, decisions 0.5.123–0.5.129). */
independentPilots: z.object({
/** Credits: the one-time Dispatch Office (0.5.119). */
/** Credits: the one-time Dispatch Office (0.5.123). */
dispatchOfficePrice: positiveInt,
/** Real minutes into each board period that its jobs are the player's alone (0.5.122). */
/** Real minutes into each board period that its jobs are the player's alone (0.5.126). */
firstLookRealMinutes: z.number().positive(),
/** Game minutes from a pilot becoming able to act to their next job (CR-020 §2). */
/** Game minutes from a pilot becoming able to act to their next job (CR-021 §2). */
turnaroundGameMinutes: z.number().positive(),
/** Empty board periods in a row away from home before heading home (0.5.124). */
/** Empty board periods in a row away from home before heading home (0.5.128). */
emptyPeriodsBeforeHome: positiveInt,
/** Real days of absence catch-up replays at most (0.5.125). */
/** Real days of absence catch-up replays at most (0.5.129). */
maxCatchUpRealDays: z.number().positive(),
/** Credits pilots never spend the company below, by default (0.5.123). */
/** Credits pilots never spend the company below, by default (0.5.127). */
defaultSpendingFloor: positiveInt,
}),
})

View File

@ -97,7 +97,7 @@ describe("tutorial settings (Step 17.1, decisions 0.5.106, 0.5.107)", () => {
});
});
describe("independent pilots config (Step 20.1, CR-020)", () => {
describe("independent pilots config (Step 21.1, CR-021)", () => {
const settings = gameConfig.independentPilots;
const withSettings = (change: Partial<typeof settings>) =>
gameConfigSchema.safeParse({ ...gameConfig, independentPilots: { ...settings, ...change } })

View File

@ -21,7 +21,7 @@ type SavedPilot = SaveDocument["pilots"][number];
const DAY_MS = 24 * 60 * MS_PER_MINUTE;
/** When the current board period's first-look window ends (decision 0.5.122). */
/** When the current board period's first-look window ends (decision 0.5.126). */
function firstLookEnd(t: RealTime): RealTime {
const period = boardPeriodMs(content.config);
return (
@ -37,7 +37,7 @@ function nextBoardLook(t: RealTime): RealTime {
/**
* Ids for what pilots do during catch-up come from the pilot and the moment,
* not chance, so replaying the same time gives the same save (decision 0.5.125).
* not chance, so replaying the same time gives the same save (decision 0.5.129).
*/
function replayRandom(pilotId: string, t: RealTime): RandomSource {
let n = 0;
@ -49,7 +49,7 @@ export interface CatchUpOptions {
decideUntil: RealTime;
/** Stop after this many events (a chunk); the rest waits for the next call. */
maxEvents?: number;
/** The contract the player has open: pilots leave it alone (decision 0.5.122). */
/** The contract the player has open: pilots leave it alone (decision 0.5.126). */
viewedContractId?: string | null;
}
@ -88,7 +88,7 @@ function nextArrival(save: SaveDocument, limit: RealTime): RealTime | null {
}
/**
* One Independent pilot's decision point at `t` (CR-020 §2). Rest, the shop
* One Independent pilot's decision point at `t` (CR-021 §2). Rest, the shop
* and the first-look window push it later; otherwise the sim chooses, and the
* choice goes through the same commands the player uses.
*/
@ -133,7 +133,7 @@ function decide(save: SaveDocument, pilot: SavedPilot, t: RealTime, viewed: stri
pilot.nextDecisionAt = nextBoardLook(t);
return;
}
// The same commands, and so the same checks, as the player's (CR-020 §3). The
// The same commands, and so the same checks, as the player's (CR-021 §3). The
// sim chose with the same rules, so a refusal is a bug; the pilot then waits.
try {
if (action.buyFu > 0) refuelShip(save, { shipId: ship.id, amountFu: action.buyFu }, t);
@ -163,7 +163,7 @@ function decide(save: SaveDocument, pilot: SavedPilot, t: RealTime, viewed: stri
}
/**
* Replays time up to `until` as an event queue (decision 0.5.125): arrivals
* Replays time up to `until` as an event queue (decision 0.5.129): arrivals
* and Independent pilots' decision points in time order; at the same moment
* arrivals first, then pilots by id. Each arrival resolves at its own time, so
* the result doesn't depend on how the time is split.
@ -212,7 +212,7 @@ export function catchUp(
return { resolved, decisions, done: false, reached };
}
/** The decision cap for a catch-up starting from the last saved time (decision 0.5.125). */
/** The decision cap for a catch-up starting from the last saved time (decision 0.5.129). */
export function decideUntil(save: SaveDocument): RealTime {
return save.lastTimeSeen + content.config.independentPilots.maxCatchUpRealDays * DAY_MS;
}

View File

@ -58,7 +58,7 @@ 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).
// Independent pilots (CR-021).
buyDispatchOffice: ({ save }) => buyDispatchOffice(save),
setPilotMode: ({ save, now }, input) => ({ pilot: setPilotMode(save, input, now) }),
setPilotOrders: ({ save, now }, input) => ({ pilot: setPilotOrders(save, input, now) }),

View File

@ -107,17 +107,17 @@ export class GameEngine {
return this.options.notifications?.requestPermission() ?? Promise.resolve();
}
/** The contract the player has open (decision 0.5.122): pilots leave it alone. */
/** The contract the player has open (decision 0.5.126): pilots leave it alone. */
#viewedContractId: string | null = null;
/** Tells the engine which contract the player has open, or null when none (Step 20.5). */
/** Tells the engine which contract the player has open, or null when none (Step 21.5). */
setViewedContract(contractId: string | null): void {
this.#viewedContractId = contractId;
}
/**
* The resolution loop's pass (Step 14.6, CR-020): replays everything due up
* to now (arrivals and Independent pilots' decisions, decision 0.5.125) in
* The resolution loop's pass (Step 14.6, CR-021): replays everything due up
* to now (arrivals and Independent pilots' decisions, decision 0.5.129) in
* chunks of `chunkEvents` events, each its own save, reporting progress in
* between so the screen stays responsive. Counts rests and shop jobs that
* ended after `since`. Does nothing before a company exists.
@ -249,7 +249,7 @@ export class GameEngine {
return this.core.write((save, now) => {
if (!save) throw noCompany();
const before = JSON.stringify(save);
// Everything due first: arrivals and pilots' decisions (decision 0.5.125).
// Everything due first: arrivals and pilots' decisions (decision 0.5.129).
catchUp(save, now, {
decideUntil: decideUntil(save),
viewedContractId: this.#viewedContractId,

View File

@ -18,7 +18,7 @@ import {
toApiPilot,
} from "./state";
/** The turnaround (CR-020 §2) in real milliseconds. */
/** The turnaround (CR-021 §2) in real milliseconds. */
export function turnaroundMs(): number {
const { turnaroundGameMinutes } = content.config.independentPilots;
return (turnaroundGameMinutes * MS_PER_MINUTE) / content.config.timeCompression;
@ -29,7 +29,7 @@ export function scheduleDecision(pilot: SaveDocument["pilots"][number], ableAt:
pilot.nextDecisionAt = pilot.mode === "independent" ? ableAt + turnaroundMs() : null;
}
/** A new pilot's orders (CR-020 §2): anything goes, home at `homeLocationId`. */
/** A new pilot's orders (CR-021 §2): anything goes, home at `homeLocationId`. */
export function newPilotOrders(homeLocationId: string): PilotOrders {
const orders = defaultOrders(homeLocationId, content);
return { ...orders, cargoClassIds: [...orders.cargoClassIds], destinationIds: null };
@ -39,7 +39,7 @@ export function dispatchOfficeView(save: SaveDocument): DispatchOffice {
return { owned: save.company.dispatchOffice, spendingFloor: save.company.npcSpendingFloor };
}
/** The one-time Dispatch Office (decision 0.5.119). */
/** The one-time Dispatch Office (decision 0.5.123). */
export function buyDispatchOffice(save: SaveDocument): {
company: Company;
dispatchOffice: DispatchOffice;
@ -54,7 +54,7 @@ export function buyDispatchOffice(save: SaveDocument): {
}
/**
* Manual or Independent (decisions 0.5.118–0.5.120). Independent needs the
* Manual or Independent (decisions 0.5.122–0.5.124). 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.
*/
@ -85,7 +85,7 @@ export function setPilotMode(
return toApiPilot(save, pilot, now);
}
/** A pilot's orders (decision 0.5.120), checked against content and the player's bays. */
/** A pilot's orders (decision 0.5.124), checked against content and the player's bays. */
export function setPilotOrders(
save: SaveDocument,
input: { pilotId: string; orders: PilotOrders },
@ -114,7 +114,7 @@ export function setPilotOrders(
return toApiPilot(save, pilot, now);
}
/** The credits Independent pilots never spend the company below (decision 0.5.123). */
/** The credits Independent pilots never spend the company below (decision 0.5.127). */
export function setSpendingFloor(
save: SaveDocument,
credits: number,

View File

@ -44,7 +44,7 @@ export function upcomingEvents(save: SaveDocument, now: RealTime): GameEvent[] {
const events: GameEvent[] = [];
for (const d of save.dispatches) {
if (d.status !== "in-flight" || d.arrivalAt <= now) continue;
// Pilots' own trips aren't announced (decision 0.5.126).
// Pilots' own trips aren't announced (decision 0.5.130).
if (d.independent) continue;
const ship = d.tutorial
? "The company loaner"

View File

@ -46,7 +46,7 @@ async function copyOf(test: TestApp): Promise<TestApp> {
return copy;
}
describe("event-queue catch-up (Step 20.4, decision 0.5.125)", () => {
describe("event-queue catch-up (Step 21.4, decision 0.5.129)", () => {
it("gives the same save whether 8 hours are caught up at once or an hour at a time", async () => {
const once = newTestApp();
await independentFleet(once, ["pa", "pb"]);
@ -156,7 +156,7 @@ describe("event-queue catch-up (Step 20.4, decision 0.5.125)", () => {
expect({ ...b, lastTimeSeen: 0 }).toEqual({ ...a, lastTimeSeen: 0 });
});
it("schedules no notification for a pilot's own trips (decision 0.5.126)", async () => {
it("schedules no notification for a pilot's own trips (decision 0.5.130)", async () => {
const test = newTestApp();
test.clock.now = TEST_NOW + FIRST_LOOK;
await independentFleet(test);

View File

@ -23,7 +23,7 @@ async function ownerWithPilot() {
return { app, owner };
}
describe("Dispatch Office, pilot modes and orders (Step 20.3, decisions 0.5.118–0.5.120)", () => {
describe("Dispatch Office, pilot modes and orders (Step 21.3, decisions 0.5.122–0.5.124)", () => {
it("refuses Independent before the Dispatch Office, and allows it after", async () => {
const { app } = await ownerWithPilot();
await expect(
@ -132,7 +132,7 @@ describe("Dispatch Office, pilot modes and orders (Step 20.3, decisions 0.5.118
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 () => {
it("opens a save from before CR-021 with every pilot Manual and no Dispatch Office", async () => {
const { app } = await ownerWithPilot();
const save = await readSave(app);
const old = {

View File

@ -45,7 +45,7 @@ const takenBy = async (test: TestApp, pilotId: string) =>
(await readSave(test.app)).dispatches.find((d) => d.pilotId === pilotId && d.independent)?.record
.contract?.id;
describe("Independent pilots in live play (Step 20.5, decision 0.5.122)", () => {
describe("Independent pilots in live play (Step 21.5, decision 0.5.126)", () => {
it("leaves the contract the player has open, and takes it once it's closed", async () => {
const test = await setup();
const { app, clock } = test;

View File

@ -74,7 +74,7 @@ export const shipSchema = z.object({
});
export type Ship = z.infer<typeof shipSchema>;
/** What an Independent pilot may take (decision 0.5.120). */
/** What an Independent pilot may take (decision 0.5.124). */
export const pilotOrdersSchema = z.object({
/** Allowed destinations; null means every discovered location. */
destinationIds: z.array(idSchema).nullable(),
@ -90,7 +90,7 @@ 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). */
/** The company's Dispatch Office (decision 0.5.123) and the pilots' spending floor (0.5.127). */
export const dispatchOfficeSchema = z.object({
owned: z.boolean(),
/** Credits Independent pilots never spend the company below. */
@ -119,7 +119,7 @@ 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. */
/** Manual, or Independent: flies jobs on their own (CR-021). The avatar is always Manual. */
mode: pilotModeSchema,
orders: pilotOrdersSchema,
});

View File

@ -79,10 +79,10 @@ 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). */
/** CR-021: a hired pilot's mode and orders (decision 0.5.124). */
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). */
/** CR-021: the credits Independent pilots never spend below (decision 0.5.127). */
export const spendingFloorRequestSchema = z.object({ credits: z.number().int().nonnegative() });
export const dispatchOfficeResponseSchema = z.object({
company: companySchema,

View File

@ -175,7 +175,7 @@ export const routes = {
response: pilotResponseSchema,
auth: true,
},
/** CR-020: Independent pilots (decisions 0.5.119–0.5.123). */
/** CR-021: Independent pilots (decisions 0.5.123–0.5.127). */
buyDispatchOffice: {
method: "POST",
path: "/api/company/dispatch-office",

View File

@ -114,12 +114,12 @@ export const savedPilotSchema = z.object({
dutyHours: z.number().nonnegative(),
restEndsAt: timeSchema.nullable(),
createdAt: timeSchema,
/** Manual or Independent, and orders (decision 0.5.120). */
/** Manual or Independent, and orders (decision 0.5.124). */
mode: pilotModeSchema,
orders: pilotOrdersSchema,
/** An Independent pilot's next decision point (CR-020 §2); null while flying or Manual. */
/** An Independent pilot's next decision point (CR-021 §2); null while flying or Manual. */
nextDecisionAt: timeSchema.nullable(),
/** Board periods in a row an Independent pilot found nothing to take (decision 0.5.124). */
/** Board periods in a row an Independent pilot found nothing to take (decision 0.5.128). */
emptyPeriods: z.number().int().nonnegative(),
});
@ -153,7 +153,7 @@ export const savedDispatchSchema = z.object({
shipId: idSchema.nullable(),
pilotId: idSchema,
tutorial: z.boolean(),
/** Flown by an Independent pilot's own choice (CR-020): no notification, in the logbook. */
/** Flown by an Independent pilot's own choice (CR-021): no notification, in the logbook. */
independent: z.boolean(),
record: dispatchRecordSchema,
arrivalAt: timeSchema,
@ -186,9 +186,9 @@ export const saveDocumentSchema = z.object({
name: z.string().min(1),
credits,
loanBalance: credits.nonnegative(),
/** The Dispatch Office is bought (decision 0.5.119). */
/** The Dispatch Office is bought (decision 0.5.123). */
dispatchOffice: z.boolean(),
/** Credits Independent pilots never spend below (decision 0.5.123). */
/** Credits Independent pilots never spend below (decision 0.5.127). */
npcSpendingFloor: z.number().int().nonnegative(),
}),
ships: z.array(savedShipSchema),
@ -285,7 +285,7 @@ function dropBoards(save: Doc): Doc {
}
/**
* Version 4 → 5 (CR-020 §3): every pilot Manual with default orders (home at
* Version 4 → 5 (CR-021 §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 {
@ -312,7 +312,7 @@ function independentPilots(save: Doc): Doc {
}
/**
* Version 5 → 6 (CR-020 Step 20.4): catch-up state. No pilot has a decision
* Version 5 → 6 (CR-021 Step 21.4): catch-up state. No pilot has a decision
* pending or empty periods counted, and no dispatch was an Independent choice.
*/
function catchUpState(save: Doc): Doc {

View File

@ -148,7 +148,7 @@ describe("save migration 3 → 4: completable boards (CR-019 §3)", () => {
});
});
describe("save migration 4 → 5: independent pilots (CR-020 §3)", () => {
describe("save migration 4 → 5: independent pilots (CR-021 §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> & {

View File

@ -15,7 +15,7 @@ import {
import { fuelPriceAt, refuel, type Tank } from "./fuel";
import { pilotShare } from "./pay";
/** What the player tells an Independent pilot (decision 0.5.120). */
/** What the player tells an Independent pilot (decision 0.5.124). */
export interface PilotOrders {
/** Destinations allowed; null means every discovered location. */
destinationIds: readonly string[] | null;
@ -24,11 +24,11 @@ export interface PilotOrders {
takePriority: boolean;
/** Credits: the least profit (payout − fuel − pilot share) a job must make. */
minProfit: number;
/** Where the pilot heads after empty boards away (decision 0.5.124). */
/** Where the pilot heads after empty boards away (decision 0.5.128). */
homeLocationId: string;
}
/** The orders a pilot starts with (CR-020 §2): anything goes, home where hired. */
/** The orders a pilot starts with (CR-021 §2): anything goes, home where hired. */
export function defaultOrders(homeLocationId: string, content: SimContent): PilotOrders {
return {
destinationIds: null,
@ -48,12 +48,12 @@ export interface IndependentInput {
board: readonly Contract[];
now: RealTime;
credits: number;
/** Credits pilots never spend the company below (decision 0.5.123). */
/** Credits pilots never spend the company below (decision 0.5.127). */
spendingFloor: number;
discoveredIds: ReadonlySet<string>;
/** Board periods in a row this pilot has found nothing to take (decision 0.5.124). */
/** Board periods in a row this pilot has found nothing to take (decision 0.5.128). */
emptyPeriods: number;
/** The contract the player has open, which pilots leave alone (decision 0.5.122). */
/** The contract the player has open, which pilots leave alone (decision 0.5.126). */
viewedContractId?: string | null;
}
@ -64,7 +64,7 @@ function fuelToBuy(burnFu: number, tank: Tank): number {
}
/**
* The fuel a pilot may buy for a leg (decision 0.5.123): none if the tank holds
* The fuel a pilot may buy for a leg (decision 0.5.127): none if the tank holds
* enough; otherwise exactly what the leg needs, if the location sells it and
* credits stay at or above the spending floor. Null when they can't.
*/
@ -85,7 +85,7 @@ export function legRefuel(
return { buyFu, cost: bought.cost, tank: bought.tank };
}
/** A job an Independent pilot could take, with its fuel and profit (decision 0.5.121). */
/** A job an Independent pilot could take, with its fuel and profit (decision 0.5.125). */
export interface JobOption {
contract: Contract;
/** Fuel bought first (0 if the tank holds enough). */
@ -98,14 +98,14 @@ export interface JobOption {
profitPerHour: number;
}
/** Whether the first-look window of the current board period is over (decision 0.5.122). */
/** Whether the first-look window of the current board period is over (decision 0.5.126). */
export function pastFirstLook(now: RealTime, content: SimContent): boolean {
const periodStart = boardPeriodIndex(now, content.config) * boardPeriodMs(content.config);
return now - periodStart >= content.config.independentPilots.firstLookRealMinutes * MS_PER_MINUTE;
}
/**
* The jobs on the board this pilot may take now (decisions 0.5.120–0.5.123):
* The jobs on the board this pilot may take now (decisions 0.5.124–0.5.127):
* past the first-look window, not first contacts, not the one the player has
* open, within the orders, flyable by this pair once the leg's fuel is bought
* (every dispatch check), and making at least the minimum profit.
@ -165,14 +165,14 @@ export function compareOptions(a: JobOption, b: JobOption): number {
return a.contract.id < b.contract.id ? -1 : a.contract.id > b.contract.id ? 1 : 0;
}
/** What an Independent pilot does when they can act (CR-020 §2). */
/** What an Independent pilot does when they can act (CR-021 §2). */
export type PilotAction =
| { kind: "take"; contractId: string; buyFu: number }
| { kind: "deadhead-home"; destinationId: string; buyFu: number }
| { kind: "wait" };
/**
* An Independent pilot's decision (decisions 0.5.121, 0.5.124): the best job
* An Independent pilot's decision (decisions 0.5.125, 0.5.128): the best job
* on the board; with none, after enough empty periods away from home, a
* deadhead home if the ship can make it under the fuel rules; else wait.
*/

View File

@ -71,7 +71,7 @@ function input(board: Contract[], over: Partial<IndependentInput> = {}): Indepen
};
}
describe("independent pilots' job choice (Step 20.2, decisions 0.5.121–0.5.124)", () => {
describe("independent pilots' job choice (Step 21.2, decisions 0.5.125–0.5.128)", () => {
it("never takes a first-contact job", () => {
const discovery = job("a", { firstContact: true, payout: 50_000 });
expect(chooseAction(input([discovery]), content)).toEqual({ kind: "wait" });