104 lines
3.6 KiB
TypeScript
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>
|
|
);
|
|
}
|