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

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