// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine. import { content } from "@deadhead/content"; import { contractBoardResponseSchema, dispatchResponseSchema, errorResponseSchema, } from "@deadhead/schema"; import { boardPeriodMs, contractTripGameHours } from "@deadhead/sim"; import { describe, expect, it } from "vitest"; import { TEST_NOW, useTestApp, signUp, call, getState, edit, readSave, setCredits, testContract, putBoard, makeOwner, addPilot, type Guest, } from "../support"; const randomUUID = () => globalThis.crypto.randomUUID(); const testApp = useTestApp(); const errorCode = (response: { json(): unknown }) => errorResponseSchema.parse(response.json()).error.code; interface Owner { guest: Guest; shipId: string; } /** An owner with a crewed Courier at Earth, an Earth bay and 10,000 credits. */ async function owner(): Promise { const guest = await signUp(testApp().app); const { shipId } = await makeOwner(testApp().app, guest.avatarId); await setCredits(testApp().app, 10_000); return { guest, shipId }; } function dispatch( o: Owner, contractId: string, overrides: { shipId?: string; pilotId?: string } = {}, ) { return call(testApp().app, o.guest, "POST", "/api/dispatches", { kind: "contract", contractId, shipId: overrides.shipId ?? o.shipId, pilotId: overrides.pilotId ?? o.guest.avatarId, }); } /** Asserts the request failed with `code` and changed nothing. */ async function expectRejected( o: Owner, response: Awaited>, code: string, ) { expect(response.statusCode).toBe(409); expect(errorCode(response)).toBe(code); const save = await readSave(testApp().app); expect(save.dispatches).toEqual([]); expect(save.acceptedContracts).toEqual([]); expect(save.company.credits).toBe(10_000); } describe("contract dispatch", () => { it("dispatches, burns fuel from the tank, stores the record and removes the contract from the board", async () => { const { app, clock } = testApp(); const o = await owner(); const contract = testContract(); await putBoard(testApp().app, [contract]); const response = await dispatch(o, contract.id); expect(response.statusCode).toBe(200); const record = dispatchResponseSchema.parse(response.json()).dispatch; expect(record).toMatchObject({ status: "in-flight", shipId: o.shipId, pilotId: o.guest.avatarId, departureAt: clock.now, expectedPayout: contract.payout, tutorial: false, }); expect(record.fuelBurnedFu).toBeGreaterThan(0); expect(record.fuelCost).toBeGreaterThan(0); // Fuel comes out of the tank; no credits change hands (decision 0.5.94). const state = await getState(app, o.guest); expect(state.company.credits).toBe(10_000); const full = content.shipModel("courier")!.tankCapacityFu; expect(state.ships[0]!.fuelFu).toBeCloseTo(full - record.fuelBurnedFu); expect(state.ships[0]).toMatchObject({ status: "in-flight", dispatchId: record.id }); expect(state.pilots[0]!.status).toBe("in-flight"); const board = contractBoardResponseSchema.parse( (await call(app, o.guest, "GET", "/api/locations/earth/board")).json(), ); expect(board.contracts).toEqual([]); }); it("rejects ship-not-idle", async () => { const o = await owner(); const contract = testContract(); await putBoard(testApp().app, [contract]); await edit(testApp().app, (save) => { save.ships.find((s) => s.id === o.shipId)!.status = "in-flight"; }); await expectRejected(o, await dispatch(o, contract.id), "ship-not-idle"); }); it("rejects ship-not-at-origin", async () => { const o = await owner(); const contract = testContract({ originId: "moon", destinationId: "earth" }); await putBoard(testApp().app, [contract]); await expectRejected(o, await dispatch(o, contract.id), "ship-not-at-origin"); }); it("rejects pilot-not-idle", async () => { const o = await owner(); const contract = testContract(); await putBoard(testApp().app, [contract]); await edit(testApp().app, (save) => { save.pilots.find((p) => p.id === o.guest.avatarId)!.status = "in-flight"; }); await expectRejected(o, await dispatch(o, contract.id), "pilot-not-idle"); }); it("rejects pilot-not-assigned", async () => { const o = await owner(); const contract = testContract(); await putBoard(testApp().app, [contract]); const otherPilotId = randomUUID(); await addPilot(testApp().app, { id: otherPilotId, name: "Spare Pilot", locationId: "earth" }); await expectRejected( o, await dispatch(o, contract.id, { pilotId: otherPilotId }), "pilot-not-assigned", ); }); it("rejects over-capacity", async () => { const o = await owner(); const contract = testContract({ massTonnes: 50 }); await putBoard(testApp().app, [contract]); await expectRejected(o, await dispatch(o, contract.id), "over-capacity"); }); it("rejects out-of-range", async () => { const o = await owner(); const contract = testContract({ destinationId: "mars" }); await putBoard(testApp().app, [contract]); await expectRejected(o, await dispatch(o, contract.id), "out-of-range"); }); it("rejects cargo-skill-too-low", async () => { const o = await owner(); const contract = testContract({ cargoTypeId: "heavy", goodId: "machine-parts", shipper: "Pacific Orbital Logistics", priority: false, deliveryWindowGameHours: null, tier: 2, requiredSkill: { skillId: "heavy-cargo", level: 2 }, }); await putBoard(testApp().app, [contract]); await expectRejected(o, await dispatch(o, contract.id), "cargo-skill-too-low"); }); it("rejects long-haul-too-low", async () => { const o = await owner(); const contract = testContract({ requiredLongHaulLevel: 1 }); await putBoard(testApp().app, [contract]); await expectRejected(o, await dispatch(o, contract.id), "long-haul-too-low"); }); it("rejects contract-expired once the board's period has rolled over", async () => { const { clock } = testApp(); const o = await owner(); const contract = testContract(); await putBoard(testApp().app, [contract]); // The contract expires at the end of its board period (TEST_NOW is a period start). expect(contract.expiresAt).toBe(TEST_NOW + boardPeriodMs(content.config)); clock.now = contract.expiresAt; await expectRejected(o, await dispatch(o, contract.id), "contract-expired"); }); it("rejects not-enough-fuel (CR-015)", async () => { const o = await owner(); const contract = testContract(); await putBoard(testApp().app, [contract]); await edit(testApp().app, (save) => { save.ships.find((s) => s.id === o.shipId)!.fuelFu = 0.5; }); const response = await dispatch(o, contract.id); expect(errorCode(response)).toBe("not-enough-fuel"); expect((await readSave(testApp().app)).dispatches).toEqual([]); }); it("rejects an unknown contract and a contract taken twice", async () => { const o = await owner(); const contract = testContract(); await putBoard(testApp().app, [contract]); expect((await dispatch(o, "earth:0:99")).statusCode).toBe(404); expect((await dispatch(o, contract.id)).statusCode).toBe(200); expect(errorCode(await dispatch(o, contract.id))).toBe("contract-already-accepted"); }); }); describe("Priority contracts (CR-007)", () => { const courier = content.shipModel("courier")!; /** A Priority Earth→Moon mail job whose window is the Courier's trip plus `slackHours`. */ async function priorityJob(o: Owner, id: string, slackHours: number) { const { clock } = testApp(); const base = testContract({ id, goodId: "mail", massTonnes: 4, priority: true }); const trip = contractTripGameHours(courier, base, content); const contract = { ...base, deliveryWindowGameHours: trip + slackHours }; await putBoard(testApp().app, [contract], clock.now); return contract; } it("refuses a ship too slow for the window with too-slow and changes nothing", async () => { const o = await owner(); const job = await priorityJob(o, "earth:1:0", -0.25); await expectRejected(o, await dispatch(o, job.id), "too-slow"); }); it("dispatches a ship fast enough for the window", async () => { const o = await owner(); const job = await priorityJob(o, "earth:1:0", 0.25); const response = await dispatch(o, job.id); expect(response.statusCode).toBe(200); const { dispatch: stored } = dispatchResponseSchema.parse(response.json()); expect(stored.contract).toMatchObject({ priority: true, goodId: "mail" }); }); it("serves generated boards whose contracts all name a known good and a shipper", async () => { const o = await owner(); const response = await call(testApp().app, o.guest, "GET", "/api/locations/earth/board"); const { contracts } = contractBoardResponseSchema.parse(response.json()); expect(contracts.length).toBeGreaterThan(0); for (const c of contracts) { const good = content.good(c.goodId); expect(good?.name).toBeTruthy(); expect(good!.cargoClassId).toBe(c.cargoTypeId); expect(content.location("earth")!.shippers).toContain(c.shipper); } }); });