Step 10D.2: sim duty and rest functions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 21:44:11 -04:00
parent 935d9b43f0
commit 6b6753f3b2
5 changed files with 109 additions and 1 deletions

View File

@ -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.

View File

@ -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

43
packages/sim/src/duty.ts Normal file
View File

@ -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;
}

View File

@ -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";

View File

@ -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),
);
}
});
});