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

213 lines
8.3 KiB
TypeScript
Raw Permalink 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 {
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> = {}): 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> = {}): 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" },
);
});
});