376 lines
15 KiB
TypeScript
376 lines
15 KiB
TypeScript
import { content } from "@deadhead/content";
|
||
import { describe, expect, it } from "vitest";
|
||
import {
|
||
applyEventOption,
|
||
MS_PER_HOUR,
|
||
payoutAfterEvent,
|
||
pilotShare,
|
||
resolveDispatch,
|
||
rollTripEvent,
|
||
routeFacts,
|
||
type AppliedEvent,
|
||
type Contract,
|
||
type DispatchRecord,
|
||
type EventOutcome,
|
||
type EventRollInput,
|
||
type TripEvent,
|
||
} from "../src/index";
|
||
|
||
const T0 = Date.parse("2100-01-01T00:00:00Z");
|
||
const { timeCompression } = content.config;
|
||
|
||
const contract = (over: Partial<Contract> = {}): Contract => ({
|
||
id: "c",
|
||
originId: "earth",
|
||
destinationId: "moon",
|
||
cargoTypeId: "standard",
|
||
goodId: "consumer-goods",
|
||
shipper: "Pacific Orbital Logistics",
|
||
priority: false,
|
||
deliveryWindowGameHours: null,
|
||
tier: 0,
|
||
massTonnes: 10,
|
||
payout: 10_000,
|
||
requiredSkill: null,
|
||
requiredLongHaulLevel: 0,
|
||
offeredAt: T0,
|
||
expiresAt: T0 + 600_000,
|
||
firstContact: false,
|
||
...over,
|
||
});
|
||
|
||
/** A trip of `hours` game hours from `origin` to `destination`. */
|
||
function record(
|
||
seed: number,
|
||
origin: string,
|
||
destination: string,
|
||
hours: number,
|
||
job: Partial<Contract> | null = {},
|
||
): DispatchRecord {
|
||
return {
|
||
id: `d${seed}`,
|
||
shipId: "s1",
|
||
pilotId: "p1",
|
||
contract:
|
||
job === null ? null : contract({ originId: origin, destinationId: destination, ...job }),
|
||
originId: origin,
|
||
destinationId: destination,
|
||
tutorial: false,
|
||
departureAt: T0,
|
||
arrivalAt: T0 + Math.round((hours * MS_PER_HOUR) / timeCompression),
|
||
distanceMillionKm: 1,
|
||
fuelBurnedFu: 100,
|
||
fuelCost: 500,
|
||
expectedPayout: 10_000,
|
||
seed,
|
||
};
|
||
}
|
||
|
||
const input = (rec: DispatchRecord, over: Partial<EventRollInput> = {}): EventRollInput => ({
|
||
record: rec,
|
||
ship: {
|
||
hullRating: 1,
|
||
hullConditionPct: 100,
|
||
cargoCapacityTonnes: 100,
|
||
fuelAfterDepartureFu: 500,
|
||
quietTrips: 5,
|
||
},
|
||
pilotSkills: {},
|
||
fleetRadioCalls: [],
|
||
company: { firstEventDone: true, tripsSinceDeal: 10 },
|
||
...over,
|
||
});
|
||
|
||
/** Rolls `n` trips made by `make(seed)` and returns the events. */
|
||
function rolls(n: number, make: (seed: number) => EventRollInput) {
|
||
return Array.from({ length: n }, (_, seed) => rollTripEvent(make(seed + 1), content));
|
||
}
|
||
|
||
describe("rolling a trip's event (Step 26.2, decisions 0.5.143–0.5.145)", () => {
|
||
it("gives the same event, outcomes and all, for the same trip", () => {
|
||
const trip = input(record(42, "earth", "jupiter-cloud-platform", 200));
|
||
expect(rollTripEvent(trip, content)).toEqual(rollTripEvent(trip, content));
|
||
const events = rolls(500, (s) => input(record(s, "earth", "jupiter-cloud-platform", 200)));
|
||
expect(new Set(events.map((e) => e?.eventId ?? "quiet")).size).toBeGreaterThan(3);
|
||
});
|
||
|
||
it("has none in the tutorial or on the first 3 trips after the deal", () => {
|
||
const long = (s: number) => record(s, "earth", "jupiter-cloud-platform", 200);
|
||
const tutorial = rolls(500, (s) => input({ ...long(s), tutorial: true }));
|
||
expect(tutorial.every((e) => e === null)).toBe(true);
|
||
for (const tripsSinceDeal of [0, 1, 2]) {
|
||
const early = rolls(500, (s) =>
|
||
input(long(s), { company: { firstEventDone: true, tripsSinceDeal } }),
|
||
);
|
||
expect(
|
||
early.every((e) => e === null),
|
||
`trip ${tripsSinceDeal + 1}`,
|
||
).toBe(true);
|
||
}
|
||
const after = rolls(500, (s) =>
|
||
input(long(s), { company: { firstEventDone: true, tripsSinceDeal: 3 } }),
|
||
);
|
||
expect(after.some((e) => e !== null)).toBe(true);
|
||
});
|
||
|
||
it("makes the first event the derelict pod, on the first contract of 6+ game hours", () => {
|
||
const first = { company: { firstEventDone: false, tripsSinceDeal: 3 } };
|
||
const short = rolls(200, (s) => input(record(s, "earth", "moon", 5), first));
|
||
expect(short.every((e) => e === null)).toBe(true);
|
||
const long = rolls(200, (s) => input(record(s, "earth", "mars", 6), first));
|
||
expect(long.every((e) => e?.eventId === "derelict-pod")).toBe(true);
|
||
const deadhead = rolls(50, (s) => input(record(s, "earth", "mars", 40, null), first));
|
||
expect(deadhead.every((e) => e === null)).toBe(true);
|
||
});
|
||
|
||
it("gives a ship none on its next 2 trips after an event", () => {
|
||
const long = (s: number) => record(s, "earth", "jupiter-cloud-platform", 200);
|
||
for (const quietTrips of [0, 1]) {
|
||
const cooling = rolls(500, (s) =>
|
||
input(long(s), { ship: { ...input(long(s)).ship, quietTrips } }),
|
||
);
|
||
expect(cooling.every((e) => e === null)).toBe(true);
|
||
}
|
||
const ready = rolls(500, (s) =>
|
||
input(long(s), { ship: { ...input(long(s)).ship, quietTrips: 2 } }),
|
||
);
|
||
expect(ready.some((e) => e !== null)).toBe(true);
|
||
});
|
||
|
||
it("drops a radio call within 45 real minutes of another one in the fleet", () => {
|
||
const long = (s: number) => record(s, "earth", "jupiter-cloud-platform", 200);
|
||
const seed = Array.from({ length: 5_000 }, (_, i) => i + 1).find(
|
||
(s) => rollTripEvent(input(long(s)), content)?.kind === "radio",
|
||
)!;
|
||
const call = rollTripEvent(input(long(seed)), content)!;
|
||
const near = [call.fireAt + 44 * 60_000];
|
||
const far = [call.fireAt - 46 * 60_000];
|
||
expect(rollTripEvent(input(long(seed), { fleetRadioCalls: near }), content)).toBeNull();
|
||
expect(rollTripEvent(input(long(seed), { fleetRadioCalls: far }), content)).toEqual(call);
|
||
// A radio call's window: 30 minutes, or what's left of the trip.
|
||
expect(call.windowEndsAt).toBe(Math.min(call.fireAt + 30 * 60_000, long(seed).arrivalAt));
|
||
const trip = long(seed);
|
||
const span = trip.arrivalAt - trip.departureAt;
|
||
expect(call.fireAt).toBeGreaterThanOrEqual(trip.departureAt + span * 0.2 - 1);
|
||
expect(call.fireAt).toBeLessThanOrEqual(trip.departureAt + span * 0.7 + 1);
|
||
});
|
||
|
||
it("over 100,000 trips: Earth↔Moon at 3% or less, long hauls at 35% or less, every event where it may happen", () => {
|
||
const routes: [string, string, number, Partial<Contract>][] = [
|
||
["earth", "moon", 4, { cargoTypeId: "dangerous", goodId: "helium-3", tier: 1 }],
|
||
["moon", "earth", 4, { cargoTypeId: "heavy", goodId: "machine-parts", tier: 1 }],
|
||
[
|
||
"earth",
|
||
"mars",
|
||
40,
|
||
{ priority: true, goodId: "medical-supplies", deliveryWindowGameHours: 60 },
|
||
],
|
||
[
|
||
"earth",
|
||
"jupiter-cloud-platform",
|
||
220,
|
||
{ cargoTypeId: "dangerous", goodId: "helium-3", tier: 1 },
|
||
],
|
||
["ceres-station", "mars", 30, { cargoTypeId: "heavy", goodId: "machine-parts", tier: 1 }],
|
||
["io-sulfur-works", "europa-ice-station", 8, {}],
|
||
[
|
||
"venus-cloud-city",
|
||
"earth",
|
||
20,
|
||
{ priority: true, goodId: "cloud-botanicals", deliveryWindowGameHours: 30 },
|
||
],
|
||
];
|
||
const tally = new Map<string, { trips: number; events: number }>();
|
||
for (let s = 1; s <= 100_000; s++) {
|
||
const [origin, destination, hours, job] = routes[s % routes.length]!;
|
||
const trip = record(s, origin, destination, hours, s % 11 === 0 ? null : job);
|
||
const event = rollTripEvent(input(trip), content);
|
||
const key = `${origin}>${destination}`;
|
||
const row = tally.get(key) ?? { trips: 0, events: 0 };
|
||
row.trips++;
|
||
if (event) row.events++;
|
||
tally.set(key, row);
|
||
if (!event) continue;
|
||
const def = content.tripEvent(event.eventId)!;
|
||
const route = routeFacts(content.location(origin)!, content.location(destination)!);
|
||
const c = trip.contract;
|
||
const where = `${event.eventId} on ${key}`;
|
||
if (def.when.minTripGameHours)
|
||
expect(hours, where).toBeGreaterThanOrEqual(def.when.minTripGameHours);
|
||
if (event.eventId === "hazmat-leak") expect(c?.cargoTypeId, where).toBe("dangerous");
|
||
if (event.eventId === "load-shift") expect(c?.cargoTypeId, where).toBe("heavy");
|
||
if (event.eventId === "debris-field") expect(route.crossesBelt, where).toBe(true);
|
||
if (event.eventId === "storm-approach") expect(route.stormArrival, where).toBe(true);
|
||
if (event.eventId === "customs-hold") expect(["earth", "mars"], where).toContain(destination);
|
||
if (event.eventId === "spoilage-risk") expect(c?.priority, where).toBe(true);
|
||
if (event.eventId === "spot-freight") expect(route.innerOrBelt, where).toBe(true);
|
||
if (def.when.contract) expect(c, where).not.toBeNull();
|
||
}
|
||
const rate = (key: string) => tally.get(key)!.events / tally.get(key)!.trips;
|
||
expect(rate("earth>moon")).toBeLessThanOrEqual(0.03);
|
||
expect(rate("moon>earth")).toBeLessThanOrEqual(0.03);
|
||
expect(rate("earth>jupiter-cloud-platform")).toBeLessThanOrEqual(0.35);
|
||
expect(rate("earth>jupiter-cloud-platform")).toBeGreaterThan(0.25);
|
||
});
|
||
|
||
it("makes leaks rarer for a pilot whose Dangerous Cargo skill is above the job's tier", () => {
|
||
const share = (level: number) => {
|
||
const events = rolls(30_000, (s) =>
|
||
input(
|
||
record(s, "earth", "jupiter-cloud-platform", 220, {
|
||
cargoTypeId: "dangerous",
|
||
goodId: "helium-3",
|
||
tier: 1,
|
||
}),
|
||
{ pilotSkills: { "dangerous-cargo": level } },
|
||
),
|
||
).filter((e) => e !== null);
|
||
return events.filter((e) => e!.eventId === "hazmat-leak").length / events.length;
|
||
};
|
||
const atTier = share(1);
|
||
const twoAbove = share(3);
|
||
expect(atTier).toBeGreaterThan(0.015);
|
||
// Two levels above: a quarter of the weight, so far rarer among events.
|
||
expect(twoAbove).toBeLessThan(atTier * 0.4);
|
||
});
|
||
});
|
||
|
||
describe("applying an event's outcome (Step 26.3, decisions 0.5.143, 0.5.146, 0.5.147)", () => {
|
||
const HOUR_REAL = MS_PER_HOUR / timeCompression;
|
||
const none: EventOutcome = {
|
||
delayMs: 0,
|
||
hullLoss: 0,
|
||
payLoss: 0,
|
||
fee: 0,
|
||
bonus: 0,
|
||
xpBonusPct: 0,
|
||
extraFuelFu: 0,
|
||
};
|
||
const event = (
|
||
trip: DispatchRecord,
|
||
outcome: Partial<EventOutcome>,
|
||
available = true,
|
||
): TripEvent => ({
|
||
eventId: "debris-field",
|
||
kind: "radio",
|
||
good: false,
|
||
fireAt: trip.departureAt + Math.round((trip.arrivalAt - trip.departureAt) * 0.4),
|
||
windowEndsAt: trip.arrivalAt,
|
||
options: [
|
||
{ id: "a", available, outcome: { ...none, ...outcome } },
|
||
{ id: "b", available: true, outcome: none },
|
||
],
|
||
defaultOptionId: "b",
|
||
});
|
||
const apply = (trip: DispatchRecord, e: TripEvent, optionId = "a") =>
|
||
applyEventOption({ record: trip, event: e, optionId, answered: true }, content);
|
||
|
||
it("moves arrival by exactly a 3-hour detour", () => {
|
||
const trip = record(1, "earth", "jupiter-cloud-platform", 200);
|
||
const applied = apply(trip, event(trip, { delayMs: 3 * HOUR_REAL }));
|
||
expect(applied.arrivalAt - trip.arrivalAt).toBe(3 * HOUR_REAL);
|
||
});
|
||
|
||
it("never lets a tailwind shorten a trip below half of it, or before the event", () => {
|
||
const trip = record(1, "earth", "mars", 10);
|
||
const applied = apply(trip, event(trip, { delayMs: -100 * HOUR_REAL }));
|
||
const half = trip.departureAt + (trip.arrivalAt - trip.departureAt) / 2;
|
||
expect(applied.arrivalAt).toBe(Math.ceil(half));
|
||
expect(applied.arrivalAt).toBeGreaterThanOrEqual(event(trip, {}).fireAt);
|
||
});
|
||
|
||
it("never takes pay below zero, and pays bonuses on top", () => {
|
||
const trip = record(1, "earth", "mars", 10, { payout: 1_000 });
|
||
const lost = apply(trip, event(trip, { payLoss: 900, fee: 500 }));
|
||
expect(payoutAfterEvent(1_000, lost)).toBe(0);
|
||
const tipped = apply(trip, event(trip, { payLoss: 900, fee: 500, bonus: 200 }));
|
||
expect(payoutAfterEvent(1_000, tipped)).toBe(200);
|
||
});
|
||
|
||
it("costs only the Priority bonus when a delay misses the window", () => {
|
||
const trip = record(1, "earth", "mars", 10, {
|
||
payout: 1_500,
|
||
priority: true,
|
||
deliveryWindowGameHours: 12,
|
||
});
|
||
const inTime = apply(trip, event(trip, { delayMs: 1 * HOUR_REAL }));
|
||
expect(inTime.priorityLost).toBe(0);
|
||
const late = apply(trip, event(trip, { delayMs: 3 * HOUR_REAL }));
|
||
// 1,500 is 1,000 × the 1.5 Priority bonus: the base 1,000 is still paid.
|
||
expect(late.priorityLost).toBe(500);
|
||
expect(payoutAfterEvent(1_500, late)).toBe(1_000);
|
||
});
|
||
|
||
it("refuses an option the trip can't take: salvage and spot freight need 10% of the hold free", () => {
|
||
const full = (s: number) =>
|
||
input(record(s, "earth", "mars", 40, { massTonnes: 95 }), {
|
||
company: { firstEventDone: false, tripsSinceDeal: 3 },
|
||
});
|
||
const pod = rollTripEvent(full(7), content)!;
|
||
expect(pod.eventId).toBe("derelict-pod");
|
||
expect(pod.options.find((o) => o.id === "salvage")!.available).toBe(false);
|
||
expect(() => apply(full(7).record, pod, "salvage")).toThrow(/isn't available/);
|
||
const roomy = rollTripEvent({ ...full(7), record: record(7, "earth", "mars", 40) }, content)!;
|
||
expect(roomy.options.find((o) => o.id === "salvage")!.available).toBe(true);
|
||
});
|
||
|
||
it("gives an option its pre-rolled outcome whenever it's chosen", () => {
|
||
const trip = record(3, "earth", "mars", 40);
|
||
const pod = rollTripEvent(
|
||
input(trip, { company: { firstEventDone: false, tripsSinceDeal: 3 } }),
|
||
content,
|
||
)!;
|
||
const early = apply(trip, pod, "salvage");
|
||
const late = applyEventOption(
|
||
{ record: trip, event: pod, optionId: "salvage", answered: true },
|
||
content,
|
||
);
|
||
expect(late).toEqual(early);
|
||
const salvage = pod.options.find((o) => o.id === "salvage")!.outcome;
|
||
expect(early.bonus).toBe(salvage.bonus);
|
||
expect(salvage.bonus).toBeGreaterThanOrEqual(10_000 * 0.15);
|
||
expect(salvage.bonus).toBeLessThanOrEqual(10_000 * 0.4);
|
||
});
|
||
});
|
||
|
||
describe("resolving a trip with its event (Step 26.3)", () => {
|
||
it("pays the event's bonus and extra XP, and takes the pilot's share of the new payout", () => {
|
||
const trip = record(5, "earth", "mars", 10, { payout: 4_000 });
|
||
const resolve = (event: AppliedEvent | null, pilotIsAvatar: boolean) =>
|
||
resolveDispatch(
|
||
{
|
||
record: trip,
|
||
pilotIsAvatar,
|
||
discoveredIds: new Set(["earth", "mars"]),
|
||
loanBalance: 0,
|
||
pilotDutyHours: 0,
|
||
pilotSkills: {},
|
||
now: trip.arrivalAt,
|
||
event,
|
||
},
|
||
content,
|
||
);
|
||
const applied: AppliedEvent = {
|
||
eventId: "distress-beacon",
|
||
optionId: "help",
|
||
answered: true,
|
||
arrivalAt: trip.arrivalAt,
|
||
hullLoss: 0,
|
||
payLoss: 0,
|
||
fee: 0,
|
||
bonus: 1_000,
|
||
priorityLost: 0,
|
||
xpBonusPct: 50,
|
||
extraFuelFu: 0,
|
||
};
|
||
const plain = resolve(null, true);
|
||
const helped = resolve(applied, true);
|
||
if (plain.status !== "resolved" || helped.status !== "resolved") throw new Error("in flight");
|
||
expect(helped.resolution.credits).toBe(plain.resolution.credits + 1_000);
|
||
expect(helped.resolution.pilotXp).toBe(Math.round(plain.resolution.pilotXp * 1.5));
|
||
const hired = resolve(applied, false);
|
||
if (hired.status !== "resolved") throw new Error("in flight");
|
||
expect(hired.resolution.pilotPay).toBe(pilotShare(5_000, trip.fuelCost, content.config));
|
||
});
|
||
});
|