Step 24.2: engine: manual decision flag
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:11:48 -04:00
parent bf55eb8d9f
commit 587f504ae1
7 changed files with 153 additions and 3 deletions

View File

@ -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. - **"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 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. - **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.

View File

@ -42,7 +42,7 @@ Every refusal carries `{ code, message }`: the engine's `CommandError`, which th
## API routes (`packages/schema/src/routes.ts`) ## 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 | | Name | Method & path | Request | Response | Auth |
|---|---|---|---|---| |---|---|---|---|---|

View File

@ -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. 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). - [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.3 — Engine: sleep in live play and catch-up
- [ ] Step 24.4 — Engine: waking and "Back to work" - [ ] Step 24.4 — Engine: waking and "Back to work"
- [ ] Step 24.5 — Client: return banner, status, and setting - [ ] Step 24.5 — Client: return banner, status, and setting

View File

@ -255,6 +255,8 @@ export class GameEngine {
viewedContractId: this.#viewedContractId, viewedContractId: this.#viewedContractId,
}); });
const result = handler({ save, now, random }, input); 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); pruneSave(save, now);
return { save, result, unchanged: JSON.stringify(save) === before }; return { save, result, unchanged: JSON.stringify(save) === before };
}); });

View File

@ -1,7 +1,16 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { encodeSave } from "../src/index"; 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; 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); 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);
});
});

View File

@ -50,6 +50,12 @@ export interface RouteDef {
response: z.ZodType; response: z.ZodType;
/** Whether it needs a company (was: a session, before CR-014). */ /** Whether it needs a company (was: a session, before CR-014). */
auth: boolean; 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. */ /** Every endpoint of the base game: the contract between server and client. */
@ -60,6 +66,7 @@ export const routes = {
request: emptyRequestSchema, request: emptyRequestSchema,
response: serverTimeSchema, response: serverTimeSchema,
auth: false, auth: false,
manualDecision: false,
}, },
createGuest: { createGuest: {
method: "POST", method: "POST",
@ -67,6 +74,7 @@ export const routes = {
request: emptyRequestSchema, request: emptyRequestSchema,
response: createGuestResponseSchema, response: createGuestResponseSchema,
auth: false, auth: false,
manualDecision: false,
}, },
getPlayerState: { getPlayerState: {
method: "GET", method: "GET",
@ -74,6 +82,7 @@ export const routes = {
request: emptyRequestSchema, request: emptyRequestSchema,
response: playerStateSchema, response: playerStateSchema,
auth: true, auth: true,
manualDecision: false,
}, },
getContractBoard: { getContractBoard: {
method: "GET", method: "GET",
@ -81,6 +90,7 @@ export const routes = {
request: contractBoardRequestSchema, request: contractBoardRequestSchema,
response: contractBoardResponseSchema, response: contractBoardResponseSchema,
auth: true, auth: true,
manualDecision: false,
}, },
getTutorialBoard: { getTutorialBoard: {
method: "GET", method: "GET",
@ -88,6 +98,7 @@ export const routes = {
request: emptyRequestSchema, request: emptyRequestSchema,
response: contractBoardResponseSchema, response: contractBoardResponseSchema,
auth: true, auth: true,
manualDecision: false,
}, },
createDispatch: { createDispatch: {
method: "POST", method: "POST",
@ -95,6 +106,7 @@ export const routes = {
request: createDispatchRequestSchema, request: createDispatchRequestSchema,
response: dispatchResponseSchema, response: dispatchResponseSchema,
auth: true, auth: true,
manualDecision: true,
}, },
buyShip: { buyShip: {
method: "POST", method: "POST",
@ -102,6 +114,7 @@ export const routes = {
request: buyShipRequestSchema, request: buyShipRequestSchema,
response: shipResponseSchema, response: shipResponseSchema,
auth: true, auth: true,
manualDecision: true,
}, },
startUpgrade: { startUpgrade: {
method: "POST", method: "POST",
@ -109,6 +122,7 @@ export const routes = {
request: startUpgradeRequestSchema, request: startUpgradeRequestSchema,
response: shipResponseSchema, response: shipResponseSchema,
auth: true, auth: true,
manualDecision: true,
}, },
/** CR-015: fill the tank (or buy `amountFu`) at the ship's location. */ /** CR-015: fill the tank (or buy `amountFu`) at the ship's location. */
refuelShip: { refuelShip: {
@ -117,6 +131,7 @@ export const routes = {
request: refuelRequestSchema, request: refuelRequestSchema,
response: shipResponseSchema, response: shipResponseSchema,
auth: true, auth: true,
manualDecision: true,
}, },
setRefuelPolicy: { setRefuelPolicy: {
method: "POST", method: "POST",
@ -124,6 +139,7 @@ export const routes = {
request: refuelPolicyRequestSchema, request: refuelPolicyRequestSchema,
response: shipResponseSchema, response: shipResponseSchema,
auth: true, auth: true,
manualDecision: true,
}, },
/** CR-015: fuel for the cheapest way out, added to the loan (decision 0.5.99). */ /** CR-015: fuel for the cheapest way out, added to the loan (decision 0.5.99). */
orderEmergencyFuel: { orderEmergencyFuel: {
@ -132,6 +148,7 @@ export const routes = {
request: emergencyFuelRequestSchema, request: emergencyFuelRequestSchema,
response: shipResponseSchema, response: shipResponseSchema,
auth: true, auth: true,
manualDecision: true,
}, },
leaseBay: { leaseBay: {
method: "POST", method: "POST",
@ -139,6 +156,7 @@ export const routes = {
request: leaseBayRequestSchema, request: leaseBayRequestSchema,
response: bayResponseSchema, response: bayResponseSchema,
auth: true, auth: true,
manualDecision: true,
}, },
hirePilot: { hirePilot: {
method: "POST", method: "POST",
@ -146,6 +164,7 @@ export const routes = {
request: hirePilotRequestSchema, request: hirePilotRequestSchema,
response: pilotResponseSchema, response: pilotResponseSchema,
auth: true, auth: true,
manualDecision: true,
}, },
assignPilot: { assignPilot: {
method: "POST", method: "POST",
@ -153,6 +172,7 @@ export const routes = {
request: assignPilotRequestSchema, request: assignPilotRequestSchema,
response: pilotResponseSchema, response: pilotResponseSchema,
auth: true, auth: true,
manualDecision: true,
}, },
unassignPilot: { unassignPilot: {
method: "POST", method: "POST",
@ -160,6 +180,7 @@ export const routes = {
request: unassignPilotRequestSchema, request: unassignPilotRequestSchema,
response: pilotResponseSchema, response: pilotResponseSchema,
auth: true, auth: true,
manualDecision: true,
}, },
restPilot: { restPilot: {
method: "POST", method: "POST",
@ -167,6 +188,7 @@ export const routes = {
request: restPilotRequestSchema, request: restPilotRequestSchema,
response: pilotResponseSchema, response: pilotResponseSchema,
auth: true, auth: true,
manualDecision: true,
}, },
spendSkillPoint: { spendSkillPoint: {
method: "POST", method: "POST",
@ -174,6 +196,7 @@ export const routes = {
request: spendSkillPointRequestSchema, request: spendSkillPointRequestSchema,
response: pilotResponseSchema, response: pilotResponseSchema,
auth: true, auth: true,
manualDecision: true,
}, },
/** CR-021: Independent pilots (decisions 0.5.123–0.5.127). */ /** CR-021: Independent pilots (decisions 0.5.123–0.5.127). */
buyDispatchOffice: { buyDispatchOffice: {
@ -182,6 +205,7 @@ export const routes = {
request: emptyRequestSchema, request: emptyRequestSchema,
response: dispatchOfficeResponseSchema, response: dispatchOfficeResponseSchema,
auth: true, auth: true,
manualDecision: true,
}, },
setPilotMode: { setPilotMode: {
method: "POST", method: "POST",
@ -189,6 +213,7 @@ export const routes = {
request: pilotModeRequestSchema, request: pilotModeRequestSchema,
response: pilotResponseSchema, response: pilotResponseSchema,
auth: true, auth: true,
manualDecision: true,
}, },
setPilotOrders: { setPilotOrders: {
method: "POST", method: "POST",
@ -196,6 +221,7 @@ export const routes = {
request: pilotOrdersRequestSchema, request: pilotOrdersRequestSchema,
response: pilotResponseSchema, response: pilotResponseSchema,
auth: true, auth: true,
manualDecision: true,
}, },
setSpendingFloor: { setSpendingFloor: {
method: "POST", method: "POST",
@ -203,6 +229,7 @@ export const routes = {
request: spendingFloorRequestSchema, request: spendingFloorRequestSchema,
response: dispatchOfficeResponseSchema, response: dispatchOfficeResponseSchema,
auth: true, auth: true,
manualDecision: true,
}, },
acknowledgeSummaries: { acknowledgeSummaries: {
method: "POST", method: "POST",
@ -210,6 +237,7 @@ export const routes = {
request: acknowledgeSummariesRequestSchema, request: acknowledgeSummariesRequestSchema,
response: acknowledgeSummariesResponseSchema, response: acknowledgeSummariesResponseSchema,
auth: true, auth: true,
manualDecision: false,
}, },
/** CR-022: the welcome cards are closed for this company (decision 0.5.131). */ /** CR-022: the welcome cards are closed for this company (decision 0.5.131). */
markWelcomeSeen: { markWelcomeSeen: {
@ -218,6 +246,7 @@ export const routes = {
request: emptyRequestSchema, request: emptyRequestSchema,
response: playerStateSchema, response: playerStateSchema,
auth: true, auth: true,
manualDecision: false,
}, },
acceptDeal: { acceptDeal: {
method: "POST", method: "POST",
@ -225,6 +254,7 @@ export const routes = {
request: emptyRequestSchema, request: emptyRequestSchema,
response: playerStateSchema, response: playerStateSchema,
auth: true, auth: true,
manualDecision: true,
}, },
/** On the device only (CR-014): the save as an export code. */ /** On the device only (CR-014): the save as an export code. */
exportSave: { exportSave: {
@ -233,6 +263,7 @@ export const routes = {
request: emptyRequestSchema, request: emptyRequestSchema,
response: exportSaveResponseSchema, response: exportSaveResponseSchema,
auth: true, auth: true,
manualDecision: false,
}, },
/** On the device only (CR-014): replaces this device's company with an exported one. */ /** On the device only (CR-014): replaces this device's company with an exported one. */
importSave: { importSave: {
@ -241,6 +272,7 @@ export const routes = {
request: importSaveRequestSchema, request: importSaveRequestSchema,
response: createGuestResponseSchema, response: createGuestResponseSchema,
auth: false, auth: false,
manualDecision: false,
}, },
/** Non-production only (CR-011): not registered when NODE_ENV is production. */ /** Non-production only (CR-011): not registered when NODE_ENV is production. */
devAdvanceClock: { devAdvanceClock: {
@ -249,6 +281,7 @@ export const routes = {
request: devAdvanceClockRequestSchema, request: devAdvanceClockRequestSchema,
response: devAdvanceClockResponseSchema, response: devAdvanceClockResponseSchema,
auth: true, auth: true,
manualDecision: false,
}, },
devAddCredits: { devAddCredits: {
method: "POST", method: "POST",
@ -256,6 +289,7 @@ export const routes = {
request: devAddCreditsRequestSchema, request: devAddCreditsRequestSchema,
response: companyResponseSchema, response: companyResponseSchema,
auth: true, auth: true,
manualDecision: false,
}, },
devPilot: { devPilot: {
method: "POST", method: "POST",
@ -263,6 +297,7 @@ export const routes = {
request: devPilotRequestSchema, request: devPilotRequestSchema,
response: playerStateSchema, response: playerStateSchema,
auth: true, auth: true,
manualDecision: false,
}, },
devShip: { devShip: {
method: "POST", method: "POST",
@ -270,6 +305,7 @@ export const routes = {
request: devShipRequestSchema, request: devShipRequestSchema,
response: playerStateSchema, response: playerStateSchema,
auth: true, auth: true,
manualDecision: false,
}, },
devDiscoverAll: { devDiscoverAll: {
method: "POST", method: "POST",
@ -277,6 +313,7 @@ export const routes = {
request: emptyRequestSchema, request: emptyRequestSchema,
response: playerStateSchema, response: playerStateSchema,
auth: true, auth: true,
manualDecision: false,
}, },
newCompany: { newCompany: {
method: "POST", method: "POST",
@ -284,6 +321,7 @@ export const routes = {
request: emptyRequestSchema, request: emptyRequestSchema,
response: playerStateSchema, response: playerStateSchema,
auth: true, auth: true,
manualDecision: false,
}, },
renameCompany: { renameCompany: {
method: "POST", method: "POST",
@ -291,6 +329,7 @@ export const routes = {
request: renameCompanyRequestSchema, request: renameCompanyRequestSchema,
response: companyResponseSchema, response: companyResponseSchema,
auth: true, auth: true,
manualDecision: true,
}, },
repayLoan: { repayLoan: {
method: "POST", method: "POST",
@ -298,6 +337,7 @@ export const routes = {
request: repayLoanRequestSchema, request: repayLoanRequestSchema,
response: companyResponseSchema, response: companyResponseSchema,
auth: true, auth: true,
manualDecision: true,
}, },
claimGoal: { claimGoal: {
method: "POST", method: "POST",
@ -305,6 +345,7 @@ export const routes = {
request: claimGoalRequestSchema, request: claimGoalRequestSchema,
response: companyResponseSchema, response: companyResponseSchema,
auth: true, auth: true,
manualDecision: true,
}, },
} as const satisfies Record<string, RouteDef>; } as const satisfies Record<string, RouteDef>;

View File

@ -79,3 +79,42 @@ describe("route catalog", () => {
expect(open.sort()).toEqual(["createGuest", "getServerTime", "importSave"]); 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(),
);
});
});