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 { activateDungeonEncounterDefinitions, advanceDungeonEncounters, dispatchActiveDungeonEntityEvent, resetDungeonEncounters, } from "./dungeonEncounterRuntime"; import { deriveDungeonEncounterCombatEvents, type DungeonEncounterMobCursor, } from "./dungeonEncounterCombatEvents"; import { requireDungeonDefinition } from "./dungeonRegistry"; 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 activeDungeonId = useGameStore((state) => state.activeDungeonId); const activeDifficultyId = useGameStore((state) => state.activeDifficultyId); const activeRole = useShellStore((state) => state.activeDungeonRole); const encounterResetRevision = useGameStore((state) => state.encounterResetRevision); const mountedResetRevision = useRef(encounterResetRevision); const encounterMobCursor = useRef({}); useEffect(() => { if (!character) return; useCombatStore.getState().initializeCharacter({ ...character, bossCards: useShellStore.getState().bossCards, role: activeRole }); useCombatStore.getState().synchronizeActors(); if (gameMode === "manastorm") { activateManastormActiveSpellLoadout( useManastormStore.getState().activeSpellLoadout, ); } }, [activeRole, character?.id, gameMode]); useEffect(() => { const definition = requireDungeonDefinition(activeDungeonId); encounterMobCursor.current = {}; activateDungeonEncounterDefinitions(activeDungeonId, definition.encounters ?? [], activeDifficultyId); }, [activeDifficultyId, activeDungeonId]); useEffect(() => { if (mountedResetRevision.current === encounterResetRevision) return; mountedResetRevision.current = encounterResetRevision; encounterMobCursor.current = {}; useCombatStore.getState().resetEncounter(); resetDungeonEncounters("dungeon", useGameStore.getState().activeDungeonId); }, [encounterResetRevision]); useEffect(() => { if (!character) return; const characterId = character.id; const ownerId = useShellStore.getState().session?.ownerId; 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 || shell.session?.ownerId !== ownerId) return; if (useGameStore.getState().bossTest) 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.bossCards !== previousState.bossCards) { shell.saveAccountBossCards(state.bossCards); } 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") { const mobs = useCombatStore.getState().mobs; const combatEvents = deriveDungeonEncounterCombatEvents( encounterMobCursor.current, mobs, epochNow, ); encounterMobCursor.current = combatEvents.cursor; for (const event of combatEvents.events) dispatchActiveDungeonEntityEvent(event); const registeredEntityIds = Object.keys(mobs); advanceDungeonEncounters(game.activeDungeonId, { kind: "tick", now: epochNow, facts: { registeredEntityIds, deadEntityIds: registeredEntityIds.filter((id) => mobs[id].dead), }, }); } } }; 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; }