Step 24.2: engine: manual decision flag
CI / check (push) Has been cancelled
Details
CI / check (push) Has been cancelled
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bf55eb8d9f
commit
587f504ae1
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|---|---|---|---|---|
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, RouteDef>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue