import { content } from "@deadhead/content"; import { errorResponseSchema, playerStateSchema } from "@deadhead/schema"; import { eq } from "drizzle-orm"; import { describe, expect, it } from "vitest"; import { bays, companies, pilots, players, ships } from "../src/db/schema"; import { useTestDatabase } from "./db"; import { call, getState, signUp, useTestApp, type Guest } from "./game"; const database = useTestDatabase(); const testApp = useTestApp(database); /** Fast-forwards a new guest to "four runs done, loaner back at Earth". */ async function readyForDeal(guest: Guest) { await database() .db.update(players) .set({ tutorialRunsCompleted: content.config.tutorialRunsBeforeDeal, loanerLocationId: "earth", }) .where(eq(players.id, guest.playerId)); } const accept = (guest: Guest) => call(testApp().app, guest, "POST", "/api/deal/accept"); /** Everything the deal touches, read straight from the database. */ async function snapshot(guest: Guest) { const { db } = database(); return { player: (await db.select().from(players).where(eq(players.id, guest.playerId)))[0], company: (await db.select().from(companies).where(eq(companies.playerId, guest.playerId)))[0], ships: await db.select().from(ships).where(eq(ships.playerId, guest.playerId)), bays: await db.select().from(bays).where(eq(bays.playerId, guest.playerId)), pilots: await db.select().from(pilots).where(eq(pilots.playerId, guest.playerId)), }; } describe("accepting the deal", () => { it("hands over a crewed Courier at Earth, an Earth bay and the loan, and ends the tutorial", async () => { const guest = await signUp(testApp().app); await readyForDeal(guest); expect((await getState(testApp().app, guest)).tutorial?.dealAvailable).toBe(true); const response = await accept(guest); expect(response.statusCode).toBe(200); const state = playerStateSchema.parse(response.json()); expect(state.tutorial).toBeNull(); expect(state.ships).toHaveLength(1); const [ship] = state.ships; expect(ship).toMatchObject({ modelId: "courier", status: "idle", locationId: "earth", pilotId: guest.avatarId, }); expect(state.bays.map((b) => b.locationId)).toEqual(["earth"]); expect(state.pilots[0]).toMatchObject({ isAvatar: true, assignedShipId: ship!.id, locationId: "earth", }); expect(state.company.loanBalance).toBe(content.dealLoanAmount); const after = await snapshot(guest); expect(after.player).toMatchObject({ tutorialActive: false, dealAccepted: true, loanerLocationId: null, }); }); it("fails with no changes when the deal is not available", async () => { const guest = await signUp(testApp().app); const before = await snapshot(guest); const response = await accept(guest); expect(response.statusCode).toBe(409); expect(errorResponseSchema.parse(response.json()).error.code).toBe("deal-not-available"); expect(await snapshot(guest)).toEqual(before); }); it("fails while the loaner is at the Moon", async () => { const guest = await signUp(testApp().app); await readyForDeal(guest); await database() .db.update(players) .set({ loanerLocationId: "moon" }) .where(eq(players.id, guest.playerId)); expect((await accept(guest)).statusCode).toBe(409); }); it("fails the second time", async () => { const guest = await signUp(testApp().app); await readyForDeal(guest); expect((await accept(guest)).statusCode).toBe(200); const before = await snapshot(guest); const again = await accept(guest); expect(errorResponseSchema.parse(again.json()).error.code).toBe("deal-not-available"); expect(await snapshot(guest)).toEqual(before); }); it("produces exactly one deal from two simultaneous accepts", async () => { const guest = await signUp(testApp().app); await readyForDeal(guest); const responses = await Promise.all([accept(guest), accept(guest)]); expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]); const after = await snapshot(guest); expect(after.ships).toHaveLength(1); expect(after.bays).toHaveLength(1); expect(after.company!.loanBalance).toBe(content.dealLoanAmount); }); });