import type { CargoType, GameConfig, Location, ScriptedTutorialContract, ShipModel, 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[]; shipModels: readonly ShipModel[]; scriptedTutorialContracts: readonly ScriptedTutorialContract[]; 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 { /** The location whose freight market this is; every contract starts here (CR-002). */ location: 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 location's contract board for one period (any discovered * location, bay or not, CR-002): `contractMassBands` decide how * many contracts fall in each ship-size mass band. Same inputs always give the * same board. */ export function generateContractBoard(input: BoardInput, content: SimContent): Contract[] { const { location: 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)); // One slot per contract, band by band. The first slot of each band is a // guaranteed job: Standard cargo (no skill) to a discovered place, so every // ship size always has something a new pilot can fly. const slots = config.contractMassBands.flatMap((band) => Array.from({ length: band.count }, (_, k) => ({ band, guaranteed: k === 0 })), ); return slots.map(({ band, guaranteed }, i) => { const plain = guaranteed && discovered.length > 0; const firstContact = !plain && undiscovered.length > 0 && (discovered.length === 0 || rng.next() < config.firstContactChance); const destination = rng.pick(firstContact ? undiscovered : discovered); const cargoType = plain ? standardCargo(content) : pickCargo(rng, origin, destination, content); return buildContract( { id: `${origin.id}:${periodIndex}:${i}`, origin, destination, cargoType, tier: pickTier(rng, cargoType, content), massTonnes: rng.int(band.min, band.max), firstContact, periodIndex, }, content, ); }); }