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-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`.
|
- **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.
|
- **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,
|
assignedShipId: row.assignedShipId,
|
||||||
inFlight: row.status === "in_flight",
|
inFlight: row.status === "in_flight",
|
||||||
skills: row.skills,
|
skills: row.skills,
|
||||||
// Stored from Step 10D.4; until then nobody rests.
|
restEndsAt: row.restEndsAt,
|
||||||
restEndsAt: null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { Pilot } from "@deadhead/schema";
|
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 { and, count, eq } from "drizzle-orm";
|
||||||
import type { Queryable } from "../db/client";
|
import type { Queryable } from "../db/client";
|
||||||
import { bays, pilots, ships } from "../db/schema";
|
import { bays, pilots, ships } from "../db/schema";
|
||||||
|
|
@ -142,6 +148,35 @@ export async function unassignPilot(
|
||||||
return toApiPilot(updated!, null, now);
|
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). */
|
/** Spends one available skill point on a skill below its max level (Step 6.12). */
|
||||||
export async function spendSkillPoint(
|
export async function spendSkillPoint(
|
||||||
tx: Queryable,
|
tx: Queryable,
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,7 @@ export async function resolveArrivedDispatches(
|
||||||
pilotIsAvatar: pilot.isAvatar,
|
pilotIsAvatar: pilot.isAvatar,
|
||||||
discoveredIds,
|
discoveredIds,
|
||||||
loanBalance,
|
loanBalance,
|
||||||
// Duty is stored from Step 10D.4.
|
pilotDutyHours: pilot.dutyHours,
|
||||||
pilotDutyHours: 0,
|
|
||||||
pilotSkills: pilot.skills,
|
pilotSkills: pilot.skills,
|
||||||
now,
|
now,
|
||||||
},
|
},
|
||||||
|
|
@ -82,6 +81,9 @@ export async function resolveArrivedDispatches(
|
||||||
.set({
|
.set({
|
||||||
xp: sql`${pilots.xp} + ${resolution.pilotXp}`,
|
xp: sql`${pilots.xp} + ${resolution.pilotXp}`,
|
||||||
status: "idle",
|
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.
|
// Only unassigned pilots store a location (decision 0.5.27): the tutorial avatar.
|
||||||
...(pilot.assignedShipId === null ? { locationId: resolution.shipLocationId } : {}),
|
...(pilot.assignedShipId === null ? { locationId: resolution.shipLocationId } : {}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { routes } from "@deadhead/schema";
|
import { routes } from "@deadhead/schema";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { buyShip, leaseBay } from "../game/fleet";
|
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 { registerRoute } from "../http/route";
|
||||||
import type { AppContext } from "./context";
|
import type { AppContext } from "./context";
|
||||||
import { withPlayer } from "./with-player";
|
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 }) =>
|
registerRoute(app, routes.spendSkillPoint, ctx.options, async (input, { playerId }) =>
|
||||||
withPlayer(ctx, playerId, async (tx, now) => ({
|
withPlayer(ctx, playerId, async (tx, now) => ({
|
||||||
pilot: await spendSkillPoint(tx, playerId, input.pilotId, input.skillId, 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.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.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.
|
- [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.6 — Client: pilot duty on the Pilots screen
|
||||||
- [ ] Step 10D.7 — Client: duty in the dispatch flow
|
- [ ] Step 10D.7 — Client: duty in the dispatch flow
|
||||||
- [ ] Step 10D.8 — Client: board refresh and the "nothing here" case
|
- [ ] Step 10D.8 — Client: board refresh and the "nothing here" case
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue