import { useEffect, useRef } from "react"; import { useShellStore } from "../app/shellStore"; import { useOnlineSessionStore } from "../app/onlineSessionStore"; import { useCombatStore } from "./combatStore"; import { advanceDungeonPartyCombat } from "./partyRuntime"; import { useGameStore } from "./store"; import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout"; import { useManastormStore } from "./manastormStore"; import { useWailingEncounterStore } from "./wailingCavernsEncounter"; import { onlineSessionIsActive, onlineSessionOwnsWorldSimulation, } from "./onlineSessionRuntime"; import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle"; /** * Connects the pure combat runtime to the active character and persistent * roster. Three.js remains a renderer/position source; this bridge owns the * low-frequency simulation and save boundary. */ export function CombatBridge() { const character = useShellStore((state) => state.activeCharacter); const gameMode = useGameStore((state) => state.gameMode); const activeRole = useShellStore((state) => state.activeDungeonRole); const resetRevision = useGameStore((state) => state.resetRevision); const mountedResetRevision = useRef(resetRevision); useEffect(() => { if (!character) return; useCombatStore.getState().initializeCharacter({ ...character, role: activeRole }); useCombatStore.getState().synchronizeActors(); if (gameMode === "manastorm") { activateManastormActiveSpellLoadout( useManastormStore.getState().activeSpellLoadout, ); } }, [activeRole, character?.id, gameMode]); useEffect(() => { if (mountedResetRevision.current === resetRevision) return; mountedResetRevision.current = resetRevision; useCombatStore.getState().resetEncounter(); useWailingEncounterStore.getState().reset(); }, [resetRevision]); useEffect(() => { if (!character) return; const characterId = character.id; const unsubscribe = useCombatStore.subscribe((state, previousState) => { // Save APIs intentionally target the active character. Avoid persisting a // final combat notification during the render-to-effect-cleanup window // after Character Select has already replaced the active character. const shell = useShellStore.getState(); if (shell.activeCharacter?.id !== characterId) return; // Persisted collections are replaced immutably by combat actions. A // reference/primitive comparison therefore detects the same changes as // serializing each collection, without doing five full-store callbacks // and JSON.stringify work for every 100 ms simulation notification. if ( state.level !== previousState.level || state.xp !== previousState.xp || state.talentRanks !== previousState.talentRanks ) { // Kills and talent changes are infrequent, so persist immediately. This // also guarantees a fast return to Character Select cannot outrun a // deferred save and lose the last level or talent point. shell.saveActiveProgression(state.level, state.xp, state.talentRanks); if (state.level !== previousState.level) useCombatStore.getState().refreshResolvedGear(); } if (state.actionBindings !== previousState.actionBindings) { // Manastorm's local spell-selection substitute temporarily rewrites the // base controls. Never persist that run-only overlay to normal dungeons. if (useGameStore.getState().gameMode !== "manastorm") { shell.saveActiveActionBindings(state.actionBindings); } } if (state.inventory !== previousState.inventory) { shell.saveActiveInventory(state.inventory); } if (state.equipment !== previousState.equipment) { shell.saveActiveEquipment(state.equipment); } if (state.settings !== previousState.settings) { shell.saveActiveSettings(state.settings); } }); return unsubscribe; }, [character?.id]); useEffect(() => { let previous = performance.now(); let timer: number | null = null; const advance = () => { const now = performance.now(); const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000)); previous = now; const game = useGameStore.getState(); const sharedOnline = onlineSessionIsActive(); if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) { const epochNow = Date.now(); const combat = useCombatStore.getState(); combat.setPlayerPosition(game.playerPosition); combat.tick(delta, epochNow); if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") { useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition); } if (onlineSessionOwnsWorldSimulation()) { useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition); advanceDungeonPartyCombat(epochNow); } useCombatStore.getState().synchronizeActors(); if ( onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon" && game.activeDungeonId === "wailing-caverns" ) { useWailingEncounterStore.getState().advance( useCombatStore.getState().mobs, epochNow, ); } } }; const reconcileTimer = () => { const game = useGameStore.getState(); const shouldRun = combatSimulationTimerShouldRun({ sharedOnline: onlineSessionIsActive(), paused: game.paused, companionOpen: game.companionOpen, documentHidden: document.hidden, }); if (shouldRun && timer === null) { // Reset the clock on resume so a long pause/background period cannot // leak into the first simulation delta. previous = performance.now(); timer = window.setInterval(advance, 100); } else if (!shouldRun && timer !== null) { window.clearInterval(timer); timer = null; } }; const unsubscribeGame = useGameStore.subscribe((state, previousState) => { if ( state.paused !== previousState.paused || state.companionOpen !== previousState.companionOpen ) { reconcileTimer(); } }); // Online runtime configuration is intentionally module-local; the session // store notification gives the bridge a reactive point at which to // reconcile while preserving continuous shared-online simulation. const unsubscribeOnlineSession = useOnlineSessionStore.subscribe(reconcileTimer); document.addEventListener("visibilitychange", reconcileTimer); reconcileTimer(); return () => { if (timer !== null) window.clearInterval(timer); unsubscribeGame(); unsubscribeOnlineSession(); document.removeEventListener("visibilitychange", reconcileTimer); }; }, []); return null; }