Step 10D.4: pilot duty columns, API fields and rest route

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 21:56:39 -04:00
parent 055cbceaaa
commit 07704e6732
26 changed files with 1465 additions and 20 deletions

View File

@ -779,3 +779,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Validation never looks at duty hours** (decision 0.5.44), so a pilot over the limit who is not resting can always be dispatched. Duty only matters when a trip resolves. - **Validation never looks at duty hours** (decision 0.5.44), so a pilot over the limit who is not resting can always be dispatched. Duty only matters when a trip resolves.
- **Resolution owns the duty outcome:** it adds the trip's game hours (none for tutorial runs), and at or over the limit returns duty 0 and rest-ends-at = arrival + rest. The resolution time does not matter, so offline resolution matches live. - **Resolution owns the duty outcome:** it adds the trip's game hours (none for tutorial runs), and at or over the limit returns duty 0 and rest-ends-at = arrival + rest. The resolution time does not matter, so offline resolution matches live.
- **Interim wiring:** until the pilots table has the columns (10D.4–10D.5), the server passes `restEndsAt: null` and duty 0, and the client passes `restEndsAt: null`, so behaviour is unchanged. - **Interim wiring:** until the pilots table has the columns (10D.4–10D.5), the server passes `restEndsAt: null` and duty 0, and the client passes `restEndsAt: null`, so behaviour is unchanged.
## Step 10D.4
- **`restEndsAt` in the API is null unless the pilot is resting now.** The database keeps the last value (which goes stale harmlessly), but clients only see a time when it matters, next to `resting: true`.
- **`dutyLimit` is sent with each pilot** so the client does not recompute Long Haul bonuses for display (it still uses the sim for predictions).
- **`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.

View File

@ -0,0 +1,2 @@
ALTER TABLE "pilots" ADD COLUMN "duty_hours" double precision DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "pilots" ADD COLUMN "rest_ends_at" bigint;

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,13 @@
"when": 1791053200611, "when": 1791053200611,
"tag": "0003_cr002_location_boards", "tag": "0003_cr002_location_boards",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1791078648879,
"tag": "0004_cr004_pilot_duty",
"breakpoints": true
} }
] ]
} }

View File

@ -2,6 +2,7 @@ import type { Contract, DispatchRecord, Resolution } from "@deadhead/sim";
import { import {
bigint, bigint,
boolean, boolean,
doublePrecision,
index, index,
integer, integer,
jsonb, jsonb,
@ -88,6 +89,10 @@ export const pilots = pgTable(
/** Only unassigned pilots store a location (decision 0.5.27). */ /** Only unassigned pilots store a location (decision 0.5.27). */
locationId: text("location_id"), locationId: text("location_id"),
status: activityStatus("status").notNull().default("idle"), status: activityStatus("status").notNull().default("idle"),
/** Game hours flown since the last rest (decision 0.5.42). */
dutyHours: doublePrecision("duty_hours").notNull().default(0),
/** Resting until this time; null (or past) when not resting (decision 0.5.45). */
restEndsAt: time("rest_ends_at"),
createdAt: time("created_at").notNull(), createdAt: time("created_at").notNull(),
}, },
(t) => [index("pilots_player_idx").on(t.playerId)], (t) => [index("pilots_player_idx").on(t.playerId)],

View File

@ -1,6 +1,12 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import type { Dispatch, Pilot } from "@deadhead/schema"; import type { Dispatch, Pilot } from "@deadhead/schema";
import { pilotLevel, skillPointsAvailable } from "@deadhead/sim"; import {
dutyLimit,
isResting,
pilotLevel,
skillPointsAvailable,
type RealTime,
} from "@deadhead/sim";
import type { dispatches, pilots } from "../db/schema"; import type { dispatches, pilots } from "../db/schema";
/** Database status values use `in_flight`; the API uses `in-flight`. */ /** Database status values use `in_flight`; the API uses `in-flight`. */
@ -25,7 +31,9 @@ export function toApiDispatch(row: typeof dispatches.$inferSelect): Dispatch {
export function toApiPilot( export function toApiPilot(
row: typeof pilots.$inferSelect, row: typeof pilots.$inferSelect,
assignedShipLocationId: string | null, assignedShipLocationId: string | null,
now: RealTime,
): Pilot { ): Pilot {
const resting = isResting(row.restEndsAt, now);
const level = pilotLevel(row.xp, content.config); const level = pilotLevel(row.xp, content.config);
return { return {
id: row.id, id: row.id,
@ -43,5 +51,9 @@ export function toApiPilot(
: row.assignedShipId : row.assignedShipId
? assignedShipLocationId ? assignedShipLocationId
: row.locationId, : row.locationId,
dutyHours: row.dutyHours,
dutyLimit: dutyLimit(row.skills, content.config),
resting,
restEndsAt: resting ? row.restEndsAt : null,
}; };
} }

View File

@ -65,7 +65,7 @@ export async function hirePilot(
createdAt: now, createdAt: now,
}) })
.returning(); .returning();
return toApiPilot(pilot!, null); return toApiPilot(pilot!, null, now);
} }
/** /**
@ -77,6 +77,7 @@ export async function assignPilot(
playerId: string, playerId: string,
pilotId: string, pilotId: string,
shipId: string, shipId: string,
now: RealTime,
): Promise<Pilot> { ): Promise<Pilot> {
const pilot = await requireOwnPilot(tx, playerId, pilotId); const pilot = await requireOwnPilot(tx, playerId, pilotId);
const ship = await requireOwnShip(tx, playerId, shipId); const ship = await requireOwnShip(tx, playerId, shipId);
@ -111,7 +112,7 @@ export async function assignPilot(
.set({ assignedShipId: shipId, locationId: null }) .set({ assignedShipId: shipId, locationId: null })
.where(eq(pilots.id, pilotId)) .where(eq(pilots.id, pilotId))
.returning(); .returning();
return toApiPilot(updated!, ship.locationId); return toApiPilot(updated!, ship.locationId, now);
} }
/** Unassigns a pilot from its ship; only allowed where the player has a bay (decision 0.5.2). */ /** Unassigns a pilot from its ship; only allowed where the player has a bay (decision 0.5.2). */
@ -119,6 +120,7 @@ export async function unassignPilot(
tx: Queryable, tx: Queryable,
playerId: string, playerId: string,
pilotId: string, pilotId: string,
now: RealTime,
): Promise<Pilot> { ): Promise<Pilot> {
const pilot = await requireOwnPilot(tx, playerId, pilotId); const pilot = await requireOwnPilot(tx, playerId, pilotId);
if (pilot.assignedShipId === null) { if (pilot.assignedShipId === null) {
@ -137,7 +139,7 @@ export async function unassignPilot(
.set({ assignedShipId: null, locationId: ship.locationId }) .set({ assignedShipId: null, locationId: ship.locationId })
.where(eq(pilots.id, pilotId)) .where(eq(pilots.id, pilotId))
.returning(); .returning();
return toApiPilot(updated!, null); return toApiPilot(updated!, 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). */
@ -146,6 +148,7 @@ export async function spendSkillPoint(
playerId: string, playerId: string,
pilotId: string, pilotId: string,
skillId: string, skillId: string,
now: RealTime,
): Promise<Pilot> { ): Promise<Pilot> {
const pilot = await requireOwnPilot(tx, playerId, pilotId); const pilot = await requireOwnPilot(tx, playerId, pilotId);
const skill = content.skill(skillId); const skill = content.skill(skillId);
@ -171,5 +174,5 @@ export async function spendSkillPoint(
const [ship] = updated!.assignedShipId const [ship] = updated!.assignedShipId
? await tx.select().from(ships).where(eq(ships.id, updated!.assignedShipId)) ? await tx.select().from(ships).where(eq(ships.id, updated!.assignedShipId))
: []; : [];
return toApiPilot(updated!, ship?.locationId ?? null); return toApiPilot(updated!, ship?.locationId ?? null, now);
} }

View File

@ -1,5 +1,6 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import type { PlayerState } from "@deadhead/schema"; import type { PlayerState } from "@deadhead/schema";
import type { RealTime } from "@deadhead/sim";
import { and, eq, isNull, max } from "drizzle-orm"; import { and, eq, isNull, max } from "drizzle-orm";
import type { Queryable } from "../db/client"; import type { Queryable } from "../db/client";
import { import {
@ -16,7 +17,11 @@ import { goalStates } from "./goals";
import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers"; import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers";
/** Reads everything the client needs about one player. */ /** Reads everything the client needs about one player. */
export async function loadPlayerState(db: Queryable, playerId: string): Promise<PlayerState> { export async function loadPlayerState(
db: Queryable,
playerId: string,
now: RealTime,
): Promise<PlayerState> {
const [player] = await db.select().from(players).where(eq(players.id, playerId)); const [player] = await db.select().from(players).where(eq(players.id, playerId));
const [company] = await db.select().from(companies).where(eq(companies.playerId, playerId)); const [company] = await db.select().from(companies).where(eq(companies.playerId, playerId));
if (!player || !company) throw new Error(`Player ${playerId} not found`); if (!player || !company) throw new Error(`Player ${playerId} not found`);
@ -74,7 +79,11 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
lastArrivalAt: lastArrivalByShip.get(s.id) ?? s.createdAt, lastArrivalAt: lastArrivalByShip.get(s.id) ?? s.createdAt,
})), })),
pilots: pilotRows.map((p) => pilots: pilotRows.map((p) =>
toApiPilot(p, p.assignedShipId ? (shipById.get(p.assignedShipId)?.locationId ?? null) : null), toApiPilot(
p,
p.assignedShipId ? (shipById.get(p.assignedShipId)?.locationId ?? null) : null,
now,
),
), ),
bays: bayRows.map((b) => ({ id: b.id, locationId: b.locationId })), bays: bayRows.map((b) => ({ id: b.id, locationId: b.locationId })),
locations: content.locations.map((l) => ({ id: l.id, discovered: discoveredIds.has(l.id) })), locations: content.locations.map((l) => ({ id: l.id, discovered: discoveredIds.has(l.id) })),
@ -103,6 +112,7 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
pilotXp: d.resolution?.pilotXp ?? 0, pilotXp: d.resolution?.pilotXp ?? 0,
discoveredLocationId: d.resolution?.discoversDestination ? d.record.destinationId : null, discoveredLocationId: d.resolution?.discoversDestination ? d.record.destinationId : null,
scriptedId: d.record.contract?.scriptedId ?? null, scriptedId: d.record.contract?.scriptedId ?? null,
restEndsAt: d.resolution?.pilotRestEndsAt ?? null,
resolvedAt: d.resolvedAt ?? d.arrivalAt, resolvedAt: d.resolvedAt ?? d.arrivalAt,
})), })),
goals: await goalStates(db, playerId), goals: await goalStates(db, playerId),

View File

@ -27,20 +27,20 @@ export function registerFleetRoutes(app: FastifyInstance, ctx: AppContext): void
); );
registerRoute(app, routes.assignPilot, ctx.options, async (input, { playerId }) => registerRoute(app, routes.assignPilot, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx) => ({ withPlayer(ctx, playerId, async (tx, now) => ({
pilot: await assignPilot(tx, playerId, input.pilotId, input.shipId), pilot: await assignPilot(tx, playerId, input.pilotId, input.shipId, now),
})), })),
); );
registerRoute(app, routes.unassignPilot, ctx.options, async (input, { playerId }) => registerRoute(app, routes.unassignPilot, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx) => ({ withPlayer(ctx, playerId, async (tx, now) => ({
pilot: await unassignPilot(tx, playerId, input.pilotId), pilot: await unassignPilot(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) => ({ withPlayer(ctx, playerId, async (tx, now) => ({
pilot: await spendSkillPoint(tx, playerId, input.pilotId, input.skillId), pilot: await spendSkillPoint(tx, playerId, input.pilotId, input.skillId, now),
})), })),
); );
} }

View File

@ -39,12 +39,12 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi
}); });
// Pass Better Auth's session cookie through to the client. // Pass Better Auth's session cookie through to the client.
reply.header("set-cookie", signIn.headers.getSetCookie()); reply.header("set-cookie", signIn.headers.getSetCookie());
return { playerId, state: await loadPlayerState(db, playerId) }; return { playerId, state: await loadPlayerState(db, playerId, clock()) };
}); });
// Lazy resolution on read (Step 6.1): apply every arrival before returning state. // Lazy resolution on read (Step 6.1): apply every arrival before returning state.
registerRoute(app, routes.getPlayerState, options, async (_input, { playerId }) => registerRoute(app, routes.getPlayerState, options, async (_input, { playerId }) =>
withPlayer(ctx, playerId, (tx) => loadPlayerState(tx, playerId)), withPlayer(ctx, playerId, (tx, now) => loadPlayerState(tx, playerId, now)),
); );
registerRoute(app, routes.acknowledgeSummaries, options, async (input, { playerId }) => registerRoute(app, routes.acknowledgeSummaries, options, async (input, { playerId }) =>
@ -56,7 +56,7 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi
registerRoute(app, routes.acceptDeal, options, async (_input, { playerId }) => registerRoute(app, routes.acceptDeal, options, async (_input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => { withPlayer(ctx, playerId, async (tx, now) => {
await acceptDeal(tx, playerId, now); await acceptDeal(tx, playerId, now);
return loadPlayerState(tx, playerId); return loadPlayerState(tx, playerId, now);
}), }),
); );

View File

@ -38,6 +38,10 @@ describe("typed API client", () => {
assignedShipId: "s1", assignedShipId: "s1",
status: "idle", status: "idle",
locationId: "earth", locationId: "earth",
dutyHours: 0,
dutyLimit: 12,
resting: false,
restEndsAt: null,
}; };
const fetch = mockFetch(200, { pilot }); const fetch = mockFetch(200, { pilot });
const api = createApiClient({ fetch }); const api = createApiClient({ fetch });

View File

@ -36,6 +36,7 @@ function returningPlayer(acknowledged: boolean): PlayerState {
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: null, scriptedId: null,
restEndsAt: null,
resolvedAt: 1, resolvedAt: 1,
}, },
], ],

View File

@ -28,6 +28,7 @@ const summaries: ResolutionSummary[] = [
pilotXp: 32, pilotXp: 32,
discoveredLocationId: "mars", discoveredLocationId: "mars",
scriptedId: null, scriptedId: null,
restEndsAt: null,
resolvedAt: 1, resolvedAt: 1,
}, },
{ {
@ -42,6 +43,7 @@ const summaries: ResolutionSummary[] = [
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: null, scriptedId: null,
restEndsAt: null,
resolvedAt: 2, resolvedAt: 2,
}, },
]; ];

View File

@ -86,6 +86,7 @@ describe("the deal", () => {
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: null, scriptedId: null,
restEndsAt: null,
resolvedAt: 1, resolvedAt: 1,
}, },
], ],

View File

@ -41,6 +41,10 @@ const pilot = (id: string, name: string, shipId: string, isAvatar: boolean): Pil
assignedShipId: shipId, assignedShipId: shipId,
status: "idle", status: "idle",
locationId: "earth", locationId: "earth",
dutyHours: 0,
dutyLimit: 12,
resting: false,
restEndsAt: null,
}); });
const marsJob: Contract = { const marsJob: Contract = {

View File

@ -150,6 +150,7 @@ describe("loan on HQ", () => {
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: null, scriptedId: null,
restEndsAt: null,
resolvedAt: 1, resolvedAt: 1,
}, },
], ],

View File

@ -29,6 +29,10 @@ const rae: Pilot = {
assignedShipId: null, assignedShipId: null,
status: "idle", status: "idle",
locationId: "moon", locationId: "moon",
dutyHours: 0,
dutyLimit: 12,
resting: false,
restEndsAt: null,
}; };
function pilotsState(overrides: Partial<PlayerState> = {}): PlayerState { function pilotsState(overrides: Partial<PlayerState> = {}): PlayerState {

View File

@ -48,6 +48,10 @@ export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerS
assignedShipId: null, assignedShipId: null,
status: "idle", status: "idle",
locationId: "earth", locationId: "earth",
dutyHours: 0,
dutyLimit: 12,
resting: false,
restEndsAt: null,
}, },
], ],
bays: [], bays: [],

View File

@ -153,6 +153,7 @@ describe("scripted delivery summary", () => {
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: "unmarked-crates", scriptedId: "unmarked-crates",
restEndsAt: null,
resolvedAt: NOW, resolvedAt: NOW,
}, },
], ],

View File

@ -26,12 +26,12 @@ Living reference for how the code is organised. Update it after any database sch
| `ServerTime` | `realTime`, `gameTime` | | `ServerTime` | `realTime`, `gameTime` |
| `Company` | `id`, `name`, `credits`, `loanBalance` | | `Company` | `id`, `name`, `credits`, `loanBalance` |
| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId`, `lastArrivalAt` (latest resolved arrival, else delivery time; picks the default Contracts location) | | `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId`, `lastArrivalAt` (latest resolved arrival, else delivery time; picks the default Contracts location) |
| `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight) | | `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight), `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting; CR-004) |
| `Bay` | `id`, `locationId` | | `Bay` | `id`, `locationId` |
| `LocationState` | `id`, `discovered` | | `LocationState` | `id`, `discovered` |
| `Contract` | mirrors sim `Contract` (optional `scriptedId` on a scripted tutorial contract) | | `Contract` | mirrors sim `Contract` (optional `scriptedId` on a scripted tutorial contract) |
| `Dispatch` | mirrors sim `DispatchRecord` + `status` (`in-flight` / `resolved`), `resolvedAt` | | `Dispatch` | mirrors sim `DispatchRecord` + `status` (`in-flight` / `resolved`), `resolvedAt` |
| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits` (net), `loanRepayment`, `pilotXp`, `discoveredLocationId`, `scriptedId`, `resolvedAt` | | `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits` (net), `loanRepayment`, `pilotXp`, `discoveredLocationId`, `scriptedId`, `restEndsAt` (set when the trip ended in mandatory rest), `resolvedAt` |
| `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight`, `runsCompleted`, `dealAvailable` | | `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight`, `runsCompleted`, `dealAvailable` |
| `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) | | `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) |
| `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) | | `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) |
@ -57,6 +57,7 @@ The `routes` catalog is the single source for both server and client. Each entry
| `hirePilot` | `POST /api/pilots` | `locationId` (a player bay) | `{ pilot }` | yes | | `hirePilot` | `POST /api/pilots` | `locationId` (a player bay) | `{ pilot }` | yes |
| `assignPilot` | `POST /api/pilots/:pilotId/assign` | `pilotId`, `shipId` | `{ pilot }` | yes | | `assignPilot` | `POST /api/pilots/:pilotId/assign` | `pilotId`, `shipId` | `{ pilot }` | yes |
| `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ pilot }` | yes | | `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ pilot }` | yes |
| `restPilot` | `POST /api/pilots/:pilotId/rest` | `pilotId` (idle, not resting) | `{ pilot }` | yes |
| `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes | | `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes |
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds` | `{ acknowledged }` | yes | | `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds` | `{ acknowledged }` | yes |
| `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes | | `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes |
@ -72,7 +73,7 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri
| `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location, tutorial runs completed, deal accepted | pk `id`, unique `user_id` → `user.id` | | `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location, tutorial runs completed, deal accepted | pk `id`, unique `user_id` → `user.id` |
| `companies` | Name, credits and loan balance (the row locked for money changes) | unique `player_id` | | `companies` | Name, credits and loan balance (the row locked for money changes) | unique `player_id` |
| `ships` | Owned ships: model, name, status, location (null in flight) | — | | `ships` | Owned ships: model, name, status, location (null in flight) | — |
| `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status | unique `assigned_ship_id` (one pilot per ship) | | `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status, duty hours, rest-ends-at (CR-004) | unique `assigned_ship_id` (one pilot per ship) |
| `bays` | Leased bays | unique (`player_id`, `location_id`) | | `bays` | Leased bays | unique (`player_id`, `location_id`) |
| `discovered_locations` | Discovered location ids | pk (`player_id`, `location_id`) | | `discovered_locations` | Discovered location ids | pk (`player_id`, `location_id`) |
| `contract_boards` | Stored location boards (one per discovered location, CR-002) and tutorial boards per period (tutorial boards also per run); `kind` is `location` or `tutorial` (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`, `tutorial_run`) | | `contract_boards` | Stored location boards (one per discovered location, CR-002) and tutorial boards per period (tutorial boards also per run); `kind` is `location` or `tutorial` (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`, `tutorial_run`) |
@ -176,6 +177,10 @@ export const pilots = pgTable(
/** Only unassigned pilots store a location (decision 0.5.27). */ /** Only unassigned pilots store a location (decision 0.5.27). */
locationId: text("location_id"), locationId: text("location_id"),
status: activityStatus("status").notNull().default("idle"), status: activityStatus("status").notNull().default("idle"),
/** Game hours flown since the last rest (decision 0.5.42). */
dutyHours: doublePrecision("duty_hours").notNull().default(0),
/** Resting until this time; null (or past) when not resting (decision 0.5.45). */
restEndsAt: time("rest_ends_at"),
createdAt: time("created_at").notNull(), createdAt: time("created_at").notNull(),
}, },
(t) => [index("pilots_player_idx").on(t.playerId)], (t) => [index("pilots_player_idx").on(t.playerId)],

View File

@ -167,7 +167,7 @@ Spec: `memory-bank/change-requests/CR-004-pilot-duty-rest.md`. New decisions rec
- [x] **Step 10D.1 — Content config.** GameConfig gains `dutyLimitGameHours` (12), `longHaulDutyBonusGameHours` (2) and `restGameHours` (60 = one 60-minute board period at 60× compression). Tests: rest in real time ≥ board period; duty numbers positive. - [x] **Step 10D.1 — Content config.** GameConfig gains `dutyLimitGameHours` (12), `longHaulDutyBonusGameHours` (2) and `restGameHours` (60 = one 60-minute board period at 60× compression). Tests: rest in real time ≥ board period; duty numbers positive.
- [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`.
- [ ] Step 10D.4 — Schema and database - [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 - [ ] Step 10D.5 — Server: apply duty and rest
- [ ] 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

View File

@ -58,6 +58,12 @@ export const pilotSchema = z.object({
status: z.enum(["idle", "in-flight"]), status: z.enum(["idle", "in-flight"]),
/** Where the pilot is (their ship's location if assigned); null while in flight. */ /** Where the pilot is (their ship's location if assigned); null while in flight. */
locationId: idSchema.nullable(), locationId: idSchema.nullable(),
/** Game hours flown since the last rest, and the limit before mandatory rest (CR-004). */
dutyHours: z.number().nonnegative(),
dutyLimit: z.number().positive(),
/** Resting pilots cannot fly until `restEndsAt` (null when not resting). */
resting: z.boolean(),
restEndsAt: timeSchema.nullable(),
}); });
export type Pilot = z.infer<typeof pilotSchema>; export type Pilot = z.infer<typeof pilotSchema>;
@ -129,6 +135,8 @@ export const resolutionSummarySchema = z.object({
discoveredLocationId: idSchema.nullable(), discoveredLocationId: idSchema.nullable(),
/** The scripted tutorial contract delivered, if any (for its flavor message). */ /** The scripted tutorial contract delivered, if any (for its flavor message). */
scriptedId: idSchema.nullable(), scriptedId: idSchema.nullable(),
/** Set when the trip ended in mandatory rest: the pilot rests until then (CR-004). */
restEndsAt: timeSchema.nullable(),
resolvedAt: timeSchema, resolvedAt: timeSchema,
}); });
export type ResolutionSummary = z.infer<typeof resolutionSummarySchema>; export type ResolutionSummary = z.infer<typeof resolutionSummarySchema>;

View File

@ -56,6 +56,8 @@ export const bayResponseSchema = z.object({ bay: baySchema });
export const hirePilotRequestSchema = z.object({ locationId: idSchema }); export const hirePilotRequestSchema = z.object({ locationId: idSchema });
export const assignPilotRequestSchema = z.object({ pilotId: idSchema, shipId: idSchema }); export const assignPilotRequestSchema = z.object({ pilotId: idSchema, shipId: idSchema });
export const unassignPilotRequestSchema = z.object({ pilotId: idSchema }); export const unassignPilotRequestSchema = z.object({ pilotId: idSchema });
/** CR-004: rest an idle pilot now, at any duty level. */
export const restPilotRequestSchema = z.object({ pilotId: idSchema });
export const spendSkillPointRequestSchema = z.object({ pilotId: idSchema, skillId: idSchema }); export const spendSkillPointRequestSchema = z.object({ pilotId: idSchema, skillId: idSchema });
export const pilotResponseSchema = z.object({ pilot: pilotSchema }); export const pilotResponseSchema = z.object({ pilot: pilotSchema });

View File

@ -20,6 +20,7 @@ import {
repayLoanRequestSchema, repayLoanRequestSchema,
shipResponseSchema, shipResponseSchema,
spendSkillPointRequestSchema, spendSkillPointRequestSchema,
restPilotRequestSchema,
unassignPilotRequestSchema, unassignPilotRequestSchema,
} from "./requests"; } from "./requests";
@ -114,6 +115,13 @@ export const routes = {
response: pilotResponseSchema, response: pilotResponseSchema,
auth: true, auth: true,
}, },
restPilot: {
method: "POST",
path: "/api/pilots/:pilotId/rest",
request: restPilotRequestSchema,
response: pilotResponseSchema,
auth: true,
},
spendSkillPoint: { spendSkillPoint: {
method: "POST", method: "POST",
path: "/api/pilots/:pilotId/skills", path: "/api/pilots/:pilotId/skills",

View File

@ -19,6 +19,7 @@ describe("route catalog", () => {
"hirePilot", "hirePilot",
"assignPilot", "assignPilot",
"unassignPilot", "unassignPilot",
"restPilot",
"spendSkillPoint", "spendSkillPoint",
"acknowledgeSummaries", "acknowledgeSummaries",
"acceptDeal", "acceptDeal",

View File

@ -33,6 +33,7 @@ import {
locationStateSchema, locationStateSchema,
pilotResponseSchema, pilotResponseSchema,
pilotSchema, pilotSchema,
restPilotRequestSchema,
playerStateSchema, playerStateSchema,
resolutionSummarySchema, resolutionSummarySchema,
serverTimeSchema, serverTimeSchema,
@ -67,6 +68,10 @@ const pilot = {
assignedShipId: "s1", assignedShipId: "s1",
status: "idle", status: "idle",
locationId: "earth", locationId: "earth",
dutyHours: 7.5,
dutyLimit: 14,
resting: false,
restEndsAt: null,
}; };
const bay = { id: "b1", locationId: "earth" }; const bay = { id: "b1", locationId: "earth" };
const location = { id: "mars", discovered: false }; const location = { id: "mars", discovered: false };
@ -113,6 +118,7 @@ const summary = {
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: "unmarked-crates", scriptedId: "unmarked-crates",
restEndsAt: null,
resolvedAt: 3_900_000, resolvedAt: 3_900_000,
}; };
const tutorial = { const tutorial = {
@ -144,11 +150,25 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
["company", companySchema, company, { ...company, credits: 1.5 }], ["company", companySchema, company, { ...company, credits: 1.5 }],
["ship", shipSchema, ship, { ...ship, status: "lost" }], ["ship", shipSchema, ship, { ...ship, status: "lost" }],
["pilot", pilotSchema, pilot, { ...pilot, xp: -1 }], ["pilot", pilotSchema, pilot, { ...pilot, xp: -1 }],
[
"resting pilot",
pilotSchema,
{ ...pilot, dutyHours: 0, resting: true, restEndsAt: 4_000_000 },
{ ...pilot, dutyHours: -1 },
],
["pilot duty limit", pilotSchema, { ...pilot, dutyLimit: 12 }, { ...pilot, dutyLimit: 0 }],
["rest pilot request", restPilotRequestSchema, { pilotId: "p1" }, { pilotId: "" }],
["bay", baySchema, bay, { id: "b1" }], ["bay", baySchema, bay, { id: "b1" }],
["location", locationStateSchema, location, { id: "mars", discovered: "no" }], ["location", locationStateSchema, location, { id: "mars", discovered: "no" }],
["contract", contractSchema, contract, { ...contract, tier: -1 }], ["contract", contractSchema, contract, { ...contract, tier: -1 }],
["dispatch", dispatchSchema, dispatch, { ...dispatch, status: "lost" }], ["dispatch", dispatchSchema, dispatch, { ...dispatch, status: "lost" }],
["resolution summary", resolutionSummarySchema, summary, { ...summary, pilotXp: -5 }], ["resolution summary", resolutionSummarySchema, summary, { ...summary, pilotXp: -5 }],
[
"summary with rest started",
resolutionSummarySchema,
{ ...summary, restEndsAt: 7_500_000 },
{ ...summary, restEndsAt: -1 },
],
["tutorial", tutorialStateSchema, tutorial, { ...tutorial, loanerLocationId: "mars" }], ["tutorial", tutorialStateSchema, tutorial, { ...tutorial, loanerLocationId: "mars" }],
["goal state", goalStateSchema, goal, { ...goal, status: "done" }], ["goal state", goalStateSchema, goal, { ...goal, status: "done" }],
["repay loan request", repayLoanRequestSchema, { amount: 500 }, { amount: 0 }], ["repay loan request", repayLoanRequestSchema, { amount: 500 }, { amount: 0 }],