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

157 lines
5.5 KiB
TypeScript

import type {
CargoType,
GameConfig,
Location,
ScriptedTutorialContract,
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[];
scriptedTutorialContracts: readonly ScriptedTutorialContract[];
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 {
/** The location whose freight market this is; every contract starts here (CR-002). */
location: 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 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. 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));
// One slot per contract, band by band. The first slot of each band is a
// guaranteed job: Standard cargo (no skill) to a discovered place, so every
// ship size always has something a new pilot can fly.
const slots = config.contractMassBands.flatMap((band) =>
Array.from({ length: band.count }, (_, k) => ({ band, guaranteed: k === 0 })),
);
return slots.map(({ band, guaranteed }, i) => {
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);
return buildContract(
{
id: `${origin.id}:${periodIndex}:${i}`,
origin,
destination,
cargoType,
tier: pickTier(rng, cargoType, content),
massTonnes: rng.int(band.min, band.max),
firstContact,
periodIndex,
},
content,
);
});
}