diff --git a/DECISIONS.md b/DECISIONS.md index d8451bc..1406aa3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -766,3 +766,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Field names:** `dutyLimitGameHours`, `longHaulDutyBonusGameHours`, `restGameHours`. All in game hours, like other trip times. - **The "rest ≥ board period" rule is a content test, not a schema refinement**, as the CR asks. The schema only requires the three numbers to be positive. + +## Step 10D.2 + +- **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. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index c044df0..fcd11f4 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -165,7 +165,7 @@ Spec: `memory-bank/change-requests/CR-003-map-art.md`. New decisions recorded as Spec: `memory-bank/change-requests/CR-004-pilot-duty-rest.md`. New decisions recorded as plan §0.5.42–0.5.46. - [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. -- [ ] Step 10D.2 — Sim: duty and rest functions +- [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 - [ ] Step 10D.4 — Schema and database - [ ] Step 10D.5 — Server: apply duty and rest diff --git a/packages/sim/src/duty.ts b/packages/sim/src/duty.ts new file mode 100644 index 0000000..ff1beae --- /dev/null +++ b/packages/sim/src/duty.ts @@ -0,0 +1,43 @@ +import type { GameConfig } from "@deadhead/content"; +import { MS_PER_HOUR, type RealTime } from "./clock"; +import { LONG_HAUL_SKILL_ID, skillLevel, type SkillLevels } from "./pilot"; + +/** + * Pilot duty and rest (CR-004, decisions 0.5.42–0.5.46). Rest is derived from a + * stored rest-ends-at time, so nothing has to run when it ends. + */ + +/** Game hours a pilot may fly before mandatory rest: base plus a Long Haul bonus. */ +export function dutyLimit(skills: SkillLevels, config: GameConfig): number { + const longHaul = skillLevel(skills, LONG_HAUL_SKILL_ID); + return config.dutyLimitGameHours + longHaul * config.longHaulDutyBonusGameHours; +} + +/** Whether a pilot is resting at real time `now`: until (not at) rest-ends-at. */ +export function isResting(restEndsAt: RealTime | null, now: RealTime): boolean { + return restEndsAt !== null && now < restEndsAt; +} + +/** Game hours of a trip from its real departure and arrival times. */ +export function tripGameHours( + departureAt: RealTime, + arrivalAt: RealTime, + config: GameConfig, +): number { + return ((arrivalAt - departureAt) * config.timeCompression) / MS_PER_HOUR; +} + +/** Duty hours after a trip: tutorial runs add nothing (decision 0.5.42). */ +export function dutyAfterTrip(dutyHours: number, tripHours: number, tutorial: boolean): number { + return tutorial ? dutyHours : dutyHours + tripHours; +} + +/** Whether duty at or over the limit forces rest on arrival (decision 0.5.44). */ +export function needsRest(dutyHours: number, limit: number): boolean { + return dutyHours >= limit; +} + +/** When a rest starting at real time `start` ends (decision 0.5.45). */ +export function restEndsAt(start: RealTime, config: GameConfig): RealTime { + return start + (config.restGameHours * MS_PER_HOUR) / config.timeCompression; +} diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index f5c29b6..ab815e6 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -69,3 +69,5 @@ export type { LoanSplit } from "./loan"; export { resolveDispatch } from "./resolution"; export type { Resolution, ResolveInput, ResolveResult } from "./resolution"; + +export { dutyAfterTrip, dutyLimit, isResting, needsRest, restEndsAt, tripGameHours } from "./duty"; diff --git a/packages/sim/test/duty.test.ts b/packages/sim/test/duty.test.ts new file mode 100644 index 0000000..ba7ae09 --- /dev/null +++ b/packages/sim/test/duty.test.ts @@ -0,0 +1,57 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { + boardPeriodIndex, + createRng, + dutyAfterTrip, + dutyLimit, + isResting, + needsRest, + restEndsAt, + tripGameHours, +} from "../src/index"; + +const config = content.config; + +describe("pilot duty and rest (CR-004)", () => { + it("raises the duty limit by the bonus for each Long Haul level", () => { + expect(dutyLimit({}, config)).toBe(config.dutyLimitGameHours); + for (let level = 1; level <= 5; level++) { + expect(dutyLimit({ "long-haul": level }, config)).toBe( + config.dutyLimitGameHours + level * config.longHaulDutyBonusGameHours, + ); + } + // Other skills do not count. + expect(dutyLimit({ "heavy-cargo": 3 }, config)).toBe(config.dutyLimitGameHours); + }); + + it("is resting until, but not at, rest-ends-at", () => { + const ends = Date.parse("2100-01-01T12:00:00Z"); + expect(isResting(ends, ends - 1000)).toBe(true); + expect(isResting(ends, ends)).toBe(false); + expect(isResting(null, ends)).toBe(false); + }); + + it("adds a trip's hours to duty, except for tutorial runs", () => { + expect(dutyAfterTrip(3, 4, false)).toBe(7); + expect(dutyAfterTrip(3, 4, true)).toBe(3); + // One real minute is one game hour at 60× compression. + expect(tripGameHours(0, 4 * 60_000, config)).toBeCloseTo(4); + }); + + it("needs rest at or over the limit", () => { + expect(needsRest(dutyAfterTrip(10, 4, false), 12)).toBe(true); + expect(needsRest(dutyAfterTrip(4, 4, false), 12)).toBe(false); + expect(needsRest(12, 12)).toBe(true); + }); + + it("always wakes a rested pilot in a later board period", () => { + const rng = createRng(2026); + for (let i = 0; i < 2000; i++) { + const start = Math.floor(rng.next() * 1e13); + expect(boardPeriodIndex(restEndsAt(start, config), config)).toBeGreaterThan( + boardPeriodIndex(start, config), + ); + } + }); +});