539 lines
20 KiB
TypeScript
539 lines
20 KiB
TypeScript
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<string>;
|
||
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;
|
||
}
|