import { content } from "@deadhead/content"; import { routes, type RouteName, type RouteRequest, type RouteResponse, type SaveDocument, type SaveParse, } from "@deadhead/schema"; import { realToGame, type RealTime } from "@deadhead/sim"; import type { z } from "zod"; import { EngineCore, type CoreDeps, type RandomSource } from "../core"; import type { NotificationSync } from "../notifications"; import { decodeSave, encodeSave } from "../save-code"; import { commandTable } from "./commands"; import { startingSave } from "./company"; import { CommandError, fail } from "./errors"; import { pruneSave, resolveArrivals } from "./resolve"; import { playerState } from "./state"; /** The commands the engine runs: the whole route catalog (CR-014). */ export type CommandName = RouteName; export const DEV_COMMANDS: readonly CommandName[] = [ "devAdvanceClock", "devAddCredits", "devPilot", "devShip", "devDiscoverAll", ]; /** What a command gets: a copy of the save (with due arrivals already applied). */ export interface CommandContext { save: SaveDocument; now: RealTime; random: RandomSource; } export type CommandHandler = ( ctx: CommandContext, input: z.output<(typeof routes)[N]["request"]>, ) => RouteResponse; /** Commands that run before a company exists, or touch the clock rather than the save. */ type SpecialCommand = "getServerTime" | "createGuest" | "newCompany" | "devAdvanceClock" | "exportSave" | "importSave"; export type CommandTable = { [N in Exclude]: CommandHandler }; export interface GameEngineOptions extends CoreDeps { clock: CoreDeps["clock"] & { advance?(ms: number): void; offsetMs?: number }; /** Developer commands (non-production builds only, CR-011). */ devTools?: boolean; /** Check every response against its schema (tests and development). Default true. */ validateResponses?: boolean; /** Replaces command handlers (tests only). */ overrides?: Partial; /** Local notifications (decision 0.5.91); none if absent. */ notifications?: NotificationSync; /** Migrates and validates an imported save (tests replace it). Default: the schema's `migrateSave`. */ migrate?: (raw: unknown) => SaveParse; } function describeIssues(error: z.ZodError): string { return error.issues .map((issue) => (issue.path.length ? `${issue.path.join(".")}: ` : "") + issue.message) .join("; "); } const noCompany = () => fail("unauthorized", "There is no company on this device yet."); /** * The game on the device (CR-014): every former server endpoint as a command * with the same request and response schemas. Each command first applies any * arrivals that are due (as the server's lazy resolution did), runs on a copy * of the save through the single-writer queue, and is saved only if it changed * something. */ export class GameEngine { readonly core: EngineCore; readonly #table: CommandTable; constructor(private readonly options: GameEngineOptions) { const notifications = options.notifications; this.core = new EngineCore({ ...options, afterSave: notifications ? (save, now) => notifications.sync(save, now) : options.afterSave, }); this.#table = { ...commandTable(), ...options.overrides } as CommandTable; } now(): RealTime { return this.core.now(); } /** Reschedules notifications from the current save (the Settings toggle changed). */ resyncNotifications(): Promise { const notifications = this.options.notifications; if (!notifications) return Promise.resolve(); return this.core.read((save, now) => notifications.sync(save, now)); } /** Asks for notification permission (right after the deal, decision 0.5.91). */ requestNotificationPermission(): Promise { return this.options.notifications?.requestPermission() ?? Promise.resolve(); } /** * The resolution loop's pass (Step 14.6): applies any due arrivals and counts * rests and shop jobs that ended after `since`. Saves only when an arrival * was resolved; does nothing before a company exists. */ resolveDue( since: RealTime | null, ): Promise<{ resolved: number; eventsPassed: number; now: RealTime }> { return this.core.write((save, now) => { if (!save) return { save: save!, result: { resolved: 0, eventsPassed: 0, now }, unchanged: true }; const resolved = resolveArrivals(save, now); if (resolved > 0) pruneSave(save, now); const ended = (t: RealTime | null) => since !== null && t !== null && t > since && t <= now; const eventsPassed = save.pilots.filter((p) => ended(p.restEndsAt)).length + save.ships.filter((s) => ended(s.inShopUntil)).length; return { save, result: { resolved, eventsPassed, now }, unchanged: resolved === 0 }; }); } /** Runs one command; refusals throw `CommandError` with the server's codes. */ async call(name: N, input: RouteRequest): Promise> { const route = routes[name]; if (!route) { throw fail("not-found", `No command "${name}".`); } if (DEV_COMMANDS.includes(name) && !this.options.devTools) { throw fail("not-found", `No command "${name}".`); } const parsed = route.request.safeParse(input); if (!parsed.success) throw fail("validation-error", describeIssues(parsed.error)); const result = await this.#run(name, parsed.data); if (this.options.validateResponses ?? true) { const checked = route.response.safeParse(result); if (!checked.success) { throw new Error( `Response for ${name} violates its schema: ${describeIssues(checked.error)}`, ); } } return result as RouteResponse; } async #run(name: CommandName, input: unknown): Promise { const { random } = this.core; switch (name) { case "getServerTime": { const realTime = this.core.now(); return { realTime, gameTime: realToGame(realTime, content.config) }; } case "devAdvanceClock": { const clock = this.options.clock; if (!clock.advance) throw fail("not-found", "This clock can't be moved."); clock.advance((input as { advanceMs: number }).advanceMs); return { offsetMs: clock.offsetMs ?? 0, realTime: this.core.now() }; } case "createGuest": // "Start your company": a new save when there is none; otherwise the existing one. return this.core.write((save, now) => { const next = save ?? startingSave(random, now); resolveArrivals(next, now); return { save: next, result: { playerId: next.saveId, state: playerState(next, now) }, unchanged: save !== null, }; }); case "newCompany": // "Start a new company" (decision 0.5.74): the starting state with a new world seed. return this.core.write((save, now) => { if (!save) throw noCompany(); const next = startingSave(random, now, { saveId: save.saveId, avoidSeed: save.player.worldSeed, }); return { save: next, result: playerState(next, now) }; }); case "exportSave": return this.core.read(async (save) => { if (!save) throw noCompany(); return { code: await encodeSave(save) }; }); case "importSave": { // Decoded before the queue, so a bad code never waits behind or blocks a write. const parsed = await decodeSave((input as { code: string }).code, this.options.migrate); if (!parsed.ok) throw fail("invalid-save", parsed.error); return this.core.write((_current, now) => { const next = parsed.save; resolveArrivals(next, now); pruneSave(next, now); return { save: next, result: { playerId: next.saveId, state: playerState(next, now) } }; }); } default: { const handler = this.#table[name] as (ctx: CommandContext, input: unknown) => unknown; return this.core.write((save, now) => { if (!save) throw noCompany(); const before = JSON.stringify(save); resolveArrivals(save, now); const result = handler({ save, now, random }, input); pruneSave(save, now); return { save, result, unchanged: JSON.stringify(save) === before }; }); } } } } export { CommandError };