deadhead-space-freight-tycoon/packages/sim/test/tutorial.test.ts

171 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import {
boardPeriodIndex,
contractPayout,
deliveryXp,
generateTutorialBoard,
loanerModel,
MS_PER_HOUR,
realToGame,
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)!,
realToGame(contract.offeredAt, config),
);
}
/** 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,
]);
}
}
},
);
});