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, insertDispatch, 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 at Earth, unassigned, level 2 with Heavy Cargo 1", async () => { const { state } = await dealt(); expect(state.pilots).toHaveLength(2); const courier = state.ships[0]!; 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: { "heavy-cargo": 1 }, skillPointsAvailable: 2 * content.config.skillPointsPerLevel - spentPoints, assignedShipId: null, 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("swaps in the veteran while the avatar rests, and pays the veteran a profit share", async () => { const { app, clock } = testApp(); const { guest, state } = await dealt(); const courier = state.ships[0]!; const veteran = veteranOf(state); await setCredits(database().db, guest.playerId, 10_000); expect( (await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/rest`, {})).statusCode, ).toBe(200); expect( (await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/unassign`, {})).statusCode, ).toBe(200); expect( (await call(app, guest, "POST", `/api/pilots/${veteran.id}/assign`, { shipId: courier.id })) .statusCode, ).toBe(200); const job = testContract({ payout: 2000, expiresAt: FAR_FUTURE }); await putBoard(database().db, guest.playerId, [job], clock.now); const response = await call(app, guest, "POST", "/api/dispatches", { kind: "contract", contractId: job.id, shipId: courier.id, pilotId: veteran.id, }); expect(response.statusCode).toBe(200); const { dispatch } = dispatchResponseSchema.parse(response.json()); clock.now = dispatch.arrivalAt; const after = await getState(app, guest); const summary = after.summaries.find((s) => s.dispatchId === dispatch.id)!; const pay = pilotShare(job.payout, dispatch.fuelCost, content.config); expect(pay).toBeGreaterThan(0); expect(summary.credits + summary.loanRepayment).toBe(job.payout - pay); expect(after.goals.find((g) => g.id === "veteran-contract")?.status).toBe("ready"); }); it("counts only contracts the veteran flew toward goal 4", async () => { const { app, clock } = testApp(); const { guest, state } = await dealt(); const courier = state.ships[0]!; const veteran = veteranOf(state); const goal = async () => (await getState(app, guest)).goals.find((g) => g.id === "veteran-contract")?.status; // The avatar's delivery doesn't count. const byAvatar = await insertDispatch(database().db, { playerId: guest.playerId, shipId: courier.id, pilotId: guest.avatarId, departAt: clock.now, contract: testContract({ expiresAt: FAR_FUTURE }), }); clock.now = byAvatar.arrivalAt; expect(await goal()).toBe("incomplete"); // A deadhead by the veteran doesn't count either. await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/unassign`, {}); await call(app, guest, "POST", `/api/pilots/${veteran.id}/assign`, { shipId: courier.id }); const deadhead = await insertDispatch(database().db, { playerId: guest.playerId, shipId: courier.id, pilotId: veteran.id, departAt: clock.now, contract: null, destinationId: "earth", }); clock.now = deadhead.arrivalAt; expect(await goal()).toBe("incomplete"); }); });