Files
healer-man/src/game/OnlineSessionBridge.tsx
T
2026-10-02 10:38:12 -04:00

385 lines
15 KiB
TypeScript

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<T extends object>(state: T): Readonly<Record<string, unknown>> {
return JSON.parse(JSON.stringify(state)) as Readonly<Record<string, unknown>>;
}
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<typeof usePartyStore.getState>["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<typeof useCombatStore.getState>["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<boolean>,
): 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<OnlineRelayEventInput[]>([]);
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<string>();
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;
}