deadhead-space-freight-tycoon/apps/web/src/GameRoot.tsx

104 lines
3.6 KiB
TypeScript

import type { CommandName } from "@deadhead/engine";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { createEngineClient } from "./api/engine-client";
import { ApiProvider } from "./api/context";
import { CatchingUp } from "./components/PilotLogbook";
import { GameContext } from "./platform/context";
import { startResolutionLoop, type Game } from "./platform/game";
import { platformPresence, startPresence } from "./platform/presence";
import { PLAYER_STATE_KEY } from "./state/player";
import { Button } from "./ui";
declare global {
interface Window {
/** Test builds only: run engine commands from browser tests (CR-014). */
deadhead?: { call(name: CommandName, input: unknown): Promise<unknown> };
}
}
type Status = "starting" | "here" | "elsewhere";
/**
* Runs the game in this tab (decision 0.5.90): takes the tab lock, provides the
* engine as the API, and keeps the resolution loop going. Another tab holding
* the game gets "open in another tab" with "Use here".
*/
export function GameRoot({ game, children }: { game: Game; children: ReactNode }) {
const queryClient = useQueryClient();
const api = useMemo(() => createEngineClient(game.engine), [game]);
const [status, setStatus] = useState<Status>("starting");
// A slow catch-up of Independent pilots shows "Catching up on your fleet…" (CR-021).
const [catchingUp, setCatchingUp] = useState(false);
useEffect(() => {
game.lock.onLost(() => setStatus("elsewhere"));
let cancelled = false;
void game.lock.acquire().then((held) => {
if (!cancelled) setStatus(held ? "here" : "elsewhere");
});
return () => {
cancelled = true;
};
}, [game]);
useEffect(() => {
if (status !== "here") return;
let cancelled = false;
let stop = () => {};
// The save's last time seen, read before the loop moves it on: a cold start
// counts as coming back from then (decision 0.5.162).
void game.storage.load().then((loaded) => {
if (cancelled) return;
const savedAt = "save" in loaded ? loaded.save.lastTimeSeen : null;
const stopPresence = startPresence(platformPresence(), () => game.clock.now(), savedAt);
const loop = startResolutionLoop(
game,
() => {
void queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY });
void queryClient.invalidateQueries({ queryKey: ["board"] });
},
setCatchingUp,
);
stop = () => {
loop.stop();
stopPresence();
};
});
if (game.devTools) {
window.deadhead = { call: (name, input) => game.engine.call(name, input as never) };
}
return () => {
cancelled = true;
stop();
};
}, [status, game, queryClient]);
const useHere = async () => {
if (await game.lock.acquire({ takeOver: true })) {
// Continue from whatever the other tab saved.
game.engine.core.reload();
await queryClient.invalidateQueries();
setStatus("here");
}
};
if (status === "starting") {
return <p className="grid min-h-dvh place-items-center font-display text-ink-400">Loading…</p>;
}
if (status === "elsewhere") {
return (
<div role="alert" className="grid min-h-dvh place-content-center gap-4 p-6 text-center">
<p className="font-display text-lg text-ink-100">Deadhead is open in another tab.</p>
<Button onClick={() => void useHere()}>Use here</Button>
</div>
);
}
return (
<GameContext.Provider value={game}>
<ApiProvider api={api}>{children}</ApiProvider>
<CatchingUp busy={catchingUp} />
</GameContext.Provider>
);
}