From b7a40aa4907c97a170f6091721a5de841db20b6e Mon Sep 17 00:00:00 2001 From: fireshaper Date: Fri, 2 Oct 2026 18:45:01 -0400 Subject: [PATCH] Step 3.9: add contract board generation Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 9 +++ memory-bank/progress.md | 2 +- packages/sim/src/board.ts | 136 ++++++++++++++++++++++++++++++++ packages/sim/src/index.ts | 1 + packages/sim/test/board.test.ts | 87 ++++++++++++++++++++ 5 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 packages/sim/src/board.ts create mode 100644 packages/sim/test/board.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index eb0f3e3..527f2fe 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -114,3 +114,12 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Tier scaling is compounding:** pay is multiplied by `tierPayMultiplier ** tier`. Standard (tier 0) is ×1; a tier-5 Dangerous contract pays 1.3⁵ ≈ 3.7× its base rate. - **Payouts are rounded to whole credits** when the contract is offered. - **Contracts refer to content by id** (`originId`, `destinationId`, `cargoTypeId`), so they can be stored and sent over the API as plain data. `requiredSkill` is `{ skillId, level }`, or `null` for Standard. + +## Step 3.9 + +- **Board seed:** `deriveSeed(worldSeed, "board::")`, with one generator per board. +- **Board periods count from the Unix epoch:** period index = floor(real ms ÷ period length). Contracts are offered at the start of their period, and their payout and Long Haul level use the distance at that moment, so a board generated later in the period is identical (decision 0.5.5). +- **Contract ids** are `::`, unique within a player's world. The server may add its own database ids. +- **First contact** is rolled only when an undiscovered location exists. If no other location is discovered, every contract is first contact. Destinations are picked uniformly from the eligible list. +- **Mass** is a whole number of tonnes drawn uniformly from the configured range. **Tier** is drawn uniformly from 1 to the required skill's max level. +- **Sim functions take a `SimContent` object** (`locations`, `cargoTypes`, `skills`, `config`); the `content` bundle satisfies it, and tests can pass fixtures. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index cf3e16f..3195c7a 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -27,7 +27,7 @@ - [x] **Step 3.6 — Fuel cost.** `src/fuel.ts`: `fuelCost(ship, origin, distance)` = distance × cost per million km × origin launch multiplier. - [x] **Step 3.7 — Range and landing eligibility.** `src/eligibility.ts`: `checkRoute` returns `ok` or `cannot-land` / `out-of-range`; `canLandAt` ranks vacuum < atmospheric < hardened. Step 2.3 ranges confirmed against sampled real distances. - [x] **Step 3.8 — Contract model and payout.** `src/contract.ts`: `Contract` interface and `contractPayout` (base × multiplier^tier × mass × distance × first-contact multiplier, rounded). -- [ ] Step 3.9 — Contract board generation +- [x] **Step 3.9 — Contract board generation.** `src/board.ts`: `generateContractBoard` (seeded per world, bay and period), plus `boardPeriodIndex`, `requiredLongHaulLevel` and the `SimContent` input type. First contact ≈10% over 1,000 boards. - [ ] Step 3.10 — Tutorial contract board - [ ] Step 3.11 — Pilot eligibility - [ ] Step 3.12 — Dispatch validation diff --git a/packages/sim/src/board.ts b/packages/sim/src/board.ts new file mode 100644 index 0000000..b279143 --- /dev/null +++ b/packages/sim/src/board.ts @@ -0,0 +1,136 @@ +import type { CargoType, GameConfig, Location, Skill } from "@deadhead/content"; +import { MS_PER_MINUTE, type RealTime, realToGame } from "./clock"; +import { contractPayout, type Contract } from "./contract"; +import { routeDistance } from "./distance"; +import { createRng, deriveSeed, type Rng, type Seed } from "./rng"; + +/** The static data the sim needs. The `content` bundle satisfies this. */ +export interface SimContent { + locations: readonly Location[]; + cargoTypes: readonly CargoType[]; + skills: readonly Skill[]; + config: GameConfig; +} + +export function boardPeriodMs(config: GameConfig): number { + return config.contractBoardPeriodRealMinutes * MS_PER_MINUTE; +} + +/** The board period a real time falls in. */ +export function boardPeriodIndex(real: RealTime, config: GameConfig): number { + return Math.floor(real / boardPeriodMs(config)); +} + +/** Required Long Haul level: the number of cut-offs the distance exceeds (decision 0.5.7). */ +export function requiredLongHaulLevel(distanceMillionKm: number, config: GameConfig): number { + return config.longHaulCutoffsMillionKm.filter((cutoff) => distanceMillionKm > cutoff).length; +} + +export function standardCargo(content: SimContent): CargoType { + const standard = content.cargoTypes.find((c) => c.requiredSkillId === null); + if (!standard) throw new Error("Content has no Standard cargo type"); + return standard; +} + +export interface BoardInput { + origin: Location; + discoveredIds: ReadonlySet; + periodIndex: number; + worldSeed: Seed; +} + +/** Builds one contract from a chosen route, cargo, tier and mass. */ +export function buildContract( + args: { + id: string; + origin: Location; + destination: Location; + cargoType: CargoType; + tier: number; + massTonnes: number; + firstContact: boolean; + periodIndex: number; + }, + content: SimContent, +): Contract { + const { config } = content; + const offeredAt = args.periodIndex * boardPeriodMs(config); + const distance = routeDistance(args.origin, args.destination, realToGame(offeredAt, config)); + return { + id: args.id, + originId: args.origin.id, + destinationId: args.destination.id, + cargoTypeId: args.cargoType.id, + tier: args.tier, + massTonnes: args.massTonnes, + payout: contractPayout( + { + cargoType: args.cargoType, + tier: args.tier, + massTonnes: args.massTonnes, + distanceMillionKm: distance, + firstContact: args.firstContact, + }, + config, + ), + requiredSkill: + args.cargoType.requiredSkillId === null + ? null + : { skillId: args.cargoType.requiredSkillId, level: args.tier }, + requiredLongHaulLevel: requiredLongHaulLevel(distance, config), + offeredAt, + expiresAt: offeredAt + boardPeriodMs(config), + firstContact: args.firstContact, + }; +} + +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), + ); + // Decision 0.5.17: fall back to Standard when no produced cargo is demanded. + return matching.length > 0 ? rng.pick(matching) : standardCargo(content); +} + +function pickTier(rng: Rng, cargoType: CargoType, content: SimContent): number { + if (cargoType.requiredSkillId === null) return 0; + const skill = content.skills.find((s) => s.id === cargoType.requiredSkillId); + if (!skill) throw new Error(`Unknown skill ${cargoType.requiredSkillId}`); + return rng.int(1, skill.maxLevel); +} + +/** Generates a bay's contract board for one period. Same inputs always give the same board. */ +export function generateContractBoard(input: BoardInput, content: SimContent): Contract[] { + const { origin, discoveredIds, periodIndex, worldSeed } = input; + const { config } = content; + const rng = createRng(deriveSeed(worldSeed, `board:${origin.id}:${periodIndex}`)); + + const others = content.locations.filter((l) => l.id !== origin.id); + const discovered = others.filter((l) => discoveredIds.has(l.id)); + const undiscovered = others.filter((l) => !discoveredIds.has(l.id)); + + const board: Contract[] = []; + for (let i = 0; i < config.contractBoardSize; i++) { + const firstContact = + undiscovered.length > 0 && + (discovered.length === 0 || rng.next() < config.firstContactChance); + const destination = rng.pick(firstContact ? undiscovered : discovered); + const cargoType = pickCargo(rng, origin, destination, content); + board.push( + buildContract( + { + id: `${origin.id}:${periodIndex}:${i}`, + origin, + destination, + cargoType, + tier: pickTier(rng, cargoType, content), + massTonnes: rng.int(config.contractMassTonnes.min, config.contractMassTonnes.max), + firstContact, + periodIndex, + }, + content, + ), + ); + } + return board; +} diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index 440839c..a4f42aa 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -6,3 +6,4 @@ export * from "./travel"; export * from "./fuel"; export * from "./eligibility"; export * from "./contract"; +export * from "./board"; diff --git a/packages/sim/test/board.test.ts b/packages/sim/test/board.test.ts new file mode 100644 index 0000000..dd27173 --- /dev/null +++ b/packages/sim/test/board.test.ts @@ -0,0 +1,87 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { + boardPeriodIndex, + boardPeriodMs, + generateContractBoard, + requiredLongHaulLevel, + type BoardInput, +} from "../src/index"; + +const earth = content.location("earth")!; +const discoveredIds = new Set(["earth", "moon"]); +const periodIndex = boardPeriodIndex(Date.parse("2100-01-01T00:00:00Z"), content.config); +const input: BoardInput = { origin: earth, discoveredIds, periodIndex, worldSeed: 12345 }; + +describe("contract board generation", () => { + it("produces an identical board from the same inputs", () => { + expect(generateContractBoard(input, content)).toEqual(generateContractBoard(input, content)); + }); + + it("produces a different board for a different period", () => { + const next = generateContractBoard({ ...input, periodIndex: periodIndex + 1 }, content); + expect(next).not.toEqual(generateContractBoard(input, content)); + }); + + it("has the configured number of contracts, none to its own origin", () => { + const board = generateContractBoard(input, content); + expect(board).toHaveLength(content.config.contractBoardSize); + for (const contract of board) { + expect(contract.originId).toBe("earth"); + expect(contract.destinationId).not.toBe("earth"); + } + }); + + it("expires every contract at the end of its board period", () => { + const periodEnd = (periodIndex + 1) * boardPeriodMs(content.config); + for (const contract of generateContractBoard(input, content)) { + expect(contract.expiresAt).toBe(periodEnd); + } + }); + + it("ties tier to skill: Standard is tier 0 with no skill, others 1–5 matching the skill level", () => { + const moon = content.location("moon")!; + for (let p = 0; p < 50; p++) { + for (const c of generateContractBoard({ ...input, origin: moon, periodIndex: p }, content)) { + if (c.cargoTypeId === "standard") { + expect(c.tier).toBe(0); + expect(c.requiredSkill).toBeNull(); + } else { + expect(c.tier).toBeGreaterThanOrEqual(1); + expect(c.tier).toBeLessThanOrEqual(5); + expect(c.requiredSkill?.level).toBe(c.tier); + } + expect(c.massTonnes).toBeGreaterThanOrEqual(content.config.contractMassTonnes.min); + expect(c.massTonnes).toBeLessThanOrEqual(content.config.contractMassTonnes.max); + } + } + }); + + it("makes 5–15% of contracts first contact over 1,000 boards, all to undiscovered places", () => { + let total = 0; + let firstContact = 0; + for (let p = 0; p < 1000; p++) { + for (const contract of generateContractBoard({ ...input, periodIndex: p }, content)) { + total++; + if (contract.firstContact) { + firstContact++; + expect(discoveredIds.has(contract.destinationId)).toBe(false); + } else { + expect(discoveredIds.has(contract.destinationId)).toBe(true); + } + } + } + const share = firstContact / total; + expect(share).toBeGreaterThanOrEqual(0.05); + expect(share).toBeLessThanOrEqual(0.15); + }); +}); + +describe("required Long Haul level", () => { + it("counts the cut-offs a distance exceeds", () => { + expect(requiredLongHaulLevel(0.4, content.config)).toBe(0); + expect(requiredLongHaulLevel(100, content.config)).toBe(0); + expect(requiredLongHaulLevel(101, content.config)).toBe(1); + expect(requiredLongHaulLevel(900, content.config)).toBe(5); + }); +});