| import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
|
|
| import { |
| ApiResponseError, |
| fetchAdminModels, |
| fetchWorldSnapshot, |
| setNpcModelProfile, |
| tickWorld, |
| } from "../api"; |
| import type { GameLogEventSnapshot, ModelProfileSnapshot, WorldSnapshot } from "../types"; |
|
|
| |
| |
| |
| const AUTOPLAY = !["0", "false", "no"].includes( |
| String(import.meta.env.VITE_WORLD_SIMULATOR_AUTOPLAY ?? "").toLowerCase(), |
| ); |
|
|
| export function useWorldSimulation() { |
| const [snapshot, setSnapshot] = useState<WorldSnapshot | null>(null); |
| const [isTickPending, setIsTickPending] = useState(false); |
| const [isAutoTicking, setIsAutoTicking] = useState(AUTOPLAY); |
| const [isModelSwitchPending, setIsModelSwitchPending] = useState(false); |
| const [modelProfiles, setModelProfiles] = useState<ModelProfileSnapshot[]>([]); |
| const [error, setError] = useState<string | null>(null); |
| const [selectedId, setSelectedId] = useState<string | null>(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<Set<string>>(new Set()); |
| const [history, setHistory] = useState<GameLogEventSnapshot[]>([]); |
|
|
| 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<void>, |
| setError: (error: string | null) => void, |
| ) { |
| useEffect(() => { |
| if (!isServerTickPending) { |
| return undefined; |
| } |
| return pollEvery(1500, refresh, setError); |
| }, [isServerTickPending, refresh, setError]); |
| } |
|
|
| type IdleSnapshotPollingOptions = { |
| isIdle: boolean; |
| refresh: () => Promise<void>; |
| 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<void>; |
| 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, |
| ]); |
| } |
|
|
| |
| |
| |
| 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<void>, |
| 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<void>, |
| 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); |
| } |
|
|