import { randomUUID } from "node:crypto"; import { content } from "@deadhead/content"; import { contractBoardResponseSchema, dispatchResponseSchema, errorResponseSchema, } from "@deadhead/schema"; import { boardPeriodMs } from "@deadhead/sim"; import { eq } from "drizzle-orm"; import { describe, expect, it } from "vitest"; import { acceptedContracts, companies, dispatches, pilots, ships } from "../src/db/schema"; import { makeOwner, setCredits, TEST_NOW, useTestDatabase } from "./db"; import { call, getState, putBoard, signUp, testContract, useTestApp, type Guest } from "./game"; const database = useTestDatabase(); const testApp = useTestApp(database); 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(database().db, guest.playerId, guest.avatarId); await setCredits(database().db, guest.playerId, 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 { db } = database(); expect( await db.select().from(dispatches).where(eq(dispatches.playerId, o.guest.playerId)), ).toEqual([]); expect(await db.select().from(acceptedContracts)).toEqual([]); const [company] = await db .select() .from(companies) .where(eq(companies.playerId, o.guest.playerId)); expect(company!.credits).toBe(10_000); } describe("contract dispatch", () => { it("dispatches, charges fuel, stores the record and removes the contract from the board", async () => { const { app, clock } = testApp(); const o = await owner(); const contract = testContract(); await putBoard(database().db, o.guest.playerId, [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.fuelCost).toBeGreaterThan(0); const state = await getState(app, o.guest); expect(state.company.credits).toBe(10_000 - record.fuelCost); 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(database().db, o.guest.playerId, [contract]); await database().db.update(ships).set({ status: "in_flight" }).where(eq(ships.id, o.shipId)); 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(database().db, o.guest.playerId, [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(database().db, o.guest.playerId, [contract]); await database() .db.update(pilots) .set({ status: "in_flight" }) .where(eq(pilots.id, o.guest.avatarId)); 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(database().db, o.guest.playerId, [contract]); const otherPilotId = randomUUID(); await database().db.insert(pilots).values({ id: otherPilotId, playerId: o.guest.playerId, name: "Spare Pilot", locationId: "earth", createdAt: TEST_NOW, }); 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(database().db, o.guest.playerId, [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(database().db, o.guest.playerId, [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", tier: 2, requiredSkill: { skillId: "heavy-cargo", level: 2 }, }); await putBoard(database().db, o.guest.playerId, [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(database().db, o.guest.playerId, [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(database().db, o.guest.playerId, [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 insufficient-credits", async () => { const o = await owner(); const contract = testContract(); await putBoard(database().db, o.guest.playerId, [contract]); await setCredits(database().db, o.guest.playerId, 0); const response = await dispatch(o, contract.id); expect(errorCode(response)).toBe("insufficient-credits"); expect(await database().db.select().from(dispatches)).toEqual([]); }); it("rejects an unknown contract and a contract taken twice", async () => { const o = await owner(); const contract = testContract(); await putBoard(database().db, o.guest.playerId, [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"); }); });