169 lines
6.0 KiB
TypeScript
169 lines
6.0 KiB
TypeScript
import { content } from "@deadhead/content";
|
||
import { describe, expect, it } from "vitest";
|
||
import {
|
||
boardPeriodIndex,
|
||
contractPayout,
|
||
deliveryXp,
|
||
generateTutorialBoard,
|
||
loanerModel,
|
||
MS_PER_HOUR,
|
||
routeDistance,
|
||
travelTimeGameHours,
|
||
type Contract,
|
||
type TutorialBoardInput,
|
||
} from "../src/index";
|
||
|
||
const config = content.config;
|
||
const START = Date.parse("2100-01-01T00:00:00Z");
|
||
const periodIndex = boardPeriodIndex(START, config);
|
||
const atEarth: TutorialBoardInput = { loanerLocationId: "earth", runsCompleted: 0, periodIndex };
|
||
const atMoon: TutorialBoardInput = { ...atEarth, loanerLocationId: "moon", runsCompleted: 1 };
|
||
const loaner = loanerModel(content);
|
||
|
||
function distanceOf(contract: Contract): number {
|
||
return routeDistance(
|
||
content.location(contract.originId)!,
|
||
content.location(contract.destinationId)!,
|
||
);
|
||
}
|
||
|
||
/** The normal (non-tutorial) payout for the same Standard good and route. */
|
||
function normalPayout(contract: Contract): number {
|
||
return contractPayout(
|
||
{
|
||
cargoType: content.cargoType("standard")!,
|
||
payMultiplier: content.good(contract.goodId)!.payMultiplier,
|
||
tier: 0,
|
||
massTonnes: contract.massTonnes,
|
||
distanceMillionKm: distanceOf(contract),
|
||
firstContact: false,
|
||
priority: false,
|
||
},
|
||
config,
|
||
);
|
||
}
|
||
|
||
/** Real minutes for the loaner to fly a contract, departing at its offer time. */
|
||
function tripMinutes(contract: Contract): number {
|
||
const hours = travelTimeGameHours(loaner, distanceOf(contract), contract.massTonnes, config);
|
||
return (hours * MS_PER_HOUR) / config.timeCompression / 60_000;
|
||
}
|
||
|
||
function xpPerMinute(contract: Contract): number {
|
||
return deliveryXp(distanceOf(contract), contract.tier, config) / tripMinutes(contract);
|
||
}
|
||
|
||
describe("tutorial board v2", () => {
|
||
it("uses the Company Courier as the loaner", () => {
|
||
expect(loaner.id).toBe("company-courier");
|
||
});
|
||
|
||
it("has exactly three Standard contracts at three different masses within the loaner's capacity", () => {
|
||
const board = generateTutorialBoard(atEarth, content);
|
||
expect(board).toHaveLength(3);
|
||
expect(board.map((c) => c.massTonnes)).toEqual([
|
||
config.tutorialMassTonnes.light,
|
||
config.tutorialMassTonnes.medium,
|
||
config.tutorialMassTonnes.full,
|
||
]);
|
||
expect(new Set(board.map((c) => c.massTonnes)).size).toBe(3);
|
||
for (const c of board) {
|
||
expect(c.cargoTypeId).toBe("standard");
|
||
expect(c.tier).toBe(0);
|
||
expect(c.requiredSkill).toBeNull();
|
||
expect(c.massTonnes).toBeLessThanOrEqual(loaner.cargoCapacityTonnes);
|
||
}
|
||
});
|
||
|
||
it("runs from the loaner's location to the other end of the Earth–Moon route", () => {
|
||
for (const c of generateTutorialBoard(atEarth, content)) {
|
||
expect([c.originId, c.destinationId]).toEqual(["earth", "moon"]);
|
||
}
|
||
for (const c of generateTutorialBoard(atMoon, content)) {
|
||
expect([c.originId, c.destinationId]).toEqual(["moon", "earth"]);
|
||
}
|
||
});
|
||
|
||
it("pays the tutorial share of the normal payout", () => {
|
||
for (const c of generateTutorialBoard(atEarth, content)) {
|
||
expect(c.payout).toBe(Math.round(normalPayout(c) * config.tutorialPayoutShare));
|
||
}
|
||
});
|
||
|
||
it("gives an identical board for the same inputs, and a new one per run", () => {
|
||
expect(generateTutorialBoard(atEarth, content)).toEqual(
|
||
generateTutorialBoard(atEarth, content),
|
||
);
|
||
const nextRun = generateTutorialBoard({ ...atEarth, runsCompleted: 2 }, content);
|
||
expect(nextRun.map((c) => c.id)).not.toEqual(
|
||
generateTutorialBoard(atEarth, content).map((c) => c.id),
|
||
);
|
||
});
|
||
|
||
it("puts Unmarked crates in the medium slot at double pay with two runs completed, and never otherwise", () => {
|
||
const [, crates] = generateTutorialBoard({ ...atEarth, runsCompleted: 2 }, content);
|
||
expect(crates).toMatchObject({
|
||
scriptedId: "unmarked-crates",
|
||
massTonnes: config.tutorialMassTonnes.medium,
|
||
});
|
||
expect(crates!.payout).toBe(Math.round(normalPayout(crates!) * config.tutorialPayoutShare * 2));
|
||
|
||
for (const runsCompleted of [0, 1, 3, 4, 7]) {
|
||
const board = generateTutorialBoard({ ...atEarth, runsCompleted }, content);
|
||
expect(board.some((c) => c.scriptedId !== undefined)).toBe(false);
|
||
}
|
||
});
|
||
|
||
it("trades XP per minute (light) against credits per minute (full)", () => {
|
||
const [light, , full] = generateTutorialBoard(atEarth, content);
|
||
expect(xpPerMinute(light!)).toBeGreaterThan(xpPerMinute(full!));
|
||
expect(full!.payout / tripMinutes(full!)).toBeGreaterThan(light!.payout / tripMinutes(light!));
|
||
});
|
||
|
||
it("keeps the four slowest possible tutorial runs under 6 real minutes in total", () => {
|
||
const slowest = Math.max(
|
||
...[atEarth, atMoon].map((input) => tripMinutes(generateTutorialBoard(input, content)[2]!)),
|
||
);
|
||
expect(4 * slowest).toBeLessThan(6);
|
||
});
|
||
});
|
||
|
||
describe("tutorial goods (CR-007)", () => {
|
||
const goodsOn = (from: "earth" | "moon", to: string) =>
|
||
content.goods
|
||
.filter(
|
||
(g) =>
|
||
g.cargoClassId === "standard" && g.producedAt.includes(from) && g.demandedAt.includes(to),
|
||
)
|
||
.map((g) => g.id);
|
||
|
||
it.each([
|
||
["earth", "moon"],
|
||
["moon", "earth"],
|
||
] as const)(
|
||
"at %s, carries only Standard goods made there and wanted at %s, never Priority",
|
||
(from, to) => {
|
||
const allowed = goodsOn(from, to);
|
||
for (let run = 0; run < 6; run++) {
|
||
for (let periodIndex = 0; periodIndex < 20; periodIndex++) {
|
||
const board = generateTutorialBoard(
|
||
{ loanerLocationId: from, runsCompleted: run, periodIndex },
|
||
content,
|
||
);
|
||
for (const c of board) {
|
||
expect(allowed).toContain(c.goodId);
|
||
expect(c.priority).toBe(false);
|
||
expect(content.location(from)!.shippers).toContain(c.shipper);
|
||
}
|
||
// The CR-001 mass tiers hold: some good on each route fits each tier.
|
||
expect(board.map((c) => c.massTonnes)).toEqual([
|
||
config.tutorialMassTonnes.light,
|
||
config.tutorialMassTonnes.medium,
|
||
config.tutorialMassTonnes.full,
|
||
]);
|
||
}
|
||
}
|
||
},
|
||
);
|
||
});
|