From f5cf2a84243e9a63f653a591a5ce6bf9bcdcb4e2 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Thu, 15 Oct 2026 21:17:14 -0400 Subject: [PATCH] Step 24.3: engine: sleep in live play and catch-up Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 ++ apps/web/test/fixtures.ts | 2 + memory-bank/architecture.md | 4 +- memory-bank/progress.md | 2 +- packages/engine/src/game/catchup.ts | 8 +- packages/engine/src/game/state.ts | 15 ++++ packages/engine/test/catch-up.test.ts | 4 + packages/engine/test/fleet-sleep.test.ts | 96 ++++++++++++++++++++++++ packages/engine/test/start.test.ts | 5 ++ packages/schema/src/entities.ts | 13 ++++ packages/schema/test/schemas.test.ts | 1 + 11 files changed, 150 insertions(+), 6 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 5fbe53d..f7acf0d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/apps/web/test/fixtures.ts b/apps/web/test/fixtures.ts index c4a3b0a..a003453 100644 --- a/apps/web/test/fixtures.ts +++ b/apps/web/test/fixtures.ts @@ -94,6 +94,8 @@ export function samplePlayerState(overrides: Partial = {}): 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, }; diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index e0a6766..b7d58ab 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index c8de1bc..5b23e4e 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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) diff --git a/packages/engine/src/game/catchup.ts b/packages/engine/src/game/catchup.ts index 968525b..de747d6 100644 --- a/packages/engine/src/game/catchup.ts +++ b/packages/engine/src/game/catchup.ts @@ -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; diff --git a/packages/engine/src/game/state.ts b/packages/engine/src/game/state.ts index 360ba66..2fab159 100644 --- a/packages/engine/src/game/state.ts +++ b/packages/engine/src/game/state.ts @@ -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, diff --git a/packages/engine/test/catch-up.test.ts b/packages/engine/test/catch-up.test.ts index 02b22e7..c58ac04 100644 --- a/packages/engine/test/catch-up.test.ts +++ b/packages/engine/test/catch-up.test.ts @@ -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", {}); diff --git a/packages/engine/test/fleet-sleep.test.ts b/packages/engine/test/fleet-sleep.test.ts index fa7a8e4..8e52d0e 100644 --- a/packages/engine/test/fleet-sleep.test.ts +++ b/packages/engine/test/fleet-sleep.test.ts @@ -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); + }); +}); diff --git a/packages/engine/test/start.test.ts b/packages/engine/test/start.test.ts index e3ddfda..0273823 100644 --- a/packages/engine/test/start.test.ts +++ b/packages/engine/test/start.test.ts @@ -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, + }, }); }); diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts index a9e83e6..ad20210 100644 --- a/packages/schema/src/entities.ts +++ b/packages/schema/src/entities.ts @@ -98,6 +98,18 @@ export const dispatchOfficeSchema = z.object({ }); export type DispatchOffice = z.infer; +/** + * 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; + 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(), }); diff --git a/packages/schema/test/schemas.test.ts b/packages/schema/test/schemas.test.ts index 64ad63e..d4b640a 100644 --- a/packages/schema/test/schemas.test.ts +++ b/packages/schema/test/schemas.test.ts @@ -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, };