import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ApiResponseError, fetchAdminModels, fetchWorldSnapshot, setNpcModelProfile, tickWorld, } from "../api"; import type { GameLogEventSnapshot, ModelProfileSnapshot, WorldSnapshot } from "../types"; // When enabled, the manual tick controls are hidden and the world advances on its // own. Defaults to on; set VITE_WORLD_SIMULATOR_AUTOPLAY=0/false/no to show the // manual controls instead. const AUTOPLAY = !["0", "false", "no"].includes( String(import.meta.env.VITE_WORLD_SIMULATOR_AUTOPLAY ?? "").toLowerCase(), ); export function useWorldSimulation() { const [snapshot, setSnapshot] = useState(null); const [isTickPending, setIsTickPending] = useState(false); const [isAutoTicking, setIsAutoTicking] = useState(AUTOPLAY); const [isModelSwitchPending, setIsModelSwitchPending] = useState(false); const [modelProfiles, setModelProfiles] = useState([]); const [error, setError] = useState(null); const [selectedId, setSelectedId] = useState(null); const isTickingRef = useRef(false); const eventHistory = useEventHistory(snapshot); const refresh = useCallback(async () => { const nextSnapshot = await fetchWorldSnapshot(); setSnapshot(nextSnapshot); setError(null); }, []); const isServerTickPending = snapshot?.simulation.tick_in_progress ?? false; const isWaitingForTick = isTickPending || isServerTickPending; const isWorldCommandPending = isWaitingForTick; const step = useCallback(async () => { if (isTickingRef.current || snapshot?.simulation.tick_in_progress) { return; } isTickingRef.current = true; setIsTickPending(true); setError(null); try { await tickWorld(); await refresh(); } catch (nextError) { if (nextError instanceof ApiResponseError && nextError.status === 409) { await refresh().catch((refreshError: unknown) => { setError(refreshError instanceof Error ? refreshError.message : "World refresh failed"); }); } else { setError(nextError instanceof Error ? nextError.message : "World update failed"); setIsAutoTicking(false); } } finally { isTickingRef.current = false; setIsTickPending(false); } }, [refresh, snapshot?.simulation.tick_in_progress]); const refreshModelProfiles = useCallback(async () => { const adminModels = await fetchAdminModels(); setModelProfiles(adminModels.profiles); }, []); const setNpcModel = useCallback( async (npcId: string, profileId: string) => { setIsModelSwitchPending(true); setError(null); try { await setNpcModelProfile(npcId, profileId); await refresh(); await refreshModelProfiles(); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : "NPC model switch failed"); } finally { setIsModelSwitchPending(false); } }, [refresh, refreshModelProfiles], ); useInitialSnapshot(setSnapshot, setSelectedId, setError); useInitialModelProfiles(setModelProfiles); useSelectedEntityFallback(snapshot, setSelectedId); useServerTickPolling(isServerTickPending, refresh, setError); useIdleSnapshotPolling({ isIdle: snapshot !== null && !isAutoTicking && !isWaitingForTick, refresh, setError, }); useAutoTicks({ currentTick: snapshot?.tick ?? null, isAutoTicking, isWaitingForTick, hasSnapshot: snapshot !== null, step, setError, setIsAutoTicking, }); useModelStatusPolling(snapshot, refresh, setError); useAutoplayRearm(isAutoTicking, snapshot !== null, setIsAutoTicking); const selectedEntity = useMemo( () => snapshot?.entities.find((entity) => entity.id === selectedId) ?? null, [selectedId, snapshot], ); const selectedBeast = useMemo( () => snapshot?.beasts?.find((beast) => beast.id === selectedId) ?? null, [selectedId, snapshot], ); const selectedResource = useMemo( () => snapshot?.resource_nodes?.find((node) => node.id === selectedId) ?? null, [selectedId, snapshot], ); const selectedHouse = useMemo( () => snapshot?.houses?.find((house) => house.id === selectedId) ?? null, [selectedId, snapshot], ); return { error, eventHistory, hideTickControls: AUTOPLAY, isAutoTicking, isModelSwitchPending, isWaitingForTick, isWorldCommandPending, modelProfiles, selectedBeast, selectedEntity, selectedHouse, selectedResource, selectedId, setIsAutoTicking, setNpcModel, setSelectedId, snapshot, step, }; } function useInitialModelProfiles( setModelProfiles: (profiles: ModelProfileSnapshot[]) => void, ) { useEffect(() => { const abortController = new AbortController(); fetchAdminModels(abortController.signal) .then((adminModels) => { setModelProfiles(adminModels.profiles); }) .catch(() => { if (!abortController.signal.aborted) { setModelProfiles([]); } }); return () => abortController.abort(); }, [setModelProfiles]); } function useEventHistory(snapshot: WorldSnapshot | null): GameLogEventSnapshot[] { const seenKeysRef = useRef>(new Set()); const [history, setHistory] = useState([]); useEffect(() => { const log = snapshot?.event_log ?? []; if (log.length === 0) { return; } const fresh: GameLogEventSnapshot[] = []; for (const event of log) { const key = [ event.tick, event.type, event.actor_id ?? "", event.target_id ?? "", event.summary, ].join("|"); if (!seenKeysRef.current.has(key)) { seenKeysRef.current.add(key); fresh.push(event); } } if (fresh.length > 0) { setHistory((current) => [...current, ...fresh].slice(-500)); } }, [snapshot]); return history; } function useInitialSnapshot( setSnapshot: (snapshot: WorldSnapshot) => void, setSelectedId: (id: string | null) => void, setError: (error: string | null) => void, ) { useEffect(() => { const abortController = new AbortController(); fetchWorldSnapshot(abortController.signal, { warmup: true }) .then((nextSnapshot) => { setSnapshot(nextSnapshot); setSelectedId(nextSnapshot.entities[0]?.id ?? null); setError(null); }) .catch((nextError: unknown) => { if (!abortController.signal.aborted) { setError(nextError instanceof Error ? nextError.message : "World load failed"); } }); return () => abortController.abort(); }, [setError, setSelectedId, setSnapshot]); } function useSelectedEntityFallback( snapshot: WorldSnapshot | null, setSelectedId: (updater: (currentId: string | null) => string | null) => void, ) { useEffect(() => { if (!snapshot) { return; } setSelectedId((currentId) => { if (!currentId) { return null; } const stillExists = snapshot.entities.some((entity) => entity.id === currentId) || (snapshot.beasts ?? []).some((beast) => beast.id === currentId) || (snapshot.resource_nodes ?? []).some((node) => node.id === currentId) || (snapshot.houses ?? []).some((house) => house.id === currentId); return stillExists ? currentId : null; }); }, [snapshot, setSelectedId]); } function useServerTickPolling( isServerTickPending: boolean, refresh: () => Promise, setError: (error: string | null) => void, ) { useEffect(() => { if (!isServerTickPending) { return undefined; } return pollEvery(1500, refresh, setError); }, [isServerTickPending, refresh, setError]); } type IdleSnapshotPollingOptions = { isIdle: boolean; refresh: () => Promise; setError: (error: string | null) => void; }; function useIdleSnapshotPolling(options: IdleSnapshotPollingOptions) { const { isIdle, refresh, setError } = options; useEffect(() => { if (!isIdle) { return undefined; } return pollEvery(2000, refresh, setError); }, [isIdle, refresh, setError]); } type AutoTickOptions = { currentTick: number | null; isAutoTicking: boolean; isWaitingForTick: boolean; hasSnapshot: boolean; step: () => Promise; setError: (error: string | null) => void; setIsAutoTicking: (value: boolean) => void; }; function useAutoTicks(options: AutoTickOptions) { const { currentTick, isAutoTicking, isWaitingForTick, hasSnapshot, step, setError, setIsAutoTicking, } = options; useEffect(() => { if (!isAutoTicking || !hasSnapshot || isWaitingForTick) { return undefined; } const timeoutId = window.setTimeout(() => { step().catch((nextError: unknown) => { setError(nextError instanceof Error ? nextError.message : "World update failed"); setIsAutoTicking(false); }); }, 500); return () => window.clearTimeout(timeoutId); }, [ currentTick, hasSnapshot, isAutoTicking, isWaitingForTick, setError, setIsAutoTicking, step, ]); } // In autoplay mode the manual controls are hidden, so a transient error that // pauses auto-ticking must be recovered automatically; otherwise the world would // halt with no way to restart it. function useAutoplayRearm( isAutoTicking: boolean, hasSnapshot: boolean, setIsAutoTicking: (value: boolean) => void, ) { useEffect(() => { if (!AUTOPLAY || isAutoTicking || !hasSnapshot) { return undefined; } const timeoutId = window.setTimeout(() => setIsAutoTicking(true), 2000); return () => window.clearTimeout(timeoutId); }, [isAutoTicking, hasSnapshot, setIsAutoTicking]); } function useModelStatusPolling( snapshot: WorldSnapshot | null, refresh: () => Promise, setError: (error: string | null) => void, ) { const modelStatuses = snapshot?.simulation.models ?? []; useEffect(() => { if (!snapshot || !modelStatuses.some((model) => model.status !== "ready")) { return undefined; } return pollEvery(2000, refresh, setError); }, [modelStatuses, refresh, setError, snapshot]); } function pollEvery( intervalMs: number, refresh: () => Promise, setError: (error: string | null) => void, ) { const intervalId = window.setInterval(() => { refresh().catch((nextError: unknown) => { setError(nextError instanceof Error ? nextError.message : "World refresh failed"); }); }, intervalMs); return () => window.clearInterval(intervalId); }