import type { CargoType, GameConfig, Good, Location, ScriptedTutorialContract, ShipModel, Skill, Upgrade, } from "@deadhead/content"; import { MS_PER_MINUTE, type RealTime } from "./clock"; import { contractPayout, type Contract } from "./contract"; import { routeDistance } from "./distance"; import { isCompletable } from "./completable"; import { checkRoute } from "./eligibility"; import { LONG_HAUL_SKILL_ID, skillLevel, type SkillLevels } from "./pilot"; import { createRng, deriveSeed, type Rng, type Seed } from "./rng"; import { travelTimeGameHours } from "./travel"; import { effectiveStats } from "./upgrades"; /** The static data the sim needs. The `content` bundle satisfies this. */ export interface SimContent { locations: readonly Location[]; cargoTypes: readonly CargoType[]; goods: readonly Good[]; skills: readonly Skill[]; shipModels: readonly ShipModel[]; scriptedTutorialContracts: readonly ScriptedTutorialContract[]; upgrades: readonly Upgrade[]; 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 type { FleetMember } from "./completable"; import type { FleetMember } from "./completable"; export interface BoardInput { /** The location whose freight market this is; every contract starts here (CR-002). */ location: Location; discoveredIds: ReadonlySet; periodIndex: number; worldSeed: Seed; /** * The qualifying fleet (decision 0.5.115): the board keeps only jobs some * pair can complete (0.5.114). Absent: an unfiltered board (tools and tests). */ fleet?: readonly FleetMember[]; } type MassBand = GameConfig["contractMassBands"][number]; type SlotKind = "guaranteed" | "open" | "fit"; interface Crew { model: ShipModel; skills: SkillLevels; } /** * Which contracts a board has, band by band (decision 0.5.52). Without a crew * every band keeps its count and opens with a guaranteed job. With one, a band * no ship can carry shrinks to a preview, its other slots go to the flyable * bands (largest first, round-robin), and flyable bands are fit jobs but for * `boardOpenSlotsPerBand` open ones. */ function planSlots(crews: readonly Crew[], config: GameConfig) { const bands = config.contractMassBands; const flyable = bands.map((band) => crews.some((c) => c.model.cargoCapacityTonnes >= band.min)); if (!flyable.some(Boolean)) { return bands.flatMap((band) => Array.from({ length: band.count }, (_, k) => ({ band, kind: (k === 0 ? "guaranteed" : "open") as SlotKind, })), ); } const counts = bands.map((band, i) => flyable[i] ? band.count : Math.min(band.count, config.boardPreviewSlots), ); const targets = bands .map((_, i) => i) .filter((i) => flyable[i]) .reverse(); let freed = bands.reduce((sum, band, i) => sum + band.count - counts[i]!, 0); for (let k = 0; freed > 0; k++, freed--) counts[targets[k % targets.length]!]!++; return bands.flatMap((band, i) => { const fit = flyable[i] ? Math.max(0, counts[i]! - config.boardOpenSlotsPerBand) : 0; return Array.from({ length: counts[i]! }, (_, k) => ({ band, kind: (k < fit ? "fit" : !flyable[i] && k === 0 ? "guaranteed" : "open") as SlotKind, })); }); } /** * A job one crew can take (decision 0.5.52): within its capacity, to one of * `destinations` (discovered places, or undiscovered ones for a first contact) it can reach and land at with its Long Haul, with cargo its * pilot is skilled for. Crews are tried from a random start; null if none fits. */ function fitJob( rng: Rng, band: MassBand, crews: readonly Crew[], origin: Location, discovered: readonly Location[], offeredAt: RealTime, content: SimContent, ) { const able = crews.filter((c) => c.model.cargoCapacityTonnes >= band.min); if (able.length === 0) return null; const start = rng.int(0, able.length - 1); for (let k = 0; k < able.length; k++) { const { model, skills } = able[(start + k) % able.length]!; const reachable = discovered.filter( (d) => checkRoute(model, origin, d, content.config).ok && requiredLongHaulLevel(routeDistance(origin, d), content.config) <= skillLevel(skills, LONG_HAUL_SKILL_ID), ); if (reachable.length === 0) continue; const destination = rng.pick(reachable); // Only goods this pilot may carry: a good's own tier must not exceed their skill. const qualified = (g: Good) => { const skillId = cargoClassOf(g, content).requiredSkillId; return skillId === null || skillLevel(skills, skillId) >= (g.tier ?? 1); }; // Any good on this route the crew is qualified for and the ship can carry // (CR-008 fix): picking a class first left e.g. a Heavy-skilled Courier at the // Moon with nothing but undersized Lunar Titanium lots. const fitting = content.goods.filter( (g) => g.producedAt.includes(origin.id) && g.demandedAt.includes(destination.id) && qualified(g) && fitsSlot(g, band, content, model.cargoCapacityTonnes), ); let candidates = fitting; if (candidates.length === 0) { // Nothing on the route fits: a class from the class mix, as a bulk lot if need be. const skilled = content.cargoTypes.filter( (c) => origin.cargoClassMix.produces.includes(c.id) && destination.cargoClassMix.demands.includes(c.id) && (c.requiredSkillId === null || skillLevel(skills, c.requiredSkillId) >= 1), ); const cargoType = skilled.length > 0 ? rng.pick(skilled) : standardCargo(content); candidates = slotGoods( cargoType, origin, destination, band, content, qualified, model.cargoCapacityTonnes, ); } const { good, massTonnes } = chooseGood( rng, candidates, band, content, model.cargoCapacityTonnes, ); const skillId = cargoClassOf(good, content).requiredSkillId; const tier = tierFor(rng, good, content, skillId ? skillLevel(skills, skillId) : undefined); return { destination, good, tier, massTonnes, model }; } return null; } /** A mass in [lo, hi]: to 0.1 t under 10 t, whole tonnes from there (CR-007). */ function drawMass(rng: Rng, lo: number, hi: number): number { const raw = lo + rng.next() * (hi - lo); const rounded = raw < 10 ? Math.round(raw * 10) / 10 : Math.round(raw); return Math.min(hi, Math.max(lo, rounded)); } /** * A good and its mass for a slot (CR-007, "the band wins"): a good whose mass * range overlaps the slot's band (the lightest band has no floor), with mass in * both and within `capacity`; with no overlap, the candidate with the largest * range and a mass from the band, a bulk lot. */ /** The mass range a good can take in a slot (the lightest band has no floor), within `capacity`. */ function slotRange(good: Good, band: MassBand, content: SimContent, capacity: number) { const lightest = Math.min(...content.config.contractMassBands.map((b) => b.min)); const floor = band.min === lightest ? 0 : band.min; return { lo: Math.max(floor, good.massTonnes.min), hi: Math.min(band.max, capacity, good.massTonnes.max), }; } /** * Goods for a slot of this class (decision 0.5.54): the class's goods on the * route. If every one of them is too heavy for the slot (band and ship), * Standard goods on the route that fit instead (CR-008 fix: no 8 t lots of a * 30 t+ good). Goods too light for a big band stay, and `chooseGood` makes a * bulk lot of them ("the band wins"). */ export function slotGoods( cargoType: CargoType, origin: Location, destination: Location, band: MassBand, content: SimContent, allow: (good: Good) => boolean = () => true, capacity = Infinity, ): Good[] { const goods = routeGoods(cargoType, origin, destination, content, allow); const ceiling = Math.min(band.max, capacity); if (!goods.every((g) => g.massTonnes.min > ceiling)) return goods; const standard = routeGoods(standardCargo(content), origin, destination, content, allow); return standard.some((g) => fitsSlot(g, band, content, capacity)) ? standard : goods; } /** Whether a good's own mass range fits the slot's band and the ship. */ export function fitsSlot(good: Good, band: MassBand, content: SimContent, capacity = Infinity) { const { lo, hi } = slotRange(good, band, content, capacity); return lo <= hi; } export function chooseGood( rng: Rng, candidates: readonly Good[], band: MassBand, content: SimContent, capacity = Infinity, ): { good: Good; massTonnes: number } { const ceiling = Math.min(band.max, capacity); const overlapping = candidates.filter((g) => fitsSlot(g, band, content, capacity)); if (overlapping.length > 0) { const good = rng.pick(overlapping); const { lo, hi } = slotRange(good, band, content, capacity); return { good, massTonnes: drawMass(rng, lo, hi) }; } const good = candidates.reduce((a, b) => (b.massTonnes.max > a.massTonnes.max ? b : a)); return { good, massTonnes: drawMass(rng, band.min, ceiling) }; } /** * A Priority contract's delivery window (decision 0.5.56): the reference * ship's trip time for this mass and route at offer time × the window factor. */ export function priorityWindow( origin: Location, destination: Location, massTonnes: number, content: SimContent, ): number { const { config } = content; const reference = content.shipModels.find((s) => s.id === config.priorityReferenceShipModelId); if (!reference) throw new Error(`Unknown ship model ${config.priorityReferenceShipModelId}`); const distance = routeDistance(origin, destination); return travelTimeGameHours(reference, distance, massTonnes, config) * config.priorityWindowFactor; } /** * Rolls Priority for an eligible good (decision 0.5.56). A fit job is Priority * only when its own ship (`model`) makes the window. */ function rollPriority( rng: Rng, args: { good: Good; origin: Location; destination: Location; massTonnes: number; offeredAt: RealTime; model?: ShipModel; }, content: SimContent, ): { priority: boolean; deliveryWindowGameHours: number | null } { const none = { priority: false, deliveryWindowGameHours: null }; if (!args.good.priorityEligible || rng.next() >= content.config.priorityChance) return none; const window = priorityWindow(args.origin, args.destination, args.massTonnes, content); if (args.model) { const distance = routeDistance(args.origin, args.destination); const trip = travelTimeGameHours(args.model, distance, args.massTonnes, content.config); if (trip > window) return none; } return { priority: true, deliveryWindowGameHours: window }; } /** A board contract's market rate: uniform within ± `contractPayVariance` of 1 (decision 0.5.60). */ export function payRate(rng: Rng, content: SimContent): number { return 1 + content.config.contractPayVariance * (2 * rng.next() - 1); } export function cargoClassOf(good: Good, content: SimContent): CargoType { const cargoType = content.cargoTypes.find((c) => c.id === good.cargoClassId); if (!cargoType) throw new Error(`Unknown cargo class ${good.cargoClassId}`); return cargoType; } /** * A contract's tier (decision 0.5.55): Standard is 0, High-Value and Dangerous * take the good's tier, and Heavy rolls 1 to `maxTier` (the skill's maximum, * or a pilot's level for a fit job). */ export function tierFor(rng: Rng, good: Good, content: SimContent, maxTier?: number): number { const cargoType = cargoClassOf(good, content); if (cargoType.requiredSkillId === null) return 0; if (good.tier !== undefined) return good.tier; return maxTier === undefined ? pickTier(rng, cargoType, content) : rng.int(1, maxTier); } /** Builds one contract from a chosen route, good, tier and mass. */ export function buildContract( args: { id: string; origin: Location; destination: Location; good: Good; tier: number; massTonnes: number; firstContact: boolean; periodIndex: number; shipper: string; priority: boolean; deliveryWindowGameHours: number | null; /** Market rate (decision 0.5.60); 1 when absent, as on the tutorial board. */ payRate?: number; }, content: SimContent, ): Contract { const { config } = content; const cargoType = cargoClassOf(args.good, content); const offeredAt = args.periodIndex * boardPeriodMs(config); const distance = routeDistance(args.origin, args.destination); return { id: args.id, originId: args.origin.id, destinationId: args.destination.id, cargoTypeId: cargoType.id, goodId: args.good.id, shipper: args.shipper, priority: args.priority, deliveryWindowGameHours: args.priority ? args.deliveryWindowGameHours : null, tier: args.tier, massTonnes: args.massTonnes, payout: contractPayout( { cargoType, payMultiplier: args.good.payMultiplier, tier: args.tier, massTonnes: args.massTonnes, distanceMillionKm: distance, firstContact: args.firstContact, priority: args.priority, marketRate: args.payRate ?? 1, }, config, ), requiredSkill: cargoType.requiredSkillId === null ? null : { skillId: cargoType.requiredSkillId, level: args.tier }, requiredLongHaulLevel: requiredLongHaulLevel(distance, config), offeredAt, expiresAt: offeredAt + boardPeriodMs(config), firstContact: args.firstContact, }; } /** * The goods a contract of this class can carry on this route (decision 0.5.54): * that class's goods made at the origin and wanted at the destination; else * Standard goods on the route; else any Standard good made at the origin. * `allow` filters out goods a crew can't take (fit jobs). */ export function routeGoods( cargoType: CargoType, origin: Location, destination: Location, content: SimContent, allow: (good: Good) => boolean = () => true, ): Good[] { const made = content.goods.filter((g) => g.producedAt.includes(origin.id) && allow(g)); const onRoute = made.filter((g) => g.demandedAt.includes(destination.id)); const ofClass = onRoute.filter((g) => g.cargoClassId === cargoType.id); if (ofClass.length > 0) return ofClass; const standardId = standardCargo(content).id; const standardOnRoute = onRoute.filter((g) => g.cargoClassId === standardId); if (standardOnRoute.length > 0) return standardOnRoute; const anyStandard = content.goods.filter( (g) => g.producedAt.includes(origin.id) && g.cargoClassId === standardId, ); if (anyStandard.length === 0) throw new Error(`${origin.id} makes no Standard good`); return anyStandard; } function pickCargo(rng: Rng, origin: Location, destination: Location, content: SimContent) { const matching = content.cargoTypes.filter( (c) => origin.cargoClassMix.produces.includes(c.id) && destination.cargoClassMix.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, shaped by the player's fleet * when given (CR-006). With a fleet, only jobs some pair can complete are kept * (decision 0.5.114): candidates are drawn slot by slot, round and round, until * the board is full or `boardMaxAttempts` candidates have been tried, so a * fleet that can do little gets a smaller board. 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)); const crews: Crew[] = (input.fleet ?? []).flatMap((f) => { const model = content.shipModels.find((m) => m.id === f.modelId); return model ? [{ model: effectiveStats(model, f.upgrades ?? {}, content), skills: f.skills }] : []; }); const offeredAt = periodIndex * boardPeriodMs(config); // One slot per contract, band by band. A guaranteed slot is Standard cargo // (no skill) to a discovered place, so every ship size always has something // a new pilot can fly; fit slots are built for the player's own ships. // One fit slot per board is a first contact the fleet can fly, while anywhere // reachable is undiscovered (decision 0.5.85): exploring never waits on luck. let firstContactOffered = false; const candidate = ({ band, kind }: { band: MassBand; kind: SlotKind }, id: string) => { const explore = kind === "fit" && !firstContactOffered && undiscovered.length > 0 ? fitJob(rng, band, crews, origin, undiscovered, offeredAt, content) : null; const fit = explore ?? (kind === "fit" ? fitJob(rng, band, crews, origin, discovered, offeredAt, content) : null); if (fit) { const { model, ...job } = fit; const contract = buildContract( { id, origin, ...job, firstContact: explore !== null, periodIndex, ...rollPriority(rng, { ...job, origin, offeredAt, model }, content), shipper: rng.pick(origin.shippers), payRate: payRate(rng, content), }, content, ); return { contract, explored: explore !== null }; } const guaranteed = kind !== "open"; 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); const { good, massTonnes } = chooseGood( rng, slotGoods(cargoType, origin, destination, band, content), band, content, ); const contract = buildContract( { id, origin, destination, good, tier: tierFor(rng, good, content), massTonnes, firstContact, periodIndex, ...rollPriority(rng, { good, origin, destination, massTonnes, offeredAt }, content), shipper: rng.pick(origin.shippers), payRate: payRate(rng, content), }, content, ); return { contract, explored: false }; }; const slots = planSlots(crews, config); const fleet = input.fleet; if (!fleet) return slots.map((slot, i) => candidate(slot, `${origin.id}:${periodIndex}:${i}`).contract); const board: Contract[] = []; for ( let attempt = 0; board.length < slots.length && attempt < config.boardMaxAttempts; attempt++ ) { const { contract, explored } = candidate( slots[attempt % slots.length]!, `${origin.id}:${periodIndex}:${board.length}`, ); if (!isCompletable(contract, fleet, discoveredIds, content)) continue; board.push(contract); if (explored) firstContactOffered = true; } return board; }