world-simulator / frontend /src /hooks /useWorldSimulation.ts
DeltaZN
feat: disable model selector & autoplay tick by default
7fee07b
Raw
History Blame Contribute Delete
10.7 kB
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);
}