Step 3.9: add contract board generation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-02 18:45:01 -04:00
parent cbcc981b6d
commit b7a40aa490
5 changed files with 234 additions and 1 deletions

View File

@ -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:<bayLocationId>:<periodIndex>")`, 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 `<origin>:<periodIndex>:<slot>`, 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.

View File

@ -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

136
packages/sim/src/board.ts Normal file
View File

@ -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<string>;
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;
}

View File

@ -6,3 +6,4 @@ export * from "./travel";
export * from "./fuel";
export * from "./eligibility";
export * from "./contract";
export * from "./board";

View File

@ -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);
});
});