Step 20.5: engine: live play
CI / check (push) Has been cancelled Details

The dispatch sheet tells the engine which contract the player has open;
Independent pilots leave it alone, and the loop runs their decisions
while the app is open (decision 0.5.122).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 23:32:39 -04:00
parent 4b2f1b1a85
commit 42a7be028e
7 changed files with 155 additions and 2 deletions

View File

@ -1703,3 +1703,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Splitting time leaves only `lastTimeSeen` different**: a piece that changes nothing isn't saved, so it doesn't move `lastTimeSeen`.
- **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
- **"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.

View File

@ -21,4 +21,9 @@ export interface ApiClient {
* Missing means there is no prompt.
*/
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.
*/
setViewedContract?(contractId: string | null): void;
}

View File

@ -30,5 +30,6 @@ export function createEngineClient(engine: GameEngine): ApiClient {
}
},
notificationPrompt: () => prompt,
setViewedContract: (contractId) => engine.setViewedContract(contractId),
};
}

View File

@ -16,7 +16,8 @@ import {
wouldStrand,
type DispatchFailure,
} from "@deadhead/sim";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useApi } from "../api/context";
import { formatFu, refuelForLeg } from "../components/Fuel";
import { useClock } from "../clock/clock";
import { formatGameTime } from "../clock/GameClock";
@ -292,6 +293,12 @@ 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).
useEffect(() => {
api.setViewedContract?.(contract.id);
return () => api.setViewedContract?.(null);
}, [api, contract.id]);
const state = useLoadedPlayer();
const prefsCountdown = usePreferences((p) => p.countdown);
const now = useClock((s) => s.now);

View File

@ -0,0 +1,45 @@
import type { Contract } from "@deadhead/schema";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const NOW = Date.now();
const job: Contract = {
id: "earth:7:0",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 6,
payout: 1_000,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: NOW + 600_000,
firstContact: false,
};
describe("the open contract (Step 20.5, decision 0.5.122)", () => {
it("tells the engine which contract the dispatch sheet has open, and when it closes", async () => {
const setViewedContract = vi.fn();
const api = Object.assign(
fakeApi({
getPlayerState: () =>
samplePlayerState({ tutorial: null, bays: [{ id: "b", locationId: "earth" }] }),
getContractBoard: () => ({ contracts: [job], periodEndsAt: NOW + 600_000 }),
}),
{ setViewedContract },
);
renderApp(api, "/contracts?location=earth");
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
expect(setViewedContract).toHaveBeenLastCalledWith("earth:7:0");
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(setViewedContract).toHaveBeenLastCalledWith(null);
});
});

View File

@ -466,7 +466,7 @@ Spec: `memory-bank/change-requests/CR-020-independent-pilots.md` (received as "C
- [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.
- [ ] Step 20.5 — Engine: live play
- [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

View File

@ -0,0 +1,90 @@
import { content } from "@deadhead/content";
import { MS_PER_MINUTE } from "@deadhead/sim";
import { describe, expect, it, vi } from "vitest";
import { turnaroundMs } from "../src/game/independent";
import { ResolutionLoop, type LoopHost } from "../src/index";
import {
addPilot,
addShip,
newTestApp,
putBoard,
readSave,
setCredits,
signUpOwner,
testContract,
TEST_NOW,
type TestApp,
} from "./support";
const FIRST_LOOK = content.config.independentPilots.firstLookRealMinutes * MS_PER_MINUTE;
/** An owner with the Dispatch Office and two Couriers at Earth with hired (Manual) pilots. */
async function setup(): Promise<TestApp> {
const test = newTestApp();
test.clock.now = TEST_NOW + FIRST_LOOK;
const { app } = test;
await signUpOwner(app);
await setCredits(app, content.config.independentPilots.dispatchOfficePrice + 50_000);
await app.call("buyDispatchOffice", {});
for (const id of ["pa", "pb"]) {
await addShip(app, { id: `ship-${id}`, modelId: "courier", name: `Courier ${id}` });
await addPilot(app, { id, name: `Pilot ${id}`, assignedShipId: `ship-${id}` });
}
await putBoard(
app,
[
testContract({ id: "earth:best", payout: 5_000 }),
testContract({ id: "earth:next", payout: 3_000 }),
],
test.clock.now,
);
return test;
}
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)", () => {
it("leaves the contract the player has open, and takes it once it's closed", async () => {
const test = await setup();
const { app, clock } = test;
app.setViewedContract("earth:best");
await app.call("setPilotMode", { pilotId: "pa", mode: "independent" });
clock.now += turnaroundMs();
await app.call("getPlayerState", {});
expect(await takenBy(test, "pa")).toBe("earth:next");
// The player closes the sheet without taking it: the next pilot may.
app.setViewedContract(null);
await app.call("setPilotMode", { pilotId: "pb", mode: "independent" });
clock.now += turnaroundMs();
await app.call("getPlayerState", {});
expect(await takenBy(test, "pb")).toBe("earth:best");
});
it("runs pilots' decisions from the resolution loop while the app is open", async () => {
const test = await setup();
await test.app.call("setPilotMode", { pilotId: "pa", mode: "independent" });
let tick: () => void = () => {};
const host: LoopHost = {
setInterval: (fn) => {
tick = fn;
return 1;
},
clearInterval: () => {},
onResume: () => () => {},
};
const onChange = vi.fn();
const loop = new ResolutionLoop(test.app, host, onChange);
await loop.start();
expect(await takenBy(test, "pa")).toBeUndefined();
test.clock.now += turnaroundMs();
tick();
await loop.tick();
expect(await takenBy(test, "pa")).toBe("earth:best");
expect(onChange).toHaveBeenCalled();
loop.stop();
});
});