Step 10D.2: sim duty and rest functions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
935d9b43f0
commit
6b6753f3b2
|
|
@ -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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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.
|
- [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.3 — Sim: validation and resolution
|
||||||
- [ ] Step 10D.4 — Schema and database
|
- [ ] Step 10D.4 — Schema and database
|
||||||
- [ ] Step 10D.5 — Server: apply duty and rest
|
- [ ] Step 10D.5 — Server: apply duty and rest
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -69,3 +69,5 @@ export type { LoanSplit } from "./loan";
|
||||||
|
|
||||||
export { resolveDispatch } from "./resolution";
|
export { resolveDispatch } from "./resolution";
|
||||||
export type { Resolution, ResolveInput, ResolveResult } from "./resolution";
|
export type { Resolution, ResolveInput, ResolveResult } from "./resolution";
|
||||||
|
|
||||||
|
export { dutyAfterTrip, dutyLimit, isResting, needsRest, restEndsAt, tripGameHours } from "./duty";
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue