138 lines
4.8 KiB
TypeScript
138 lines
4.8 KiB
TypeScript
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<string>;
|
|
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;
|
|
}
|