From 3dba7c20190b6f7287c9c0607b87bcc4cccf9274 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 22:01:45 -0400 Subject: [PATCH] Step 10D.5: apply duty and rest on the server Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 7 ++ apps/server/src/game/dispatch.ts | 3 +- apps/server/src/game/pilots.ts | 37 ++++++- apps/server/src/game/resolve.ts | 6 +- apps/server/src/routes/fleet.ts | 8 +- apps/server/test/duty-rest.test.ts | 158 +++++++++++++++++++++++++++++ memory-bank/progress.md | 2 +- 7 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 apps/server/test/duty-rest.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 4a8972b..e5fa39b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -787,3 +787,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **`pilot-resting` needed no new server code:** the API's error codes include every sim validation reason, and 10D.3 added it there. - **Pilot responses depend on the time** (resting or not), so `toApiPilot`, `loadPlayerState` and the pilot actions now take `now` from `withPlayer`. - **Duty hours are a double** (game hours with fractions, as trip times are). Summaries read `restEndsAt` from the stored resolution, so older resolutions without it show null. + +## Step 10D.5 + +- **One code path for both resolutions:** lazy reads and the pg-boss arrival job both call `resolveArrivedDispatches`, so duty and rest are written in one place. `rest_ends_at` is only written when a trip starts mandatory rest, so a voluntary rest is never cut short by an unrelated resolution. +- **Resting is refused during the tutorial (`tutorial-active`)**, like purchases and hiring: tutorial runs accrue no duty, and a resting avatar would stall the tutorial with no other pilot. +- **Resting a pilot already resting is `pilot-resting`**, and resting one in flight is `pilot-not-idle`, reusing the dispatch codes. +- **Crew swaps:** assign and unassign never check rest (decision 0.5.44), so a resting pilot can be left at a bay and a fresh pilot can take the ship straight away. diff --git a/apps/server/src/game/dispatch.ts b/apps/server/src/game/dispatch.ts index 1e56866..1137358 100644 --- a/apps/server/src/game/dispatch.ts +++ b/apps/server/src/game/dispatch.ts @@ -78,8 +78,7 @@ export function pilotState(row: typeof pilots.$inferSelect): PilotState { assignedShipId: row.assignedShipId, inFlight: row.status === "in_flight", skills: row.skills, - // Stored from Step 10D.4; until then nobody rests. - restEndsAt: null, + restEndsAt: row.restEndsAt, }; } diff --git a/apps/server/src/game/pilots.ts b/apps/server/src/game/pilots.ts index e2194f7..c4ecbfa 100644 --- a/apps/server/src/game/pilots.ts +++ b/apps/server/src/game/pilots.ts @@ -1,7 +1,13 @@ import { randomUUID } from "node:crypto"; import { content } from "@deadhead/content"; import type { Pilot } from "@deadhead/schema"; -import { pilotLevel, skillPointsAvailable, type RealTime } from "@deadhead/sim"; +import { + isResting, + pilotLevel, + restEndsAt, + skillPointsAvailable, + type RealTime, +} from "@deadhead/sim"; import { and, count, eq } from "drizzle-orm"; import type { Queryable } from "../db/client"; import { bays, pilots, ships } from "../db/schema"; @@ -142,6 +148,35 @@ export async function unassignPilot( return toApiPilot(updated!, null, now); } +/** + * Rests an idle pilot now, at any duty level (CR-004): duty resets and they + * cannot fly until the rest ends. No bay is needed; crew swaps stay allowed. + */ +export async function restPilot( + tx: Queryable, + playerId: string, + pilotId: string, + now: RealTime, +): Promise { + await requireTutorialOver(tx, playerId, "resting a pilot"); + const pilot = await requireOwnPilot(tx, playerId, pilotId); + if (pilot.status === "in_flight") { + throw gameError("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]); + } + if (isResting(pilot.restEndsAt, now)) { + throw gameError("pilot-resting", "That pilot is already resting."); + } + const [updated] = await tx + .update(pilots) + .set({ dutyHours: 0, restEndsAt: restEndsAt(now, content.config) }) + .where(eq(pilots.id, pilotId)) + .returning(); + const ship = updated!.assignedShipId + ? await requireOwnShip(tx, playerId, updated!.assignedShipId) + : null; + return toApiPilot(updated!, ship?.locationId ?? null, now); +} + /** Spends one available skill point on a skill below its max level (Step 6.12). */ export async function spendSkillPoint( tx: Queryable, diff --git a/apps/server/src/game/resolve.ts b/apps/server/src/game/resolve.ts index 2ee7f5b..519d2d3 100644 --- a/apps/server/src/game/resolve.ts +++ b/apps/server/src/game/resolve.ts @@ -50,8 +50,7 @@ export async function resolveArrivedDispatches( pilotIsAvatar: pilot.isAvatar, discoveredIds, loanBalance, - // Duty is stored from Step 10D.4. - pilotDutyHours: 0, + pilotDutyHours: pilot.dutyHours, pilotSkills: pilot.skills, now, }, @@ -82,6 +81,9 @@ export async function resolveArrivedDispatches( .set({ xp: sql`${pilots.xp} + ${resolution.pilotXp}`, status: "idle", + // Duty and mandatory rest from the arrival time (decisions 0.5.42–0.5.45). + dutyHours: resolution.pilotDutyHours, + ...(resolution.pilotRestEndsAt !== null ? { restEndsAt: resolution.pilotRestEndsAt } : {}), // Only unassigned pilots store a location (decision 0.5.27): the tutorial avatar. ...(pilot.assignedShipId === null ? { locationId: resolution.shipLocationId } : {}), }) diff --git a/apps/server/src/routes/fleet.ts b/apps/server/src/routes/fleet.ts index c36bb4c..c5bf7de 100644 --- a/apps/server/src/routes/fleet.ts +++ b/apps/server/src/routes/fleet.ts @@ -1,7 +1,7 @@ import { routes } from "@deadhead/schema"; import type { FastifyInstance } from "fastify"; import { buyShip, leaseBay } from "../game/fleet"; -import { assignPilot, hirePilot, spendSkillPoint, unassignPilot } from "../game/pilots"; +import { assignPilot, hirePilot, restPilot, spendSkillPoint, unassignPilot } from "../game/pilots"; import { registerRoute } from "../http/route"; import type { AppContext } from "./context"; import { withPlayer } from "./with-player"; @@ -38,6 +38,12 @@ export function registerFleetRoutes(app: FastifyInstance, ctx: AppContext): void })), ); + registerRoute(app, routes.restPilot, ctx.options, async (input, { playerId }) => + withPlayer(ctx, playerId, async (tx, now) => ({ + pilot: await restPilot(tx, playerId, input.pilotId, now), + })), + ); + registerRoute(app, routes.spendSkillPoint, ctx.options, async (input, { playerId }) => withPlayer(ctx, playerId, async (tx, now) => ({ pilot: await spendSkillPoint(tx, playerId, input.pilotId, input.skillId, now), diff --git a/apps/server/test/duty-rest.test.ts b/apps/server/test/duty-rest.test.ts new file mode 100644 index 0000000..13ee6cc --- /dev/null +++ b/apps/server/test/duty-rest.test.ts @@ -0,0 +1,158 @@ +import { content } from "@deadhead/content"; +import { + dispatchResponseSchema, + errorResponseSchema, + pilotResponseSchema, + type PlayerState, +} from "@deadhead/schema"; +import { restEndsAt } from "@deadhead/sim"; +import { describe, expect, it } from "vitest"; +import { setCredits, useTestDatabase } from "./db"; +import { + call, + getState, + insertDispatch, + putBoard, + signUp, + signUpOwner, + testContract, + useTestApp, + type Guest, +} from "./game"; + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +const errorCode = (response: { json(): unknown }) => + errorResponseSchema.parse(response.json()).error.code; +const avatar = (state: PlayerState) => state.pilots.find((p) => p.isAvatar)!; +const FAR_FUTURE = Date.parse("2200-01-01T00:00:00Z"); + +/** Flies the owner's Courier Earth↔Moon until a trip ends in mandatory rest. */ +async function chainUntilRest(owner: Guest & { shipId: string }) { + const { clock } = testApp(); + let at = "earth"; + for (let trip = 0; trip < 20; trip++) { + const to = at === "earth" ? "moon" : "earth"; + const record = await insertDispatch(database().db, { + playerId: owner.playerId, + shipId: owner.shipId, + pilotId: owner.avatarId, + departAt: clock.now, + contract: testContract({ originId: at, destinationId: to, expiresAt: FAR_FUTURE }), + }); + clock.now = record.arrivalAt; + const state = await getState(testApp().app, owner); + at = to; + if (avatar(state).resting) return { state, record, at }; + expect(avatar(state).dutyHours).toBeGreaterThan(0); + } + throw new Error("never needed rest"); +} + +describe("pilot duty and rest on the server (Step 10D.5)", () => { + it("rests a pilot after chained runs, refuses them until rest ends, then dispatches", async () => { + const { app, clock } = testApp(); + const owner = await signUpOwner(app, database().db); + await setCredits(database().db, owner.playerId, 50_000); + + const { state, record, at } = await chainUntilRest(owner); + const rested = avatar(state); + expect(rested).toMatchObject({ dutyHours: 0, resting: true }); + // Rest starts at the arrival time (decision 0.5.45). + expect(rested.restEndsAt).toBe(restEndsAt(record.arrivalAt, content.config)); + expect(state.summaries.at(-1)!.restEndsAt).toBe(rested.restEndsAt); + + const job = testContract({ + originId: at, + destinationId: at === "earth" ? "moon" : "earth", + expiresAt: FAR_FUTURE, + }); + await putBoard(database().db, owner.playerId, [job], clock.now, at); + const dispatch = () => + call(app, owner, "POST", "/api/dispatches", { + kind: "contract", + contractId: job.id, + shipId: owner.shipId, + pilotId: owner.avatarId, + }); + + const refused = await dispatch(); + expect(refused.statusCode).toBe(409); + expect(errorCode(refused)).toBe("pilot-resting"); + + clock.now = rested.restEndsAt!; + const accepted = await dispatch(); + expect(accepted.statusCode).toBe(200); + dispatchResponseSchema.parse(accepted.json()); + }); + + it("rests an idle pilot by hand, and refuses resting one who is flying or already resting", async () => { + const { app, clock } = testApp(); + const owner = await signUpOwner(app, database().db); + + const response = await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/rest`, {}); + expect(response.statusCode).toBe(200); + expect(pilotResponseSchema.parse(response.json()).pilot).toMatchObject({ + dutyHours: 0, + resting: true, + restEndsAt: restEndsAt(clock.now, content.config), + }); + + const again = await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/rest`, {}); + expect(again.statusCode).toBe(409); + expect(errorCode(again)).toBe("pilot-resting"); + + const other = await signUpOwner(app, database().db); + await insertDispatch(database().db, { + playerId: other.playerId, + shipId: other.shipId, + pilotId: other.avatarId, + departAt: clock.now, + }); + const flying = await call(app, other, "POST", `/api/pilots/${other.avatarId}/rest`, {}); + expect(flying.statusCode).toBe(409); + expect(errorCode(flying)).toBe("pilot-not-idle"); + }); + + it("lets a resting pilot leave the ship at a bay and a fresh pilot fly it at once", async () => { + const { app, clock } = testApp(); + const owner = await signUpOwner(app, database().db); + await setCredits(database().db, owner.playerId, 50_000); + await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/rest`, {}); + + const unassign = await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/unassign`, {}); + expect(unassign.statusCode).toBe(200); + + const hired = pilotResponseSchema.parse( + (await call(app, owner, "POST", "/api/pilots", { locationId: "earth" })).json(), + ).pilot; + const assign = await call(app, owner, "POST", `/api/pilots/${hired.id}/assign`, { + shipId: owner.shipId, + }); + expect(assign.statusCode).toBe(200); + + const job = testContract({ expiresAt: FAR_FUTURE }); + await putBoard(database().db, owner.playerId, [job], clock.now); + const response = await call(app, owner, "POST", "/api/dispatches", { + kind: "contract", + contractId: job.id, + shipId: owner.shipId, + pilotId: hired.id, + }); + expect(response.statusCode).toBe(200); + }); + + it("does not rest the avatar during the tutorial", async () => { + const guest = await signUp(testApp().app); + const response = await call( + testApp().app, + guest, + "POST", + `/api/pilots/${guest.avatarId}/rest`, + {}, + ); + expect(response.statusCode).toBe(409); + expect(errorCode(response)).toBe("tutorial-active"); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 32513d0..236a9f2 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -168,7 +168,7 @@ Spec: `memory-bank/change-requests/CR-004-pilot-duty-rest.md`. New decisions rec - [x] **Step 10D.2 — Sim: duty and rest functions.** `packages/sim/src/duty.ts`: `dutyLimit(skills)`, `isResting(restEndsAt, now)`, `tripGameHours(departure, arrival)`, `dutyAfterTrip(duty, hours, tutorial)`, `needsRest(duty, limit)`, `restEndsAt(start)`. Test: `duty.test.ts` (including 2,000 random rest starts all waking in a later board period). - [x] **Step 10D.3 — Sim: validation and resolution.** `PilotState.restEndsAt`; `validateDispatch` and `validateReposition` fail with `pilot-resting` before the other pilot checks. `ResolveInput` takes `pilotDutyHours` and `pilotSkills`; `Resolution` returns `pilotDutyHours` and `pilotRestEndsAt` (rest from the arrival time). Server and client pass interim values (no rest, zero duty) until the columns exist (10D.4–10D.5). Test: `duty-dispatch.test.ts`. - [x] **Step 10D.4 — Schema and database.** Migration `0004_cr004_pilot_duty`: `pilots.duty_hours` (double, default 0) and `pilots.rest_ends_at` (nullable). API `Pilot` gains `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting); `ResolutionSummary` gains `restEndsAt` (mandatory rest). Route `restPilot` (`POST /api/pilots/:pilotId/rest`). `pilot-resting` is an API code via the sim's failure list. `toApiPilot` and `loadPlayerState` take `now`. Dev database migrated. -- [ ] Step 10D.5 — Server: apply duty and rest +- [x] **Step 10D.5 — Server: apply duty and rest.** `resolveArrivedDispatches` (used by both lazy and job resolution) writes the pilot's duty hours and, on mandatory rest, rest-ends-at from the arrival. Dispatch validation reads the stored rest. `restPilot` (`POST /api/pilots/:pilotId/rest`): idle and not resting, any duty level, no bay; `pilot-not-idle` / `pilot-resting` / `tutorial-active` otherwise. Assign and unassign ignore rest. Test: `duty-rest.test.ts`. - [ ] Step 10D.6 — Client: pilot duty on the Pilots screen - [ ] Step 10D.7 — Client: duty in the dispatch flow - [ ] Step 10D.8 — Client: board refresh and the "nothing here" case