export type PartyWorldPosition = readonly [x: number, y: number, z: number]; export type MutablePartyWorldPosition = [x: number, y: number, z: number]; const positions = new Map(); const targetVisibility = new Map(); const rejoins = new Map(); const combatRelocations = new Map(); /** One-shot movement authored by combat, without an attached return-to-player task. */ export function requestPartyRuntimeCombatRelocation( id: string, position: PartyWorldPosition, yaw: number, ): void { const destination: PartyWorldPosition = [finite(position[0]), finite(position[1]), finite(position[2])]; const facing = finite(yaw); combatRelocations.set(id, { position: destination, yaw: facing }); updatePartyRuntimePosition(id, destination[0], destination[1], destination[2]); } /** Returns and clears a pending combat relocation so the scene applies it once. */ export function consumePartyRuntimeCombatRelocation( id: string, ): { readonly position: PartyWorldPosition; readonly yaw: number } | null { const relocation = combatRelocations.get(id); if (!relocation) return null; combatRelocations.delete(id); return { position: [...relocation.position], yaw: relocation.yaw }; } /** A one-shot scene relocation followed by an individual return-to-player task. */ export function beginPartyRuntimeRejoin(id: string, position: PartyWorldPosition): void { combatRelocations.delete(id); rejoins.set(id, { position: [...position], pending: true }); updatePartyRuntimePosition(id, position[0], position[1], position[2]); targetVisibility.delete(id); } export function consumePartyRuntimeRejoin(id: string): PartyWorldPosition | null { const rejoin = rejoins.get(id); if (!rejoin?.pending) return null; rejoin.pending = false; return [...rejoin.position]; } export function partyRuntimeIsRejoining(id: string): boolean { return rejoins.has(id); } export function finishPartyRuntimeRejoin(id: string): void { rejoins.delete(id); } let attackCorridor: { readonly origin: MutablePartyWorldPosition; readonly waypoints: readonly PartyWorldPosition[]; readonly cursor: number; } | null = null; function finite(value: number): number { return Number.isFinite(value) ? value : 0; } export function registerPartyRuntimePosition( id: string, initial: PartyWorldPosition, ): () => void { positions.set(id, [finite(initial[0]), finite(initial[1]), finite(initial[2])]); return () => { positions.delete(id); targetVisibility.delete(id); rejoins.delete(id); combatRelocations.delete(id); }; } /** Scene-authored static-collision visibility used by the combat tick. */ export function updatePartyRuntimeTargetVisibility( memberId: string, targetId: string | null, visible: boolean, ): void { targetVisibility.set(memberId, { targetId, visible: Boolean(targetId && visible) }); } export function partyRuntimeTargetVisible(memberId: string, targetId: string): boolean { const access = targetVisibility.get(memberId); return access?.targetId === targetId && access.visible; } /** Publishes the remaining approved Attack route for route-local add selection. */ export function updatePartyRuntimeAttackCorridor( origin: PartyWorldPosition, waypoints: readonly PartyWorldPosition[], cursor: number, ): void { attackCorridor = { origin: [finite(origin[0]), finite(origin[1]), finite(origin[2])], waypoints, cursor: Math.max(0, Math.min(waypoints.length, Math.trunc(cursor))), }; } export function clearPartyRuntimeAttackCorridor(): void { attackCorridor = null; } /** Rejects side branches and vertically overlapping floors outside the road. */ export function partyRuntimePositionAlongAttackCorridor( position: PartyWorldPosition, maximumPlanarDistance = 10, maximumVerticalDistance = 4, ): boolean { const corridor = attackCorridor; if (!corridor) return false; const planarLimitSquared = Math.max(0, maximumPlanarDistance) ** 2; const verticalLimit = Math.max(0, maximumVerticalDistance); let start: PartyWorldPosition = corridor.origin; const lastIndex = Math.max(corridor.cursor, corridor.waypoints.length - 1); for (let index = corridor.cursor; index <= lastIndex; index += 1) { const end = corridor.waypoints[index] ?? start; const segmentX = end[0] - start[0]; const segmentZ = end[2] - start[2]; const lengthSquared = segmentX * segmentX + segmentZ * segmentZ; const progress = lengthSquared > 1e-9 ? Math.max(0, Math.min(1, ( (position[0] - start[0]) * segmentX + (position[2] - start[2]) * segmentZ ) / lengthSquared)) : 0; const projectedX = start[0] + segmentX * progress; const projectedY = start[1] + (end[1] - start[1]) * progress; const projectedZ = start[2] + segmentZ * progress; const dx = position[0] - projectedX; const dz = position[2] - projectedZ; if ( dx * dx + dz * dz <= planarLimitSquared && Math.abs(position[1] - projectedY) <= verticalLimit ) return true; start = end; } return false; } export function updatePartyRuntimePosition( id: string, x: number, y: number, z: number, ): void { const position = positions.get(id); if (position) { position[0] = finite(x); position[1] = finite(y); position[2] = finite(z); } else { positions.set(id, [finite(x), finite(y), finite(z)]); } } export function removePartyRuntimePosition(id: string): void { positions.delete(id); targetVisibility.delete(id); rejoins.delete(id); combatRelocations.delete(id); } export function getPartyRuntimePosition(id: string): PartyWorldPosition | null { const position = positions.get(id); return position ? [...position] as MutablePartyWorldPosition : null; } export function partyRuntimeDistanceSquared( id: string, origin: PartyWorldPosition, ): number { const position = positions.get(id); if (!position) return Number.POSITIVE_INFINITY; const dx = position[0] - origin[0]; const dy = position[1] - origin[1]; const dz = position[2] - origin[2]; return dx * dx + dy * dy + dz * dz; } export function readPartyRuntimePosition( id: string, out: MutablePartyWorldPosition, ): boolean { const position = positions.get(id); if (!position) return false; out[0] = position[0]; out[1] = position[1]; out[2] = position[2]; return true; } export function partyRuntimeCenter( memberIds: readonly string[], fallback: PartyWorldPosition, ): PartyWorldPosition { let x = 0; let y = 0; let z = 0; let count = 0; for (const id of memberIds) { const position = positions.get(id); if (!position) continue; x += position[0]; y += position[1]; z += position[2]; count += 1; } return count > 0 ? [x / count, y / count, z / count] : [fallback[0], fallback[1], fallback[2]]; } export function clearPartyRuntimeRegistry(): void { positions.clear(); targetVisibility.clear(); rejoins.clear(); combatRelocations.clear(); attackCorridor = null; }