From 055cbceaaaf68ef3a507715fc6cde7f99404e334 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 21:48:59 -0400 Subject: [PATCH] Step 10D.3: pilot-resting validation and duty in resolution Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 7 ++ apps/server/src/game/dispatch.ts | 2 + apps/server/src/game/messages.ts | 1 + apps/server/src/game/resolve.ts | 11 +- apps/server/test/game.ts | 1 + apps/server/test/tutorial-board.test.ts | 1 + apps/web/src/screens/DispatchSheet.tsx | 2 + apps/web/src/screens/Fleet.tsx | 2 + apps/web/test/fleet.test.tsx | 1 + memory-bank/progress.md | 2 +- packages/sim/src/dispatch.ts | 8 ++ packages/sim/src/resolution.ts | 26 ++++- packages/sim/test/dispatch.test.ts | 1 + packages/sim/test/duty-dispatch.test.ts | 134 ++++++++++++++++++++++++ packages/sim/test/flight.test.ts | 9 +- packages/sim/test/loan.test.ts | 11 +- packages/sim/test/record.test.ts | 9 +- packages/sim/test/reposition.test.ts | 1 + packages/sim/test/resolution.test.ts | 5 +- 19 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 packages/sim/test/duty-dispatch.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 1406aa3..9b3c685 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -772,3 +772,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Trip duty is measured from the dispatch's real departure and arrival times** (`tripGameHours`), so it is exactly the travel time the player saw, handling time included. - **"Contains a board boundary" is tested as "wakes in a later board period"**: `boardPeriodIndex(restEndsAt(start)) > boardPeriodIndex(start)`, which is what "wakes to a fresh board" means. - **Resting ends exactly at rest-ends-at** (`now < restEndsAt`), the same convention as dispatch arrival. + +## Step 10D.3 + +- **Deadheads can also be refused with `pilot-resting`**: they are flights, and rest blocks flying. The reason is first among the pilot checks in both validators, after the ship checks. +- **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. +- **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. diff --git a/apps/server/src/game/dispatch.ts b/apps/server/src/game/dispatch.ts index 430047c..1e56866 100644 --- a/apps/server/src/game/dispatch.ts +++ b/apps/server/src/game/dispatch.ts @@ -78,6 +78,8 @@ 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, }; } diff --git a/apps/server/src/game/messages.ts b/apps/server/src/game/messages.ts index a9f2af6..b7c78c0 100644 --- a/apps/server/src/game/messages.ts +++ b/apps/server/src/game/messages.ts @@ -4,6 +4,7 @@ import type { DispatchFailure, RepositionFailure } from "@deadhead/sim"; export const VALIDATION_MESSAGES: Record = { "ship-not-idle": "That ship is already flying.", "ship-not-at-origin": "That ship is not at the contract's pickup location.", + "pilot-resting": "That pilot is resting and cannot fly until their rest ends.", "pilot-not-idle": "That pilot is already flying.", "pilot-not-assigned": "That pilot is not assigned to that ship.", "over-capacity": "The cargo is too heavy for that ship.", diff --git a/apps/server/src/game/resolve.ts b/apps/server/src/game/resolve.ts index 8740124..2ee7f5b 100644 --- a/apps/server/src/game/resolve.ts +++ b/apps/server/src/game/resolve.ts @@ -45,7 +45,16 @@ export async function resolveArrivedDispatches( if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`); const result = resolveDispatch( - { record: dispatch.record, pilotIsAvatar: pilot.isAvatar, discoveredIds, loanBalance, now }, + { + record: dispatch.record, + pilotIsAvatar: pilot.isAvatar, + discoveredIds, + loanBalance, + // Duty is stored from Step 10D.4. + pilotDutyHours: 0, + pilotSkills: pilot.skills, + now, + }, content, ); if (result.status !== "resolved") continue; diff --git a/apps/server/test/game.ts b/apps/server/test/game.ts index 293f601..0e26870 100644 --- a/apps/server/test/game.ts +++ b/apps/server/test/game.ts @@ -141,6 +141,7 @@ export async function insertDispatch( assignedShipId: pilot!.assignedShipId, inFlight: false, skills: pilot!.skills, + restEndsAt: null, }; const id = randomUUID(); const contract = args.contract === undefined ? testContract() : args.contract; diff --git a/apps/server/test/tutorial-board.test.ts b/apps/server/test/tutorial-board.test.ts index b4e4b0c..a45fec2 100644 --- a/apps/server/test/tutorial-board.test.ts +++ b/apps/server/test/tutorial-board.test.ts @@ -31,6 +31,7 @@ async function accept(db: Db, guest: Guest, contract: Contract, now: number) { assignedShipId: null, inFlight: false, skills: {}, + restEndsAt: null, }, contract, tutorial: true, diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index 24eedda..5cfbc68 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -20,6 +20,7 @@ import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; export const INELIGIBLE_REASONS: Record = { "ship-not-idle": "Ship is flying", "ship-not-at-origin": "Not at pickup", + "pilot-resting": "Pilot is resting", "pilot-not-idle": "Pilot is flying", "pilot-not-assigned": "No pilot assigned", "over-capacity": "Too heavy", @@ -140,6 +141,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) assignedShipId: pilot.assignedShipId, inFlight: pilot.status === "in-flight", skills: pilot.skills, + restEndsAt: null, }, contract, tutorial: false, diff --git a/apps/web/src/screens/Fleet.tsx b/apps/web/src/screens/Fleet.tsx index 770320a..f0190f1 100644 --- a/apps/web/src/screens/Fleet.tsx +++ b/apps/web/src/screens/Fleet.tsx @@ -115,6 +115,7 @@ const DEADHEAD_REASONS: Partial> = { "cannot-land": "Can't land there", "out-of-range": "Out of range", "insufficient-credits": "Not enough credits for fuel", + "pilot-resting": "Pilot is resting", "pilot-not-idle": "Pilot is flying", "pilot-not-assigned": "No pilot assigned", }; @@ -146,6 +147,7 @@ function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => void }) assignedShipId: pilot ? ship.id : null, inFlight: pilot?.status === "in-flight", skills: pilot?.skills ?? {}, + restEndsAt: null, }, destinationId: id, discoveredIds, diff --git a/apps/web/test/fleet.test.tsx b/apps/web/test/fleet.test.tsx index ff5e7fb..6242ada 100644 --- a/apps/web/test/fleet.test.tsx +++ b/apps/web/test/fleet.test.tsx @@ -154,6 +154,7 @@ describe("fleet screen", () => { assignedShipId: "s-idle", inFlight: false, skills: {}, + restEndsAt: null, }, destinationId: "earth", now: NOW, diff --git a/memory-bank/progress.md b/memory-bank/progress.md index fcd11f4..aa9247c 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -166,7 +166,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.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). -- [ ] Step 10D.3 — Sim: validation and resolution +- [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 - [ ] Step 10D.5 — Server: apply duty and rest - [ ] Step 10D.6 — Client: pilot duty on the Pilots screen diff --git a/packages/sim/src/dispatch.ts b/packages/sim/src/dispatch.ts index 2dab792..aaf6c1a 100644 --- a/packages/sim/src/dispatch.ts +++ b/packages/sim/src/dispatch.ts @@ -3,6 +3,7 @@ import type { SimContent } from "./board"; import { type GameTime, MS_PER_HOUR, type RealTime, realToGame } from "./clock"; import type { Contract } from "./contract"; import { routeDistance } from "./distance"; +import { isResting } from "./duty"; import { checkRoute } from "./eligibility"; import { fuelCost } from "./fuel"; import { checkPilotEligibility, type SkillLevels } from "./pilot"; @@ -23,6 +24,8 @@ export interface PilotState { assignedShipId: string | null; inFlight: boolean; skills: SkillLevels; + /** Resting until this real time; null when not resting (CR-004). */ + restEndsAt: RealTime | null; } export interface DispatchProposal { @@ -40,6 +43,7 @@ export interface DispatchProposal { export const DISPATCH_FAILURES = [ "ship-not-idle", "ship-not-at-origin", + "pilot-resting", "pilot-not-idle", "pilot-not-assigned", "over-capacity", @@ -88,6 +92,8 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent if (ship.inFlight) return fail("ship-not-idle"); if (ship.locationId !== contract.originId) return fail("ship-not-at-origin"); + // Rest blocks flying only (decision 0.5.44); checked before the other pilot checks. + if (isResting(pilot.restEndsAt, now)) return fail("pilot-resting"); if (pilot.inFlight) return fail("pilot-not-idle"); // Decision 0.5.23: during the tutorial the avatar flies only the loaner. if (tutorial ? !pilot.isAvatar : pilot.assignedShipId !== ship.id) { @@ -203,6 +209,7 @@ export interface RepositionProposal { /** Every reason `validateReposition` can fail, in check order. */ export const REPOSITION_FAILURES = [ "ship-not-idle", + "pilot-resting", "pilot-not-idle", "pilot-not-assigned", "already-there", @@ -228,6 +235,7 @@ export function validateReposition( const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason }); if (ship.inFlight) return failWith("ship-not-idle"); + if (isResting(pilot.restEndsAt, now)) return failWith("pilot-resting"); if (pilot.inFlight) return failWith("pilot-not-idle"); if (pilot.assignedShipId !== ship.id) return failWith("pilot-not-assigned"); if (destinationId === ship.locationId) return failWith("already-there"); diff --git a/packages/sim/src/resolution.ts b/packages/sim/src/resolution.ts index 120e2b1..43e7eaf 100644 --- a/packages/sim/src/resolution.ts +++ b/packages/sim/src/resolution.ts @@ -1,7 +1,9 @@ import type { SimContent } from "./board"; import type { RealTime } from "./clock"; import type { DispatchRecord } from "./dispatch"; +import { dutyAfterTrip, dutyLimit, needsRest, restEndsAt, tripGameHours } from "./duty"; import { splitLoanRepayment } from "./loan"; +import type { SkillLevels } from "./pilot"; import { deliveryXp } from "./xp"; export interface Resolution { @@ -17,6 +19,10 @@ export interface Resolution { shipLocationId: string; /** True when this delivery discovers the destination. */ discoversDestination: boolean; + /** The pilot's duty hours after this trip: 0 if it ended in mandatory rest (CR-004). */ + pilotDutyHours: number; + /** Mandatory rest from the arrival time, or null (decision 0.5.45). */ + pilotRestEndsAt: RealTime | null; } export type ResolveResult = @@ -29,16 +35,34 @@ export interface ResolveInput { discoveredIds: ReadonlySet; /** The company's loan balance before this resolution. */ loanBalance: number; + /** The pilot's duty hours before this trip, and skills (Long Haul sets the limit). */ + pilotDutyHours: number; + pilotSkills: SkillLevels; now: RealTime; } +/** + * The pilot's duty after a trip (decisions 0.5.42–0.5.45): the trip's game + * hours are added (tutorial runs add none); at or over the limit, rest starts + * at the arrival time and duty resets, however late the trip is resolved. + */ +function dutyOutcome(input: ResolveInput, content: SimContent) { + const { record, pilotDutyHours, pilotSkills } = input; + const hours = tripGameHours(record.departureAt, record.arrivalAt, content.config); + const duty = dutyAfterTrip(pilotDutyHours, hours, record.tutorial); + if (!record.tutorial && needsRest(duty, dutyLimit(pilotSkills, content.config))) { + return { pilotDutyHours: 0, pilotRestEndsAt: restEndsAt(record.arrivalAt, content.config) }; + } + return { pilotDutyHours: duty, pilotRestEndsAt: null }; +} + /** Resolves a dispatch once it has arrived. Same inputs always give the same result. */ export function resolveDispatch(input: ResolveInput, content: SimContent): ResolveResult { const { record, pilotIsAvatar, discoveredIds, loanBalance, now } = input; if (now < record.arrivalAt) return { status: "in-flight" }; const { contract } = record; - const base = { shipLocationId: record.destinationId }; + const base = { shipLocationId: record.destinationId, ...dutyOutcome(input, content) }; if (contract === null) { // Deadheads earn nothing and only fly to discovered locations. return { diff --git a/packages/sim/test/dispatch.test.ts b/packages/sim/test/dispatch.test.ts index 6d75ace..b2ee54f 100644 --- a/packages/sim/test/dispatch.test.ts +++ b/packages/sim/test/dispatch.test.ts @@ -43,6 +43,7 @@ const pilot: PilotState = { assignedShipId: "s1", inFlight: false, skills: {}, + restEndsAt: null, }; const valid: DispatchProposal = { ship, pilot, contract, tutorial: false, credits: 10_000, now }; diff --git a/packages/sim/test/duty-dispatch.test.ts b/packages/sim/test/duty-dispatch.test.ts new file mode 100644 index 0000000..0058c2a --- /dev/null +++ b/packages/sim/test/duty-dispatch.test.ts @@ -0,0 +1,134 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { + MS_PER_HOUR, + createDispatchRecord, + createRepositionRecord, + dutyLimit, + resolveDispatch, + restEndsAt, + tripGameHours, + validateDispatch, + validateReposition, + type Contract, + type DispatchProposal, + type DispatchRecord, + type PilotState, + type ResolveInput, + type ShipState, +} from "../src/index"; + +const now = Date.parse("2100-01-01T00:30:00Z"); +const contract: Contract = { + id: "c1", + originId: "earth", + destinationId: "moon", + cargoTypeId: "standard", + tier: 0, + massTonnes: 10, + payout: 2000, + requiredSkill: null, + requiredLongHaulLevel: 0, + offeredAt: Date.parse("2100-01-01T00:00:00Z"), + expiresAt: Date.parse("2100-01-01T01:00:00Z"), + firstContact: false, +}; +const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }; +const pilot: PilotState = { + id: "p1", + isAvatar: false, + assignedShipId: "s1", + inFlight: false, + skills: {}, + restEndsAt: null, +}; +const proposal: DispatchProposal = { ship, pilot, contract, tutorial: false, credits: 10_000, now }; +const limit = dutyLimit({}, content.config); + +const resolveAt = (record: DispatchRecord, dutyBefore: number, at = record.arrivalAt) => { + const input: ResolveInput = { + record, + pilotIsAvatar: false, + discoveredIds: new Set(["earth", "moon"]), + loanBalance: 0, + pilotDutyHours: dutyBefore, + pilotSkills: {}, + now: at, + }; + const result = resolveDispatch(input, content); + if (result.status !== "resolved") throw new Error("not resolved"); + return result.resolution; +}; + +describe("duty in validation (Step 10D.3)", () => { + it("refuses a resting pilot with pilot-resting, before other pilot checks", () => { + const resting = { ...pilot, restEndsAt: now + 60_000, inFlight: true, assignedShipId: null }; + expect(validateDispatch({ ...proposal, pilot: resting }, content)).toEqual({ + ok: false, + reason: "pilot-resting", + }); + expect( + validateReposition( + { + ship, + pilot: { ...pilot, restEndsAt: now + 1 }, + destinationId: "moon", + discoveredIds: new Set(["earth", "moon"]), + credits: 10_000, + now, + }, + content, + ), + ).toEqual({ ok: false, reason: "pilot-resting" }); + }); + + it("lets a pilot whose rest has ended fly", () => { + expect( + validateDispatch({ ...proposal, pilot: { ...pilot, restEndsAt: now } }, content), + ).toEqual({ + ok: true, + }); + }); +}); + +describe("duty in resolution (Step 10D.3)", () => { + const record = createDispatchRecord({ ...proposal, id: "d1", worldSeed: 7 }, content); + const hours = tripGameHours(record.departureAt, record.arrivalAt, content.config); + + it("adds the trip's hours to duty when under the limit", () => { + expect(resolveAt(record, 1)).toMatchObject({ + pilotDutyHours: 1 + hours, + pilotRestEndsAt: null, + }); + }); + + it("starts rest at the arrival time when duty reaches the limit, even resolved hours later", () => { + const late = record.arrivalAt + 5 * MS_PER_HOUR; + expect(resolveAt(record, limit - hours / 2, late)).toMatchObject({ + pilotDutyHours: 0, + pilotRestEndsAt: restEndsAt(record.arrivalAt, content.config), + }); + expect(restEndsAt(record.arrivalAt, content.config)).toBe( + record.arrivalAt + + (content.config.restGameHours * MS_PER_HOUR) / content.config.timeCompression, + ); + }); + + it("counts deadheads but not tutorial runs", () => { + const deadhead = createRepositionRecord( + { id: "r1", ship, pilot, destinationId: "moon", now, worldSeed: 7 }, + content, + ); + const deadheadHours = tripGameHours(deadhead.departureAt, deadhead.arrivalAt, content.config); + expect(resolveAt(deadhead, 2).pilotDutyHours).toBeCloseTo(2 + deadheadHours); + + const tutorial = createDispatchRecord( + { ...proposal, tutorial: true, pilot: { ...pilot, isAvatar: true }, id: "t1", worldSeed: 7 }, + content, + ); + expect(resolveAt(tutorial, limit - 0.1)).toMatchObject({ + pilotDutyHours: limit - 0.1, + pilotRestEndsAt: null, + }); + }); +}); diff --git a/packages/sim/test/flight.test.ts b/packages/sim/test/flight.test.ts index 6319269..d1ca55c 100644 --- a/packages/sim/test/flight.test.ts +++ b/packages/sim/test/flight.test.ts @@ -12,7 +12,14 @@ const record = createRepositionRecord( { id: "r1", ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }, - pilot: { id: "p1", isAvatar: false, assignedShipId: "s1", inFlight: false, skills: {} }, + pilot: { + id: "p1", + isAvatar: false, + assignedShipId: "s1", + inFlight: false, + skills: {}, + restEndsAt: null, + }, destinationId: "moon", now: Date.parse("2100-01-01T00:30:00Z"), worldSeed: 7, diff --git a/packages/sim/test/loan.test.ts b/packages/sim/test/loan.test.ts index 5e68004..d5c1625 100644 --- a/packages/sim/test/loan.test.ts +++ b/packages/sim/test/loan.test.ts @@ -57,7 +57,14 @@ describe("resolution with a loan", () => { { id: "d1", ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }, - pilot: { id: "p1", isAvatar: false, assignedShipId: "s1", inFlight: false, skills: {} }, + pilot: { + id: "p1", + isAvatar: false, + assignedShipId: "s1", + inFlight: false, + skills: {}, + restEndsAt: null, + }, contract, tutorial: false, now, @@ -73,6 +80,8 @@ describe("resolution with a loan", () => { pilotIsAvatar: false, discoveredIds: new Set(["earth", "moon"]), loanBalance: 5000, + pilotDutyHours: 0, + pilotSkills: {}, now: record.arrivalAt, }, content, diff --git a/packages/sim/test/record.test.ts b/packages/sim/test/record.test.ts index 7c105d9..d5092ba 100644 --- a/packages/sim/test/record.test.ts +++ b/packages/sim/test/record.test.ts @@ -26,7 +26,14 @@ const contract: Contract = { const input: RecordInput = { id: "d1", ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }, - pilot: { id: "p1", isAvatar: false, assignedShipId: "s1", inFlight: false, skills: {} }, + pilot: { + id: "p1", + isAvatar: false, + assignedShipId: "s1", + inFlight: false, + skills: {}, + restEndsAt: null, + }, contract, tutorial: false, now, diff --git a/packages/sim/test/reposition.test.ts b/packages/sim/test/reposition.test.ts index 30954a2..fa3460c 100644 --- a/packages/sim/test/reposition.test.ts +++ b/packages/sim/test/reposition.test.ts @@ -16,6 +16,7 @@ const pilot: PilotState = { assignedShipId: "s1", inFlight: false, skills: {}, + restEndsAt: null, }; /** No bays anywhere: deadheads depend only on discovery, range and landing (CR-002). */ const valid: RepositionProposal = { diff --git a/packages/sim/test/resolution.test.ts b/packages/sim/test/resolution.test.ts index 4ddc25a..78ade26 100644 --- a/packages/sim/test/resolution.test.ts +++ b/packages/sim/test/resolution.test.ts @@ -32,6 +32,7 @@ const pilot: PilotState = { assignedShipId: "s1", inFlight: false, skills: {}, + restEndsAt: null, }; const record = createDispatchRecord( { id: "d1", ship, pilot, contract, tutorial: false, now, worldSeed: 7 }, @@ -42,6 +43,8 @@ const arrived: ResolveInput = { pilotIsAvatar: false, discoveredIds: new Set(["earth", "moon"]), loanBalance: 0, + pilotDutyHours: 0, + pilotSkills: {}, now: record.arrivalAt, }; @@ -105,7 +108,7 @@ describe("dispatch resolution", () => { { id: "r1", ship, pilot, destinationId: "moon", now, worldSeed: 7 }, content, ); - expect(resolve({ ...arrived, record: reposition, now: reposition.arrivalAt })).toEqual({ + expect(resolve({ ...arrived, record: reposition, now: reposition.arrivalAt })).toMatchObject({ credits: 0, loanRepayment: 0, pilotXp: 0,