72 lines
2.4 KiB
TypeScript
72 lines
2.4 KiB
TypeScript
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<void> | null = null;
|
|
|
|
constructor(
|
|
private readonly engine: GameEngine,
|
|
private readonly host: LoopHost,
|
|
private readonly onChange: () => void,
|
|
private readonly intervalMs = RESOLUTION_INTERVAL_MS,
|
|
/** True while a pass runs, false when it ends (CR-021: "Catching up on your fleet…"). */
|
|
private readonly onBusy?: (busy: boolean) => void,
|
|
) {}
|
|
|
|
/** Resolves once now, then keeps going until `stop`. */
|
|
async start(): Promise<void> {
|
|
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<void> {
|
|
this.#running ??= (async () => {
|
|
this.onBusy?.(true);
|
|
try {
|
|
const result = await this.engine.resolveDue(this.#since);
|
|
this.#since = result.now;
|
|
if (result.resolved > 0 || result.decisions > 0 || result.eventsPassed > 0) {
|
|
this.onChange();
|
|
}
|
|
} finally {
|
|
this.#running = null;
|
|
this.onBusy?.(false);
|
|
}
|
|
})();
|
|
return this.#running;
|
|
}
|
|
}
|