diff --git a/apps/web/src/screens/Locations.tsx b/apps/web/src/screens/Locations.tsx index c6fdf63..7be1707 100644 --- a/apps/web/src/screens/Locations.tsx +++ b/apps/web/src/screens/Locations.tsx @@ -103,11 +103,11 @@ export function Locations() {
Produces
-
{location.produces.map(cargoName).join(", ") || "—"}
+
{location.cargoClassMix.produces.map(cargoName).join(", ") || "—"}
Demands
-
{location.demands.map(cargoName).join(", ") || "—"}
+
{location.cargoClassMix.demands.map(cargoName).join(", ") || "—"}

- 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"}

{hasBay ? "Your HQ is here." : "No HQ here."} diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 47f30e3..bbdbb04 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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. diff --git a/packages/content/src/bundle.ts b/packages/content/src/bundle.ts index 570a521..13a6b68 100644 --- a/packages/content/src/bundle.ts +++ b/packages/content/src/bundle.ts @@ -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), }; diff --git a/packages/content/src/data/config.ts b/packages/content/src/data/config.ts index 9e617eb..4f97239 100644 --- a/packages/content/src/data/config.ts +++ b/packages/content/src/data/config.ts @@ -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: [ diff --git a/packages/content/src/data/goods.ts b/packages/content/src/data/goods.ts new file mode 100644 index 0000000..76a66ec --- /dev/null +++ b/packages/content/src/data/goods.ts @@ -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); +} diff --git a/packages/content/src/data/locations.ts b/packages/content/src/data/locations.ts index d6dbc15..5751df9 100644 --- a/packages/content/src/data/locations.ts +++ b/packages/content/src/data/locations.ts @@ -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,8 +39,13 @@ export const locations: Location[] = [ launchCostMultiplier: 0.5, startsDiscovered: true, bayLeasePrice: 800, - produces: ["standard", "heavy"], - demands: ["standard", "high-value", "dangerous"], + 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", @@ -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"], }, ]; diff --git a/packages/content/src/index.ts b/packages/content/src/index.ts index 1cad3bc..7762b72 100644 --- a/packages/content/src/index.ts +++ b/packages/content/src/index.ts @@ -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"; diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index a689ed0..a74ea80 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -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; +/** 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; + 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 diff --git a/packages/content/test/goods.test.ts b/packages/content/test/goods.test.ts new file mode 100644 index 0000000..11944a6 --- /dev/null +++ b/packages/content/test/goods.test.ts @@ -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); + }); +}); diff --git a/packages/content/test/locations.test.ts b/packages/content/test/locations.test.ts index 332b23c..fa6b92e 100644 --- a/packages/content/test/locations.test.ts +++ b/packages/content/test/locations.test.ts @@ -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) { diff --git a/packages/content/test/schemas.test.ts b/packages/content/test/schemas.test.ts index 8705b92..df619fa 100644 --- a/packages/content/test/schemas.test.ts +++ b/packages/content/test/schemas.test.ts @@ -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][] = [ ["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 } }; diff --git a/packages/sim/src/board.ts b/packages/sim/src/board.ts index 3d0c26d..dfb8a64 100644 --- a/packages/sim/src/board.ts +++ b/packages/sim/src/board.ts @@ -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);