import { content } from "@deadhead/content"; import { describe, expect, it } from "vitest"; import { boardPeriodMs, chooseAction, compareOptions, defaultOrders, fuelPriceAt, independentOptions, MS_PER_MINUTE, tripFuel, type Contract, type IndependentInput, type JobOption, } from "../src/index"; const { config } = content; const loc = (id: string) => content.location(id)!; const clipper = content.shipModel("clipper")!; const PERIOD = boardPeriodMs(config); const PERIOD_START = 1000 * PERIOD; const AFTER_LOOK = PERIOD_START + (config.independentPilots.firstLookRealMinutes + 1) * MS_PER_MINUTE; const EARTH_PRICE = fuelPriceAt(loc("earth"), config); const job = (id: string, over: Partial = {}): Contract => ({ id, originId: "earth", destinationId: "moon", cargoTypeId: "standard", goodId: "consumer-goods", shipper: "Pacific Orbital Logistics", priority: false, deliveryWindowGameHours: null, tier: 0, massTonnes: 10, payout: 1_000, requiredSkill: null, requiredLongHaulLevel: 0, offeredAt: PERIOD_START, expiresAt: PERIOD_START + PERIOD, firstContact: false, ...over, }); function input(board: Contract[], over: Partial = {}): IndependentInput { return { ship: { id: "s", modelId: "clipper", locationId: "earth", inFlight: false, tank: { fuelFu: clipper.tankCapacityFu, avgPrice: EARTH_PRICE }, }, pilot: { id: "p", isAvatar: false, assignedShipId: "s", inFlight: false, skills: { "long-haul": 1 }, restEndsAt: null, }, orders: defaultOrders("earth", content), board, now: AFTER_LOOK, credits: 100_000, spendingFloor: config.independentPilots.defaultSpendingFloor, discoveredIds: new Set(["earth", "moon", "mars"]), emptyPeriods: 0, ...over, }; } describe("independent pilots' job choice (Step 21.2, decisions 0.5.125–0.5.128)", () => { it("never takes a first-contact job", () => { const discovery = job("a", { firstContact: true, payout: 50_000 }); expect(chooseAction(input([discovery]), content)).toEqual({ kind: "wait" }); expect(chooseAction(input([discovery, job("b")]), content)).toMatchObject({ kind: "take", contractId: "b", }); }); it("leaves the board alone in the first-look window, then takes the job", () => { const early = PERIOD_START + (config.independentPilots.firstLookRealMinutes - 1) * MS_PER_MINUTE; expect(chooseAction(input([job("a")], { now: early }), content)).toEqual({ kind: "wait" }); expect(chooseAction(input([job("a")]), content)).toMatchObject({ kind: "take", contractId: "a", }); }); it("skips the job the player has open", () => { const board = [job("a", { payout: 2_000 }), job("b")]; expect(chooseAction(input(board, { viewedContractId: "a" }), content)).toMatchObject({ contractId: "b", }); }); it("keeps to the orders: destinations, cargo classes, Priority and minimum profit", () => { const orders = defaultOrders("earth", content); const toMoon = [job("a")]; expect( chooseAction(input(toMoon, { orders: { ...orders, destinationIds: ["mars"] } }), content), ).toEqual({ kind: "wait" }); expect( chooseAction(input(toMoon, { orders: { ...orders, cargoClassIds: ["heavy"] } }), content), ).toEqual({ kind: "wait" }); const rush = [job("r", { priority: true, deliveryWindowGameHours: 100 })]; expect(chooseAction(input(rush), content)).toMatchObject({ contractId: "r" }); expect( chooseAction(input(rush, { orders: { ...orders, takePriority: false } }), content), ).toEqual({ kind: "wait" }); const [option] = independentOptions(input(toMoon), content); expect( chooseAction( input(toMoon, { orders: { ...orders, minProfit: option!.profit + 1 } }), content, ), ).toEqual({ kind: "wait" }); expect( chooseAction(input(toMoon, { orders: { ...orders, minProfit: option!.profit } }), content), ).toMatchObject({ contractId: "a" }); }); it("takes the job with the highest profit per hour, not the highest payout", () => { // Mars pays more in total, but the Moon run pays more per hour. const mars = job("mars", { destinationId: "mars", payout: 20_000, requiredLongHaulLevel: 1 }); const moon = job("moon", { payout: 1_500 }); const options = independentOptions(input([mars, moon]), content).sort(compareOptions); expect(options.map((o) => o.contract.id)).toEqual(["moon", "mars"]); expect(options[0]!.profitPerHour).toBeGreaterThan(options[1]!.profitPerHour); expect(options[1]!.profit).toBeGreaterThan(options[0]!.profit); expect(chooseAction(input([mars, moon]), content)).toMatchObject({ contractId: "moon" }); }); it("prices fuel aboard at the tank's average, bought fuel at the local price, and pays the pilot's share", () => { const moonFu = tripFuel(clipper, loc("earth"), loc("moon"), config); const aboard = { fuelFu: moonFu / 2, avgPrice: 3 }; const [option] = independentOptions( input([job("a")], { ship: { ...input([]).ship, tank: aboard } }), content, ); expect(option!.buyFu).toBeCloseTo(moonFu / 2, 2); const bought = Math.min( Math.round(option!.buyFu * EARTH_PRICE), Math.ceil(option!.buyFu * EARTH_PRICE), ); expect(option!.fuelCost).toBe(Math.round((moonFu / 2) * 3 + bought)); expect(option!.pilotShare).toBe( Math.round((1_000 - option!.fuelCost) * config.pilotProfitShare), ); expect(option!.profit).toBe(1_000 - option!.fuelCost - option!.pilotShare); }); it("breaks ties on the shorter trip, then the lower contract id", () => { const option = (id: string, profitPerHour: number, tripGameHours: number) => ({ contract: job(id), profitPerHour, tripGameHours }) as JobOption; expect( [option("b", 10, 5), option("a", 10, 6), option("c", 11, 9)] .sort(compareOptions) .map((o) => o.contract.id), ).toEqual(["c", "b", "a"]); expect( [option("b", 10, 5), option("a", 10, 5)].sort(compareOptions).map((o) => o.contract.id), ).toEqual(["a", "b"]); // Identical jobs on a real board: the lower id wins. expect(chooseAction(input([job("z"), job("y")]), content)).toMatchObject({ contractId: "y" }); }); it("flies on the fuel aboard when buying more would go below the spending floor", () => { const moonFu = tripFuel(clipper, loc("earth"), loc("moon"), config); const marsFu = tripFuel(clipper, loc("earth"), loc("mars"), config); // Enough aboard for the Moon, not for Mars; Mars pays far more per hour. const tank = { fuelFu: moonFu * 2, avgPrice: EARTH_PRICE }; const ship = { ...input([]).ship, tank }; const mars = job("mars", { destinationId: "mars", payout: 500_000, requiredLongHaulLevel: 1 }); const moon = job("moon"); const marsCost = Math.ceil((marsFu - tank.fuelFu) * EARTH_PRICE); const floor = config.independentPilots.defaultSpendingFloor; expect( chooseAction(input([mars, moon], { ship, credits: floor + marsCost + 10 }), content), ).toMatchObject({ kind: "take", contractId: "mars" }); const broke = chooseAction( input([mars, moon], { ship, credits: floor + marsCost - 10 }), content, ); expect(broke).toEqual({ kind: "take", contractId: "moon", buyFu: 0 }); }); it("waits with nothing to take, and heads home after enough empty periods away", () => { const atMoon = { ...input([]).ship, locationId: "moon" }; const away = (emptyPeriods: number) => chooseAction(input([], { ship: atMoon, emptyPeriods }), content); const needed = config.independentPilots.emptyPeriodsBeforeHome; expect(away(needed - 1)).toEqual({ kind: "wait" }); expect(away(needed)).toEqual({ kind: "deadhead-home", destinationId: "earth", buyFu: 0 }); // At home there's nowhere to head: keep waiting. expect(chooseAction(input([], { emptyPeriods: needed }), content)).toEqual({ kind: "wait" }); // Home out of reach (a Courier can't fly Mars → Earth): keep waiting. const courierAtMars = { ...atMoon, modelId: "courier", locationId: "mars", tank: { fuelFu: content.shipModel("courier")!.tankCapacityFu, avgPrice: EARTH_PRICE }, }; expect(chooseAction(input([], { ship: courierAtMars, emptyPeriods: needed }), content)).toEqual( { kind: "wait" }, ); }); });