Spaces:
Runtime error
Runtime error
| 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<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, | |
| ]); | |
| } | |
| // 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<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); | |
| } | |