import { useEffect, useRef } from "react"; import { synchronizeOnlineSession } from "../app/onlineAccountClient"; import { useOnlineGroupStore } from "../app/onlineGroupStore"; import { useOnlineSessionStore } from "../app/onlineSessionStore"; import type { OnlineRelayEvent, OnlineRelayEventInput, OnlineSessionSnapshot, OnlineSharedWorld, } from "../app/onlineSessionTypes"; import { useShellStore } from "../app/shellStore"; import { PLAYER_AGGRO_ID } from "./aggro"; import { useCombatStore } from "./combatStore"; import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment"; import { resolvedEquipmentItems } from "./heirloomEquipment"; import { getMobRuntimeTransform } from "./mobRuntimeRegistry"; import { configureOnlineSessionRuntime, drainOnlineInteractions, isOnlinePlayerActorId, onlineAccountIdFromActor, onlinePlayerActorId, } from "./onlineSessionRuntime"; import { getPartyRuntimePosition, removePartyRuntimePosition, updatePartyRuntimePosition, } from "./partyRuntimeRegistry"; import { usePartyStore } from "./partyStore"; import { useGameStore } from "./store"; import { ingestRelayDamageText, withSuppressedReplayDamageText } from "./floatingCombatText"; import { useManastormStore } from "./manastormStore"; import { advanceManastormSession } from "./manastormSession"; import { useWailingEncounterStore } from "./wailingCavernsEncounter"; import { exportDungeonEncounterSnapshots, importDungeonEncounterSnapshots, } from "./dungeonEncounterRuntime"; import { dispatchDungeonWorldObjectInteraction, dungeonInteractionCandidateAt, } from "./dungeonWorldInteraction"; const SYNC_INTERVAL_MS = 100; const WORLD_INTERVAL_MS = 200; function serializableState(state: T): Readonly> { return JSON.parse(JSON.stringify(state)) as Readonly>; } function sharedWorld(): OnlineSharedWorld { const combat = useCombatStore.getState(); const party = usePartyStore.getState(); const game = useGameStore.getState(); const mobTransforms = Object.fromEntries( Object.keys(combat.mobs).flatMap((id) => { const transform = getMobRuntimeTransform(id); return transform ? [[id, transform]] : []; }), ); const partyPositions = Object.fromEntries( party.members.flatMap((member) => { const position = getPartyRuntimePosition(member.id); return position ? [[member.id, position]] : []; }), ); return { dungeonId: game.activeDungeonId, encounters: game.gameMode === "dungeon" ? exportDungeonEncounterSnapshots(game.activeDungeonId) : [], mobs: combat.mobs, mobTransforms, partyMembers: party.members, partyPositions, partyRuntime: { command: party.command, activeMobId: party.activeMobId, objectiveBossId: party.objectiveBossId, memberTargetIds: party.memberTargetIds, }, wailing: game.gameMode === "dungeon" ? serializableState(useWailingEncounterStore.getState()) : null, manastorm: game.gameMode === "manastorm" ? serializableState(useManastormStore.getState()) : null, }; } export function applySharedWorld(world: OnlineSharedWorld): void { useCombatStore.setState({ mobs: world.mobs }); for (const mob of Object.values(world.mobs)) { if (mob.dead && mob.looted && mob.bossCardDrop) { useCombatStore.getState().collectSharedBossCards(mob.bossCardDrop, mob.name, mob.diedAt ?? Date.now()); } } usePartyStore.setState({ members: world.partyMembers, command: world.partyRuntime.command as ReturnType["command"], activeMobId: world.partyRuntime.activeMobId, objectiveBossId: world.partyRuntime.objectiveBossId, memberTargetIds: world.partyRuntime.memberTargetIds, }); for (const [id, position] of Object.entries(world.partyPositions)) { updatePartyRuntimePosition(id, position[0], position[1], position[2]); } if (world.wailing) useWailingEncounterStore.setState(world.wailing as never); if (world.manastorm) useManastormStore.setState(world.manastorm as never); const activeDungeonId = useGameStore.getState().activeDungeonId; if (world.dungeonId === activeDungeonId && world.encounters) { importDungeonEncounterSnapshots(activeDungeonId, world.encounters, { authoritative: true }); } } export function eventForRelay( event: ReturnType["combatEvents"][number], localAccountId: string, authority: boolean, ): OnlineRelayEventInput | null { if (!["damage", "healing", "resurrection"].includes(event.kind)) return null; const combat = useCombatStore.getState(); const playerOwnedSummon = combat.summons.some((summon) => ( summon.id === event.sourceActorId && summon.ownerActorId === PLAYER_AGGRO_ID )); const sourceIsLocal = event.sourceActorId === PLAYER_AGGRO_ID || playerOwnedSummon; const targetsRemotePlayer = isOnlinePlayerActorId(event.targetActorId); const partyMembers = usePartyStore.getState().members; const sourceIsCompanion = partyMembers.some((member) => member.id === event.sourceActorId) || combat.summons.some((summon) => partyMembers.some((member) => member.id === summon.ownerActorId) && summon.id === event.sourceActorId); const companionDamageToMob = authority && event.kind === "damage" && Boolean(combat.mobs[event.targetActorId]) && sourceIsCompanion; const authorityDamageToFriendly = authority && event.kind === "damage" && !sourceIsLocal && (event.targetActorId === PLAYER_AGGRO_ID || partyMembers.some((member) => member.id === event.targetActorId)); if (!sourceIsLocal && !(authority && targetsRemotePlayer) && !companionDamageToMob && !authorityDamageToFriendly) return null; const localActorId = onlinePlayerActorId(localAccountId); const targetActorId = event.targetActorId === PLAYER_AGGRO_ID ? localActorId : event.targetActorId; if (sourceIsLocal && targetActorId === localActorId) return null; return { clientEventId: `combat:${localAccountId}:${event.occurredAt}:${event.id}:${event.kind}`, kind: event.kind as "damage" | "healing" | "resurrection", sourceActorId: sourceIsLocal ? localActorId : event.sourceActorId, targetActorId, abilityId: event.abilityId, school: event.school, rawAmount: event.rawAmount, effectiveAmount: event.effectiveAmount, critical: event.critical, occurredAt: event.occurredAt, }; } function applyRelayEvent( event: OnlineRelayEvent, snapshot: OnlineSessionSnapshot, applyingRef: React.MutableRefObject, ): void { if (event.sourceAccountId === snapshot.localAccountId) return; const localActorId = onlinePlayerActorId(snapshot.localAccountId); applyingRef.current = true; try { if (event.kind === "portal" && snapshot.authority) { advanceManastormSession(); return; } if (event.kind === "loot" && snapshot.authority && event.targetActorId) { useCombatStore.getState().lootMob(event.targetActorId, event.occurredAt || Date.now()); return; } if (event.kind === "interact" && snapshot.authority && event.targetActorId) { const source = snapshot.players.find((player) => player.accountId === event.sourceAccountId); const candidate = source ? dungeonInteractionCandidateAt(source.position) : null; if (candidate?.object.id === event.targetActorId) { dispatchDungeonWorldObjectInteraction( candidate.object, event.sourceActorId, event.occurredAt || Date.now(), ); } return; } if (event.kind === "party-teleport" && !snapshot.authority && event.position) { const game = useGameStore.getState(); game.relocatePlayer({ footPosition: event.position, yaw: game.cameraYaw }, true); for (const member of usePartyStore.getState().members) { updatePartyRuntimePosition(member.id, ...event.position); } return; } if (event.targetActorId === localActorId) { if (event.kind === "damage") { const sourceLevel = useCombatStore.getState().mobs[event.sourceActorId]?.level; useCombatStore.getState().damagePlayer( event.rawAmount ?? event.effectiveAmount ?? 0, event.school ?? "physical", sourceLevel, ); } else if (event.kind === "healing") { useCombatStore.getState().healActor(PLAYER_AGGRO_ID, event.effectiveAmount ?? 0, { sourceActorId: event.sourceActorId, abilityId: event.abilityId ?? undefined, school: event.school ?? undefined, now: event.occurredAt, amountAlreadyModified: true, }); } else if (event.kind === "resurrection") { const combat = useCombatStore.getState(); combat.revivePlayer((event.effectiveAmount ?? 1) / Math.max(1, combat.maxHealth)); } return; } if (!snapshot.authority) return; const partyMember = usePartyStore.getState().members.find((member) => member.id === event.targetActorId); if (partyMember) { if (event.kind === "healing") { useCombatStore.getState().healActor(event.targetActorId, event.effectiveAmount ?? 0, { sourceActorId: event.sourceActorId, abilityId: event.abilityId ?? undefined, school: event.school ?? undefined, now: event.occurredAt, amountAlreadyModified: true, }); } else if (event.kind === "resurrection") { useCombatStore.getState().reviveActor( event.targetActorId, (event.effectiveAmount ?? 1) / Math.max(1, partyMember.maxHealth), event.sourceActorId, event.abilityId ?? undefined, event.occurredAt, ); } return; } const mob = useCombatStore.getState().mobs[event.targetActorId]; if (mob && event.kind === "damage") { const sourceMember = snapshot.players.find((player) => player.accountId === event.sourceAccountId); useCombatStore.getState().damageMob( mob.id, event.rawAmount ?? event.effectiveAmount ?? 0, event.occurredAt, { actorId: event.sourceActorId, role: sourceMember?.role ?? "damage", abilityId: event.abilityId ?? undefined, }, event.school ?? "physical", ); } } finally { applyingRef.current = false; } } export function OnlineSessionBridge() { const bossTest = useGameStore((state) => state.bossTest); const session = useShellStore((state) => state.session); const activeActivity = useOnlineGroupStore((state) => state.activeActivity); const group = useOnlineGroupStore((state) => state.snapshot.group); const accept = useOnlineSessionStore((state) => state.accept); const fail = useOnlineSessionStore((state) => state.fail); const reset = useOnlineSessionStore((state) => state.reset); const pendingEventsRef = useRef([]); const applyingRef = useRef(false); useEffect(() => { if (bossTest || !session?.accessToken || !activeActivity || !group) { configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null }); reset(); return undefined; } const localAccountId = session.ownerId; const immediateAuthority = group.leaderAccountId === localAccountId; configureOnlineSessionRuntime({ active: true, authority: immediateAuthority, localAccountId, }); let disposed = false; let syncing = false; let latestEventId = 0; let lastWorldSentAt = 0; const remotePositionIds = new Set(); const unsubscribeCombat = useCombatStore.subscribe((state, previous) => { if (applyingRef.current || state.combatEvents === previous.combatEvents) return; const previousKeys = new Set(previous.combatEvents.map((event) => `${event.id}:${event.occurredAt}:${event.kind}`)); for (const event of state.combatEvents) { if (previousKeys.has(`${event.id}:${event.occurredAt}:${event.kind}`)) continue; const relay = eventForRelay(event, localAccountId, group.leaderAccountId === localAccountId); if (relay) pendingEventsRef.current.push(relay); } }); const synchronize = async () => { if (disposed || syncing) return; syncing = true; const combat = useCombatStore.getState(); const game = useGameStore.getState(); const outgoingEvents = [...pendingEventsRef.current.splice(0), ...drainOnlineInteractions()]; const now = Date.now(); const publishWorld = group.leaderAccountId === localAccountId && now - lastWorldSentAt >= WORLD_INTERVAL_MS; if (publishWorld) lastWorldSentAt = now; try { const snapshot = await synchronizeOnlineSession(session, { afterEventId: latestEventId, presence: { position: game.playerPosition, yaw: game.cameraYaw, grounded: game.playerGrounded, health: combat.health, maxHealth: combat.maxHealth, resource: combat.resource, maxResource: combat.maxResource, resourceName: combat.resourceName, selectedTargetId: combat.selectedTargetId, activeCast: combat.activeCast, animationEvent: combat.playerAnimationEvent, equipment: resolvedEquipmentItems( combat.equipment, combat.inventory, { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: combat.classId, secondaryClassId: combat.secondaryClassId, specializationId: combat.specializationId, role: combat.gearRole, level: combat.level, }, ), }, ...(publishWorld ? { world: sharedWorld() } : {}), ...(outgoingEvents.length ? { events: outgoingEvents } : {}), }); if (disposed) return; latestEventId = Math.max(latestEventId, snapshot.latestEventId); configureOnlineSessionRuntime({ active: true, authority: snapshot.authority, localAccountId, players: snapshot.players, mobTransforms: snapshot.world?.mobTransforms, }); for (const player of snapshot.players) { if (player.accountId === localAccountId) continue; const actorId = onlinePlayerActorId(player.accountId); remotePositionIds.add(actorId); updatePartyRuntimePosition(actorId, player.position[0], player.position[1], player.position[2]); } if (!snapshot.authority && snapshot.world) applySharedWorld(snapshot.world); for (const event of snapshot.events) { withSuppressedReplayDamageText(() => applyRelayEvent(event, snapshot, applyingRef)); ingestRelayDamageText(event, snapshot); } useCombatStore.getState().synchronizeActors(); accept(snapshot); } catch (error) { if (outgoingEvents.length) pendingEventsRef.current.unshift(...outgoingEvents); if (!disposed) fail(error instanceof Error ? error.message : "The shared activity lost connection."); } finally { syncing = false; } }; void synchronize(); const timer = window.setInterval(() => void synchronize(), SYNC_INTERVAL_MS); return () => { disposed = true; window.clearInterval(timer); unsubscribeCombat(); for (const id of remotePositionIds) removePartyRuntimePosition(id); pendingEventsRef.current = []; configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null }); reset(); }; }, [accept, activeActivity?.id, bossTest, fail, group?.id, group?.leaderAccountId, reset, session?.accessToken, session?.ownerId]); return null; }