Step 10G.2: contracts carry goods, shippers and Priority fields

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 16:59:00 -04:00
parent 8a7b612bc7
commit 13f5b04ffb
24 changed files with 244 additions and 25 deletions

View File

@ -157,6 +157,10 @@ describe("contract dispatch", () => {
const o = await owner();
const contract = testContract({
cargoTypeId: "heavy",
goodId: "machine-parts",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 2,
requiredSkill: { skillId: "heavy-cargo", level: 2 },
});

View File

@ -100,6 +100,10 @@ export function testContract(overrides: Partial<Contract> = {}): Contract {
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 2000,

View File

@ -117,8 +117,8 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
</p>
<p className="text-ink-400">
Produces {location.cargoClassMix.produces.map(cargoName).join(", ") || "nothing"} · demands{" "}
{location.cargoClassMix.demands.map(cargoName).join(", ") || "nothing"}
Produces {location.cargoClassMix.produces.map(cargoName).join(", ") || "nothing"} ·
demands {location.cargoClassMix.demands.map(cargoName).join(", ") || "nothing"}
</p>
<p className={hasBay ? "text-emerald-300" : "text-ink-400"}>
{hasBay ? "Your HQ is here." : "No HQ here."}

View File

@ -45,6 +45,10 @@ const heavyJob: Contract = {
originId: "earth",
destinationId: "moon",
cargoTypeId: "heavy",
goodId: "machine-parts",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 300,
payout: 9000,

View File

@ -44,6 +44,10 @@ const moonJob: Contract = {
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 900,

View File

@ -17,6 +17,10 @@ const job = (
originId: "earth",
destinationId: "moon",
cargoTypeId: skill ? "high-value" : "standard",
goodId: skill ? "medical-supplies" : "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: skill?.level ?? 0,
massTonnes,
payout: 1000,

View File

@ -52,6 +52,10 @@ const marsJob: Contract = {
originId: "earth",
destinationId: "mars",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 15,
payout: 500_000,

View File

@ -12,6 +12,10 @@ const contract = (overrides: Partial<Contract>): Contract => ({
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 50,
payout: 2000,
@ -30,6 +34,10 @@ const boards: Record<string, Contract[]> = {
id: "e2",
destinationId: "mars",
cargoTypeId: "heavy",
goodId: "machine-parts",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 2,
requiredSkill: { skillId: "heavy-cargo", level: 2 },
requiredLongHaulLevel: 1,

View File

@ -102,6 +102,10 @@ export function sampleTutorialBoard(
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes,
payout,

View File

@ -11,6 +11,10 @@ const contract = (id: string, massTonnes: number): Contract => ({
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes,
payout: 1000 + massTonnes,

View File

@ -203,3 +203,4 @@ Spec: `memory-bank/change-requests/CR-006-boards-that-fit.md`. New decisions rec
Spec: `memory-bank/change-requests/CR-007-cargo-goods.md` (supplied as "CR-005 / Phase 10E"; renumbered, see its implementation notes). New decisions recorded as plan §0.5.54–0.5.59. Baseline class mix before the change (1,000 Earth boards, all discovered): Standard 76.79 %, High-Value 23.21 %.
- [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).

View File

@ -85,6 +85,13 @@ export const contractSchema = z.object({
originId: idSchema,
destinationId: idSchema,
cargoTypeId: idSchema,
/** The named good (CR-007). */
goodId: idSchema,
/** Flavor: the shipping company (decision 0.5.58). */
shipper: z.string().min(1),
priority: z.boolean(),
/** Priority only: the longest allowed trip in game hours (decision 0.5.56). */
deliveryWindowGameHours: z.number().positive().nullable(),
tier: z.number().int().min(0),
massTonnes: z.number().positive(),
payout: credits,

View File

@ -80,6 +80,10 @@ const contract = {
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 12,
payout: 480,

View File

@ -1,6 +1,7 @@
import type {
CargoType,
GameConfig,
Good,
Location,
ScriptedTutorialContract,
ShipModel,
@ -17,6 +18,7 @@ import { createRng, deriveSeed, type Rng, type Seed } from "./rng";
export interface SimContent {
locations: readonly Location[];
cargoTypes: readonly CargoType[];
goods: readonly Good[];
skills: readonly Skill[];
shipModels: readonly ShipModel[];
scriptedTutorialContracts: readonly ScriptedTutorialContract[];
@ -138,54 +140,85 @@ function fitJob(
(c.requiredSkillId === null || skillLevel(skills, c.requiredSkillId) >= 1),
);
const cargoType = skilled.length > 0 ? rng.pick(skilled) : standardCargo(content);
const tier =
cargoType.requiredSkillId === null
? 0
: rng.int(1, skillLevel(skills, cargoType.requiredSkillId));
// Only goods this pilot may carry: a good's own tier must not exceed their skill.
const good = routeGoods(cargoType, origin, destination, content, (g) => {
const skillId = cargoClassOf(g, content).requiredSkillId;
return skillId === null || skillLevel(skills, skillId) >= (g.tier ?? 1);
})[0]!;
const skillId = cargoClassOf(good, content).requiredSkillId;
const tier = tierFor(rng, good, content, skillId ? skillLevel(skills, skillId) : undefined);
const massTonnes = rng.int(band.min, Math.min(band.max, model.cargoCapacityTonnes));
return { destination, cargoType, tier, massTonnes };
return { destination, good, tier, massTonnes };
}
return null;
}
/** Builds one contract from a chosen route, cargo, tier and mass. */
export function cargoClassOf(good: Good, content: SimContent): CargoType {
const cargoType = content.cargoTypes.find((c) => c.id === good.cargoClassId);
if (!cargoType) throw new Error(`Unknown cargo class ${good.cargoClassId}`);
return cargoType;
}
/**
* A contract's tier (decision 0.5.55): Standard is 0, High-Value and Dangerous
* take the good's tier, and Heavy rolls 1 to `maxTier` (the skill's maximum,
* or a pilot's level for a fit job).
*/
export function tierFor(rng: Rng, good: Good, content: SimContent, maxTier?: number): number {
const cargoType = cargoClassOf(good, content);
if (cargoType.requiredSkillId === null) return 0;
if (good.tier !== undefined) return good.tier;
return maxTier === undefined ? pickTier(rng, cargoType, content) : rng.int(1, maxTier);
}
/** Builds one contract from a chosen route, good, tier and mass. */
export function buildContract(
args: {
id: string;
origin: Location;
destination: Location;
cargoType: CargoType;
good: Good;
tier: number;
massTonnes: number;
firstContact: boolean;
periodIndex: number;
shipper: string;
priority: boolean;
deliveryWindowGameHours: number | null;
},
content: SimContent,
): Contract {
const { config } = content;
const cargoType = cargoClassOf(args.good, content);
const offeredAt = args.periodIndex * boardPeriodMs(config);
const distance = routeDistance(args.origin, args.destination, realToGame(offeredAt, config));
return {
id: args.id,
originId: args.origin.id,
destinationId: args.destination.id,
cargoTypeId: args.cargoType.id,
cargoTypeId: cargoType.id,
goodId: args.good.id,
shipper: args.shipper,
priority: args.priority,
deliveryWindowGameHours: args.priority ? args.deliveryWindowGameHours : null,
tier: args.tier,
massTonnes: args.massTonnes,
payout: contractPayout(
{
cargoType: args.cargoType,
cargoType,
payMultiplier: args.good.payMultiplier,
tier: args.tier,
massTonnes: args.massTonnes,
distanceMillionKm: distance,
firstContact: args.firstContact,
priority: args.priority,
},
config,
),
requiredSkill:
args.cargoType.requiredSkillId === null
cargoType.requiredSkillId === null
? null
: { skillId: args.cargoType.requiredSkillId, level: args.tier },
: { skillId: cargoType.requiredSkillId, level: args.tier },
requiredLongHaulLevel: requiredLongHaulLevel(distance, config),
offeredAt,
expiresAt: offeredAt + boardPeriodMs(config),
@ -193,6 +226,33 @@ export function buildContract(
};
}
/**
* The goods a contract of this class can carry on this route (decision 0.5.54):
* that class's goods made at the origin and wanted at the destination; else
* Standard goods on the route; else any Standard good made at the origin.
* `allow` filters out goods a crew can't take (fit jobs).
*/
export function routeGoods(
cargoType: CargoType,
origin: Location,
destination: Location,
content: SimContent,
allow: (good: Good) => boolean = () => true,
): Good[] {
const made = content.goods.filter((g) => g.producedAt.includes(origin.id) && allow(g));
const onRoute = made.filter((g) => g.demandedAt.includes(destination.id));
const ofClass = onRoute.filter((g) => g.cargoClassId === cargoType.id);
if (ofClass.length > 0) return ofClass;
const standardId = standardCargo(content).id;
const standardOnRoute = onRoute.filter((g) => g.cargoClassId === standardId);
if (standardOnRoute.length > 0) return standardOnRoute;
const anyStandard = content.goods.filter(
(g) => g.producedAt.includes(origin.id) && g.cargoClassId === standardId,
);
if (anyStandard.length === 0) throw new Error(`${origin.id} makes no Standard good`);
return anyStandard;
}
function pickCargo(rng: Rng, origin: Location, destination: Location, content: SimContent) {
const matching = content.cargoTypes.filter(
(c) =>
@ -239,7 +299,19 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C
const fit =
kind === "fit" ? fitJob(rng, band, crews, origin, discovered, offeredAt, content) : null;
if (fit) {
return buildContract({ id, origin, ...fit, firstContact: false, periodIndex }, content);
return buildContract(
{
id,
origin,
...fit,
firstContact: false,
periodIndex,
shipper: origin.shippers[0]!,
priority: false,
deliveryWindowGameHours: null,
},
content,
);
}
const guaranteed = kind !== "open";
const plain = guaranteed && discovered.length > 0;
@ -249,16 +321,20 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C
(discovered.length === 0 || rng.next() < config.firstContactChance);
const destination = rng.pick(firstContact ? undiscovered : discovered);
const cargoType = plain ? standardCargo(content) : pickCargo(rng, origin, destination, content);
const good = routeGoods(cargoType, origin, destination, content)[0]!;
return buildContract(
{
id,
origin,
destination,
cargoType,
tier: pickTier(rng, cargoType, content),
good,
tier: tierFor(rng, good, content),
massTonnes: rng.int(band.min, band.max),
firstContact,
periodIndex,
shipper: origin.shippers[0]!,
priority: false,
deliveryWindowGameHours: null,
},
content,
);

View File

@ -5,8 +5,20 @@ export interface Contract {
id: string;
originId: string;
destinationId: string;
/** The cargo class (decision 0.5.6). */
cargoTypeId: string;
/** 0 for Standard; 1–5 otherwise, and equal to the required cargo-skill level (decision 0.5.6). */
/** The named good (CR-007, decision 0.5.54); its class is `cargoTypeId`. */
goodId: string;
/** Flavor: the company shipping it, from the origin's list (decision 0.5.58). */
shipper: string;
/** A Priority contract must arrive within its delivery window (decision 0.5.56). */
priority: boolean;
/** Priority only: the longest trip allowed, in game hours; null otherwise. */
deliveryWindowGameHours: number | null;
/**
* 0 for Standard; 1–5 otherwise, and equal to the required cargo-skill level
* (decision 0.5.6). High-Value and Dangerous take it from the good (0.5.55).
*/
tier: number;
massTonnes: number;
/** Credits, fixed at offer time (decision 0.5.11). */
@ -24,24 +36,30 @@ export interface Contract {
export interface PayoutInput {
cargoType: CargoType;
/** The good's pay multiplier (CR-007). */
payMultiplier: number;
tier: number;
massTonnes: number;
/** Route distance at offer time. */
distanceMillionKm: number;
firstContact: boolean;
priority: boolean;
}
/**
* Payout in whole credits: base pay per tonne per million km × tierMultiplier^tier
* × mass × distance, × the first-contact multiplier when flagged.
* Payout in whole credits: class base pay per tonne per million km × the good's
* pay multiplier × tierMultiplier^tier × mass × distance, × the first-contact
* multiplier and the Priority bonus when flagged (CR-007).
*/
export function contractPayout(input: PayoutInput, config: GameConfig): number {
const { cargoType, tier, massTonnes, distanceMillionKm, firstContact } = input;
const { cargoType, payMultiplier, tier, massTonnes, distanceMillionKm, firstContact } = input;
const pay =
cargoType.basePayPerTonnePerMillionKm *
payMultiplier *
cargoType.tierPayMultiplier ** tier *
massTonnes *
distanceMillionKm *
(firstContact ? config.firstContactPayoutMultiplier : 1);
(firstContact ? config.firstContactPayoutMultiplier : 1) *
(input.priority ? config.priorityPayoutMultiplier : 1);
return Math.round(pay);
}

View File

@ -1,5 +1,5 @@
import type { Location, ShipModel } from "@deadhead/content";
import { buildContract, standardCargo, type SimContent } from "./board";
import { buildContract, routeGoods, standardCargo, type SimContent } from "./board";
import type { Contract } from "./contract";
/** The tutorial loaner: the content's company ship (decision 0.5.29). */
@ -48,16 +48,20 @@ export function generateTutorialBoard(input: TutorialBoardInput, content: SimCon
? content.cargoTypes.find((c) => c.id === special.cargoTypeId)
: standardCargo(content);
if (!cargoType) throw new Error(`Unknown cargo ${special?.cargoTypeId}`);
const good = routeGoods(cargoType, origin, destination, content)[0]!;
const contract = buildContract(
{
id: `tutorial:${loanerLocationId}:${run}:${periodIndex}:${i}`,
origin,
destination,
cargoType,
good,
tier: 0,
massTonnes,
firstContact: false,
periodIndex,
shipper: origin.shippers[0]!,
priority: false,
deliveryWindowGameHours: null,
},
content,
);

View File

@ -1,6 +1,7 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { contractPayout, type PayoutInput } from "../src/index";
import { tierFor } from "../src/board";
import { contractPayout, createRng, type PayoutInput } from "../src/index";
const config = content.config;
const standard = content.cargoType("standard")!;
@ -8,10 +9,12 @@ const dangerous = content.cargoType("dangerous")!;
const base: PayoutInput = {
cargoType: standard,
payMultiplier: 1,
tier: 0,
massTonnes: 10,
distanceMillionKm: 100,
firstContact: false,
priority: false,
};
describe("contract payout", () => {
@ -50,3 +53,35 @@ describe("contract payout", () => {
);
});
});
describe("contracts carry goods (CR-007)", () => {
const rng = createRng(42);
it("takes the tier of a High-Value or Dangerous contract from its good", () => {
for (const good of content.goods.filter(
(g) => g.cargoClassId === "high-value" || g.cargoClassId === "dangerous",
)) {
expect(tierFor(rng, good, content)).toBe(good.tier);
}
});
it("keeps the random 1–5 tier for Heavy goods and tier 0 for Standard", () => {
const parts = content.good("machine-parts")!;
const seen = new Set<number>();
for (let i = 0; i < 200; i++) seen.add(tierFor(rng, parts, content));
expect([...seen].sort()).toEqual([1, 2, 3, 4, 5]);
expect(tierFor(rng, content.good("mail")!, content)).toBe(0);
});
it("doubles the payout when the good's pay multiplier doubles", () => {
expect(contractPayout({ ...base, payMultiplier: 2 }, config)).toBe(
2 * contractPayout(base, config),
);
});
it("pays a Priority contract exactly the bonus times an identical normal one", () => {
expect(contractPayout({ ...base, priority: true }, config)).toBe(
contractPayout(base, config) * config.priorityPayoutMultiplier,
);
});
});

View File

@ -27,6 +27,10 @@ const contract: Contract = {
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 2000,
@ -99,6 +103,10 @@ describe("dispatch validation", () => {
const heavy = {
...contract,
cargoTypeId: "heavy",
goodId: "machine-parts",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 1,
requiredSkill: { skillId: "heavy-cargo", level: 1 },
};

View File

@ -24,6 +24,10 @@ const contract: Contract = {
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 2000,

View File

@ -44,6 +44,10 @@ describe("resolution with a loan", () => {
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 2000,

View File

@ -6,6 +6,10 @@ const shortStandard: Contract = {
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 1000,
@ -18,6 +22,10 @@ const shortStandard: Contract = {
const heavyTier2: Contract = {
...shortStandard,
cargoTypeId: "heavy",
goodId: "machine-parts",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 2,
requiredSkill: { skillId: "heavy-cargo", level: 2 },
};

View File

@ -14,6 +14,10 @@ const contract: Contract = {
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 2000,

View File

@ -16,6 +16,10 @@ const contract: Contract = {
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 2000,

View File

@ -29,15 +29,17 @@ function distanceOf(contract: Contract): number {
);
}
/** The normal (non-tutorial) payout for the same Standard cargo and route. */
/** The normal (non-tutorial) payout for the same Standard good and route. */
function normalPayout(contract: Contract): number {
return contractPayout(
{
cargoType: content.cargoType("standard")!,
payMultiplier: content.good(contract.goodId)!.payMultiplier,
tier: 0,
massTonnes: contract.massTonnes,
distanceMillionKm: distanceOf(contract),
firstContact: false,
priority: false,
},
config,
);