Step 3.9: add contract board generation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
cbcc981b6d
commit
b7a40aa490
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -6,3 +6,4 @@ export * from "./travel";
|
|||
export * from "./fuel";
|
||||
export * from "./eligibility";
|
||||
export * from "./contract";
|
||||
export * from "./board";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue