import { randomUUID } from "node:crypto"; import { content } from "@deadhead/content"; import { createGuestResponseSchema, playerStateSchema, type PlayerState } from "@deadhead/schema"; import { boardPeriodIndex, boardPeriodMs, createDispatchRecord, createRepositionRecord, type Contract, type DispatchRecord, } from "@deadhead/sim"; import { eq } from "drizzle-orm"; import { afterEach, beforeEach } from "vitest"; import { buildApp } from "../src/app"; import { createRecordingQueue } from "../src/jobs/queue"; import type { Database, Db } from "../src/db/client"; import { contractBoards, dispatches, pilots, players, ships } from "../src/db/schema"; import { makeOwner, TEST_NOW } from "./db"; import { testConfig } from "./helpers"; /** A clock tests can move. Starts at `TEST_NOW`. */ export class TestClock { now = TEST_NOW; read = () => this.now; advance(ms: number) { this.now += ms; } } export interface TestApp { app: ReturnType; clock: TestClock; /** Jobs the app scheduled; tests run their handlers directly. */ jobs: ReturnType; } /** A fresh app per test on the shared test database, with a controllable clock. */ export function useTestApp(database: () => Database): () => TestApp { let current: TestApp | undefined; beforeEach(() => { const clock = new TestClock(); const jobs = createRecordingQueue(); current = { app: buildApp(testConfig(), { database: database(), clock: clock.read, jobs }), clock, jobs, }; }); afterEach(() => current?.app.close()); return () => current!; } function cookieFrom(setCookie: string | string[] | undefined): string { const values = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : []; return values.map((c) => c.split(";")[0]).join("; "); } export interface Guest { cookie: string; playerId: string; avatarId: string; } /** Signs up a guest through the API (a tutorial player) and returns its session. */ export async function signUp(app: TestApp["app"]): Promise { const response = await app.inject({ method: "POST", url: "/api/guest" }); const body = createGuestResponseSchema.parse(response.json()); return { cookie: cookieFrom(response.headers["set-cookie"]), playerId: body.playerId, avatarId: body.state.pilots[0]!.id, }; } /** Calls an endpoint as a guest. */ export async function call( app: TestApp["app"], guest: Guest, method: "GET" | "POST", url: string, payload?: object, ) { return app.inject({ method, url, headers: { cookie: guest.cookie }, ...(payload ? { payload } : {}), }); } export async function getState(app: TestApp["app"], guest: Guest): Promise { const response = await call(app, guest, "GET", "/api/state"); if (response.statusCode !== 200) throw new Error(`state failed: ${response.body}`); return playerStateSchema.parse(response.json()); } export function testContract(overrides: Partial = {}): Contract { return { id: `test:${randomUUID()}`, originId: "earth", destinationId: "moon", cargoTypeId: "standard", tier: 0, massTonnes: 10, payout: 2000, requiredSkill: null, requiredLongHaulLevel: 0, offeredAt: TEST_NOW, // Contracts expire at the end of their board period (TEST_NOW is a period start). expiresAt: TEST_NOW + boardPeriodMs(content.config), firstContact: false, ...overrides, }; } /** * Puts an in-flight dispatch straight into the database, as if it had been * dispatched at `departAt`: the ship and pilot are marked in flight. */ export async function insertDispatch( db: Db, args: { playerId: string; shipId: string; pilotId: string; departAt: number; contract?: Contract | null; destinationId?: string; }, ): Promise { const [player] = await db.select().from(players).where(eq(players.id, args.playerId)); const [ship] = await db.select().from(ships).where(eq(ships.id, args.shipId)); const [pilot] = await db.select().from(pilots).where(eq(pilots.id, args.pilotId)); const shipState = { id: ship!.id, modelId: ship!.modelId, locationId: ship!.locationId!, inFlight: false, }; const pilotState = { id: pilot!.id, isAvatar: pilot!.isAvatar, assignedShipId: pilot!.assignedShipId, inFlight: false, skills: pilot!.skills, restEndsAt: null, }; const id = randomUUID(); const contract = args.contract === undefined ? testContract() : args.contract; const record = contract ? createDispatchRecord( { id, ship: shipState, pilot: pilotState, contract, tutorial: false, now: args.departAt, worldSeed: player!.worldSeed, }, content, ) : createRepositionRecord( { id, ship: shipState, pilot: pilotState, destinationId: args.destinationId!, now: args.departAt, worldSeed: player!.worldSeed, }, content, ); await db.insert(dispatches).values({ id, playerId: args.playerId, shipId: ship!.id, pilotId: pilot!.id, tutorial: false, record, arrivalAt: record.arrivalAt, }); await db .update(ships) .set({ status: "in_flight", locationId: null }) .where(eq(ships.id, ship!.id)); await db.update(pilots).set({ status: "in_flight" }).where(eq(pilots.id, pilot!.id)); return record; } /** Stores a hand-made location board (Earth by default) for the period containing `now`. */ export async function putBoard( db: Db, playerId: string, contracts: Contract[], now = TEST_NOW, locationId = "earth", ) { await db.insert(contractBoards).values({ id: randomUUID(), playerId, kind: "location", locationId, periodIndex: boardPeriodIndex(now, content.config), contracts, createdAt: now, }); } /** Signs up a guest and turns them into an owner: tutorial over, a Courier at Earth crewed by the avatar, an Earth bay. */ export async function signUpOwner( app: TestApp["app"], db: Db, ): Promise { const guest = await signUp(app); return { ...guest, ...(await makeOwner(db, guest.playerId, guest.avatarId)) }; }