Step 10D.5: apply duty and rest on the server
CI / check (push) Successful in 3m25s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 22:01:45 -04:00
parent 07704e6732
commit 3dba7c2019
7 changed files with 214 additions and 7 deletions

View File

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

View File

@ -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,
};
}

View File

@ -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<Pilot> {
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,

View File

@ -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 } : {}),
})

View File

@ -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),

View File

@ -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");
});
});

View File

@ -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