diff --git a/DECISIONS.md b/DECISIONS.md index 2f1d029..ef26bf7 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -130,3 +130,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The loaner is the content's Courier,** found by archetype `courier`. `SimContent` now includes `shipModels`. - **Tutorial payout** = round(normal payout × tutorial share), where the normal payout is the regular rounded payout for the same contract. The contract stores this reduced amount, so resolution credits `contract.payout` directly. - **Tutorial board seed** is `tutorial::`, giving one Earth→Moon and one Moon→Earth board per period (decision 0.5.21). Contract ids use the same prefix. + +## Step 3.11 + +- **Pilot skills are a `{ skillId: level }` map**; a missing skill counts as level 0. +- **The Long Haul skill id is the constant `long-haul`** in sim, because the rule (decision 0.5.7) is specifically about that skill. +- **The cargo skill is checked before Long Haul** when reporting a failure reason. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 2d9d6f2..3685c21 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -29,7 +29,7 @@ - [x] **Step 3.8 — Contract model and payout.** `src/contract.ts`: `Contract` interface and `contractPayout` (base × multiplier^tier × mass × distance × first-contact multiplier, rounded). - [x] **Step 3.9 — Contract board generation.** `src/board.ts`: `generateContractBoard` (seeded per world, bay and period), plus `boardPeriodIndex`, `requiredLongHaulLevel` and the `SimContent` input type. First contact ≈10% over 1,000 boards. - [x] **Step 3.10 — Tutorial contract board.** `src/tutorial.ts`: `generateTutorialBoard` (Earth↔Moon from the loaner's location, Standard cargo capped at Courier capacity, payout × tutorial share); `loanerModel`. `SimContent` gained `shipModels`. -- [ ] Step 3.11 — Pilot eligibility +- [x] **Step 3.11 — Pilot eligibility.** `src/pilot.ts`: `checkPilotEligibility(skills, contract)` returns `ok` or `cargo-skill-too-low` / `long-haul-too-low`. - [ ] Step 3.12 — Dispatch validation - [ ] Step 3.13 — Dispatch record creation - [ ] Step 3.14 — Reposition dispatch diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index b9533e4..8adf1ef 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -8,3 +8,4 @@ export * from "./eligibility"; export * from "./contract"; export * from "./board"; export * from "./tutorial"; +export * from "./pilot"; diff --git a/packages/sim/src/pilot.ts b/packages/sim/src/pilot.ts new file mode 100644 index 0000000..e8a9b48 --- /dev/null +++ b/packages/sim/src/pilot.ts @@ -0,0 +1,25 @@ +import type { Contract } from "./contract"; + +export const LONG_HAUL_SKILL_ID = "long-haul"; + +/** A pilot's skill levels by skill id; a missing skill is level 0. */ +export type SkillLevels = Readonly>; + +export type PilotCheck = + { ok: true } | { ok: false; reason: "cargo-skill-too-low" | "long-haul-too-low" }; + +export function skillLevel(skills: SkillLevels, skillId: string): number { + return skills[skillId] ?? 0; +} + +/** Whether a pilot's skills meet a contract's cargo-skill and Long Haul requirements. */ +export function checkPilotEligibility(skills: SkillLevels, contract: Contract): PilotCheck { + const required = contract.requiredSkill; + if (required && skillLevel(skills, required.skillId) < required.level) { + return { ok: false, reason: "cargo-skill-too-low" }; + } + if (skillLevel(skills, LONG_HAUL_SKILL_ID) < contract.requiredLongHaulLevel) { + return { ok: false, reason: "long-haul-too-low" }; + } + return { ok: true }; +} diff --git a/packages/sim/test/pilot.test.ts b/packages/sim/test/pilot.test.ts new file mode 100644 index 0000000..a8d440f --- /dev/null +++ b/packages/sim/test/pilot.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { checkPilotEligibility, type Contract } from "../src/index"; + +const shortStandard: Contract = { + id: "c1", + originId: "earth", + destinationId: "moon", + cargoTypeId: "standard", + tier: 0, + massTonnes: 10, + payout: 1000, + requiredSkill: null, + requiredLongHaulLevel: 0, + offeredAt: 0, + expiresAt: 3_600_000, + firstContact: false, +}; +const heavyTier2: Contract = { + ...shortStandard, + cargoTypeId: "heavy", + tier: 2, + requiredSkill: { skillId: "heavy-cargo", level: 2 }, +}; +const longRoute: Contract = { + ...shortStandard, + destinationId: "ceres-station", + requiredLongHaulLevel: 1, +}; + +describe("pilot eligibility", () => { + it("lets a level-0 pilot take a short Standard contract", () => { + expect(checkPilotEligibility({}, shortStandard)).toEqual({ ok: true }); + }); + + it("stops a level-0 pilot from taking a Heavy contract needing Heavy Cargo 2", () => { + expect(checkPilotEligibility({}, heavyTier2)).toEqual({ + ok: false, + reason: "cargo-skill-too-low", + }); + expect(checkPilotEligibility({ "heavy-cargo": 1 }, heavyTier2).ok).toBe(false); + }); + + it("lets a pilot with Heavy Cargo 2 take it", () => { + expect(checkPilotEligibility({ "heavy-cargo": 2 }, heavyTier2)).toEqual({ ok: true }); + }); + + it("stops a level-0 pilot from taking a route needing Long Haul 1", () => { + expect(checkPilotEligibility({}, longRoute)).toEqual({ + ok: false, + reason: "long-haul-too-low", + }); + expect(checkPilotEligibility({ "long-haul": 1 }, longRoute)).toEqual({ ok: true }); + }); +});