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, ]); } } }, ); });