import { useEffect, useRef } from "react"; import { useShellStore } from "../app/shellStore"; import { useOnlineSessionStore } from "../app/onlineSessionStore"; import { useCombatStore } from "./combatStore"; import { activateCharacterUi } from "./uiLayoutStore"; 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"; import { advanceDungeonRaceAffixes, resetDungeonRaceAffixEffects, resumeDungeonRaceAffixes, suspendDungeonRaceAffixes } from "./dungeonRaceAffixRuntime"; import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore"; import { DUNGEON_SIMULATION_FIXED_STEP_MS, useDungeonSimulationStore, } from "./dungeonSimulationStore"; /** * 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 }); activateCharacterUi(character); 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(); resetDungeonRaceAffixEffects(); 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().gmTestMode) 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 || state.glyphIds !== previousState.glyphIds ) { // 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, state.glyphIds); 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 advanceOneTick = (delta: number, epochNow: number, spectator: boolean) => { const game = useGameStore.getState(); const simulation = useDungeonSimulationStore.getState(); const combat = useCombatStore.getState(); combat.setPlayerPosition(game.playerPosition); combat.tick(delta, epochNow); if ( onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon" && (!spectator || simulation.mode === "full-combat") ) { useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition); } if (onlineSessionOwnsWorldSimulation()) { if (!spectator || simulation.mode === "full-combat") { 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) if (!event.entityId.startsWith("race-affix-")) dispatchActiveDungeonEntityEvent(event); advanceDungeonRaceAffixes(epochNow, combatEvents.events.filter((event) => ( event.kind === "entity-death" && !event.entityId.startsWith("race-affix-") )).map((event) => event.entityId)); const registeredEntityIds = Object.keys(mobs).filter((id) => !id.startsWith("race-affix-")); advanceDungeonEncounters(game.activeDungeonId, { kind: "tick", now: epochNow, facts: { registeredEntityIds, deadEntityIds: registeredEntityIds.filter((id) => mobs[id].dead), }, }); } }; const advance = () => { const now = performance.now(); const wallDeltaMs = Math.min(1_000, Math.max(0, now - previous)); const delta = Math.min(0.25, wallDeltaMs / 1_000); previous = now; const simulation = useDungeonSimulationStore.getState(); if (simulation.sessionMode === "spectate-replay") { simulation.consumeTicks(wallDeltaMs); return; } if (simulation.sessionMode === "spectate-live") { const assetStatus = useGameStore.getState().assetStatus; if (assetStatus !== "ready" && assetStatus !== "proxy") return; for (const tickNow of simulation.consumeTicks(wallDeltaMs)) { advanceOneTick(DUNGEON_SIMULATION_FIXED_STEP_MS / 1_000, tickNow, true); } return; } const game = useGameStore.getState(); if (game.dungeonIntroActive || game.raceStartBlocked || useBiteSizedLayoutsStore.getState().editorMode === "edit") return; const sharedOnline = onlineSessionIsActive(); if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) { advanceOneTick(delta, Date.now(), false); } }; const reconcileTimer = () => { const game = useGameStore.getState(); const simulation = useDungeonSimulationStore.getState(); const shouldRun = combatSimulationTimerShouldRun({ sharedOnline: onlineSessionIsActive(), paused: simulation.sessionMode === "play" ? game.paused : false, companionOpen: simulation.sessionMode === "play" ? game.companionOpen : false, documentHidden: simulation.sessionMode === "play" ? document.hidden : false, }); 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(); resumeDungeonRaceAffixes(); timer = window.setInterval(advance, 100); } else if (!shouldRun && timer !== null) { suspendDungeonRaceAffixes(); 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); const unsubscribeSimulation = useDungeonSimulationStore.subscribe((state, previousState) => { if (state.sessionMode !== previousState.sessionMode || state.runState !== previousState.runState) reconcileTimer(); }); document.addEventListener("visibilitychange", reconcileTimer); reconcileTimer(); return () => { if (timer !== null) window.clearInterval(timer); unsubscribeGame(); unsubscribeOnlineSession(); unsubscribeSimulation(); document.removeEventListener("visibilitychange", reconcileTimer); }; }, []); return null; }