import { content } from "@deadhead/content"; import { newPilotOrders } from "../src/game/independent"; import { createGuestResponseSchema, playerStateSchema, routes, type Contract, type PlayerState, type SaveDocument, } from "@deadhead/schema"; import { boardPeriodIndex, boardPeriodMs, createDispatchRecord, createRepositionRecord, type DispatchRecord, } from "@deadhead/sim"; const randomUUID = () => globalThis.crypto.randomUUID(); import { beforeEach } from "vitest"; import { CommandError, GameEngine, MemoryBackend, NOT_FOUND_CODES, SaveStorage, type CommandName, } from "../src/index"; import { deliveredTank } from "../src/game/state"; /** * Helpers for the ported server tests (CR-014 Step 14.5): the same names and * shapes as the server's test helpers, over an engine with in-memory storage * and a clock the test sets. `call` routes an HTTP-style request to its * command and answers with the status the server would have sent. */ export const TEST_NOW = Date.parse("2100-01-01T00:00:00Z"); /** A clock tests can move. Starts at `TEST_NOW`. */ export class TestClock { now = TEST_NOW; advance(ms: number) { this.now += ms; } } export interface TestApp { app: GameEngine; clock: TestClock; storage: SaveStorage; backend: MemoryBackend; } export function newTestApp(options: { devTools?: boolean } = {}): TestApp { const clock = new TestClock(); const backend = new MemoryBackend(); const storage = new SaveStorage(backend); const app = new GameEngine({ storage, clock: { now: () => clock.now, advance: (ms) => clock.advance(ms), get offsetMs() { return clock.now - TEST_NOW; }, }, devTools: options.devTools ?? true, }); return { app, clock, storage, backend }; } /** A fresh engine and storage per test. */ export function useTestApp(options: { devTools?: boolean } = {}): () => TestApp { let current: TestApp | undefined; beforeEach(() => { current = newTestApp(options); }); return () => current!; } export interface Guest { playerId: string; avatarId: string; } /** "Start your company" (the old guest sign-up). */ export async function signUp(app: GameEngine): Promise { const body = createGuestResponseSchema.parse(await app.call("createGuest", {})); return { playerId: body.playerId, avatarId: body.state.pilots[0]!.id }; } function routeFor( method: string, url: string, ): { name: CommandName; params: Record } { const path = url.split("?")[0]!; for (const [name, route] of Object.entries(routes)) { if (route.method !== method) continue; const pattern = new RegExp(`^${route.path.replace(/:(\w+)/g, "(?<$1>[^/]+)")}$`); const match = pattern.exec(path); if (match) return { name: name as CommandName, params: { ...match.groups } }; } throw new Error(`No command for ${method} ${url}`); } export interface CallResult { statusCode: number; body: string; json(): unknown; } /** Calls a command the way the server test called an endpoint. */ export async function call( app: GameEngine, _guest: Guest | null, method: "GET" | "POST", url: string, payload?: object, ): Promise { const { name, params } = routeFor(method, url); const query = Object.fromEntries(new URL(url, "http://x").searchParams); const input = { ...query, ...(payload ?? {}), ...params }; const reply = (statusCode: number, data: unknown): CallResult => { const body = JSON.stringify(data); return { statusCode, body, json: () => JSON.parse(body) }; }; try { return reply(200, await app.call(name, input as never)); } catch (error) { if (!(error instanceof CommandError)) throw error; const status = error.code === "validation-error" ? 400 : error.code === "unauthorized" ? 401 : NOT_FOUND_CODES.has(error.code) ? 404 : 409; return reply(status, { error: { code: error.code, message: error.message } }); } } export async function getState(app: GameEngine, _guest?: Guest): Promise { return playerStateSchema.parse(await app.call("getPlayerState", {})); } /** Changes the save directly (what the server tests did with database updates). */ export function edit(app: GameEngine, fn: (save: SaveDocument) => T): Promise { return app.core.write((save) => { if (!save) throw new Error("No company to edit"); const result = fn(save); return { save, result }; }); } /** Reads the save directly. */ export function readSave(app: GameEngine): Promise { return app.core.read((save) => { if (!save) throw new Error("No company"); return structuredClone(save); }); } export function setCredits(app: GameEngine, credits: number): Promise { return edit(app, (save) => { save.company.credits = credits; }); } export function testContract(overrides: Partial = {}): Contract { return { id: `test:${randomUUID()}`, originId: "earth", destinationId: "moon", cargoTypeId: "standard", goodId: "consumer-goods", shipper: "Pacific Orbital Logistics", priority: false, deliveryWindowGameHours: null, 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 save, as if it had been * dispatched at `departAt`: the ship and pilot are marked in flight. */ export function insertDispatch( app: GameEngine, args: { shipId: string; pilotId: string; departAt: number; contract?: Contract | null; destinationId?: string; }, ): Promise { return edit(app, (save) => { const ship = save.ships.find((s) => s.id === args.shipId)!; const pilot = save.pilots.find((p) => p.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: save.player.worldSeed, }, content, ) : createRepositionRecord( { id, ship: shipState, pilot: pilotState, destinationId: args.destinationId!, now: args.departAt, worldSeed: save.player.worldSeed, }, content, ); save.dispatches.push({ id, shipId: ship.id, independent: false, pilotId: pilot.id, tutorial: false, record, arrivalAt: record.arrivalAt, status: "in-flight", resolution: null, resolvedAt: null, acknowledgedAt: null, }); Object.assign(ship, { status: "in-flight", locationId: null }); pilot.status = "in-flight"; return record; }); } /** Stores a hand-made location board (Earth by default) for the period containing `now`. */ export function putBoard( app: GameEngine, contracts: Contract[], now = TEST_NOW, locationId = "earth", ): Promise { return edit(app, (save) => { save.contractBoards.push({ id: randomUUID(), kind: "location", locationId, periodIndex: boardPeriodIndex(now, content.config), tutorialRun: 0, contracts, fleet: null, createdAt: now, }); }); } /** * Turns a tutorial company into an owner: tutorial over, one Courier at Earth * crewed by the avatar, an Earth bay. Returns the ship and bay ids. */ export function makeOwner( app: GameEngine, avatarId: string, ): Promise<{ shipId: string; earthBayId: string }> { const shipId = randomUUID(); const earthBayId = randomUUID(); return edit(app, (save) => { Object.assign(save.player, { tutorialActive: false, loanerLocationId: null }); save.ships.push({ id: shipId, modelId: "courier", name: "Test Courier", status: "idle", locationId: "earth", upgrades: {}, inShopUntil: null, shopUpgradeId: null, shopLevel: null, shopPilotId: null, // Stands in for the deal's Courier (not a Market purchase, CR-013), with a full tank. fromDeal: true, createdAt: TEST_NOW, lastArrivalAt: TEST_NOW, ...deliveredTank("courier", "earth"), }); Object.assign( save.pilots.find((p) => p.id === avatarId)!, { assignedShipId: shipId, locationId: null, }, ); save.bays.push({ id: earthBayId, locationId: "earth", createdAt: TEST_NOW }); return { shipId, earthBayId }; }); } /** A new company turned into an owner (see `makeOwner`). */ export async function signUpOwner( app: GameEngine, ): Promise { const guest = await signUp(app); return { ...guest, ...(await makeOwner(app, guest.avatarId)) }; } /** The tutorial board the player sees now. */ export async function tutorialBoard(app: GameEngine, _guest?: Guest): Promise { return (await app.call("getTutorialBoard", {})).contracts; } /** Flies one tutorial run (the given slot, light by default) and lets it land. */ export async function flyTutorialRun( testApp: TestApp, _guest?: Guest, slot = 0, ): Promise { const { app, clock } = testApp; const contract = (await tutorialBoard(app))[slot]!; const { dispatch } = await app.call("createDispatch", { kind: "tutorial", contractId: contract.id, }); clock.now = dispatch.arrivalAt; return getState(app); } type SavedShip = SaveDocument["ships"][number]; type SavedPilot = SaveDocument["pilots"][number]; /** Adds a ship to the save (idle, uncrewed, no upgrades unless given). */ export function addShip( app: GameEngine, ship: Partial & Pick, ): Promise { return edit(app, (save) => { save.ships.push({ status: "idle", locationId: "earth", upgrades: {}, inShopUntil: null, shopUpgradeId: null, shopLevel: null, shopPilotId: null, fromDeal: false, createdAt: TEST_NOW, lastArrivalAt: TEST_NOW, // A full tank, bought where the ship is (decision 0.5.94). ...deliveredTank(ship.modelId, ship.locationId ?? "earth"), ...ship, }); }); } /** Adds a pilot to the save (idle, level 0, unassigned unless given). */ export function addPilot( app: GameEngine, pilot: Partial & Pick, ): Promise { return edit(app, (save) => { save.pilots.push({ isAvatar: false, isVeteran: false, xp: 0, skills: {}, skillPointsSpent: 0, assignedShipId: null, locationId: null, status: "idle", dutyHours: 0, restEndsAt: null, createdAt: TEST_NOW, mode: "manual", orders: newPilotOrders("earth"), nextDecisionAt: null, emptyPeriods: 0, ...pilot, }); }); }