Step 10G.4: Priority contracts fail too-slow ships

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 17:04:55 -04:00
parent 83d0b3900f
commit f8d2f538ca
7 changed files with 118 additions and 1 deletions

View File

@ -10,6 +10,7 @@ export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, st
"over-capacity": "The cargo is too heavy for that ship.", "over-capacity": "The cargo is too heavy for that ship.",
"cannot-land": "That ship cannot land at the destination.", "cannot-land": "That ship cannot land at the destination.",
"out-of-range": "The destination is out of that ship's range right now.", "out-of-range": "The destination is out of that ship's range right now.",
"too-slow": "That ship can't make this Priority delivery's window.",
"cargo-skill-too-low": "The pilot's cargo skill is too low for this contract.", "cargo-skill-too-low": "The pilot's cargo skill is too low for this contract.",
"long-haul-too-low": "The pilot's Long Haul skill is too low for this distance.", "long-haul-too-low": "The pilot's Long Haul skill is too low for this distance.",
"contract-expired": "This contract has expired.", "contract-expired": "This contract has expired.",

View File

@ -39,6 +39,7 @@ export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
"over-capacity": "Too heavy", "over-capacity": "Too heavy",
"cannot-land": "Can't land there", "cannot-land": "Can't land there",
"out-of-range": "Out of range", "out-of-range": "Out of range",
"too-slow": "Too slow for the Priority window",
"cargo-skill-too-low": "Cargo skill too low", "cargo-skill-too-low": "Cargo skill too low",
"long-haul-too-low": "Long Haul too low", "long-haul-too-low": "Long Haul too low",
"contract-expired": "Contract expired", "contract-expired": "Contract expired",

View File

@ -205,3 +205,4 @@ Spec: `memory-bank/change-requests/CR-007-cargo-goods.md` (supplied as "CR-005 /
- [x] **Step 10G.1 — Content: goods catalog.** `goodSchema` (id, name, `cargoClassId`, `massTonnes {min,max}`, `payMultiplier`, optional `tier` 1–5, `priorityEligible`, optional `priorityName`, `producedAt`, `demandedAt`; min ≤ max refinement) and `data/goods.ts` with all 29 goods from CR-007 §3, plus `goodsProducedAt`/`goodsDemandedAt`. Locations: `produces`/`demands` now derive from the catalog (good ids); the old class lists are kept as `cargoClassMix` (decision 0.5.57) and the sim's class selection and the UI's produces/demands now read them, so nothing visible changes yet; `shippers` added (≥ 1 each). Config: `priorityChance` 0.15, `priorityPayoutMultiplier` 1.5, `priorityReferenceShipModelId` "freighter", `priorityWindowFactor` 1. Bundle: `goods`, `good(id)`. Tests: `goods.test.ts` (schema, references, tiers by class, Standard good everywhere, ≥ 2 Standard goods each way Earth↔Moon, location lists in sync, Priority config), schema tests for Good and the new fields. - [x] **Step 10G.1 — Content: goods catalog.** `goodSchema` (id, name, `cargoClassId`, `massTonnes {min,max}`, `payMultiplier`, optional `tier` 1–5, `priorityEligible`, optional `priorityName`, `producedAt`, `demandedAt`; min ≤ max refinement) and `data/goods.ts` with all 29 goods from CR-007 §3, plus `goodsProducedAt`/`goodsDemandedAt`. Locations: `produces`/`demands` now derive from the catalog (good ids); the old class lists are kept as `cargoClassMix` (decision 0.5.57) and the sim's class selection and the UI's produces/demands now read them, so nothing visible changes yet; `shippers` added (≥ 1 each). Config: `priorityChance` 0.15, `priorityPayoutMultiplier` 1.5, `priorityReferenceShipModelId` "freighter", `priorityWindowFactor` 1. Bundle: `goods`, `good(id)`. Tests: `goods.test.ts` (schema, references, tiers by class, Standard good everywhere, ≥ 2 Standard goods each way Earth↔Moon, location lists in sync, Priority config), schema tests for Good and the new fields.
- [x] **Step 10G.2 — Sim: contracts carry goods.** `Contract` gains `goodId`, `shipper`, `priority`, `deliveryWindowGameHours` (null unless Priority); the API `contractSchema` mirrors them now (the schema package's type-parity test requires it), so test fixtures across packages gained a good of their class. `contractPayout` = class base × good `payMultiplier` × tierMultiplier^tier × mass × distance × first-contact × Priority bonus. `buildContract` takes a good (the class comes from it) plus shipper and Priority fields; `tierFor` (0.5.55: Standard 0, High-Value/Dangerous the good's tier, Heavy the random roll or up to a fit pilot's level); `routeGoods` (0.5.54 fallback chain). Interim choice until 10G.3: the first route good in catalog order, the origin's first shipper, never Priority; fit jobs only use goods whose tier the pilot meets. `SimContent` gains `goods`. Tests: `contract.test.ts` (tiers from goods, Heavy 1–5, ×2 pay multiplier doubles payout, Priority = bonus × normal). - [x] **Step 10G.2 — Sim: contracts carry goods.** `Contract` gains `goodId`, `shipper`, `priority`, `deliveryWindowGameHours` (null unless Priority); the API `contractSchema` mirrors them now (the schema package's type-parity test requires it), so test fixtures across packages gained a good of their class. `contractPayout` = class base × good `payMultiplier` × tierMultiplier^tier × mass × distance × first-contact × Priority bonus. `buildContract` takes a good (the class comes from it) plus shipper and Priority fields; `tierFor` (0.5.55: Standard 0, High-Value/Dangerous the good's tier, Heavy the random roll or up to a fit pilot's level); `routeGoods` (0.5.54 fallback chain). Interim choice until 10G.3: the first route good in catalog order, the origin's first shipper, never Priority; fit jobs only use goods whose tier the pilot meets. `SimContent` gains `goods`. Tests: `contract.test.ts` (tiers from goods, Heavy 1–5, ×2 pay multiplier doubles payout, Priority = bonus × normal).
- [x] **Step 10G.3 — Sim: goods, Priority and shippers in board generation.** Class selection is unchanged (by `cargoClassMix`). Then `chooseGood` (band wins): a random good of the class on the route whose mass range overlaps the slot's band (the lightest band has no floor) with mass in both and within a fit ship's capacity; else the largest-range good as a bulk lot from the band. Masses under 10 t to 0.1 t, else whole tonnes. `rollPriority`: eligible goods become Priority with `priorityChance`; window = `priorityWindow` (reference model's trip time at offer × factor); a fit job is Priority only if its own ship makes the window. Shipper: random from the origin's list. Measured over 1,000 Earth boards (all discovered, seed 12345): Standard 76.04 % (baseline 76.79), High-Value 23.96 % (23.21); Priority on 13.6 % of eligible contracts. Bulk lots make Packaged Food and Electronics the most common goods in the big bands. Tests: `board-goods.test.ts` (determinism with and without a fleet; goods made at the origin and wanted at the destination except when no Standard good goes that way; mass rules; class mix ±3 points; Priority 10–20 % of eligible, only eligible goods, window = reference trip; shippers from the origin's list; `chooseGood` overlap, bulk and capacity cases). `board.test.ts` / `board-fleet.test.ts`: the lightest band's floor is 0 for goods. - [x] **Step 10G.3 — Sim: goods, Priority and shippers in board generation.** Class selection is unchanged (by `cargoClassMix`). Then `chooseGood` (band wins): a random good of the class on the route whose mass range overlaps the slot's band (the lightest band has no floor) with mass in both and within a fit ship's capacity; else the largest-range good as a bulk lot from the band. Masses under 10 t to 0.1 t, else whole tonnes. `rollPriority`: eligible goods become Priority with `priorityChance`; window = `priorityWindow` (reference model's trip time at offer × factor); a fit job is Priority only if its own ship makes the window. Shipper: random from the origin's list. Measured over 1,000 Earth boards (all discovered, seed 12345): Standard 76.04 % (baseline 76.79), High-Value 23.96 % (23.21); Priority on 13.6 % of eligible contracts. Bulk lots make Packaged Food and Electronics the most common goods in the big bands. Tests: `board-goods.test.ts` (determinism with and without a fleet; goods made at the origin and wanted at the destination except when no Standard good goes that way; mass rules; class mix ±3 points; Priority 10–20 % of eligible, only eligible goods, window = reference trip; shippers from the origin's list; `chooseGood` overlap, bulk and capacity cases). `board.test.ts` / `board-fleet.test.ts`: the lightest band's floor is 0 for goods.
- [x] **Step 10G.4 — Sim: Priority validation.** `too-slow` joins `DISPATCH_FAILURES` after the route checks: a Priority contract fails when `contractTripGameHours` (cruise + overhead + handling, distance at departure; exported) exceeds `deliveryWindowGameHours`. Server message and the dispatch sheet's short reason added (the API error code follows from the sim's list). `priorityWindow` exported. Tests: `priority.test.ts` (Priority mail Earth→Moon with the Freighter reference: Courier and Freighter pass, Bulk Hauler fails `too-slow`; the trip is measured like the window; a normal contract never fails as too slow); `board-fleet.test.ts` now also requires fit Priority jobs to be within the crew's reach.

View File

@ -49,6 +49,7 @@ export const DISPATCH_FAILURES = [
"over-capacity", "over-capacity",
"cannot-land", "cannot-land",
"out-of-range", "out-of-range",
"too-slow",
"cargo-skill-too-low", "cargo-skill-too-low",
"long-haul-too-low", "long-haul-too-low",
"contract-expired", "contract-expired",
@ -82,6 +83,19 @@ export function tripFuelCost(
const fail = (reason: DispatchFailure): DispatchCheck => ({ ok: false, reason }); const fail = (reason: DispatchFailure): DispatchCheck => ({ ok: false, reason });
/** A ship's trip time for a contract, departing now: cruise, overhead and handling (game hours). */
export function contractTripGameHours(
model: ShipModel,
contract: Contract,
now: RealTime,
content: SimContent,
): number {
const origin = findLocation(contract.originId, content);
const destination = findLocation(contract.destinationId, content);
const distance = routeDistance(origin, destination, realToGame(now, content.config));
return travelTimeGameHours(model, distance, contract.massTonnes, content.config);
}
/** Validates a proposed contract dispatch, returning the first failing reason. */ /** Validates a proposed contract dispatch, returning the first failing reason. */
export function validateDispatch(proposal: DispatchProposal, content: SimContent): DispatchCheck { export function validateDispatch(proposal: DispatchProposal, content: SimContent): DispatchCheck {
const { ship, pilot, contract, tutorial, credits, now } = proposal; const { ship, pilot, contract, tutorial, credits, now } = proposal;
@ -104,6 +118,15 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent
const route = checkRoute(model, origin, destination, departure); const route = checkRoute(model, origin, destination, departure);
if (!route.ok) return fail(route.reason); if (!route.ok) return fail(route.reason);
// Priority (decision 0.5.56): the trip must fit the delivery window.
if (
contract.priority &&
contract.deliveryWindowGameHours !== null &&
contractTripGameHours(model, contract, now, content) > contract.deliveryWindowGameHours
) {
return fail("too-slow");
}
const pilotCheck = checkPilotEligibility(pilot.skills, contract); const pilotCheck = checkPilotEligibility(pilot.skills, contract);
if (!pilotCheck.ok) return fail(pilotCheck.reason); if (!pilotCheck.ok) return fail(pilotCheck.reason);

View File

@ -27,6 +27,7 @@ export {
requiredLongHaulLevel, requiredLongHaulLevel,
} from "./board"; } from "./board";
export type { BoardInput, FleetMember, SimContent } from "./board"; export type { BoardInput, FleetMember, SimContent } from "./board";
export { priorityWindow } from "./board";
export { generateTutorialBoard, loanerModel } from "./tutorial"; export { generateTutorialBoard, loanerModel } from "./tutorial";
export type { TutorialBoardInput, TutorialLocationId } from "./tutorial"; export type { TutorialBoardInput, TutorialLocationId } from "./tutorial";
@ -37,6 +38,7 @@ export type { PilotCheck, SkillLevels } from "./pilot";
export { export {
DISPATCH_FAILURES, DISPATCH_FAILURES,
REPOSITION_FAILURES, REPOSITION_FAILURES,
contractTripGameHours,
createDispatchRecord, createDispatchRecord,
createRepositionRecord, createRepositionRecord,
tripFuelCost, tripFuelCost,

View File

@ -4,6 +4,7 @@ import {
boardPeriodMs, boardPeriodMs,
checkPilotEligibility, checkPilotEligibility,
checkRoute, checkRoute,
contractTripGameHours,
generateContractBoard, generateContractBoard,
realToGame, realToGame,
type BoardInput, type BoardInput,
@ -25,7 +26,11 @@ function fits(contract: Contract, member: FleetMember): boolean {
!contract.firstContact && !contract.firstContact &&
contract.massTonnes <= model.cargoCapacityTonnes && contract.massTonnes <= model.cargoCapacityTonnes &&
checkRoute(model, earth, content.location(contract.destinationId)!, gameTime).ok && checkRoute(model, earth, content.location(contract.destinationId)!, gameTime).ok &&
checkPilotEligibility(member.skills, contract).ok checkPilotEligibility(member.skills, contract).ok &&
// Priority (CR-007): the ship must make the delivery window.
(!contract.priority ||
contractTripGameHours(model, contract, contract.offeredAt, content) <=
contract.deliveryWindowGameHours!)
); );
} }

View File

@ -0,0 +1,84 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { buildContract } from "../src/board";
import {
boardPeriodMs,
contractTripGameHours,
priorityWindow,
validateDispatch,
type Contract,
} from "../src/index";
const earth = content.location("earth")!;
const moon = content.location("moon")!;
const periodIndex = 4_000;
const offeredAt = periodIndex * boardPeriodMs(content.config);
function mailContract(priority: boolean): Contract {
const massTonnes = 6;
return buildContract(
{
id: "earth:4000:0",
origin: earth,
destination: moon,
good: content.good("mail")!,
tier: 0,
massTonnes,
firstContact: false,
periodIndex,
shipper: earth.shippers[0]!,
priority,
deliveryWindowGameHours: priority
? priorityWindow(earth, moon, massTonnes, offeredAt, content)
: null,
},
content,
);
}
function check(modelId: string, contract: Contract) {
return validateDispatch(
{
ship: { id: "s", modelId, locationId: "earth", inFlight: false },
pilot: {
id: "p",
isAvatar: true,
assignedShipId: "s",
inFlight: false,
skills: {},
restEndsAt: null,
},
contract,
tutorial: false,
credits: 1_000_000,
now: offeredAt,
},
content,
);
}
describe("Priority validation (CR-007)", () => {
it("lets a Courier take a Priority Earth→Moon job but not a Bulk Hauler (Freighter reference)", () => {
const contract = mailContract(true);
expect(content.config.priorityReferenceShipModelId).toBe("freighter");
expect(check("courier", contract)).toEqual({ ok: true });
expect(check("freighter", contract)).toEqual({ ok: true });
expect(check("bulk-hauler", contract)).toEqual({ ok: false, reason: "too-slow" });
});
it("measures the trip the same way the window was set", () => {
const contract = mailContract(true);
const freighter = content.shipModel("freighter")!;
expect(contractTripGameHours(freighter, contract, offeredAt, content)).toBeCloseTo(
contract.deliveryWindowGameHours!,
);
});
it("never fails a normal contract as too slow", () => {
const contract = mailContract(false);
expect(contract.deliveryWindowGameHours).toBeNull();
for (const model of ["courier", "freighter", "bulk-hauler"]) {
expect(check(model, contract)).toEqual({ ok: true });
}
});
});