Step 24.4: engine: waking and Back to work
CI / check (push) Successful in 6m56s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-15 21:20:09 -04:00
parent f5cf2a8424
commit 08015fe97c
7 changed files with 67 additions and 1 deletions

View File

@ -1826,3 +1826,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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.
## Step 24.4
- **"Back to work" stamps the time itself**, as well as through the catalog flag: the engine stamps after the handler, and the command's reply (the player state) should already read awake.
- **Waking needs no other change**: pilots asleep were left waiting at the time of the last catch-up, which is the moment of the waking command, so the next catch-up has them decide from then.

View File

@ -64,6 +64,7 @@ The `routes` catalog is the list of engine commands (CR-014). Each entry has `re
| `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes |
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds`, optional `shipIds` (finished upgrades seen; at least one id overall) | `{ acknowledged }` | yes |
| `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes |
| `backToWork` | `POST /api/fleet/wake` | — | `PlayerState` (awake) | yes; a manual decision that does nothing else: wakes the fleet (CR-024) |
| `exportSave` | engine only (CR-014) | — | `{ code }`: `DEADHEAD1.` + base64url(gzip(save JSON)) | yes |
| `importSave` | engine only (CR-014) | `code` (whitespace ignored) | `{ playerId, state }`; `invalid-save` if it isn't a good save | no |
| `devAdvanceClock` | `POST /api/test/advance-clock` | `advanceMs` | `{ offsetMs, realTime }` | yes; developer builds only |

View File

@ -524,7 +524,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).
- [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"
- [x] **Step 24.4 — Engine: waking and "Back to work".** New command `backToWork` (`POST /api/fleet/wake`, a manual decision): it sets the last decision to now and returns the player state, awake. Any manual decision does the same, and pilots waiting at that time decide from it. Tests (`packages/engine/test/fleet-sleep.test.ts`): after two days asleep, "Back to work" brings Independent trips again, none before the moment of waking; so does buying fuel. `packages/schema/test/routes.test.ts` lists it as manual.
- [ ] Step 24.5 — Client: return banner, status, and setting
- [ ] Step 24.6 — Client: sleep notification (native)
- [ ] Step 24.7 — Plan amendments and regression

View File

@ -68,6 +68,12 @@ export function commandTable(): CommandTable {
acknowledgeSummaries: ({ save, now }, input) => ({
acknowledged: acknowledge(save, input, now),
}),
// Wakes the fleet now (decision 0.5.138). The engine stamps every manual decision
// after the command; stamping here too makes the returned state awake.
backToWork: ({ save, now }) => {
save.company.lastManualDecisionAt = now;
return playerState(save, now);
},
markWelcomeSeen: ({ save, now }) => {
save.player.welcomeSeen = true;
return playerState(save, now);

View File

@ -186,3 +186,46 @@ describe("fleet sleep in catch-up (Step 24.3, decision 0.5.137)", () => {
expect(Math.max(...departures)).toBeLessThan(decided + 48 * HOUR);
});
});
describe("waking the fleet (Step 24.4, decision 0.5.138)", () => {
/** Asleep for two days, then the player decides something at `wake`. */
async function asleep() {
const test = newTestApp();
const decided = await sleepyFleet(test);
const wake = decided + 3 * DAY;
test.clock.now = wake;
expect((await test.app.call("getPlayerState", {})).fleetSleep.asleep).toBe(true);
return { test, wake };
}
const tripsFrom = async (test: TestApp, from: number) =>
independentTrips(await readSave(test.app)).filter((d) => d.record.departureAt >= from);
it('puts Independent pilots back to work with "Back to work"', async () => {
const { test, wake } = await asleep();
expect(await tripsFrom(test, wake - DAY)).toEqual([]);
const state = await test.app.call("backToWork", {});
expect(state.fleetSleep).toEqual({ afterHours: 24, sleepsAt: wake + DAY, asleep: false });
test.clock.now = wake + HOUR;
await test.app.call("getPlayerState", {});
const trips = await tripsFrom(test, wake);
expect(trips.length).toBeGreaterThan(0);
// From the moment of waking, not before.
expect(Math.min(...trips.map((d) => d.record.departureAt))).toBeGreaterThanOrEqual(wake);
});
it("wakes them with any other manual decision, such as buying fuel", async () => {
const { test, wake } = await asleep();
const ship = (await readSave(test.app)).ships.find(
(s) => s.locationId === "earth" && s.fuelFu > 10,
)!;
await edit(test.app, (save) => {
save.ships.find((s) => s.id === ship.id)!.fuelFu -= 10;
});
await test.app.call("refuelShip", { shipId: ship.id, amountFu: 5 });
test.clock.now = wake + HOUR;
expect((await test.app.call("getPlayerState", {})).fleetSleep.asleep).toBe(false);
expect((await tripsFrom(test, wake)).length).toBeGreaterThan(0);
});
});

View File

@ -240,6 +240,15 @@ export const routes = {
manualDecision: false,
},
/** CR-022: the welcome cards are closed for this company (decision 0.5.131). */
backToWork: {
method: "POST",
path: "/api/fleet/wake",
request: emptyRequestSchema,
response: playerStateSchema,
auth: true,
// "Back to work" wakes the fleet: it is a decision (decision 0.5.138).
manualDecision: true,
},
markWelcomeSeen: {
method: "POST",
path: "/api/welcome/seen",

View File

@ -31,6 +31,7 @@ describe("route catalog", () => {
"setSpendingFloor",
"acknowledgeSummaries",
"acceptDeal",
"backToWork",
"markWelcomeSeen",
"exportSave",
"importSave",
@ -95,6 +96,7 @@ describe("manual decisions in the catalog (Step 24.2, decision 0.5.136)", () =>
expect(manual).toEqual(
[
"acceptDeal",
"backToWork",
"assignPilot",
"buyDispatchOffice",
"buyShip",