import type { MutablePartyWorldPosition, PartyWorldPosition } from "./partyRuntimeRegistry"; const EPSILON = 1e-6; export interface PartyBreadcrumb { readonly id: number; readonly distance: number; readonly position: PartyWorldPosition; } /** Mutable, allocation-light trail recorded by a collision-safe leader. */ export interface PartyBreadcrumbTrail { readonly points: PartyBreadcrumb[]; nextId: number; } /** Absolute breadcrumb ID avoids invalid cursors when old trail points are pruned. */ export interface PartyBreadcrumbCursor { breadcrumbId: number | null; } export type PartyCombatMovementIntent = "hold" | "approach" | "retreat" | "reposition"; export interface PartyMovementProfile { readonly followDistanceJitter: number; readonly lateralOffset: number; readonly comfortSlack: number; readonly speedMultiplier: number; readonly swayAmplitude: number; readonly swayPeriodMs: number; readonly swayPhase: number; readonly combatAngleOffset: number; readonly combatRangeBias: number; } export interface PartyCombatRangeLike { readonly minimum: number; readonly preferred: number; readonly maximum: number; } function stableHash(value: string): number { let hash = 2166136261; for (let index = 0; index < value.length; index += 1) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 16777619); } return hash >>> 0; } function hashUnit(value: string): number { return stableHash(value) / 0xffff_ffff; } function clamp(value: number, minimum: number, maximum: number): number { return Math.min(maximum, Math.max(minimum, value)); } /** Stable per-character preferences keep motion reproducible without looking cloned. */ export function partyMovementProfile(memberId: string, livingRank: number): PartyMovementProfile { const rank = Math.max(0, Math.floor(Number.isFinite(livingRank) ? livingRank : 0)); const prefix = `${memberId}|${rank}`; const lateralMagnitude = rank === 0 ? hashUnit(`${prefix}|leader-lateral`) * 0.3 : 0.35 + hashUnit(`${prefix}|lateral`) * 0.3; const lateralSign = rank === 0 ? hashUnit(`${prefix}|leader-side`) < 0.5 ? -1 : 1 : rank % 2 === 1 ? -1 : 1; const angleSlots = [0, -35, 35, -50, 50]; const angleDegrees = clamp( (angleSlots[rank % angleSlots.length] ?? 0) + (hashUnit(`${prefix}|angle-jitter`) - 0.5) * 10, -50, 50, ); return { followDistanceJitter: (hashUnit(`${prefix}|distance`) - 0.5) * 0.7, lateralOffset: lateralSign * lateralMagnitude, comfortSlack: 0.35 + hashUnit(`${prefix}|comfort`) * 0.5, speedMultiplier: 0.94 + hashUnit(`${prefix}|speed`) * 0.12, swayAmplitude: 0.08 + hashUnit(`${prefix}|sway-amplitude`) * 0.1, swayPeriodMs: 6_000 + hashUnit(`${prefix}|sway-period`) * 4_000, swayPhase: hashUnit(`${prefix}|sway-phase`) * Math.PI * 2, combatAngleOffset: angleDegrees * Math.PI / 180, combatRangeBias: hashUnit(`${prefix}|combat-range`) * 2 - 1, }; } export const PARTY_STUCK_RECALL_DISTANCE = 12; export const PARTY_STUCK_RECALL_DELAY_MS = 3_500; export const PARTY_STUCK_PROGRESS_DISTANCE = 0.45; /** Tests whether an expected move has made enough planar progress to reset the stuck timer. */ export function partyMadeStuckProgress( previous: PartyWorldPosition, current: PartyWorldPosition, minimumDistance = PARTY_STUCK_PROGRESS_DISTANCE, ): boolean { const minimum = Math.max(0, Number.isFinite(minimumDistance) ? minimumDistance : 0); const distance = Math.hypot(current[0] - previous[0], current[2] - previous[2]); return Number.isFinite(distance) && distance >= minimum; } /** * A distant ally is stuck only while it should be moving and its progress * timer has expired. Intentional ranged/melee holds never enter this state. */ export function partyShouldRecallForStuck( current: PartyWorldPosition, player: PartyWorldPosition, movementExpected: boolean, nowMs: number, lastProgressAtMs: number, recallDistance = PARTY_STUCK_RECALL_DISTANCE, stuckDelayMs = PARTY_STUCK_RECALL_DELAY_MS, ): boolean { if (!movementExpected || !Number.isFinite(nowMs) || !Number.isFinite(lastProgressAtMs)) return false; const distance = Math.hypot(current[0] - player[0], current[2] - player[2]); const minimumDistance = Math.max(0, Number.isFinite(recallDistance) ? recallDistance : 0); const delay = Math.max(0, Number.isFinite(stuckDelayMs) ? stuckDelayMs : 0); return Number.isFinite(distance) && distance >= minimumDistance && nowMs - lastProgressAtMs >= delay; } export function createPartyBreadcrumbTrail(): PartyBreadcrumbTrail { return { points: [], nextId: 0 }; } export function createPartyBreadcrumbCursor(): PartyBreadcrumbCursor { return { breadcrumbId: null }; } export function synchronizePartyBreadcrumbCursor( trail: PartyBreadcrumbTrail, cursor: PartyBreadcrumbCursor, trailingDistance: number, ): void { const latest = trail.points[trail.points.length - 1]; if (!latest) { cursor.breadcrumbId = null; return; } const maximumDistance = latest.distance - Math.max(0, trailingDistance); let target = trail.points[0]; for (const point of trail.points) { if (point.distance > maximumDistance + EPSILON) break; target = point; } cursor.breadcrumbId = target.id; } export function resetPartyBreadcrumbTrail( trail: PartyBreadcrumbTrail, position?: PartyWorldPosition, ): void { trail.points.length = 0; trail.nextId = 0; if (position) appendPartyBreadcrumb(trail, position, 0, Number.POSITIVE_INFINITY); } function finitePosition(position: PartyWorldPosition): boolean { return Number.isFinite(position[0]) && Number.isFinite(position[1]) && Number.isFinite(position[2]); } /** Records movement without allowing an old breadcrumb list to grow forever. */ export function appendPartyBreadcrumb( trail: PartyBreadcrumbTrail, position: PartyWorldPosition, minimumSpacing = 0.45, maximumTrailDistance = 96, ): boolean { if (!finitePosition(position)) return false; const spacing = Math.max(0, Number.isFinite(minimumSpacing) ? minimumSpacing : 0); const maximumDistance = Number.isFinite(maximumTrailDistance) ? Math.max(spacing, maximumTrailDistance) : Number.POSITIVE_INFINITY; const previous = trail.points[trail.points.length - 1]; const travelled = previous ? Math.hypot( position[0] - previous.position[0], position[1] - previous.position[1], position[2] - previous.position[2], ) : 0; if (previous && travelled < spacing) return false; const point: PartyBreadcrumb = { id: trail.nextId, distance: (previous?.distance ?? 0) + travelled, position: [position[0], position[1], position[2]], }; trail.nextId += 1; trail.points.push(point); while ( trail.points.length > 2 && point.distance - trail.points[1].distance > maximumDistance ) trail.points.shift(); return true; } function breadcrumbDistanceSquared(point: PartyWorldPosition, current: PartyWorldPosition): number { const dx = point[0] - current[0]; const dy = point[1] - current[1]; const dz = point[2] - current[2]; return dx * dx + dy * dy + dz * dz; } /** * Advances only one recorded breadcrumb at a time. A lagging follower therefore * walks around every bend the leader used instead of drawing a diagonal shortcut. */ export function nextPartyBreadcrumbWaypoint( trail: PartyBreadcrumbTrail, cursor: PartyBreadcrumbCursor, current: PartyWorldPosition, trailingDistance: number, reachedDistance: number, out: MutablePartyWorldPosition, ): boolean { if (!trail.points.length) return false; const trailing = Math.max(0, Number.isFinite(trailingDistance) ? trailingDistance : 0); const reached = Math.max(0, Number.isFinite(reachedDistance) ? reachedDistance : 0); const last = trail.points[trail.points.length - 1]; const maximumDistance = last.distance - trailing; let maximumIndex = -1; for (let index = 0; index < trail.points.length; index += 1) { if (trail.points[index].distance > maximumDistance + EPSILON) break; maximumIndex = index; } if (maximumIndex < 0) return false; let cursorIndex = cursor.breadcrumbId === null ? -1 : trail.points.findIndex((point) => point.id === cursor.breadcrumbId); // A spatially-nearest recovery can jump to the far side of a hairpin. Rejoin // monotonically from the oldest retained point; the scene validates that // one rejoin connector against collision before allowing movement. if (cursorIndex < 0 || cursorIndex > maximumIndex) cursorIndex = 0; // Advance exactly one required point. Breadcrumbs are deliberately close // together, so consuming every point within a radius can skip an L-corner // and turn an approved path into a diagonal shortcut. if ( cursorIndex < maximumIndex && breadcrumbDistanceSquared(trail.points[cursorIndex].position, current) <= reached * reached ) cursorIndex += 1; const target = trail.points[cursorIndex]; cursor.breadcrumbId = target.id; out[0] = target.position[0]; out[1] = target.position[1]; out[2] = target.position[2]; return true; } /** * Follows at the requested trail distance, but still permits a distant actor * to rejoin a newly reset or not-yet-long-enough trail. The caller remains * responsible for validating the initial connector against world collision. */ export function nextPartyBreadcrumbFollowWaypoint( trail: PartyBreadcrumbTrail, cursor: PartyBreadcrumbCursor, current: PartyWorldPosition, trailingDistance: number, reachedDistance: number, out: MutablePartyWorldPosition, catchUpSlack = 0.75, ): boolean { if (nextPartyBreadcrumbWaypoint( trail, cursor, current, trailingDistance, reachedDistance, out, )) return true; const latest = trail.points[trail.points.length - 1]; if (!latest) return false; const trailing = Math.max(0, Number.isFinite(trailingDistance) ? trailingDistance : 0); const slack = Math.max(0, Number.isFinite(catchUpSlack) ? catchUpSlack : 0); const distance = Math.hypot( latest.position[0] - current[0], latest.position[2] - current[2], ); if (!Number.isFinite(distance) || distance <= trailing + slack) return false; return nextPartyBreadcrumbWaypoint( trail, cursor, current, 0, reachedDistance, out, ); } export function stepPartyPosition( current: PartyWorldPosition, target: PartyWorldPosition, maximumStep: number, out: MutablePartyWorldPosition, ): number { const dx = target[0] - current[0]; const dy = target[1] - current[1]; const dz = target[2] - current[2]; const distance = Math.hypot(dx, dy, dz); if (!Number.isFinite(distance) || distance <= EPSILON) { out[0] = Number.isFinite(target[0]) ? target[0] : current[0]; out[1] = Number.isFinite(target[1]) ? target[1] : current[1]; out[2] = Number.isFinite(target[2]) ? target[2] : current[2]; return 0; } const step = Math.min(distance, Math.max(0, Number.isFinite(maximumStep) ? maximumStep : 0)); out[0] = current[0] + dx / distance * step; out[1] = current[1] + dy / distance * step; out[2] = current[2] + dz / distance * step; return step; } /** * Resolves a render-only foot height without feeding collision-floor offsets * back into navigation progress. */ export function partyPresentationY( navigationY: number, sampledFloorY: number | null, maximumCorrection: number, ): number { if (!Number.isFinite(navigationY)) return 0; if (!Number.isFinite(sampledFloorY)) return navigationY; const correction = Number.isFinite(maximumCorrection) ? Math.max(0, maximumCorrection) : 0; return Math.abs(sampledFloorY! - navigationY) <= correction ? sampledFloorY! : navigationY; } export function partyFormationPosition( anchor: PartyWorldPosition, forwardX: number, forwardZ: number, lateral: number, trailing: number, out: MutablePartyWorldPosition, ): void { const length = Math.hypot(forwardX, forwardZ); const normalizedForwardX = length > EPSILON ? forwardX / length : 0; const normalizedForwardZ = length > EPSILON ? forwardZ / length : 1; const rightX = normalizedForwardZ; const rightZ = -normalizedForwardX; out[0] = anchor[0] + rightX * lateral - normalizedForwardX * trailing; out[1] = anchor[1]; out[2] = anchor[2] + rightZ * lateral - normalizedForwardZ * trailing; } export function partyNaturalFollowPosition( anchor: PartyWorldPosition, forwardX: number, forwardZ: number, baseTrailingDistance: number, profile: PartyMovementProfile, nowMs: number, out: MutablePartyWorldPosition, ): void { const trailing = Math.max(0.9, baseTrailingDistance + profile.followDistanceJitter); const safeNow = Number.isFinite(nowMs) ? nowMs : 0; const sway = Math.sin( safeNow / Math.max(1, profile.swayPeriodMs) * Math.PI * 2 + profile.swayPhase, ) * profile.swayAmplitude; partyFormationPosition( anchor, forwardX, forwardZ, profile.lateralOffset + sway, trailing, out, ); } /** Start outside a personal comfort radius, then finish close enough to avoid jitter. */ export function partyShouldMoveToComfortTarget( current: PartyWorldPosition, target: PartyWorldPosition, wasMoving: boolean, comfortSlack: number, ): boolean { const distance = Math.hypot( target[0] - current[0], target[1] - current[1], target[2] - current[2], ); if (!Number.isFinite(distance)) return false; const startDistance = Math.max(0.18, Number.isFinite(comfortSlack) ? comfortSlack : 0.35); return distance > (wasMoving ? 0.18 : startDistance); } export function personalizedPartyCombatRangeBand( base: PartyCombatRangeLike, profile: PartyMovementProfile, ): PartyCombatRangeLike { if (base.minimum > 0) { const minimum = clamp(base.minimum + profile.combatRangeBias * 0.75, 9, 11); const maximum = clamp(base.maximum + profile.combatRangeBias * 0.35, 17, 18.1); return { minimum, preferred: clamp( base.preferred + profile.combatRangeBias * 1.25, minimum + 0.75, maximum - 0.5, ), maximum, }; } const maximum = Math.max(2.4, base.maximum - Math.abs(profile.combatRangeBias) * 0.2); return { minimum: 0, preferred: clamp( base.preferred + profile.combatRangeBias * 0.3, 2.35, maximum - 0.12, ), maximum, }; } /** Adds a bounded local repulsion so allies do not occupy the same point. */ export function partySeparationGoal( current: PartyWorldPosition, desired: PartyWorldPosition, peers: readonly PartyWorldPosition[], fallbackAngle: number, out: MutablePartyWorldPosition, separationDistance = 0.85, maximumCorrection = 0.35, ): boolean { let offsetX = 0; let offsetZ = 0; const safeSeparation = Math.max(EPSILON, separationDistance); for (const peer of peers) { const dx = current[0] - peer[0]; const dz = current[2] - peer[2]; const distance = Math.hypot(dx, dz); if (!Number.isFinite(distance) || distance >= safeSeparation) continue; const weight = (safeSeparation - distance) / safeSeparation; if (distance <= EPSILON) { offsetX += Math.sin(fallbackAngle) * weight; offsetZ += Math.cos(fallbackAngle) * weight; } else { offsetX += dx / distance * weight; offsetZ += dz / distance * weight; } } const length = Math.hypot(offsetX, offsetZ); out[0] = desired[0]; out[1] = desired[1]; out[2] = desired[2]; if (length <= EPSILON) return false; const correction = Math.min(Math.max(0, maximumCorrection), length); out[0] += offsetX / length * correction; out[2] += offsetZ / length * correction; return true; } /** * Chooses an individual combat destination. Melee only closes distance; * ranged attackers also back out when a target collapses their preferred band. */ export function partyCombatMovementGoal( current: PartyWorldPosition, target: PartyWorldPosition, targetVisible: boolean, minimumRange: number, preferredRange: number, maximumRange: number, out: MutablePartyWorldPosition, fallbackAwayX = 0, fallbackAwayZ = 1, ): PartyCombatMovementIntent { const minimum = Math.max(0, Number.isFinite(minimumRange) ? minimumRange : 0); const maximum = Math.max(minimum, Number.isFinite(maximumRange) ? maximumRange : minimum); const preferred = Math.max( minimum, Math.min(maximum, Number.isFinite(preferredRange) ? preferredRange : maximum), ); const dx = current[0] - target[0]; const dy = current[1] - target[1]; const dz = current[2] - target[2]; const distance = Math.hypot(dx, dy, dz); if (!targetVisible || !Number.isFinite(distance) || distance > maximum) { out[0] = target[0]; out[1] = target[1]; out[2] = target[2]; return "approach"; } if (minimum <= 0 || distance >= minimum) return "hold"; let awayX = dx; let awayZ = dz; let planarLength = Math.hypot(awayX, awayZ); if (planarLength <= EPSILON) { awayX = fallbackAwayX; awayZ = fallbackAwayZ; planarLength = Math.hypot(awayX, awayZ); } if (planarLength <= EPSILON) { awayX = 0; awayZ = 1; planarLength = 1; } out[0] = target[0] + awayX / planarLength * preferred; out[1] = current[1]; out[2] = target[2] + awayZ / planarLength * preferred; return "retreat"; } export function partyPersonalizedCombatMovementGoal( current: PartyWorldPosition, target: PartyWorldPosition, targetVisible: boolean, rangeBand: PartyCombatRangeLike, preferredBearing: number, forceReposition: boolean, out: MutablePartyWorldPosition, ): PartyCombatMovementIntent { const intent = partyCombatMovementGoal( current, target, targetVisible, rangeBand.minimum, rangeBand.preferred, rangeBand.maximum, out, Math.sin(preferredBearing), Math.cos(preferredBearing), ); if (!targetVisible || (intent === "hold" && !forceReposition)) return intent; out[0] = target[0] + Math.sin(preferredBearing) * rangeBand.preferred; out[1] = target[1]; out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred; return intent === "hold" ? "reposition" : intent; }