Step 10D.5: apply duty and rest on the server
CI / check (push) Successful in 3m25s
Details
CI / check (push) Successful in 3m25s
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
07704e6732
commit
3dba7c2019
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue