import { randomUUID } from "node:crypto"; import { content } from "@deadhead/content"; import { errorResponseSchema, pilotResponseSchema } from "@deadhead/schema"; import { eq } from "drizzle-orm"; import { describe, expect, it } from "vitest"; import { bays, ships } from "../src/db/schema"; import { makeOwner, setCredits, TEST_NOW, useTestDatabase } from "./db"; import { call, getState, insertDispatch, signUp, useTestApp, type Guest } from "./game"; const database = useTestDatabase(); const testApp = useTestApp(database); const errorCode = (response: { json(): unknown }) => errorResponseSchema.parse(response.json()).error.code; /** An owner with a Moon bay, plenty of credits, and an extra uncrewed Courier at the Moon. */ async function ownerWithMoonBay() { const { app } = testApp(); const { db } = database(); const guest = await signUp(app); const earthShipId = await makeOwner(db, guest.playerId, guest.avatarId); await setCredits(db, guest.playerId, 100_000); await db.insert(bays).values({ id: randomUUID(), playerId: guest.playerId, locationId: "moon", createdAt: TEST_NOW, }); const moonShipId = randomUUID(); await db.insert(ships).values({ id: moonShipId, playerId: guest.playerId, modelId: "courier", name: "Moon Hopper", locationId: "moon", createdAt: TEST_NOW, }); return { guest, earthShipId, moonShipId }; } async function hire(guest: Guest, locationId: string) { return call(testApp().app, guest, "POST", "/api/pilots", { locationId }); } describe("hiring pilots", () => { it("hires a level-0 pilot at the Moon bay and deducts the fee", async () => { const { app } = testApp(); const { guest } = await ownerWithMoonBay(); const response = await hire(guest, "moon"); expect(response.statusCode).toBe(200); const { pilot } = pilotResponseSchema.parse(response.json()); expect(pilot).toMatchObject({ isAvatar: false, level: 0, xp: 0, locationId: "moon", assignedShipId: null, status: "idle", }); expect(pilot.name.length).toBeGreaterThan(0); expect((await getState(app, guest)).company.credits).toBe( 100_000 - content.config.pilotHireCost, ); }); it("refuses to hire where the player has no bay", async () => { const { guest } = await ownerWithMoonBay(); const response = await hire(guest, "mars"); expect(response.statusCode).toBe(409); expect(errorCode(response)).toBe("no-bay-at-location"); }); it("refuses to hire without enough credits", async () => { const { guest } = await ownerWithMoonBay(); await setCredits(database().db, guest.playerId, content.config.pilotHireCost - 1); expect(errorCode(await hire(guest, "moon"))).toBe("insufficient-funds"); }); }); describe("assigning pilots", () => { it("assigns a pilot to an idle, uncrewed ship at the same location", async () => { const { app } = testApp(); const { guest, moonShipId } = await ownerWithMoonBay(); const { pilot } = pilotResponseSchema.parse((await hire(guest, "moon")).json()); const response = await call(app, guest, "POST", `/api/pilots/${pilot.id}/assign`, { shipId: moonShipId, }); expect(response.statusCode).toBe(200); expect(pilotResponseSchema.parse(response.json()).pilot).toMatchObject({ assignedShipId: moonShipId, locationId: "moon", }); const state = await getState(app, guest); expect(state.ships.find((s) => s.id === moonShipId)!.pilotId).toBe(pilot.id); }); it("refuses a ship at another location", async () => { const { app } = testApp(); const { guest, moonShipId } = await ownerWithMoonBay(); const { pilot } = pilotResponseSchema.parse((await hire(guest, "earth")).json()); const response = await call(app, guest, "POST", `/api/pilots/${pilot.id}/assign`, { shipId: moonShipId, }); expect(errorCode(response)).toBe("ship-not-idle-here"); }); it("refuses a crewed ship", async () => { const { app } = testApp(); const { guest, earthShipId } = await ownerWithMoonBay(); const { pilot } = pilotResponseSchema.parse((await hire(guest, "earth")).json()); const response = await call(app, guest, "POST", `/api/pilots/${pilot.id}/assign`, { shipId: earthShipId, }); expect(errorCode(response)).toBe("ship-crewed"); }); it("refuses while the ship is in flight", async () => { const { app, clock } = testApp(); const { guest, earthShipId } = await ownerWithMoonBay(); const { pilot } = pilotResponseSchema.parse((await hire(guest, "earth")).json()); await insertDispatch(database().db, { playerId: guest.playerId, shipId: earthShipId, pilotId: guest.avatarId, departAt: clock.now, }); const response = await call(app, guest, "POST", `/api/pilots/${pilot.id}/assign`, { shipId: earthShipId, }); expect(errorCode(response)).toBe("ship-not-idle"); }); it("refuses to assign the avatar during the tutorial", async () => { const { app } = testApp(); const guest = await signUp(app); // Ships cannot be bought during the tutorial, so put one at Earth directly. const shipId = randomUUID(); await database().db.insert(ships).values({ id: shipId, playerId: guest.playerId, modelId: "courier", name: "Gift Courier", locationId: "earth", createdAt: TEST_NOW, }); const response = await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/assign`, { shipId, }); expect(errorCode(response)).toBe("avatar-in-tutorial"); }); }); describe("unassigning pilots", () => { it("unassigns at a bay, leaving the pilot at the ship's location", async () => { const { app } = testApp(); const { guest, earthShipId } = await ownerWithMoonBay(); const response = await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/unassign`); expect(response.statusCode).toBe(200); expect(pilotResponseSchema.parse(response.json()).pilot).toMatchObject({ assignedShipId: null, locationId: "earth", }); const state = await getState(app, guest); expect(state.ships.find((s) => s.id === earthShipId)!.pilotId).toBeNull(); }); it("refuses where the player has no bay", async () => { const { app } = testApp(); const { guest, earthShipId } = await ownerWithMoonBay(); // Park the crewed ship at Mars, where there is no bay. const { db } = database(); await db.update(ships).set({ locationId: "mars" }).where(eq(ships.id, earthShipId)); const response = await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/unassign`); expect(response.statusCode).toBe(409); expect(errorCode(response)).toBe("no-bay-at-location"); }); });