Step 10G.4: Priority contracts fail too-slow ships
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
83d0b3900f
commit
f8d2f538ca
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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!)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue