385 lines
15 KiB
TypeScript
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;
|
|
}
|