157 lines
5.5 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
}
|