import type { RealTime } from "@deadhead/sim"; import type { GameEngine } from "./game/engine"; /** * What the loop needs from the platform (CR-014 Step 14.6): a timer, and a way * to hear that the app is back in front (browser `visibilitychange`, Capacitor * `resume`). Injected so the engine stays testable. */ export interface LoopHost { setInterval(fn: () => void, ms: number): unknown; clearInterval(handle: unknown): void; /** Calls `fn` whenever the app comes back to the foreground; returns an unsubscribe. */ onResume(fn: () => void): () => void; } export const RESOLUTION_INTERVAL_MS = 5_000; /** * Keeps the game current while it's open: on start (the app has just loaded), * on every resume, and every few seconds, it resolves due arrivals (the same * idempotent resolution every command runs first) and reports when anything * changed, including a rest or a shop job ending, so the screen can refresh. */ export class ResolutionLoop { #handle: unknown = null; #unsubscribe: (() => void) | null = null; #since: RealTime | null = null; #running: Promise | null = null; constructor( private readonly engine: GameEngine, private readonly host: LoopHost, private readonly onChange: () => void, private readonly intervalMs = RESOLUTION_INTERVAL_MS, ) {} /** Resolves once now, then keeps going until `stop`. */ async start(): Promise { this.stop(); this.#handle = this.host.setInterval(() => void this.tick(), this.intervalMs); this.#unsubscribe = this.host.onResume(() => void this.tick()); await this.tick(); } stop(): void { if (this.#handle !== null) this.host.clearInterval(this.#handle); this.#handle = null; this.#unsubscribe?.(); this.#unsubscribe = null; } /** One pass (ticks never overlap: a tick that is still running is reused). */ tick(): Promise { this.#running ??= (async () => { try { const result = await this.engine.resolveDue(this.#since); this.#since = result.now; if (result.resolved > 0 || result.eventsPassed > 0) this.onChange(); } finally { this.#running = null; } })(); return this.#running; } }