From 587f504ae143fb808b3963fda32c77adad2a7660 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Thu, 15 Oct 2026 21:11:48 -0400 Subject: [PATCH] Step 24.2: engine: manual decision flag Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 8 +++ memory-bank/architecture.md | 2 +- memory-bank/progress.md | 2 +- packages/engine/src/game/engine.ts | 2 + packages/engine/test/fleet-sleep.test.ts | 62 +++++++++++++++++++++++- packages/schema/src/routes.ts | 41 ++++++++++++++++ packages/schema/test/routes.test.ts | 39 +++++++++++++++ 7 files changed, 153 insertions(+), 3 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 2f07000..5fbe53d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1812,3 +1812,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **"Set to the migration time" without a clock in the migration.** Migrations are pure functions of the save, so the 8 → 9 step leaves `lastManualDecisionAt` null and the engine's catch-up sets a null one to the time it runs, before anything else. That is the first time the engine opens the save (load or import), so the effect is the CR's: nobody's fleet is asleep the moment they update. - **The sleep settings are their own config block** (`fleetSleep`), not part of `independentPilots`, whose values are all checked as positive settings of a different kind. - **The threshold lives on the company**, beside the Dispatch Office and spending floor: it travels with the save, unlike device preferences. + +## Step 24.2 + +- **The catalog is `routes`** (`packages/schema/src/routes.ts`): `manualDecision` is a required field of `RouteDef`, so a new command fails the build until it says. +- **Counted beyond the CR's list:** unassigning and resting a pilot, refuel policy and emergency fuel (refuelling decisions), the spending floor (an order to the pilots) and renaming the company. They're all game commands the player makes; the CR counts "any game command". +- **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. diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 150b1b9..e0a6766 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -42,7 +42,7 @@ Every refusal carries `{ code, message }`: the engine's `CommandError`, which th ## API routes (`packages/schema/src/routes.ts`) -The `routes` catalog is the list of engine commands (CR-014). Each entry has `request` (path parameters merged with the body), `response` and `auth` (whether it needs a company). `method` and `path` are kept from the server's HTTP API: ported tests route by them. +The `routes` catalog is the list of engine commands (CR-014). Each entry has `request` (path parameters merged with the body), `response`, `auth` (whether it needs a company) and `manualDecision` (CR-024: whether it is one of the player's decisions; required, so every new command declares it). After a successful `manualDecision` command the engine sets `company.lastManualDecisionAt` to now; the fleet sleeps a threshold after it. `method` and `path` are kept from the server's HTTP API: ported tests route by them. | Name | Method & path | Request | Response | Auth | |---|---|---|---|---| diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 71c0be5..c8de1bc 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -522,7 +522,7 @@ Spec: `memory-bank/change-requests/CR-023-shop-time-rest.md` (received as "CR-02 Spec: `memory-bank/change-requests/CR-024-fleet-sleep.md` (received as "CR-022 … Phase 22"). New decisions recorded as plan §0.5.136–0.5.139. - [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). -- [ ] Step 24.2 — Engine: manual decision flag +- [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 - [ ] Step 24.4 — Engine: waking and "Back to work" - [ ] Step 24.5 — Client: return banner, status, and setting diff --git a/packages/engine/src/game/engine.ts b/packages/engine/src/game/engine.ts index 0edad16..1d281c8 100644 --- a/packages/engine/src/game/engine.ts +++ b/packages/engine/src/game/engine.ts @@ -255,6 +255,8 @@ export class GameEngine { viewedContractId: this.#viewedContractId, }); const result = handler({ save, now, random }, input); + // The catalog says which commands are the player's decisions (decision 0.5.136). + if (routes[name].manualDecision) save.company.lastManualDecisionAt = now; pruneSave(save, now); return { save, result, unchanged: JSON.stringify(save) === before }; }); diff --git a/packages/engine/test/fleet-sleep.test.ts b/packages/engine/test/fleet-sleep.test.ts index 94ff7c9..fa7a8e4 100644 --- a/packages/engine/test/fleet-sleep.test.ts +++ b/packages/engine/test/fleet-sleep.test.ts @@ -1,7 +1,16 @@ import { content } from "@deadhead/content"; import { describe, expect, it } from "vitest"; import { encodeSave } from "../src/index"; -import { newTestApp, readSave, signUp, TEST_NOW } from "./support"; +import { + addPilot, + addShip, + newTestApp, + readSave, + setCredits, + signUp, + signUpOwner, + TEST_NOW, +} from "./support"; const HOUR = 60 * 60_000; @@ -30,3 +39,54 @@ describe("fleet sleep in the save (Step 24.1, decisions 0.5.136, 0.5.137)", () = expect(imported.sleepAfterHours).toBe(24); }); }); + +describe("manual decisions (Step 24.2, decision 0.5.136)", () => { + it("stamps the time on a dispatch, not on viewing, dismissing or exporting", async () => { + const test = newTestApp(); + const owner = await signUpOwner(test.app); + const last = async () => (await readSave(test.app)).company.lastManualDecisionAt; + const { dispatch } = await test.app.call("createDispatch", { + kind: "reposition", + shipId: owner.shipId, + pilotId: (await readSave(test.app)).pilots.find((p) => p.isAvatar)!.id, + destinationId: "moon", + }); + const dispatchedAt = test.clock.now; + expect(await last()).toBe(dispatchedAt); + + // Later: the trip lands; viewing, dismissing its summary and exporting change nothing. + test.clock.now = dispatch.arrivalAt + HOUR; + await test.app.call("getPlayerState", {}); + await test.app.call("getContractBoard", { locationId: "earth" }); + await test.app.call("acknowledgeSummaries", { dispatchIds: [dispatch.id] }); + await test.app.call("markWelcomeSeen", {}); + await test.app.call("exportSave", {}); + expect(await last()).toBe(dispatchedAt); + }); + + it("doesn't count a refused command", async () => { + const test = newTestApp(); + await signUpOwner(test.app); + const start = (await readSave(test.app)).company.lastManualDecisionAt; + test.clock.now += HOUR; + await expect(test.app.call("buyDispatchOffice", {})).rejects.toThrow(); + expect((await readSave(test.app)).company.lastManualDecisionAt).toBe(start); + }); + + it("never counts what Independent pilots do", async () => { + const test = newTestApp(); + await signUpOwner(test.app); + await setCredits(test.app, content.config.independentPilots.dispatchOfficePrice + 50_000); + await test.app.call("buyDispatchOffice", {}); + await addShip(test.app, { id: "s1", modelId: "courier", name: "Wren" }); + await addPilot(test.app, { id: "p1", name: "Ada", assignedShipId: "s1" }); + await test.app.call("setPilotMode", { pilotId: "p1", mode: "independent" }); + const decided = (await readSave(test.app)).company.lastManualDecisionAt; + + test.clock.now += 4 * HOUR; + await test.app.call("getPlayerState", {}); + const save = await readSave(test.app); + expect(save.dispatches.some((d) => d.independent)).toBe(true); + expect(save.company.lastManualDecisionAt).toBe(decided); + }); +}); diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index 05b5b26..87d19f6 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -50,6 +50,12 @@ export interface RouteDef { response: z.ZodType; /** Whether it needs a company (was: a session, before CR-014). */ auth: boolean; + /** + * Whether running it is one of the player's manual decisions (decision + * 0.5.136): the fleet's sleep time counts from the last one. Required, so + * every new command has to say. + */ + manualDecision: boolean; } /** Every endpoint of the base game: the contract between server and client. */ @@ -60,6 +66,7 @@ export const routes = { request: emptyRequestSchema, response: serverTimeSchema, auth: false, + manualDecision: false, }, createGuest: { method: "POST", @@ -67,6 +74,7 @@ export const routes = { request: emptyRequestSchema, response: createGuestResponseSchema, auth: false, + manualDecision: false, }, getPlayerState: { method: "GET", @@ -74,6 +82,7 @@ export const routes = { request: emptyRequestSchema, response: playerStateSchema, auth: true, + manualDecision: false, }, getContractBoard: { method: "GET", @@ -81,6 +90,7 @@ export const routes = { request: contractBoardRequestSchema, response: contractBoardResponseSchema, auth: true, + manualDecision: false, }, getTutorialBoard: { method: "GET", @@ -88,6 +98,7 @@ export const routes = { request: emptyRequestSchema, response: contractBoardResponseSchema, auth: true, + manualDecision: false, }, createDispatch: { method: "POST", @@ -95,6 +106,7 @@ export const routes = { request: createDispatchRequestSchema, response: dispatchResponseSchema, auth: true, + manualDecision: true, }, buyShip: { method: "POST", @@ -102,6 +114,7 @@ export const routes = { request: buyShipRequestSchema, response: shipResponseSchema, auth: true, + manualDecision: true, }, startUpgrade: { method: "POST", @@ -109,6 +122,7 @@ export const routes = { request: startUpgradeRequestSchema, response: shipResponseSchema, auth: true, + manualDecision: true, }, /** CR-015: fill the tank (or buy `amountFu`) at the ship's location. */ refuelShip: { @@ -117,6 +131,7 @@ export const routes = { request: refuelRequestSchema, response: shipResponseSchema, auth: true, + manualDecision: true, }, setRefuelPolicy: { method: "POST", @@ -124,6 +139,7 @@ export const routes = { request: refuelPolicyRequestSchema, response: shipResponseSchema, auth: true, + manualDecision: true, }, /** CR-015: fuel for the cheapest way out, added to the loan (decision 0.5.99). */ orderEmergencyFuel: { @@ -132,6 +148,7 @@ export const routes = { request: emergencyFuelRequestSchema, response: shipResponseSchema, auth: true, + manualDecision: true, }, leaseBay: { method: "POST", @@ -139,6 +156,7 @@ export const routes = { request: leaseBayRequestSchema, response: bayResponseSchema, auth: true, + manualDecision: true, }, hirePilot: { method: "POST", @@ -146,6 +164,7 @@ export const routes = { request: hirePilotRequestSchema, response: pilotResponseSchema, auth: true, + manualDecision: true, }, assignPilot: { method: "POST", @@ -153,6 +172,7 @@ export const routes = { request: assignPilotRequestSchema, response: pilotResponseSchema, auth: true, + manualDecision: true, }, unassignPilot: { method: "POST", @@ -160,6 +180,7 @@ export const routes = { request: unassignPilotRequestSchema, response: pilotResponseSchema, auth: true, + manualDecision: true, }, restPilot: { method: "POST", @@ -167,6 +188,7 @@ export const routes = { request: restPilotRequestSchema, response: pilotResponseSchema, auth: true, + manualDecision: true, }, spendSkillPoint: { method: "POST", @@ -174,6 +196,7 @@ export const routes = { request: spendSkillPointRequestSchema, response: pilotResponseSchema, auth: true, + manualDecision: true, }, /** CR-021: Independent pilots (decisions 0.5.123–0.5.127). */ buyDispatchOffice: { @@ -182,6 +205,7 @@ export const routes = { request: emptyRequestSchema, response: dispatchOfficeResponseSchema, auth: true, + manualDecision: true, }, setPilotMode: { method: "POST", @@ -189,6 +213,7 @@ export const routes = { request: pilotModeRequestSchema, response: pilotResponseSchema, auth: true, + manualDecision: true, }, setPilotOrders: { method: "POST", @@ -196,6 +221,7 @@ export const routes = { request: pilotOrdersRequestSchema, response: pilotResponseSchema, auth: true, + manualDecision: true, }, setSpendingFloor: { method: "POST", @@ -203,6 +229,7 @@ export const routes = { request: spendingFloorRequestSchema, response: dispatchOfficeResponseSchema, auth: true, + manualDecision: true, }, acknowledgeSummaries: { method: "POST", @@ -210,6 +237,7 @@ export const routes = { request: acknowledgeSummariesRequestSchema, response: acknowledgeSummariesResponseSchema, auth: true, + manualDecision: false, }, /** CR-022: the welcome cards are closed for this company (decision 0.5.131). */ markWelcomeSeen: { @@ -218,6 +246,7 @@ export const routes = { request: emptyRequestSchema, response: playerStateSchema, auth: true, + manualDecision: false, }, acceptDeal: { method: "POST", @@ -225,6 +254,7 @@ export const routes = { request: emptyRequestSchema, response: playerStateSchema, auth: true, + manualDecision: true, }, /** On the device only (CR-014): the save as an export code. */ exportSave: { @@ -233,6 +263,7 @@ export const routes = { request: emptyRequestSchema, response: exportSaveResponseSchema, auth: true, + manualDecision: false, }, /** On the device only (CR-014): replaces this device's company with an exported one. */ importSave: { @@ -241,6 +272,7 @@ export const routes = { request: importSaveRequestSchema, response: createGuestResponseSchema, auth: false, + manualDecision: false, }, /** Non-production only (CR-011): not registered when NODE_ENV is production. */ devAdvanceClock: { @@ -249,6 +281,7 @@ export const routes = { request: devAdvanceClockRequestSchema, response: devAdvanceClockResponseSchema, auth: true, + manualDecision: false, }, devAddCredits: { method: "POST", @@ -256,6 +289,7 @@ export const routes = { request: devAddCreditsRequestSchema, response: companyResponseSchema, auth: true, + manualDecision: false, }, devPilot: { method: "POST", @@ -263,6 +297,7 @@ export const routes = { request: devPilotRequestSchema, response: playerStateSchema, auth: true, + manualDecision: false, }, devShip: { method: "POST", @@ -270,6 +305,7 @@ export const routes = { request: devShipRequestSchema, response: playerStateSchema, auth: true, + manualDecision: false, }, devDiscoverAll: { method: "POST", @@ -277,6 +313,7 @@ export const routes = { request: emptyRequestSchema, response: playerStateSchema, auth: true, + manualDecision: false, }, newCompany: { method: "POST", @@ -284,6 +321,7 @@ export const routes = { request: emptyRequestSchema, response: playerStateSchema, auth: true, + manualDecision: false, }, renameCompany: { method: "POST", @@ -291,6 +329,7 @@ export const routes = { request: renameCompanyRequestSchema, response: companyResponseSchema, auth: true, + manualDecision: true, }, repayLoan: { method: "POST", @@ -298,6 +337,7 @@ export const routes = { request: repayLoanRequestSchema, response: companyResponseSchema, auth: true, + manualDecision: true, }, claimGoal: { method: "POST", @@ -305,6 +345,7 @@ export const routes = { request: claimGoalRequestSchema, response: companyResponseSchema, auth: true, + manualDecision: true, }, } as const satisfies Record; diff --git a/packages/schema/test/routes.test.ts b/packages/schema/test/routes.test.ts index 08c976b..0f3bb79 100644 --- a/packages/schema/test/routes.test.ts +++ b/packages/schema/test/routes.test.ts @@ -79,3 +79,42 @@ describe("route catalog", () => { expect(open.sort()).toEqual(["createGuest", "getServerTime", "importSave"]); }); }); + +describe("manual decisions in the catalog (Step 24.2, decision 0.5.136)", () => { + it("has every command declare whether it is a manual decision", () => { + for (const [name, route] of Object.entries(routes) as [string, RouteDef][]) { + expect(typeof route.manualDecision, name).toBe("boolean"); + } + }); + + it("counts the player's game commands, not viewing, dismissing, saves or dev tools", () => { + const manual = Object.entries(routes) + .filter(([, route]) => route.manualDecision) + .map(([name]) => name) + .sort(); + expect(manual).toEqual( + [ + "acceptDeal", + "assignPilot", + "buyDispatchOffice", + "buyShip", + "claimGoal", + "createDispatch", + "hirePilot", + "leaseBay", + "orderEmergencyFuel", + "refuelShip", + "renameCompany", + "repayLoan", + "restPilot", + "setPilotMode", + "setPilotOrders", + "setRefuelPolicy", + "setSpendingFloor", + "spendSkillPoint", + "startUpgrade", + "unassignPilot", + ].sort(), + ); + }); +});