Step 10G.1: goods catalog, shippers and Priority config

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 16:53:17 -04:00
parent bdd5fd8509
commit 8a7b612bc7
13 changed files with 549 additions and 26 deletions

View File

@ -103,11 +103,11 @@ export function Locations() {
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase"> <dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
Produces Produces
</dt> </dt>
<dd>{location.produces.map(cargoName).join(", ") || "—"}</dd> <dd>{location.cargoClassMix.produces.map(cargoName).join(", ") || "—"}</dd>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase"> <dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
Demands Demands
</dt> </dt>
<dd>{location.demands.map(cargoName).join(", ") || "—"}</dd> <dd>{location.cargoClassMix.demands.map(cargoName).join(", ") || "—"}</dd>
</dl> </dl>
<Link <Link
to={`/contracts?location=${location.id}`} to={`/contracts?location=${location.id}`}

View File

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

View File

@ -202,3 +202,4 @@ Spec: `memory-bank/change-requests/CR-006-boards-that-fit.md`. New decisions rec
## Phase 10G — Cargo Goods, Priority Contracts and Shippers (CR-007) ## Phase 10G — Cargo Goods, Priority Contracts and Shippers (CR-007)
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 %. 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.

View File

@ -1,5 +1,6 @@
import { cargoTypes, skills } from "./data/cargo"; import { cargoTypes, skills } from "./data/cargo";
import { gameConfig } from "./data/config"; import { gameConfig } from "./data/config";
import { goods } from "./data/goods";
import { locations } from "./data/locations"; import { locations } from "./data/locations";
import { onboardingGoals, scriptedTutorialContracts } from "./data/onboarding"; import { onboardingGoals, scriptedTutorialContracts } from "./data/onboarding";
import { shipModels } from "./data/ships"; import { shipModels } from "./data/ships";
@ -17,6 +18,7 @@ export const content = {
/** Ship models players can buy (excludes company ships such as the loaner). */ /** Ship models players can buy (excludes company ships such as the loaner). */
purchasableShipModels: shipModels.filter((s) => s.purchasable), purchasableShipModels: shipModels.filter((s) => s.purchasable),
cargoTypes, cargoTypes,
goods,
skills, skills,
config: gameConfig, config: gameConfig,
scriptedTutorialContracts, scriptedTutorialContracts,
@ -24,6 +26,7 @@ export const content = {
location: byId(locations), location: byId(locations),
shipModel: byId(shipModels), shipModel: byId(shipModels),
cargoType: byId(cargoTypes), cargoType: byId(cargoTypes),
good: byId(goods),
skill: byId(skills), skill: byId(skills),
onboardingGoal: byId(onboardingGoals), onboardingGoal: byId(onboardingGoals),
}; };

View File

@ -32,6 +32,12 @@ export const gameConfig: GameConfig = {
restGameHours: 10, restGameHours: 10,
firstContactChance: 0.1, firstContactChance: 0.1,
firstContactPayoutMultiplier: 2, firstContactPayoutMultiplier: 2,
// Priority contracts (CR-007): 15% of eligible goods, ×1.5 pay, a window as
// long as a Freighter's trip.
priorityChance: 0.15,
priorityPayoutMultiplier: 1.5,
priorityReferenceShipModelId: "freighter",
priorityWindowFactor: 1,
// One band per ship size, so every board has work for every ship (playtest fix): // One band per ship size, so every board has work for every ship (playtest fix):
// up to a Courier's 20 t, a Freighter's 100 t, and a Bulk Hauler's 400 t. // up to a Courier's 20 t, a Freighter's 100 t, and a Bulk Hauler's 400 t.
contractMassBands: [ contractMassBands: [

View File

@ -0,0 +1,338 @@
import type { Good } from "../schemas";
const EARTH = "earth";
const MOON = "moon";
const MARS = "mars";
const CERES = "ceres-station";
const JUPITER = "jupiter-cloud-platform";
/**
* The goods catalog (CR-007, decision 0.5.54). Pay multipliers scale the
* class's base pay per tonne per million km. High-Value and Dangerous goods
* carry their own tier (0.5.55); Standard and Heavy goods don't.
*/
export const goods: Good[] = [
// Standard: no skill.
{
id: "mail",
name: "Mail & Parcels",
cargoClassId: "standard",
massTonnes: { min: 1, max: 8 },
payMultiplier: 1.2,
priorityEligible: true,
producedAt: [EARTH, MARS],
demandedAt: [MOON, MARS, CERES, JUPITER, EARTH],
},
{
id: "packaged-food",
name: "Packaged Food",
cargoClassId: "standard",
massTonnes: { min: 10, max: 60 },
payMultiplier: 1,
priorityEligible: false,
producedAt: [EARTH],
demandedAt: [MOON, MARS, CERES, JUPITER],
},
{
id: "consumer-goods",
name: "Consumer Goods",
cargoClassId: "standard",
massTonnes: { min: 5, max: 40 },
payMultiplier: 1,
priorityEligible: false,
producedAt: [EARTH],
demandedAt: [MOON, MARS, CERES, JUPITER],
},
{
id: "optical-glass",
name: "Lunar Optical Glass",
cargoClassId: "standard",
massTonnes: { min: 4, max: 25 },
payMultiplier: 1.1,
priorityEligible: false,
producedAt: [MOON],
demandedAt: [EARTH, MARS],
},
{
id: "moon-souvenirs",
name: "Moon Dust Souvenirs",
cargoClassId: "standard",
massTonnes: { min: 1, max: 6 },
payMultiplier: 0.9,
priorityEligible: false,
producedAt: [MOON],
demandedAt: [EARTH],
},
{
id: "hydroponic-produce",
name: "Hydroponic Produce",
cargoClassId: "standard",
massTonnes: { min: 5, max: 30 },
payMultiplier: 1.1,
priorityEligible: true,
producedAt: [MARS],
demandedAt: [MOON, CERES, JUPITER],
},
{
id: "salvaged-components",
name: "Salvaged Components",
cargoClassId: "standard",
massTonnes: { min: 5, max: 30 },
payMultiplier: 0.9,
priorityEligible: false,
producedAt: [CERES],
demandedAt: [MOON, MARS],
},
{
id: "bioplastics",
name: "Atmospheric Bioplastics",
cargoClassId: "standard",
massTonnes: { min: 5, max: 40 },
payMultiplier: 1,
priorityEligible: false,
producedAt: [JUPITER],
demandedAt: [EARTH, MARS, CERES],
},
// High-Value: Expensive Cargo skill; tier from the good.
{
id: "medical-supplies",
name: "Medical Supplies",
cargoClassId: "high-value",
massTonnes: { min: 1, max: 10 },
payMultiplier: 1,
tier: 1,
priorityEligible: true,
producedAt: [EARTH],
demandedAt: [MOON, MARS, CERES, JUPITER],
},
{
id: "electronics",
name: "Electronics",
cargoClassId: "high-value",
massTonnes: { min: 2, max: 15 },
payMultiplier: 1,
tier: 1,
priorityEligible: false,
producedAt: [EARTH],
demandedAt: [MOON, MARS, CERES, JUPITER],
},
{
id: "seed-stock",
name: "Seed Stock & Embryos",
cargoClassId: "high-value",
massTonnes: { min: 0.5, max: 4 },
payMultiplier: 1.3,
tier: 2,
priorityEligible: true,
producedAt: [EARTH],
demandedAt: [MOON, MARS, CERES],
},
{
id: "low-g-pharma",
name: "Low-G Pharmaceuticals",
cargoClassId: "high-value",
massTonnes: { min: 0.5, max: 5 },
payMultiplier: 1.4,
tier: 2,
priorityEligible: true,
producedAt: [MOON],
demandedAt: [EARTH, MARS],
},
{
id: "research-samples",
name: "Research Samples",
cargoClassId: "high-value",
massTonnes: { min: 0.2, max: 3 },
payMultiplier: 1.5,
tier: 2,
priorityEligible: true,
producedAt: [MOON, MARS, CERES],
demandedAt: [EARTH],
},
{
id: "rare-earths",
name: "Martian Rare Earths",
cargoClassId: "high-value",
massTonnes: { min: 5, max: 30 },
payMultiplier: 1.4,
tier: 3,
priorityEligible: false,
producedAt: [MARS],
demandedAt: [EARTH],
},
{
id: "platinum",
name: "Platinum Ingots",
cargoClassId: "high-value",
massTonnes: { min: 2, max: 20 },
payMultiplier: 1.8,
tier: 4,
priorityEligible: false,
producedAt: [CERES],
demandedAt: [EARTH],
},
// Heavy: Heavy Cargo skill; tier follows the generator's Heavy rule.
{
id: "machine-parts",
name: "Machine Parts",
cargoClassId: "heavy",
massTonnes: { min: 20, max: 90 },
payMultiplier: 1,
priorityEligible: true,
priorityName: "Emergency Parts",
producedAt: [EARTH, MARS],
demandedAt: [MOON, MARS, CERES, JUPITER],
},
{
id: "habitat-prefabs",
name: "Habitat Prefabs",
cargoClassId: "heavy",
massTonnes: { min: 40, max: 180 },
payMultiplier: 1,
priorityEligible: false,
producedAt: [EARTH],
demandedAt: [MOON, MARS, CERES],
},
{
id: "lunar-titanium",
name: "Lunar Titanium",
cargoClassId: "heavy",
massTonnes: { min: 30, max: 120 },
payMultiplier: 1.1,
priorityEligible: false,
producedAt: [MOON],
demandedAt: [EARTH, MARS],
},
{
id: "iron-ingots",
name: "Iron Ingots",
cargoClassId: "heavy",
massTonnes: { min: 50, max: 200 },
payMultiplier: 0.9,
priorityEligible: false,
producedAt: [MARS],
demandedAt: [MOON, CERES],
},
{
id: "nickel-iron-ore",
name: "Nickel-Iron Ore",
cargoClassId: "heavy",
massTonnes: { min: 80, max: 300 },
payMultiplier: 0.8,
priorityEligible: false,
producedAt: [CERES],
demandedAt: [EARTH, MARS, MOON],
},
{
id: "water-ice",
name: "Water Ice",
cargoClassId: "heavy",
massTonnes: { min: 60, max: 250 },
payMultiplier: 0.8,
priorityEligible: false,
producedAt: [CERES],
demandedAt: [MARS, MOON, JUPITER],
},
// Dangerous: Dangerous Cargo skill; tier is the hazard class.
{
id: "industrial-chemicals",
name: "Industrial Chemicals",
cargoClassId: "dangerous",
massTonnes: { min: 5, max: 40 },
payMultiplier: 1,
tier: 1,
priorityEligible: false,
producedAt: [EARTH],
demandedAt: [MOON, MARS, CERES, JUPITER],
},
{
id: "liquid-oxygen",
name: "Liquid Oxygen",
cargoClassId: "dangerous",
massTonnes: { min: 20, max: 100 },
payMultiplier: 1,
tier: 1,
priorityEligible: false,
producedAt: [MOON],
demandedAt: [MARS, CERES, JUPITER],
},
{
id: "mining-charges",
name: "Mining Charges",
cargoClassId: "dangerous",
massTonnes: { min: 2, max: 20 },
payMultiplier: 1.3,
tier: 2,
priorityEligible: false,
producedAt: [EARTH, MARS],
demandedAt: [MOON, CERES],
},
{
id: "methane-fuel",
name: "Methane Fuel",
cargoClassId: "dangerous",
massTonnes: { min: 20, max: 80 },
payMultiplier: 1.1,
tier: 2,
priorityEligible: false,
producedAt: [MARS],
demandedAt: [MOON, CERES],
},
{
id: "liquid-hydrogen",
name: "Liquid Hydrogen",
cargoClassId: "dangerous",
massTonnes: { min: 30, max: 150 },
payMultiplier: 1.1,
tier: 2,
priorityEligible: false,
producedAt: [JUPITER],
demandedAt: [MARS, CERES, EARTH],
},
{
id: "helium-3",
name: "Helium-3 Canisters",
cargoClassId: "dangerous",
massTonnes: { min: 1, max: 10 },
payMultiplier: 2,
tier: 3,
priorityEligible: true,
producedAt: [JUPITER],
demandedAt: [EARTH, MOON],
},
{
id: "reactor-cores",
name: "Fusion Reactor Cores",
cargoClassId: "dangerous",
massTonnes: { min: 10, max: 40 },
payMultiplier: 1.8,
tier: 4,
priorityEligible: false,
producedAt: [EARTH],
demandedAt: [CERES, JUPITER],
},
{
id: "antimatter-traps",
name: "Antimatter Containment Traps",
cargoClassId: "dangerous",
massTonnes: { min: 0.1, max: 1 },
payMultiplier: 4,
tier: 5,
priorityEligible: false,
producedAt: [JUPITER],
demandedAt: [EARTH],
},
];
/** Good ids produced at a location, in catalog order (a location's `produces`). */
export function goodsProducedAt(locationId: string): string[] {
return goods.filter((g) => g.producedAt.includes(locationId)).map((g) => g.id);
}
/** Good ids demanded at a location, in catalog order (a location's `demands`). */
export function goodsDemandedAt(locationId: string): string[] {
return goods.filter((g) => g.demandedAt.includes(locationId)).map((g) => g.id);
}

View File

@ -1,9 +1,12 @@
import type { Location } from "../schemas"; import type { Location } from "../schemas";
import { goodsDemandedAt, goodsProducedAt } from "./goods";
/** /**
* Base-game destinations (GDD §9). Cargo flows: Earth and Mars make goods, the * Base-game destinations (GDD §9). Cargo flows: Earth and Mars make goods, the
* Moon and Ceres mine ore (heavy), Jupiter refines fuel (dangerous); remote * Moon and Ceres mine ore (heavy), Jupiter refines fuel (dangerous); remote
* outposts import high-value equipment. * outposts import high-value equipment. `produces`/`demands` come from the
* goods catalog (CR-007); `cargoClassMix` keeps the class-level flows that set
* each board's class mix (decision 0.5.57).
*/ */
export const locations: Location[] = [ export const locations: Location[] = [
{ {
@ -16,8 +19,15 @@ export const locations: Location[] = [
launchCostMultiplier: 3, launchCostMultiplier: 3,
startsDiscovered: true, startsDiscovered: true,
bayLeasePrice: 1000, bayLeasePrice: 1000,
produces: ["standard", "high-value"], produces: goodsProducedAt("earth"),
demands: ["heavy", "dangerous"], demands: goodsDemandedAt("earth"),
cargoClassMix: { produces: ["standard", "high-value"], demands: ["heavy", "dangerous"] },
shippers: [
"Pacific Orbital Logistics",
"Meridian Health",
"Atlas Heavy Industries",
"Hollis & Crane Consumer",
],
}, },
{ {
id: "moon", id: "moon",
@ -29,8 +39,13 @@ export const locations: Location[] = [
launchCostMultiplier: 0.5, launchCostMultiplier: 0.5,
startsDiscovered: true, startsDiscovered: true,
bayLeasePrice: 800, bayLeasePrice: 800,
produces: ["standard", "heavy"], produces: goodsProducedAt("moon"),
demands: ["standard", "high-value", "dangerous"], demands: goodsDemandedAt("moon"),
cargoClassMix: {
produces: ["standard", "heavy"],
demands: ["standard", "high-value", "dangerous"],
},
shippers: ["Shackleton Mining Co.", "Tranquility Labs", "Copernicus Glassworks"],
}, },
{ {
id: "mars", id: "mars",
@ -42,8 +57,10 @@ export const locations: Location[] = [
launchCostMultiplier: 1.5, launchCostMultiplier: 1.5,
startsDiscovered: false, startsDiscovered: false,
bayLeasePrice: 20000, bayLeasePrice: 20000,
produces: ["standard", "high-value"], produces: goodsProducedAt("mars"),
demands: ["heavy", "dangerous"], demands: goodsDemandedAt("mars"),
cargoClassMix: { produces: ["standard", "high-value"], demands: ["heavy", "dangerous"] },
shippers: ["Jezero Agricultural Co-op", "Red Line Metals", "Tharsis Chemical"],
}, },
{ {
id: "ceres-station", id: "ceres-station",
@ -55,8 +72,10 @@ export const locations: Location[] = [
launchCostMultiplier: 0.3, launchCostMultiplier: 0.3,
startsDiscovered: false, startsDiscovered: false,
bayLeasePrice: 40000, bayLeasePrice: 40000,
produces: ["heavy"], produces: goodsProducedAt("ceres-station"),
demands: ["standard", "high-value", "dangerous"], demands: goodsDemandedAt("ceres-station"),
cargoClassMix: { produces: ["heavy"], demands: ["standard", "high-value", "dangerous"] },
shippers: ["Belt Ore Consolidated", "Piazzi Station Authority"],
}, },
{ {
id: "jupiter-cloud-platform", id: "jupiter-cloud-platform",
@ -69,7 +88,9 @@ export const locations: Location[] = [
launchCostMultiplier: 2, launchCostMultiplier: 2,
startsDiscovered: false, startsDiscovered: false,
bayLeasePrice: 80000, bayLeasePrice: 80000,
produces: ["dangerous"], produces: goodsProducedAt("jupiter-cloud-platform"),
demands: ["standard", "high-value", "heavy"], demands: goodsDemandedAt("jupiter-cloud-platform"),
cargoClassMix: { produces: ["dangerous"], demands: ["standard", "high-value", "heavy"] },
shippers: ["Galilean Fuel Combine", "Zephyr Gas Works"],
}, },
]; ];

View File

@ -2,6 +2,7 @@ export * from "./schemas";
export * from "./data/locations"; export * from "./data/locations";
export * from "./data/ships"; export * from "./data/ships";
export * from "./data/cargo"; export * from "./data/cargo";
export * from "./data/goods";
export * from "./data/config"; export * from "./data/config";
export * from "./data/onboarding"; export * from "./data/onboarding";
export * from "./bundle"; export * from "./bundle";

View File

@ -41,10 +41,17 @@ export const locationSchema = z.object({
startsDiscovered: z.boolean(), startsDiscovered: z.boolean(),
/** Credits. */ /** Credits. */
bayLeasePrice: nonNegativeInt, bayLeasePrice: nonNegativeInt,
/** Cargo type ids. */ /** Good ids made here (CR-007), derived from the goods catalog. */
produces: z.array(idSchema), produces: z.array(idSchema),
/** Cargo type ids. */ /** Good ids wanted here (CR-007), derived from the goods catalog. */
demands: z.array(idSchema), demands: z.array(idSchema),
/**
* Cargo class ids that steer a board's class mix (decision 0.5.57): a
* contract's class is one produced here and demanded at its destination.
*/
cargoClassMix: z.object({ produces: z.array(idSchema), demands: z.array(idSchema) }),
/** Flavor company names for contracts from here (decision 0.5.58). */
shippers: z.array(z.string().min(1)).min(1),
/** Map art asset id (CR-003), e.g. `body-earth`; see the web app's art manifest. */ /** Map art asset id (CR-003), e.g. `body-earth`; see the web app's art manifest. */
sprite: idSchema, sprite: idSchema,
/** Optional map badge asset id drawn on the body's edge (CR-003). */ /** Optional map badge asset id drawn on the body's edge (CR-003). */
@ -90,6 +97,30 @@ export const cargoTypeSchema = z.object({
}); });
export type CargoType = z.infer<typeof cargoTypeSchema>; export type CargoType = z.infer<typeof cargoTypeSchema>;
/** A named good (CR-007, decision 0.5.54). */
export const goodSchema = z
.object({
id: idSchema,
name: z.string().min(1),
/** Cargo class (cargo type) id. */
cargoClassId: idSchema,
massTonnes: z.object({ min: z.number().positive(), max: z.number().positive() }),
/** Multiplies the class's base pay. */
payMultiplier: z.number().positive(),
/** High-Value and Dangerous goods only: the required skill level (decision 0.5.55). */
tier: z.number().int().min(1).max(5).optional(),
priorityEligible: z.boolean(),
/** Shown instead of the name on a Priority contract, e.g. "Emergency Parts". */
priorityName: z.string().min(1).optional(),
producedAt: z.array(idSchema).min(1),
demandedAt: z.array(idSchema).min(1),
})
.refine((g) => g.massTonnes.min <= g.massTonnes.max, {
message: "Mass min must not exceed max",
path: ["massTonnes"],
});
export type Good = z.infer<typeof goodSchema>;
export const skillSchema = z.object({ export const skillSchema = z.object({
id: idSchema, id: idSchema,
name: z.string().min(1), name: z.string().min(1),
@ -142,6 +173,14 @@ export const gameConfigSchema = z
restGameHours: z.number().positive(), restGameHours: z.number().positive(),
firstContactChance: fraction, firstContactChance: fraction,
firstContactPayoutMultiplier: z.number().positive(), firstContactPayoutMultiplier: z.number().positive(),
/** Share of Priority-eligible contracts that become Priority (decision 0.5.56). */
priorityChance: fraction,
/** Payout multiplier for a Priority contract. */
priorityPayoutMultiplier: z.number().positive(),
/** Ship model whose trip time sets a Priority contract's delivery window. */
priorityReferenceShipModelId: idSchema,
/** Multiplies the reference trip time to give the window. */
priorityWindowFactor: z.number().positive(),
/** /**
* Contract mass bands, one per ship size class: each board has `count` * Contract mass bands, one per ship size class: each board has `count`
* contracts with a whole-tonne mass in [min, max]. Counts add up to the * contracts with a whole-tonne mass in [min, max]. Counts add up to the

View File

@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import {
cargoTypes,
goods,
goodSchema,
goodsDemandedAt,
goodsProducedAt,
locations,
shipModels,
} from "../src/index";
import { gameConfig } from "../src/data/config";
const classIds = cargoTypes.map((c) => c.id);
const locationIds = locations.map((l) => l.id);
const standardOn = (from: string, to: string) =>
goods.filter(
(g) =>
g.cargoClassId === "standard" && g.producedAt.includes(from) && g.demandedAt.includes(to),
);
describe("goods catalog (CR-007)", () => {
it("has unique ids and every good passes the schema", () => {
expect(new Set(goods.map((g) => g.id)).size).toBe(goods.length);
for (const good of goods) expect(() => goodSchema.parse(good)).not.toThrow();
});
it("references existing classes and locations, with min ≤ max", () => {
for (const good of goods) {
expect(classIds).toContain(good.cargoClassId);
for (const id of [...good.producedAt, ...good.demandedAt]) expect(locationIds).toContain(id);
expect(good.massTonnes.min).toBeLessThanOrEqual(good.massTonnes.max);
}
});
it("gives High-Value and Dangerous goods a tier 1–5, and Standard and Heavy goods none", () => {
for (const good of goods) {
if (good.cargoClassId === "high-value" || good.cargoClassId === "dangerous") {
expect(good.tier).toBeGreaterThanOrEqual(1);
expect(good.tier).toBeLessThanOrEqual(5);
} else {
expect(good.tier).toBeUndefined();
}
}
});
it("has every location produce at least one Standard good", () => {
for (const id of locationIds) {
expect(goods.some((g) => g.cargoClassId === "standard" && g.producedAt.includes(id))).toBe(
true,
);
}
});
it("has at least two Standard goods each way between Earth and the Moon (tutorial)", () => {
expect(standardOn("earth", "moon").length).toBeGreaterThanOrEqual(2);
expect(standardOn("moon", "earth").length).toBeGreaterThanOrEqual(2);
});
it("keeps each location's produces and demands in sync with the catalog", () => {
for (const location of locations) {
expect(location.produces).toEqual(goodsProducedAt(location.id));
expect(location.demands).toEqual(goodsDemandedAt(location.id));
}
});
it("sets Priority to a real ship model and sensible numbers", () => {
expect(shipModels.map((s) => s.id)).toContain(gameConfig.priorityReferenceShipModelId);
expect(gameConfig.priorityChance).toBe(0.15);
expect(gameConfig.priorityPayoutMultiplier).toBeGreaterThan(1);
expect(goods.some((g) => g.priorityEligible)).toBe(true);
});
});

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { cargoTypes, locationSchema, locations } from "../src/index"; import { cargoTypes, goods, locationSchema, locations } from "../src/index";
const cargoTypeIds = cargoTypes.map((c) => c.id); const cargoTypeIds = cargoTypes.map((c) => c.id);
@ -20,14 +20,21 @@ describe("locations", () => {
expect(discovered.sort()).toEqual(["earth", "moon"]); expect(discovered.sort()).toEqual(["earth", "moon"]);
}); });
it("references only existing cargo types", () => { it("references only existing goods, and cargo classes in its class mix", () => {
const goodIds = goods.map((g) => g.id);
for (const location of locations) { for (const location of locations) {
for (const cargoId of [...location.produces, ...location.demands]) { for (const goodId of [...location.produces, ...location.demands]) {
expect(cargoTypeIds).toContain(cargoId); expect(goodIds).toContain(goodId);
} }
const { produces, demands } = location.cargoClassMix;
for (const cargoId of [...produces, ...demands]) expect(cargoTypeIds).toContain(cargoId);
} }
}); });
it("has at least one shipper per location (CR-007)", () => {
for (const location of locations) expect(location.shippers.length).toBeGreaterThan(0);
});
it("gives Earth the highest launch multiplier", () => { it("gives Earth the highest launch multiplier", () => {
const earth = locations.find((l) => l.id === "earth")!; const earth = locations.find((l) => l.id === "earth")!;
for (const location of locations) { for (const location of locations) {

View File

@ -3,6 +3,7 @@ import type { z } from "zod";
import { import {
cargoTypeSchema, cargoTypeSchema,
gameConfigSchema, gameConfigSchema,
goodSchema,
locationSchema, locationSchema,
onboardingGoalSchema, onboardingGoalSchema,
scriptedTutorialContractSchema, scriptedTutorialContractSchema,
@ -20,8 +21,22 @@ const validLocation = {
startsDiscovered: true, startsDiscovered: true,
bayLeasePrice: 5000, bayLeasePrice: 5000,
sprite: "body-mars", sprite: "body-mars",
produces: ["standard"], produces: ["mail"],
demands: ["heavy"], demands: ["lunar-titanium"],
cargoClassMix: { produces: ["standard"], demands: ["heavy"] },
shippers: ["Pacific Orbital Logistics"],
};
/** Optional fields (tier, priorityName) are left out so every key here is required. */
const validGood = {
id: "mail",
name: "Mail & Parcels",
cargoClassId: "standard",
massTonnes: { min: 1, max: 8 },
payMultiplier: 1.2,
priorityEligible: true,
producedAt: ["earth"],
demandedAt: ["moon"],
}; };
const validShipModel = { const validShipModel = {
@ -68,6 +83,10 @@ const validGameConfig = {
restGameHours: 60, restGameHours: 60,
firstContactChance: 0.1, firstContactChance: 0.1,
firstContactPayoutMultiplier: 2, firstContactPayoutMultiplier: 2,
priorityChance: 0.15,
priorityPayoutMultiplier: 1.5,
priorityReferenceShipModelId: "freighter",
priorityWindowFactor: 1,
contractMassBands: [ contractMassBands: [
{ min: 1, max: 20, count: 4 }, { min: 1, max: 20, count: 4 },
{ min: 21, max: 100, count: 4 }, { min: 21, max: 100, count: 4 },
@ -108,6 +127,7 @@ const cases: [string, z.ZodType, Record<string, unknown>][] = [
["Location", locationSchema, validLocation], ["Location", locationSchema, validLocation],
["ShipModel", shipModelSchema, validShipModel], ["ShipModel", shipModelSchema, validShipModel],
["CargoType", cargoTypeSchema, validCargoType], ["CargoType", cargoTypeSchema, validCargoType],
["Good", goodSchema, validGood],
["Skill", skillSchema, validSkill], ["Skill", skillSchema, validSkill],
["GameConfig", gameConfigSchema, validGameConfig], ["GameConfig", gameConfigSchema, validGameConfig],
["ScriptedTutorialContract", scriptedTutorialContractSchema, validScripted], ["ScriptedTutorialContract", scriptedTutorialContractSchema, validScripted],
@ -126,6 +146,19 @@ describe.each(cases)("%s schema", (_name, schema, valid) => {
}); });
}); });
describe("Good refinements (CR-007)", () => {
it("rejects a mass range whose min exceeds its max", () => {
const good = { ...validGood, massTonnes: { min: 9, max: 8 } };
expect(goodSchema.safeParse(good).success).toBe(false);
});
it("rejects a tier outside 1–5 and a shipperless location", () => {
expect(goodSchema.safeParse({ ...validGood, tier: 6 }).success).toBe(false);
expect(goodSchema.safeParse({ ...validGood, tier: 0 }).success).toBe(false);
expect(locationSchema.safeParse({ ...validLocation, shippers: [] }).success).toBe(false);
});
});
describe("GameConfig refinements", () => { describe("GameConfig refinements", () => {
it("rejects tutorial mass tiers that do not increase", () => { it("rejects tutorial mass tiers that do not increase", () => {
const config = { ...validGameConfig, tutorialMassTonnes: { light: 12, medium: 12, full: 20 } }; const config = { ...validGameConfig, tutorialMassTonnes: { light: 12, medium: 12, full: 20 } };

View File

@ -133,8 +133,8 @@ function fitJob(
const destination = rng.pick(reachable); const destination = rng.pick(reachable);
const skilled = content.cargoTypes.filter( const skilled = content.cargoTypes.filter(
(c) => (c) =>
origin.produces.includes(c.id) && origin.cargoClassMix.produces.includes(c.id) &&
destination.demands.includes(c.id) && destination.cargoClassMix.demands.includes(c.id) &&
(c.requiredSkillId === null || skillLevel(skills, c.requiredSkillId) >= 1), (c.requiredSkillId === null || skillLevel(skills, c.requiredSkillId) >= 1),
); );
const cargoType = skilled.length > 0 ? rng.pick(skilled) : standardCargo(content); const cargoType = skilled.length > 0 ? rng.pick(skilled) : standardCargo(content);
@ -195,7 +195,9 @@ export function buildContract(
function pickCargo(rng: Rng, origin: Location, destination: Location, content: SimContent) { function pickCargo(rng: Rng, origin: Location, destination: Location, content: SimContent) {
const matching = content.cargoTypes.filter( const matching = content.cargoTypes.filter(
(c) => origin.produces.includes(c.id) && destination.demands.includes(c.id), (c) =>
origin.cargoClassMix.produces.includes(c.id) &&
destination.cargoClassMix.demands.includes(c.id),
); );
// Decision 0.5.17: fall back to Standard when no produced cargo is demanded. // Decision 0.5.17: fall back to Standard when no produced cargo is demanded.
return matching.length > 0 ? rng.pick(matching) : standardCargo(content); return matching.length > 0 ? rng.pick(matching) : standardCargo(content);