Step 24.3: engine: sleep in live play and catch-up
CI / check (push) Has been cancelled Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-15 21:17:14 -04:00
parent 587f504ae1
commit f5cf2a8424
11 changed files with 150 additions and 6 deletions

View File

@ -1820,3 +1820,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Not counted:** `markWelcomeSeen` (like dismissing), new company and the dev tools. A new company starts with its creation as the last decision anyway.
- **Display preferences aren't commands** (they live on the device, CR-011), so they can't stamp it; the test checks the engine-side things the player can do without deciding: reading, boards, dismissing, the welcome and export.
- **A refused command isn't a decision:** the stamp happens after the handler succeeds, in the same write.
## Step 24.3
- **Sleep is one more limit on decisions in catch-up**, beside the 7-day cap: `decideLimit = min(until, decideUntil, sleepsAt − 1 ms)`. Nothing else in the queue changes, so arrivals, rest and shop ends resolve as before, and catch-up stays deterministic. Pilots due after the limit wait at `until`, the time of the command or loop pass that caught up, which is how a manual decision then wakes them from that moment (Step 24.4).
- **"At or after the sleep time"**: a decision exactly at `sleepsAt` doesn't happen (hence the −1 ms).
- **`asleep` is computed, not stored**: `now ≥ sleepsAt`. The client decides whether to show it, since a fleet with no Independent pilots has nothing to wake.

View File

@ -94,6 +94,8 @@ export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerS
summaries: [],
goals: [],
dispatchOffice: { owned: false, spendingFloor: 500 },
// Awake: a day after the usual test time (CR-024).
fleetSleep: { afterHours: 24, sleepsAt: Date.parse("2100-01-02T00:00:00Z"), asleep: false },
welcomeSeen: true,
...overrides,
};

View File

@ -34,7 +34,7 @@ Living reference for how the code is organised. Update it after any save format
| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `deadhead`, `fuelCost`, `goodId` / `massTonnes` / `shipper` (null for a deadhead, CR-007), `credits` (net), `loanRepayment`, `pilotPay` (hired pilot's profit share, CR-010), `pilotXp`, `discoveredLocationId`, `scriptedId`, `restEndsAt` (set when the trip ended in mandatory rest), `resolvedAt` |
| `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight`, `runsCompleted`, `dealAvailable` |
| `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) |
| `PlayerState` | `playerId` (CR-011), `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) |
| `PlayerState` | `playerId` (CR-011), `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial), `fleetSleep` (`afterHours`, `sleepsAt`, `asleep`; CR-024) |
### Errors
@ -141,4 +141,4 @@ On the device the client keeps the save (IndexedDB, or files on native) and, in
### 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.
`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 at the fleet's sleep time (`sleepsAt` in `state.ts`: `company.lastManualDecisionAt` plus `sleepAfterHours`, CR-024), and later ones resume from `until`, so the next manual decision wakes pilots from then. Arrivals and shop ends always resolve. A null `lastManualDecisionAt` (a save just migrated) is set to `until` first. 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

@ -523,7 +523,7 @@ Spec: `memory-bank/change-requests/CR-024-fleet-sleep.md` (received as "CR-022
- [x] **Step 24.1 — Content and save.** Config `fleetSleep` (default 24 h; options 12, 24, 48 and 72 h, rising, the default among them). Save version 9: `company.lastManualDecisionAt` and `company.sleepAfterHours`; a new company starts with its creation time and the default. The 8 → 9 migration sets the default and leaves the last decision null; the engine's catch-up sets a null one to the time it first runs on the save, which is the migration time in effect. Tests: `packages/content/test/config.test.ts`, `packages/schema/test/save.test.ts` (8 → 9), `packages/engine/test/fleet-sleep.test.ts` (a new company; an imported version-8 save is stamped with the import time).
- [x] **Step 24.2 — Engine: manual decision flag.** `RouteDef.manualDecision` is required on every command in the catalog. Manual: dispatch and deadhead (`createDispatch`), buying ships, upgrades, refuelling, refuel policy, emergency fuel, leasing, hiring, assigning and unassigning, resting a pilot, skill points, the Dispatch Office, pilot mode and orders, the spending floor, the deal, renaming the company, the loan and goals. Not: reads, `acknowledgeSummaries`, `markWelcomeSeen`, export and import, new company, dev tools. The engine stamps `lastManualDecisionAt` after a flagged command succeeds; Independent pilots' trips go through the commands' functions, not the catalog, so they never do. Tests: `packages/schema/test/routes.test.ts` (every command declares it; the manual list); `packages/engine/test/fleet-sleep.test.ts` (a dispatch stamps it; viewing, a board, dismissing a summary, the welcome and an export don't; a refused command doesn't; Independent pilots' trips don't).
- [ ] Step 24.3 — Engine: sleep in live play and catch-up
- [x] **Step 24.3 — Engine: sleep in live play and catch-up.** `catchUp` processes Independent pilots' decisions only before the sleep time (`sleepsAt`, in `state.ts`), alongside the 7-day cap; arrivals, rest and shop ends resolve as before, and pilots due later wait at `until`. Live play is the same catch-up run by every command and by the loop. `PlayerState.fleetSleep` (`afterHours`, `sleepsAt`, `asleep`) tells the client, which shows "Waiting for orders" from it (Step 24.5). Tests (`packages/engine/test/fleet-sleep.test.ts`): two Independent pilots left 7 days start nothing at or after 24 hours, and the trips in flight then still resolve with pay; one go equals 5-hour pieces over 3 days; a trip the player sent lands after the sleep time as normal; a 48-hour threshold moves the cutoff. The 7-day cap test now uses a 30-day threshold so only the cap stops the pilots.
- [ ] Step 24.4 — Engine: waking and "Back to work"
- [ ] Step 24.5 — Client: return banner, status, and setting
- [ ] Step 24.6 — Client: sleep notification (native)

View File

@ -15,7 +15,7 @@ import { dispatchContract, dispatchReposition } from "./dispatch";
import { finishShop, refuelShip } from "./fleet";
import { scheduleDecision } from "./independent";
import { resolveArrivals } from "./resolve";
import { discoveredIds, pilotState, shipState } from "./state";
import { discoveredIds, pilotState, shipState, sleepsAt } from "./state";
type SavedPilot = SaveDocument["pilots"][number];
@ -188,7 +188,8 @@ export function catchUp(
// A save just migrated to version 9 counts its first catch-up as the last decision (CR-024 §3).
save.company.lastManualDecisionAt ??= until;
const maxEvents = options.maxEvents ?? Number.POSITIVE_INFINITY;
const decideLimit = Math.min(until, options.decideUntil);
// Pilots decide before the 7-day cap and before the fleet goes quiet (decision 0.5.137).
const decideLimit = Math.min(until, options.decideUntil, sleepsAt(save, until) - 1);
let resolved = 0;
let decisions = 0;
let reached = Number.NEGATIVE_INFINITY;
@ -208,7 +209,8 @@ export function catchUp(
continue;
}
if (arrival === null && decisionAt === null) {
// Pilots who would have decided after the cap pick up from `until`.
// Pilots who would have decided after the cap, or asleep, pick up from `until`:
// the next manual decision wakes them from there (decision 0.5.138).
if (decideLimit < until) {
for (const p of save.pilots) {
if (p.nextDecisionAt !== null && p.nextDecisionAt < until) p.nextDecisionAt = until;

View File

@ -15,6 +15,7 @@ import {
fuelPriceAt,
isInShop,
isResting,
MS_PER_HOUR,
pilotLevel,
skillPointsAvailable,
type GoalProgress,
@ -231,6 +232,15 @@ export function goalStates(save: SaveDocument): GoalState[] {
}
/** Everything the client needs (Step 8.5's state). */
/**
* When the fleet goes quiet (decision 0.5.137): the last manual decision plus
* the threshold. Independent pilots start nothing at or after it.
*/
export function sleepsAt(save: SaveDocument, now: RealTime): RealTime {
const { lastManualDecisionAt, sleepAfterHours } = save.company;
return (lastManualDecisionAt ?? now) + sleepAfterHours * MS_PER_HOUR;
}
export function playerState(save: SaveDocument, now: RealTime): PlayerState {
const active = save.dispatches.filter((d) => d.status === "in-flight");
const discovered = discoveredIds(save);
@ -285,6 +295,11 @@ export function playerState(save: SaveDocument, now: RealTime): PlayerState {
})),
goals: goalStates(save),
welcomeSeen: save.player.welcomeSeen,
fleetSleep: {
afterHours: save.company.sleepAfterHours,
sleepsAt: sleepsAt(save, now),
asleep: now >= sleepsAt(save, now),
},
dispatchOffice: {
owned: save.company.dispatchOffice,
spendingFloor: save.company.npcSpendingFloor,

View File

@ -105,6 +105,10 @@ describe("event-queue catch-up (Step 21.4, decision 0.5.129)", () => {
it("stops pilots deciding 7 days after the last saved time", async () => {
const test = newTestApp();
await independentFleet(test);
// Longer than the 10 days below, so only the cap stops them (not fleet sleep, CR-024).
await edit(test.app, (save) => {
save.company.sleepAfterHours = 30 * 24;
});
const start = (await readSave(test.app)).lastTimeSeen;
test.clock.now = start + 10 * DAY;
await test.app.call("getPlayerState", {});

View File

@ -1,15 +1,18 @@
import { content } from "@deadhead/content";
import type { SaveDocument } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { encodeSave } from "../src/index";
import {
addPilot,
addShip,
edit,
newTestApp,
readSave,
setCredits,
signUp,
signUpOwner,
TEST_NOW,
type TestApp,
} from "./support";
const HOUR = 60 * 60_000;
@ -90,3 +93,96 @@ describe("manual decisions (Step 24.2, decision 0.5.136)", () => {
expect(save.company.lastManualDecisionAt).toBe(decided);
});
});
const DAY = 24 * HOUR;
/** An owner with the Dispatch Office and two Independent pilots on Couriers at Earth. */
async function sleepyFleet(test: TestApp) {
await signUpOwner(test.app);
await setCredits(test.app, content.config.independentPilots.dispatchOfficePrice + 50_000);
await test.app.call("buyDispatchOffice", {});
for (const id of ["pa", "pb"]) {
await addShip(test.app, { id: `ship-${id}`, modelId: "courier", name: `Courier ${id}` });
await addPilot(test.app, { id, name: `Pilot ${id}`, assignedShipId: `ship-${id}` });
await test.app.call("setPilotMode", { pilotId: id, mode: "independent" });
}
return (await readSave(test.app)).company.lastManualDecisionAt!;
}
const independentTrips = (save: SaveDocument) => save.dispatches.filter((d) => d.independent);
describe("fleet sleep in catch-up (Step 24.3, decision 0.5.137)", () => {
it("starts no Independent trips after 24 hours without orders; trips in flight still pay", async () => {
const test = newTestApp();
const decided = await sleepyFleet(test);
const sleep = decided + 24 * HOUR;
test.clock.now = decided + 7 * DAY;
const state = await test.app.call("getPlayerState", {});
const trips = independentTrips(await readSave(test.app));
expect(trips.length).toBeGreaterThan(10);
expect(Math.max(...trips.map((d) => d.record.departureAt))).toBeLessThan(sleep);
const flying = trips.filter((d) => d.record.departureAt < sleep && d.arrivalAt > sleep);
expect(flying.length).toBeGreaterThan(0);
for (const d of flying) {
expect(d.status).toBe("resolved");
expect(d.resolution!.credits).toBeGreaterThan(0);
}
expect(state.fleetSleep).toEqual({ afterHours: 24, sleepsAt: sleep, asleep: true });
});
it("gives the same save caught up in one go or in pieces", async () => {
const once = newTestApp();
await sleepyFleet(once);
const { code } = await once.app.call("exportSave", {});
const pieces = newTestApp();
pieces.clock.now = once.clock.now;
await pieces.app.call("importSave", { code });
const end = once.clock.now + 3 * DAY;
once.clock.now = end;
await once.app.call("getPlayerState", {});
while (pieces.clock.now < end) {
pieces.clock.now = Math.min(end, pieces.clock.now + 5 * HOUR);
await pieces.app.call("getPlayerState", {});
}
const a = await readSave(once.app);
const b = await readSave(pieces.app);
expect({ ...b, lastTimeSeen: 0 }).toEqual({ ...a, lastTimeSeen: 0 });
});
it("lets a trip the player sent before the sleep time land after it as normal", async () => {
const test = newTestApp();
const owner = await signUpOwner(test.app);
const avatar = (await readSave(test.app)).pilots.find((p) => p.isAvatar)!.id;
const { dispatch } = await test.app.call("createDispatch", {
kind: "reposition",
shipId: owner.shipId,
pilotId: avatar,
destinationId: "moon",
});
// The fleet goes quiet a minute into the flight.
await edit(test.app, (save) => {
save.company.lastManualDecisionAt = test.clock.now - 24 * HOUR + 60_000;
});
test.clock.now = dispatch.arrivalAt + HOUR;
const state = await test.app.call("getPlayerState", {});
expect(state.fleetSleep.asleep).toBe(true);
const flown = (await readSave(test.app)).dispatches.find((d) => d.id === dispatch.id)!;
expect(flown.status).toBe("resolved");
expect(state.ships.find((s) => s.id === owner.shipId)!.locationId).toBe("moon");
});
it("moves the cutoff with a 48-hour threshold", async () => {
const test = newTestApp();
const decided = await sleepyFleet(test);
await edit(test.app, (save) => {
save.company.sleepAfterHours = 48;
});
test.clock.now = decided + 4 * DAY;
await test.app.call("getPlayerState", {});
const departures = independentTrips(await readSave(test.app)).map((d) => d.record.departureAt);
expect(Math.max(...departures)).toBeGreaterThan(decided + 24 * HOUR);
expect(Math.max(...departures)).toBeLessThan(decided + 48 * HOUR);
});
});

View File

@ -78,6 +78,11 @@ describe("company creation on the device (Step 14.8)", () => {
owned: false,
spendingFloor: content.config.independentPilots.defaultSpendingFloor,
},
fleetSleep: {
afterHours: 24,
sleepsAt: TEST_NOW + 24 * 60 * 60_000,
asleep: false,
},
});
});

View File

@ -98,6 +98,18 @@ export const dispatchOfficeSchema = z.object({
});
export type DispatchOffice = z.infer<typeof dispatchOfficeSchema>;
/**
* Fleet sleep (decisions 0.5.136–0.5.138): from `sleepsAt`, the last manual
* decision plus `afterHours`, Independent pilots start no new work.
*/
export const fleetSleepSchema = z.object({
afterHours: z.number().positive(),
sleepsAt: timeSchema,
/** Asleep now: `sleepsAt` has passed with no manual decision since. */
asleep: z.boolean(),
});
export type FleetSleep = z.infer<typeof fleetSleepSchema>;
export const pilotSchema = z.object({
id: idSchema,
name: z.string().min(1),
@ -262,6 +274,7 @@ export const playerStateSchema = z.object({
/** Onboarding goals in content order; empty during the tutorial. */
goals: z.array(goalStateSchema),
dispatchOffice: dispatchOfficeSchema,
fleetSleep: fleetSleepSchema,
/** This company's welcome cards have been seen (decision 0.5.131). */
welcomeSeen: z.boolean(),
});

View File

@ -179,6 +179,7 @@ const playerState = {
summaries: [summary],
goals: [goal],
dispatchOffice: { owned: true, spendingFloor: 500 },
fleetSleep: { afterHours: 24, sleepsAt: 90_000_000, asleep: false },
welcomeSeen: true,
};