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 => ({ 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 => ({ id, modelId: "courier", locationId: "earth", ...over, }); const pilot = (id: string, over: Partial = {}): 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 }, ]); }); });