Step 10G.1: goods catalog, shippers and Priority config
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bdd5fd8509
commit
8a7b612bc7
|
|
@ -103,11 +103,11 @@ export function Locations() {
|
|||
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||
Produces
|
||||
</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">
|
||||
Demands
|
||||
</dt>
|
||||
<dd>{location.demands.map(cargoName).join(", ") || "—"}</dd>
|
||||
<dd>{location.cargoClassMix.demands.map(cargoName).join(", ") || "—"}</dd>
|
||||
</dl>
|
||||
<Link
|
||||
to={`/contracts?location=${location.id}`}
|
||||
|
|
|
|||
|
|
@ -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.produces.map(cargoName).join(", ") || "nothing"} · demands{" "}
|
||||
{location.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."}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { cargoTypes, skills } from "./data/cargo";
|
||||
import { gameConfig } from "./data/config";
|
||||
import { goods } from "./data/goods";
|
||||
import { locations } from "./data/locations";
|
||||
import { onboardingGoals, scriptedTutorialContracts } from "./data/onboarding";
|
||||
import { shipModels } from "./data/ships";
|
||||
|
|
@ -17,6 +18,7 @@ export const content = {
|
|||
/** Ship models players can buy (excludes company ships such as the loaner). */
|
||||
purchasableShipModels: shipModels.filter((s) => s.purchasable),
|
||||
cargoTypes,
|
||||
goods,
|
||||
skills,
|
||||
config: gameConfig,
|
||||
scriptedTutorialContracts,
|
||||
|
|
@ -24,6 +26,7 @@ export const content = {
|
|||
location: byId(locations),
|
||||
shipModel: byId(shipModels),
|
||||
cargoType: byId(cargoTypes),
|
||||
good: byId(goods),
|
||||
skill: byId(skills),
|
||||
onboardingGoal: byId(onboardingGoals),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ export const gameConfig: GameConfig = {
|
|||
restGameHours: 10,
|
||||
firstContactChance: 0.1,
|
||||
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):
|
||||
// up to a Courier's 20 t, a Freighter's 100 t, and a Bulk Hauler's 400 t.
|
||||
contractMassBands: [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import type { Location } from "../schemas";
|
||||
import { goodsDemandedAt, goodsProducedAt } from "./goods";
|
||||
|
||||
/**
|
||||
* Base-game destinations (GDD §9). Cargo flows: Earth and Mars make goods, the
|
||||
* 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[] = [
|
||||
{
|
||||
|
|
@ -16,8 +19,15 @@ export const locations: Location[] = [
|
|||
launchCostMultiplier: 3,
|
||||
startsDiscovered: true,
|
||||
bayLeasePrice: 1000,
|
||||
produces: ["standard", "high-value"],
|
||||
demands: ["heavy", "dangerous"],
|
||||
produces: goodsProducedAt("earth"),
|
||||
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",
|
||||
|
|
@ -29,9 +39,14 @@ export const locations: Location[] = [
|
|||
launchCostMultiplier: 0.5,
|
||||
startsDiscovered: true,
|
||||
bayLeasePrice: 800,
|
||||
produces: goodsProducedAt("moon"),
|
||||
demands: goodsDemandedAt("moon"),
|
||||
cargoClassMix: {
|
||||
produces: ["standard", "heavy"],
|
||||
demands: ["standard", "high-value", "dangerous"],
|
||||
},
|
||||
shippers: ["Shackleton Mining Co.", "Tranquility Labs", "Copernicus Glassworks"],
|
||||
},
|
||||
{
|
||||
id: "mars",
|
||||
name: "Mars",
|
||||
|
|
@ -42,8 +57,10 @@ export const locations: Location[] = [
|
|||
launchCostMultiplier: 1.5,
|
||||
startsDiscovered: false,
|
||||
bayLeasePrice: 20000,
|
||||
produces: ["standard", "high-value"],
|
||||
demands: ["heavy", "dangerous"],
|
||||
produces: goodsProducedAt("mars"),
|
||||
demands: goodsDemandedAt("mars"),
|
||||
cargoClassMix: { produces: ["standard", "high-value"], demands: ["heavy", "dangerous"] },
|
||||
shippers: ["Jezero Agricultural Co-op", "Red Line Metals", "Tharsis Chemical"],
|
||||
},
|
||||
{
|
||||
id: "ceres-station",
|
||||
|
|
@ -55,8 +72,10 @@ export const locations: Location[] = [
|
|||
launchCostMultiplier: 0.3,
|
||||
startsDiscovered: false,
|
||||
bayLeasePrice: 40000,
|
||||
produces: ["heavy"],
|
||||
demands: ["standard", "high-value", "dangerous"],
|
||||
produces: goodsProducedAt("ceres-station"),
|
||||
demands: goodsDemandedAt("ceres-station"),
|
||||
cargoClassMix: { produces: ["heavy"], demands: ["standard", "high-value", "dangerous"] },
|
||||
shippers: ["Belt Ore Consolidated", "Piazzi Station Authority"],
|
||||
},
|
||||
{
|
||||
id: "jupiter-cloud-platform",
|
||||
|
|
@ -69,7 +88,9 @@ export const locations: Location[] = [
|
|||
launchCostMultiplier: 2,
|
||||
startsDiscovered: false,
|
||||
bayLeasePrice: 80000,
|
||||
produces: ["dangerous"],
|
||||
demands: ["standard", "high-value", "heavy"],
|
||||
produces: goodsProducedAt("jupiter-cloud-platform"),
|
||||
demands: goodsDemandedAt("jupiter-cloud-platform"),
|
||||
cargoClassMix: { produces: ["dangerous"], demands: ["standard", "high-value", "heavy"] },
|
||||
shippers: ["Galilean Fuel Combine", "Zephyr Gas Works"],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export * from "./schemas";
|
|||
export * from "./data/locations";
|
||||
export * from "./data/ships";
|
||||
export * from "./data/cargo";
|
||||
export * from "./data/goods";
|
||||
export * from "./data/config";
|
||||
export * from "./data/onboarding";
|
||||
export * from "./bundle";
|
||||
|
|
|
|||
|
|
@ -41,10 +41,17 @@ export const locationSchema = z.object({
|
|||
startsDiscovered: z.boolean(),
|
||||
/** Credits. */
|
||||
bayLeasePrice: nonNegativeInt,
|
||||
/** Cargo type ids. */
|
||||
/** Good ids made here (CR-007), derived from the goods catalog. */
|
||||
produces: z.array(idSchema),
|
||||
/** Cargo type ids. */
|
||||
/** Good ids wanted here (CR-007), derived from the goods catalog. */
|
||||
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. */
|
||||
sprite: idSchema,
|
||||
/** 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>;
|
||||
|
||||
/** 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({
|
||||
id: idSchema,
|
||||
name: z.string().min(1),
|
||||
|
|
@ -142,6 +173,14 @@ export const gameConfigSchema = z
|
|||
restGameHours: z.number().positive(),
|
||||
firstContactChance: fraction,
|
||||
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`
|
||||
* contracts with a whole-tonne mass in [min, max]. Counts add up to the
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
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);
|
||||
|
||||
|
|
@ -20,14 +20,21 @@ describe("locations", () => {
|
|||
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 cargoId of [...location.produces, ...location.demands]) {
|
||||
expect(cargoTypeIds).toContain(cargoId);
|
||||
for (const goodId of [...location.produces, ...location.demands]) {
|
||||
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", () => {
|
||||
const earth = locations.find((l) => l.id === "earth")!;
|
||||
for (const location of locations) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { z } from "zod";
|
|||
import {
|
||||
cargoTypeSchema,
|
||||
gameConfigSchema,
|
||||
goodSchema,
|
||||
locationSchema,
|
||||
onboardingGoalSchema,
|
||||
scriptedTutorialContractSchema,
|
||||
|
|
@ -20,8 +21,22 @@ const validLocation = {
|
|||
startsDiscovered: true,
|
||||
bayLeasePrice: 5000,
|
||||
sprite: "body-mars",
|
||||
produces: ["standard"],
|
||||
demands: ["heavy"],
|
||||
produces: ["mail"],
|
||||
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 = {
|
||||
|
|
@ -68,6 +83,10 @@ const validGameConfig = {
|
|||
restGameHours: 60,
|
||||
firstContactChance: 0.1,
|
||||
firstContactPayoutMultiplier: 2,
|
||||
priorityChance: 0.15,
|
||||
priorityPayoutMultiplier: 1.5,
|
||||
priorityReferenceShipModelId: "freighter",
|
||||
priorityWindowFactor: 1,
|
||||
contractMassBands: [
|
||||
{ min: 1, max: 20, count: 4 },
|
||||
{ min: 21, max: 100, count: 4 },
|
||||
|
|
@ -108,6 +127,7 @@ const cases: [string, z.ZodType, Record<string, unknown>][] = [
|
|||
["Location", locationSchema, validLocation],
|
||||
["ShipModel", shipModelSchema, validShipModel],
|
||||
["CargoType", cargoTypeSchema, validCargoType],
|
||||
["Good", goodSchema, validGood],
|
||||
["Skill", skillSchema, validSkill],
|
||||
["GameConfig", gameConfigSchema, validGameConfig],
|
||||
["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", () => {
|
||||
it("rejects tutorial mass tiers that do not increase", () => {
|
||||
const config = { ...validGameConfig, tutorialMassTonnes: { light: 12, medium: 12, full: 20 } };
|
||||
|
|
|
|||
|
|
@ -133,8 +133,8 @@ function fitJob(
|
|||
const destination = rng.pick(reachable);
|
||||
const skilled = content.cargoTypes.filter(
|
||||
(c) =>
|
||||
origin.produces.includes(c.id) &&
|
||||
destination.demands.includes(c.id) &&
|
||||
origin.cargoClassMix.produces.includes(c.id) &&
|
||||
destination.cargoClassMix.demands.includes(c.id) &&
|
||||
(c.requiredSkillId === null || skillLevel(skills, c.requiredSkillId) >= 1),
|
||||
);
|
||||
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) {
|
||||
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.
|
||||
return matching.length > 0 ? rng.pick(matching) : standardCargo(content);
|
||||
|
|
|
|||
Loading…
Reference in New Issue