diff --git a/DECISIONS.md b/DECISIONS.md index 5890368..74852e1 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -901,3 +901,7 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Mass: the band wins** (asked; the user chose it). The CR draws mass from each good's range, but boards draw it from ship-size bands, and no Standard good weighs over 60 t, so a Bulk Hauler would lose its unskilled job and CR-006's fit guarantees would break. A contract now picks a good of its class overlapping its band and draws inside both; with no overlap it uses the class's largest-range good and the band's range (a bulk lot). The lightest band has no floor for goods, so sub-5 t goods can appear. - **Class mix kept by keeping the class-level lists** (not asked: the CR requires it twice and tests it). Switching `produces`/`demands` to goods and deriving classes from them would make Earth produce all four classes and move its mix far beyond ±3 points. Locations keep the old lists as `cargoClassMix` for class selection only. - **Tutorial contracts are never Priority**: the CR doesn't say, and the tutorial is about learning the loop, not deadlines. + +## Step 10G.5 + +- **Tutorial goods prefer one whose range holds the tier mass** instead of clamping the tier to a random good, so the CR-001 light/medium/full masses (5, 12, 20 t) and the tutorial's timing tests stay exact. Earth→Moon: Mail or Consumer Goods at 5 t, Packaged Food or Consumer Goods at 12 and 20 t; Moon→Earth: Souvenirs or Optical Glass at 5 t, Optical Glass at 12 and 20 t. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index c0b0ecd..1d244d7 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -206,3 +206,4 @@ Spec: `memory-bank/change-requests/CR-007-cargo-goods.md` (supplied as "CR-005 / - [x] **Step 10G.2 — Sim: contracts carry goods.** `Contract` gains `goodId`, `shipper`, `priority`, `deliveryWindowGameHours` (null unless Priority); the API `contractSchema` mirrors them now (the schema package's type-parity test requires it), so test fixtures across packages gained a good of their class. `contractPayout` = class base × good `payMultiplier` × tierMultiplier^tier × mass × distance × first-contact × Priority bonus. `buildContract` takes a good (the class comes from it) plus shipper and Priority fields; `tierFor` (0.5.55: Standard 0, High-Value/Dangerous the good's tier, Heavy the random roll or up to a fit pilot's level); `routeGoods` (0.5.54 fallback chain). Interim choice until 10G.3: the first route good in catalog order, the origin's first shipper, never Priority; fit jobs only use goods whose tier the pilot meets. `SimContent` gains `goods`. Tests: `contract.test.ts` (tiers from goods, Heavy 1–5, ×2 pay multiplier doubles payout, Priority = bonus × normal). - [x] **Step 10G.3 — Sim: goods, Priority and shippers in board generation.** Class selection is unchanged (by `cargoClassMix`). Then `chooseGood` (band wins): a random good of the class on the route whose mass range overlaps the slot's band (the lightest band has no floor) with mass in both and within a fit ship's capacity; else the largest-range good as a bulk lot from the band. Masses under 10 t to 0.1 t, else whole tonnes. `rollPriority`: eligible goods become Priority with `priorityChance`; window = `priorityWindow` (reference model's trip time at offer × factor); a fit job is Priority only if its own ship makes the window. Shipper: random from the origin's list. Measured over 1,000 Earth boards (all discovered, seed 12345): Standard 76.04 % (baseline 76.79), High-Value 23.96 % (23.21); Priority on 13.6 % of eligible contracts. Bulk lots make Packaged Food and Electronics the most common goods in the big bands. Tests: `board-goods.test.ts` (determinism with and without a fleet; goods made at the origin and wanted at the destination except when no Standard good goes that way; mass rules; class mix ±3 points; Priority 10–20 % of eligible, only eligible goods, window = reference trip; shippers from the origin's list; `chooseGood` overlap, bulk and capacity cases). `board.test.ts` / `board-fleet.test.ts`: the lightest band's floor is 0 for goods. - [x] **Step 10G.4 — Sim: Priority validation.** `too-slow` joins `DISPATCH_FAILURES` after the route checks: a Priority contract fails when `contractTripGameHours` (cruise + overhead + handling, distance at departure; exported) exceeds `deliveryWindowGameHours`. Server message and the dispatch sheet's short reason added (the API error code follows from the sim's list). `priorityWindow` exported. Tests: `priority.test.ts` (Priority mail Earth→Moon with the Freighter reference: Courier and Freighter pass, Bulk Hauler fails `too-slow`; the trip is measured like the window; a normal contract never fails as too slow); `board-fleet.test.ts` now also requires fit Priority jobs to be within the crew's reach. +- [x] **Step 10G.5 — Sim: tutorial goods.** Each tutorial slot carries a Standard good made at the loaner's location and wanted at the other end, chosen (seeded from location, run and period) among goods whose range holds the CR-001 tier mass, else clamped to a good's range; a random shipper from the origin; never Priority. On both routes some good holds 5, 12 and 20 t, so tutorial masses are unchanged; payouts now include the good's pay multiplier (e.g. Mail ×1.2). "Unmarked crates" is unchanged. Test: `tutorial.test.ts` (goods per direction over 6 runs × 20 periods, never Priority, shippers, masses 5/12/20; the existing scripted-run test still passes). diff --git a/packages/sim/src/tutorial.ts b/packages/sim/src/tutorial.ts index dac6835..5554e70 100644 --- a/packages/sim/src/tutorial.ts +++ b/packages/sim/src/tutorial.ts @@ -1,5 +1,6 @@ import type { Location, ShipModel } from "@deadhead/content"; import { buildContract, routeGoods, standardCargo, type SimContent } from "./board"; +import { createRng, deriveSeed } from "./rng"; import type { Contract } from "./contract"; /** The tutorial loaner: the content's company ship (decision 0.5.29). */ @@ -31,7 +32,9 @@ function location(id: string, content: SimContent): Location { * from the loaner's location to the other end of the Earth–Moon route, at the * light, medium and full mass tiers, each paying the tutorial share. A scripted * contract for the upcoming run (decision 0.5.33) replaces the medium slot. - * The board is fully determined by its inputs. + * Each carries a Standard good made here and wanted there, preferring one whose + * range holds the tier's mass, else clamped to the good's range (0.5.59). + * Never Priority. The board is fully determined by its inputs. */ export function generateTutorialBoard(input: TutorialBoardInput, content: SimContent): Contract[] { const { loanerLocationId, runsCompleted, periodIndex } = input; @@ -41,14 +44,20 @@ export function generateTutorialBoard(input: TutorialBoardInput, content: SimCon const tiers = config.tutorialMassTonnes; const run = runsCompleted + 1; const scripted = content.scriptedTutorialContracts.find((s) => s.run === run); + const rng = createRng(deriveSeed(0, `tutorial:${loanerLocationId}:${run}:${periodIndex}`)); - const slot = (i: number, massTonnes: number): Contract => { + const slot = (i: number, tierMass: number): Contract => { const special = i === 1 ? scripted : undefined; const cargoType = special ? 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 goods = routeGoods(cargoType, origin, destination, content); + const holding = goods.filter( + (g) => g.massTonnes.min <= tierMass && tierMass <= g.massTonnes.max, + ); + const good = rng.pick(holding.length > 0 ? holding : goods); + const massTonnes = Math.min(good.massTonnes.max, Math.max(good.massTonnes.min, tierMass)); const contract = buildContract( { id: `tutorial:${loanerLocationId}:${run}:${periodIndex}:${i}`, @@ -59,7 +68,7 @@ export function generateTutorialBoard(input: TutorialBoardInput, content: SimCon massTonnes, firstContact: false, periodIndex, - shipper: origin.shippers[0]!, + shipper: rng.pick(origin.shippers), priority: false, deliveryWindowGameHours: null, }, diff --git a/packages/sim/test/tutorial.test.ts b/packages/sim/test/tutorial.test.ts index ca95d29..05097f7 100644 --- a/packages/sim/test/tutorial.test.ts +++ b/packages/sim/test/tutorial.test.ts @@ -129,3 +129,42 @@ describe("tutorial board v2", () => { expect(4 * slowest).toBeLessThan(6); }); }); + +describe("tutorial goods (CR-007)", () => { + const goodsOn = (from: "earth" | "moon", to: string) => + content.goods + .filter( + (g) => + g.cargoClassId === "standard" && g.producedAt.includes(from) && g.demandedAt.includes(to), + ) + .map((g) => g.id); + + it.each([ + ["earth", "moon"], + ["moon", "earth"], + ] as const)( + "at %s, carries only Standard goods made there and wanted at %s, never Priority", + (from, to) => { + const allowed = goodsOn(from, to); + for (let run = 0; run < 6; run++) { + for (let periodIndex = 0; periodIndex < 20; periodIndex++) { + const board = generateTutorialBoard( + { loanerLocationId: from, runsCompleted: run, periodIndex }, + content, + ); + for (const c of board) { + expect(allowed).toContain(c.goodId); + expect(c.priority).toBe(false); + expect(content.location(from)!.shippers).toContain(c.shipper); + } + // The CR-001 mass tiers hold: some good on each route fits each tier. + expect(board.map((c) => c.massTonnes)).toEqual([ + config.tutorialMassTonnes.light, + config.tutorialMassTonnes.medium, + config.tutorialMassTonnes.full, + ]); + } + } + }, + ); +});