263 lines
8.8 KiB
TypeScript
263 lines
8.8 KiB
TypeScript
import { content } from "@deadhead/content";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
completability,
|
|
isCompletable,
|
|
permanentFailure,
|
|
qualifyingFleet,
|
|
validateDispatch,
|
|
type Contract,
|
|
type FleetMember,
|
|
type FleetPilot,
|
|
type FleetShip,
|
|
type SimContent,
|
|
} from "../src/index";
|
|
|
|
const job = (over: Partial<Contract> = {}): Contract => ({
|
|
id: "j",
|
|
originId: "earth",
|
|
destinationId: "moon",
|
|
cargoTypeId: "standard",
|
|
goodId: "consumer-goods",
|
|
shipper: "Pacific Orbital Logistics",
|
|
priority: false,
|
|
deliveryWindowGameHours: null,
|
|
tier: 0,
|
|
massTonnes: 10,
|
|
payout: 1000,
|
|
requiredSkill: null,
|
|
requiredLongHaulLevel: 0,
|
|
offeredAt: 0,
|
|
expiresAt: 600_000,
|
|
firstContact: false,
|
|
...over,
|
|
});
|
|
const courier: FleetMember = { modelId: "courier", skills: {} };
|
|
const clipper: FleetMember = { modelId: "clipper", skills: {} };
|
|
|
|
/** Content with one location changed (no ship lands vacuum-only, and every place sells fuel). */
|
|
function withLocation(id: string, change: object): SimContent {
|
|
return {
|
|
...content,
|
|
locations: content.locations.map((l) => (l.id === id ? { ...l, ...change } : l)),
|
|
};
|
|
}
|
|
|
|
describe("completability check (Step 19.1, decisions 0.5.114, 0.5.116)", () => {
|
|
it("passes a pair that can fly the job", () => {
|
|
expect(permanentFailure(job(), courier, undefined, content)).toBeNull();
|
|
});
|
|
|
|
it("fails on cargo that doesn't fit the ship's effective capacity", () => {
|
|
expect(permanentFailure(job({ massTonnes: 25 }), courier, undefined, content)).toBe(
|
|
"over-capacity",
|
|
);
|
|
});
|
|
|
|
it("fails where the ship can't land", () => {
|
|
// Earth demanding a hardened landing: nothing in the fleet can land there.
|
|
const hardEarth = withLocation("earth", { landingRequirement: "hardened" });
|
|
const toEarth = job({ originId: "moon", destinationId: "earth" });
|
|
expect(permanentFailure(toEarth, courier, undefined, hardEarth)).toBe("cannot-land");
|
|
});
|
|
|
|
it("fails out of full-tank range", () => {
|
|
expect(permanentFailure(job({ destinationId: "mars" }), courier, undefined, content)).toBe(
|
|
"out-of-range",
|
|
);
|
|
});
|
|
|
|
it("uses only the fuel on board where the origin sells none", () => {
|
|
const dryEarth = withLocation("earth", { sellsFuel: false });
|
|
expect(permanentFailure(job(), { ...courier, fuelFu: 1 }, undefined, dryEarth)).toBe(
|
|
"out-of-range",
|
|
);
|
|
expect(permanentFailure(job(), { ...courier, fuelFu: 40 }, undefined, dryEarth)).toBeNull();
|
|
});
|
|
|
|
it("fails when the ship would be stranded at the destination", () => {
|
|
// Mars selling no fuel: a Clipper lands there with too little to leave.
|
|
const dryMars = withLocation("mars", { sellsFuel: false });
|
|
expect(permanentFailure(job({ destinationId: "mars" }), clipper, undefined, dryMars)).toBe(
|
|
"would-strand",
|
|
);
|
|
});
|
|
|
|
it("fails a Priority window the ship can't make", () => {
|
|
const rushed = job({ priority: true, deliveryWindowGameHours: 0.01 });
|
|
expect(permanentFailure(rushed, courier, undefined, content)).toBe("too-slow");
|
|
});
|
|
|
|
it("fails a cargo tier above the pilot's skill", () => {
|
|
const heavy = job({
|
|
cargoTypeId: "heavy",
|
|
tier: 2,
|
|
requiredSkill: { skillId: "heavy-cargo", level: 2 },
|
|
});
|
|
expect(
|
|
permanentFailure(heavy, { ...courier, skills: { "heavy-cargo": 1 } }, undefined, content),
|
|
).toBe("cargo-skill-too-low");
|
|
expect(
|
|
permanentFailure(heavy, { ...courier, skills: { "heavy-cargo": 2 } }, undefined, content),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("fails a Long Haul requirement above the pilot's level", () => {
|
|
expect(permanentFailure(job({ requiredLongHaulLevel: 1 }), courier, undefined, content)).toBe(
|
|
"long-haul-too-low",
|
|
);
|
|
});
|
|
|
|
it("counts upgrades: Cargo Bay makes a heavier lot fit", () => {
|
|
const bay = content.upgrades.find((u) => u.stat === "cargoCapacityTonnes")!;
|
|
const upgraded = { ...courier, upgrades: { [bay.id]: 3 } };
|
|
expect(permanentFailure(job({ massTonnes: 22 }), upgraded, undefined, content)).toBeNull();
|
|
});
|
|
|
|
it("is completable when any one pair passes, and reports each pair's first failure", () => {
|
|
const heavyLot = job({ massTonnes: 25 });
|
|
expect(completability(heavyLot, [courier, clipper], undefined, content)).toEqual({
|
|
completable: true,
|
|
failures: ["over-capacity", null],
|
|
});
|
|
expect(completability(heavyLot, [courier], undefined, content).completable).toBe(false);
|
|
expect(isCompletable(heavyLot, [], undefined, content)).toBe(false);
|
|
});
|
|
|
|
it("ignores rest, the shop, fuel on board and credits (decision 0.5.116)", () => {
|
|
const now = 1_000;
|
|
// The dispatch check refuses all of these for now; none of them is permanent.
|
|
const ship = {
|
|
id: "s",
|
|
modelId: "courier",
|
|
locationId: "earth",
|
|
inFlight: false,
|
|
tank: { fuelFu: 0, avgPrice: 8 },
|
|
};
|
|
const pilot = {
|
|
id: "p",
|
|
isAvatar: true,
|
|
assignedShipId: "s",
|
|
inFlight: false,
|
|
skills: {},
|
|
restEndsAt: null,
|
|
};
|
|
const contract = job();
|
|
const check = (over: { ship?: object; pilot?: object }) =>
|
|
validateDispatch(
|
|
{
|
|
ship: { ...ship, ...over.ship },
|
|
pilot: { ...pilot, ...over.pilot },
|
|
contract,
|
|
tutorial: false,
|
|
now,
|
|
},
|
|
content,
|
|
);
|
|
expect(check({})).toEqual({ ok: false, reason: "not-enough-fuel" });
|
|
expect(check({ pilot: { restEndsAt: now + 1 } })).toEqual({
|
|
ok: false,
|
|
reason: "pilot-resting",
|
|
});
|
|
expect(check({ ship: { inShopUntil: now + 1 } })).toEqual({
|
|
ok: false,
|
|
reason: "ship-in-shop",
|
|
});
|
|
// The same pair, empty tank and all, can complete it.
|
|
expect(permanentFailure(contract, { ...courier, fuelFu: 0 }, undefined, content)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("qualifying fleet (Step 19.1, decision 0.5.115)", () => {
|
|
const PERIOD_END = 10_000;
|
|
const ship = (id: string, over: Partial<FleetShip> = {}): FleetShip => ({
|
|
id,
|
|
modelId: "courier",
|
|
locationId: "earth",
|
|
...over,
|
|
});
|
|
const pilot = (id: string, over: Partial<FleetPilot> = {}): FleetPilot => ({
|
|
id,
|
|
skills: { [id]: 1 },
|
|
assignedShipId: null,
|
|
locationId: null,
|
|
...over,
|
|
});
|
|
const fleet = (input: {
|
|
locationId?: string;
|
|
ships: FleetShip[];
|
|
pilots: FleetPilot[];
|
|
bays?: string[];
|
|
}) =>
|
|
qualifyingFleet({
|
|
locationId: input.locationId ?? "earth",
|
|
ships: input.ships,
|
|
pilots: input.pilots,
|
|
bayLocationIds: new Set(input.bays ?? []),
|
|
periodEndsAt: PERIOD_END,
|
|
});
|
|
|
|
it("takes ships at the location with their assigned pilots", () => {
|
|
const result = fleet({
|
|
ships: [ship("a"), ship("b", { modelId: "clipper", locationId: "moon" })],
|
|
pilots: [pilot("pa", { assignedShipId: "a" }), pilot("pb", { assignedShipId: "b" })],
|
|
});
|
|
expect(result).toEqual({
|
|
members: [{ modelId: "courier", skills: { pa: 1 } }],
|
|
wholeFleet: false,
|
|
});
|
|
});
|
|
|
|
it("counts ships landing here before the period ends, not after", () => {
|
|
const flying = (id: string, arrivalAt: number) =>
|
|
ship(id, { locationId: null, destinationId: "earth", arrivalAt, modelId: "clipper" });
|
|
const result = fleet({
|
|
ships: [ship("a"), flying("soon", PERIOD_END - 1), flying("late", PERIOD_END + 1)],
|
|
pilots: [
|
|
pilot("pa", { assignedShipId: "a" }),
|
|
pilot("ps", { assignedShipId: "soon" }),
|
|
pilot("pl", { assignedShipId: "late" }),
|
|
],
|
|
});
|
|
expect(result.members.map((m) => m.skills)).toEqual([{ pa: 1 }, { ps: 1 }]);
|
|
});
|
|
|
|
it("pairs unassigned pilots here with the ships here, only where the player has a bay", () => {
|
|
const ships = [ship("a")];
|
|
const pilots = [pilot("spare", { locationId: "earth" })];
|
|
expect(fleet({ ships, pilots, bays: ["earth"] }).members).toEqual([
|
|
{ modelId: "courier", skills: { spare: 1 } },
|
|
]);
|
|
// No bay here: the spare can't be assigned, so nothing qualifies here.
|
|
expect(fleet({ ships, pilots }).wholeFleet).toBe(true);
|
|
});
|
|
|
|
it("falls back to the whole fleet, wherever it is, when nothing qualifies here", () => {
|
|
const result = fleet({
|
|
locationId: "mars",
|
|
ships: [
|
|
ship("a"),
|
|
ship("b", { modelId: "clipper", locationId: null, destinationId: "moon", arrivalAt: 5 }),
|
|
],
|
|
pilots: [pilot("pa", { assignedShipId: "a" }), pilot("pb", { assignedShipId: "b" })],
|
|
});
|
|
expect(result).toEqual({
|
|
members: [
|
|
{ modelId: "courier", skills: { pa: 1 } },
|
|
{ modelId: "clipper", skills: { pb: 1 } },
|
|
],
|
|
wholeFleet: true,
|
|
});
|
|
});
|
|
|
|
it("carries upgrades and fuel into the snapshot", () => {
|
|
const result = fleet({
|
|
ships: [ship("a", { upgrades: { "cargo-bay": 1 }, fuelFu: 12 })],
|
|
pilots: [pilot("pa", { assignedShipId: "a" })],
|
|
});
|
|
expect(result.members).toEqual([
|
|
{ modelId: "courier", skills: { pa: 1 }, upgrades: { "cargo-bay": 1 }, fuelFu: 12 },
|
|
]);
|
|
});
|
|
});
|