import type { CargoType, GameConfig, Location, 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[]; 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; }