213 lines
8.3 KiB
TypeScript
213 lines
8.3 KiB
TypeScript
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 20.2, decisions 0.5.121–0.5.124)", () => {
|
||
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" },
|
||
);
|
||
});
|
||
});
|