import { content } from "@deadhead/content"; import { dispatchResponseSchema, errorResponseSchema, playerStateSchema, type PlayerState, } from "@deadhead/schema"; import { pilotShare } from "@deadhead/sim"; import { describe, expect, it } from "vitest"; import { setCredits, useTestDatabase } from "./db"; import { call, getState, putBoard, signUp, testContract, useTestApp, type Guest } from "./game"; import { flyTutorialRun } from "./tutorial-flow"; const database = useTestDatabase(); const testApp = useTestApp(database); const errorCode = (response: { json(): unknown }) => errorResponseSchema.parse(response.json()).error.code; const veteranOf = (state: PlayerState) => state.pilots.find((p) => p.isVeteran)!; const avatarOf = (state: PlayerState) => state.pilots.find((p) => p.isAvatar)!; const FAR_FUTURE = Date.parse("2200-01-01T00:00:00Z"); /** A guest who flies the tutorial runs and accepts the deal. */ async function dealt(): Promise<{ guest: Guest; state: PlayerState }> { const guest = await signUp(testApp().app); for (let run = 0; run < content.config.tutorialRunsBeforeDeal; run++) { await flyTutorialRun(testApp(), guest); } const response = await call(testApp().app, guest, "POST", "/api/deal/accept", {}); expect(response.statusCode).toBe(200); return { guest, state: playerStateSchema.parse(response.json()) }; } describe("the veteran comes with the deal (Step 10J.4)", () => { it("adds the veteran crewing the Clipper at Earth, level 2 with Expensive Cargo 1 (CR-013)", async () => { const { state } = await dealt(); expect(state.pilots).toHaveLength(2); const [courier, clipper] = state.ships; expect(clipper!.modelId).toBe("clipper"); expect(avatarOf(state)).toMatchObject({ assignedShipId: courier!.id, locationId: "earth" }); const spentPoints = 1; expect(veteranOf(state)).toMatchObject({ name: content.veteranPilot.name, isAvatar: false, isVeteran: true, level: 2, skills: { "expensive-cargo": 1 }, skillPointsAvailable: 2 * content.config.skillPointsPerLevel - spentPoints, assignedShipId: clipper!.id, locationId: "earth", status: "idle", }); }); it("creates no veteran on a refused or repeated accept", async () => { const guest = await signUp(testApp().app); const early = await call(testApp().app, guest, "POST", "/api/deal/accept", {}); expect(errorCode(early)).toBe("deal-not-available"); expect((await getState(testApp().app, guest)).pilots).toHaveLength(1); const { guest: owner } = await dealt(); const again = await call(testApp().app, owner, "POST", "/api/deal/accept", {}); expect(again.statusCode).toBe(409); const pilots = (await getState(testApp().app, owner)).pilots; expect(pilots.filter((p) => p.isVeteran)).toHaveLength(1); }); it("dispatches the Courier and the Clipper at once, paying the veteran a profit share", async () => { const { app, clock } = testApp(); const { guest, state } = await dealt(); const [courier, clipper] = state.ships; const veteran = veteranOf(state); await setCredits(database().db, guest.playerId, 10_000); const forCourier = testContract({ payout: 2000, expiresAt: FAR_FUTURE }); const forClipper = testContract({ payout: 2000, expiresAt: FAR_FUTURE }); await putBoard(database().db, guest.playerId, [forCourier, forClipper], clock.now); const send = (contractId: string, shipId: string, pilotId: string) => call(app, guest, "POST", "/api/dispatches", { kind: "contract", contractId, shipId, pilotId, }); const avatarRun = await send(forCourier.id, courier!.id, guest.avatarId); const veteranRun = await send(forClipper.id, clipper!.id, veteran.id); expect(avatarRun.statusCode).toBe(200); expect(veteranRun.statusCode).toBe(200); expect((await getState(app, guest)).activeDispatches).toHaveLength(2); const { dispatch } = dispatchResponseSchema.parse(veteranRun.json()); clock.now = Math.max( dispatch.arrivalAt, dispatchResponseSchema.parse(avatarRun.json()).dispatch.arrivalAt, ); const after = await getState(app, guest); const summary = after.summaries.find((s) => s.dispatchId === dispatch.id)!; const pay = pilotShare(forClipper.payout, dispatch.fuelCost, content.config); expect(pay).toBeGreaterThan(0); expect(summary.pilotPay).toBe(pay); expect(summary.credits + summary.loanRepayment).toBe(forClipper.payout - pay); }); });