deadhead-space-freight-tycoon/packages/sim/src/board.ts

511 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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<string>;
periodIndex: number;
worldSeed: Seed;
/** The player's ships (CR-006). Absent or empty: a board that ignores the fleet. */
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). 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;
return planSlots(crews, config).map(({ band, kind }, i) => {
const id = `${origin.id}:${periodIndex}:${i}`;
const explore =
kind === "fit" && !firstContactOffered && undiscovered.length > 0
? fitJob(rng, band, crews, origin, undiscovered, offeredAt, content)
: null;
if (explore) firstContactOffered = true;
const fit =
explore ??
(kind === "fit" ? fitJob(rng, band, crews, origin, discovered, offeredAt, content) : null);
if (fit) {
const { model, ...job } = fit;
return 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,
);
}
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,
);
return 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,
);
});
}