Step 10G.5: tutorial contracts carry named goods
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f8d2f538ca
commit
46dd3777c9
|
|
@ -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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.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).
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Location, ShipModel } from "@deadhead/content";
|
import type { Location, ShipModel } from "@deadhead/content";
|
||||||
import { buildContract, routeGoods, standardCargo, type SimContent } from "./board";
|
import { buildContract, routeGoods, standardCargo, type SimContent } from "./board";
|
||||||
|
import { createRng, deriveSeed } from "./rng";
|
||||||
import type { Contract } from "./contract";
|
import type { Contract } from "./contract";
|
||||||
|
|
||||||
/** The tutorial loaner: the content's company ship (decision 0.5.29). */
|
/** 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
|
* 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
|
* 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.
|
* 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[] {
|
export function generateTutorialBoard(input: TutorialBoardInput, content: SimContent): Contract[] {
|
||||||
const { loanerLocationId, runsCompleted, periodIndex } = input;
|
const { loanerLocationId, runsCompleted, periodIndex } = input;
|
||||||
|
|
@ -41,14 +44,20 @@ export function generateTutorialBoard(input: TutorialBoardInput, content: SimCon
|
||||||
const tiers = config.tutorialMassTonnes;
|
const tiers = config.tutorialMassTonnes;
|
||||||
const run = runsCompleted + 1;
|
const run = runsCompleted + 1;
|
||||||
const scripted = content.scriptedTutorialContracts.find((s) => s.run === run);
|
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 special = i === 1 ? scripted : undefined;
|
||||||
const cargoType = special
|
const cargoType = special
|
||||||
? content.cargoTypes.find((c) => c.id === special.cargoTypeId)
|
? content.cargoTypes.find((c) => c.id === special.cargoTypeId)
|
||||||
: standardCargo(content);
|
: standardCargo(content);
|
||||||
if (!cargoType) throw new Error(`Unknown cargo ${special?.cargoTypeId}`);
|
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(
|
const contract = buildContract(
|
||||||
{
|
{
|
||||||
id: `tutorial:${loanerLocationId}:${run}:${periodIndex}:${i}`,
|
id: `tutorial:${loanerLocationId}:${run}:${periodIndex}:${i}`,
|
||||||
|
|
@ -59,7 +68,7 @@ export function generateTutorialBoard(input: TutorialBoardInput, content: SimCon
|
||||||
massTonnes,
|
massTonnes,
|
||||||
firstContact: false,
|
firstContact: false,
|
||||||
periodIndex,
|
periodIndex,
|
||||||
shipper: origin.shippers[0]!,
|
shipper: rng.pick(origin.shippers),
|
||||||
priority: false,
|
priority: false,
|
||||||
deliveryWindowGameHours: null,
|
deliveryWindowGameHours: null,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -129,3 +129,42 @@ describe("tutorial board v2", () => {
|
||||||
expect(4 * slowest).toBeLessThan(6);
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue