import { content } from "@deadhead/content"; import { contractBoardResponseSchema, dispatchResponseSchema, errorResponseSchema, } from "@deadhead/schema"; import { MS_PER_HOUR, travelTimeGameHours } from "@deadhead/sim"; import { describe, expect, it } from "vitest"; import { useTestDatabase } from "./db"; import { call, getState, signUp, useTestApp, type Guest } from "./game"; const database = useTestDatabase(); const testApp = useTestApp(database); async function tutorialBoard(guest: Guest) { const response = await call(testApp().app, guest, "GET", "/api/tutorial/board"); return contractBoardResponseSchema.parse(response.json()).contracts; } function flyTutorial(guest: Guest, contractId: string) { return call(testApp().app, guest, "POST", "/api/dispatches", { kind: "tutorial", contractId }); } describe("tutorial dispatch", () => { it("dispatches the avatar in the loaner, charging no fuel", async () => { const { app, clock } = testApp(); const guest = await signUp(app); const [contract] = await tutorialBoard(guest); const response = await flyTutorial(guest, contract!.id); expect(response.statusCode).toBe(200); const { dispatch } = dispatchResponseSchema.parse(response.json()); expect(dispatch).toMatchObject({ tutorial: true, status: "in-flight", originId: "earth", destinationId: "moon", pilotId: guest.avatarId, fuelCost: 0, departureAt: clock.now, expectedPayout: contract!.payout, }); const travelMs = (travelTimeGameHours(content.shipModel("courier")!, dispatch.distanceMillionKm) * MS_PER_HOUR) / content.config.timeCompression; expect(dispatch.arrivalAt - dispatch.departureAt).toBe(Math.round(travelMs)); const state = await getState(app, guest); expect(state.company.credits).toBe(content.config.startingCredits); expect(state.tutorial).toEqual({ loanerLocationId: "earth", runInFlight: true }); expect(state.pilots[0]).toMatchObject({ status: "in-flight", locationId: null }); expect(state.activeDispatches.map((d) => d.id)).toEqual([dispatch.id]); expect((await tutorialBoard(guest)).map((c) => c.id)).not.toContain(contract!.id); }); it("rejects a second tutorial run while one is in flight", async () => { const { app } = testApp(); const guest = await signUp(app); const [first, second] = await tutorialBoard(guest); await flyTutorial(guest, first!.id); const response = await flyTutorial(guest, second!.id); expect(response.statusCode).toBe(409); expect(errorResponseSchema.parse(response.json()).error.code).toBe("tutorial-run-in-flight"); }); it("pays exactly the tutorial share on arrival and moves the loaner and avatar to the Moon", async () => { const { app, clock } = testApp(); const guest = await signUp(app); const [contract] = await tutorialBoard(guest); const { dispatch } = dispatchResponseSchema.parse( (await flyTutorial(guest, contract!.id)).json(), ); clock.now = dispatch.arrivalAt; const state = await getState(app, guest); expect(state.company.credits).toBe(content.config.startingCredits + contract!.payout); expect(state.pilots[0]!.xp).toBeGreaterThan(0); expect(state.pilots[0]).toMatchObject({ status: "idle", locationId: "moon" }); expect(state.tutorial).toEqual({ loanerLocationId: "moon", runInFlight: false }); const next = await tutorialBoard(guest); expect(next.length).toBeGreaterThan(0); for (const c of next) expect([c.originId, c.destinationId]).toEqual(["moon", "earth"]); }); it("rejects unknown and already-taken contracts", async () => { const { app, clock } = testApp(); const guest = await signUp(app); const [contract] = await tutorialBoard(guest); const unknown = await flyTutorial(guest, "tutorial:earth:0:999"); expect(unknown.statusCode).toBe(404); expect(errorResponseSchema.parse(unknown.json()).error.code).toBe("unknown-contract"); const { dispatch } = dispatchResponseSchema.parse( (await flyTutorial(guest, contract!.id)).json(), ); clock.now = dispatch.arrivalAt; const again = await flyTutorial(guest, contract!.id); expect(errorResponseSchema.parse(again.json()).error.code).toBe("contract-already-accepted"); }); });