From 78cd19ff7fb2b1f7f3ca27c9fcd1b2ba13589157 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:50:19 -0400 Subject: [PATCH] Improve party runtime reliability --- src/GameServer/Actor/Actor.js | 2 +- src/GameServer/Actor/Generics/MoveTo.js | 42 +++- src/GameServer/Actor/Generics/PickupExec.js | 8 +- src/GameServer/Actor/Generics/Revive.js | 12 +- src/GameServer/Bot/AI/BotPartyChat.js | 32 +++ src/GameServer/Bot/AI/BotSocialMemory.js | 3 + .../Bot/AI/PartyCompanionService.js | 64 +++++- src/GameServer/Bot/AI/PartyPulling.js | 168 +++++++++++++-- src/GameServer/Bot/AI/PartyRevivalService.js | 32 ++- .../Bot/AI/States/FollowingState.js | 204 ++++++++++++++++-- src/GameServer/Bot/AI/States/RestingState.js | 12 +- src/GameServer/Bot/BotAI.js | 32 ++- src/GameServer/Network/Response/MoveToPawn.js | 5 +- src/GameServer/Network/Response/SpawnItem.js | 3 +- src/GameServer/World/Generics/NpcRewards.js | 10 +- tests/test_bot_party_chat.js | 19 +- tests/test_c4_protocol_packets.js | 27 +++ tests/test_party_bot_loot.js | 112 +++++++--- tests/test_party_companion_rest_follow.js | 201 ++++++++++++++++- tests/test_party_pull_pause.js | 142 ++++++++++++ tests/test_party_revival.js | 31 +++ 21 files changed, 1064 insertions(+), 97 deletions(-) diff --git a/src/GameServer/Actor/Actor.js b/src/GameServer/Actor/Actor.js index eb834cea..8650bbae 100644 --- a/src/GameServer/Actor/Actor.js +++ b/src/GameServer/Actor/Actor.js @@ -66,7 +66,7 @@ class Actor extends ActorModel { } moveTo(data) { - invoke(path.actor).moveTo( + return invoke(path.actor).moveTo( this.session, this, data ); } diff --git a/src/GameServer/Actor/Generics/MoveTo.js b/src/GameServer/Actor/Generics/MoveTo.js index 918b3280..9cf0ee87 100644 --- a/src/GameServer/Actor/Generics/MoveTo.js +++ b/src/GameServer/Actor/Generics/MoveTo.js @@ -49,8 +49,12 @@ function moveTo(session, actor, coords) { return; } - // Abort scheduled movement, user redirected the actor - actor.automation.abortAll(actor); + const previewOnly = coords.previewOnly === true; + // A route preview must not alter the actor or emit a false movement packet. + if (!previewOnly) { + // Abort scheduled movement, user redirected the actor + actor.automation.abortAll(actor); + } const isBot = session && (session.constructor.name === 'BotSession' || (session.accountId && session.accountId.startsWith('bot_'))); const requestedTo = { ...coords.to }; @@ -106,20 +110,22 @@ function moveTo(session, actor, coords) { // Low LOD: instant warp (we do not calculate movements at all) const snappedTo = { ...requestedTo }; snappedTo.locZ = GeodataEngine.getHeight(snappedTo.locX, snappedTo.locY, snappedTo.locZ); - actor.setLocXYZ(snappedTo); - invoke('GameServer/Bot/AI/PartyCompanionService').updatePosition(session, actor); session.lastPathfinding = { requestedTo, routedTo: { ...snappedTo }, townRoute: null, pathLength: 0, + routeUsable: true, lowLodWarp: true, distanceToPlayer, destinationDistanceToPlayer, strategy: 'low_lod_direct', at: Date.now() }; - return; + if (previewOnly) return session.lastPathfinding; + actor.setLocXYZ(snappedTo); + invoke('GameServer/Bot/AI/PartyCompanionService').updatePosition(session, actor); + return session.lastPathfinding; } const isClose = isCompanion || distanceToPlayer <= 500; @@ -130,19 +136,32 @@ function moveTo(session, actor, coords) { if (!path || path.length <= 1) { const TownPathfinder = invoke('GameServer/Bot/AI/TownPathfinder'); + const previousTownRoutePlan = session.townRoutePlan; const routeResult = TownPathfinder.routeWithSession(session, actor, coords.from, requestedTo); + if (previewOnly) session.townRoutePlan = previousTownRoutePlan; pathTarget = { ...routeResult.to }; townRouteDiagnostics = routeResult.diagnostics; - coords.to.locX = pathTarget.locX; - coords.to.locY = pathTarget.locY; - coords.to.locZ = pathTarget.locZ; + if (!previewOnly) { + coords.to.locX = pathTarget.locX; + coords.to.locY = pathTarget.locY; + coords.to.locZ = pathTarget.locZ; + } pathStrategy = townRouteDiagnostics?.changedTarget ? 'town_waypoint_fallback' : 'direct_fallback'; path = GeodataEngine.findPath(startX, startY, startZ, pathTarget.locX, pathTarget.locY, pathTarget.locZ); - } else if (session) { + } else if (session && !previewOnly) { session.townRoutePlan = null; } + const routeFound = Array.isArray(path) && path.length > 1; + // A* is deliberately bounded and can return null in otherwise open + // terrain. The runtime has always handled that case with a direct + // movement fallback, so distinguish a clear line from a genuinely + // blocked destination before callers decide to reject the route. + const fallbackLineOfSight = !routeFound && GeodataEngine.hasLineOfSight( + startX, startY, startZ, + pathTarget.locX, pathTarget.locY, pathTarget.locZ + ); console.log(`[PATHFIND] Bot ${actor.fetchName()}: from (${startX}, ${startY}, ${startZ}) to (${pathTarget.locX}, ${pathTarget.locY}, ${pathTarget.locZ}) strategy=${pathStrategy} -> Waypoints: ${path ? path.length : 0}`); if (!path || path.length <= 1) { path = [{ locX: pathTarget.locX, locY: pathTarget.locY, locZ: pathTarget.locZ }]; @@ -152,12 +171,16 @@ function moveTo(session, actor, coords) { routedTo: { ...pathTarget }, townRoute: townRouteDiagnostics, pathLength: path.length, + routeUsable: routeFound || fallbackLineOfSight, lowLodWarp: false, distanceToPlayer, destinationDistanceToPlayer, strategy: pathStrategy, at: Date.now() }; + if (previewOnly) { + return session.lastPathfinding; + } const moveAlongPath = (index) => { if (index >= path.length) { @@ -235,6 +258,7 @@ function moveTo(session, actor, coords) { actor.state.setTowards('move'); moveAlongPath(0); + return session.lastPathfinding; } } diff --git a/src/GameServer/Actor/Generics/PickupExec.js b/src/GameServer/Actor/Generics/PickupExec.js index 5397eb60..8e96bfe5 100644 --- a/src/GameServer/Actor/Generics/PickupExec.js +++ b/src/GameServer/Actor/Generics/PickupExec.js @@ -17,7 +17,13 @@ function pickupExec(session, actor, data, onComplete) { }, 500); }); }).catch((err) => { - utils.infoWarn('GameServer', 'Pickup -> ' + err); + utils.infoWarn( + 'GameServer', + 'Pickup failed actor=%s item=%s error=%s', + actor?.fetchName?.() || actor?.fetchId?.() || 'unknown', + data?.id || 'unknown', + err?.message || String(err) + ); onComplete?.(); }); } diff --git a/src/GameServer/Actor/Generics/Revive.js b/src/GameServer/Actor/Generics/Revive.js index 449f6778..7aca1cdd 100644 --- a/src/GameServer/Actor/Generics/Revive.js +++ b/src/GameServer/Actor/Generics/Revive.js @@ -1,5 +1,13 @@ const ServerResponse = invoke('GameServer/Network/Response'); +function finishRevive(session, actor) { + actor.state.setDead(false); + // BotAI uses this marker to run the one-time death lifecycle. A native + // in-place resurrection must release it so a later death is counted and + // announced instead of looking like the same corpse forever. + session.deathTimerStart = undefined; +} + function revive(session, actor, { delayMs = 2500, restoreFullVitals = false } = {}) { if (restoreFullVitals) { actor.automation.stopReplenish(); @@ -9,7 +17,7 @@ function revive(session, actor, { delayMs = 2500, restoreFullVitals = false } = } if (delayMs <= 0) { - actor.state.setDead(false); + finishRevive(session, actor); session.dataSendToMeAndOthers(ServerResponse.revive(actor.fetchId()), actor); session.dataSendToMeAndOthers(ServerResponse.socialAction(actor.fetchId(), 9), actor); return; @@ -18,7 +26,7 @@ function revive(session, actor, { delayMs = 2500, restoreFullVitals = false } = session.dataSendToMeAndOthers(ServerResponse.revive(actor.fetchId()), actor); setTimeout(() => { - actor.state.setDead(false); + finishRevive(session, actor); session.dataSendToMeAndOthers(ServerResponse.socialAction(actor.fetchId(), 9), actor); // SWAG stand-up }, delayMs); } diff --git a/src/GameServer/Bot/AI/BotPartyChat.js b/src/GameServer/Bot/AI/BotPartyChat.js index 15d38931..19866347 100644 --- a/src/GameServer/Bot/AI/BotPartyChat.js +++ b/src/GameServer/Bot/AI/BotPartyChat.js @@ -134,6 +134,37 @@ function didLand(outcome) { ); } +function recordSupportCredit(session, request, target, skill) { + const leaderSession = session?.partyCompanion === true ? session.followPlayerSession : null; + if (!leaderSession?.actor) return false; + const targetId = Number(target?.fetchId?.() || 0); + const belongsToParty = targetId === Number(leaderSession.actor.fetchId?.() || 0) || + (invoke('GameServer/Bot/BotManager').sessions || []).some(candidate => ( + candidate?.partyCompanion === true && + candidate.followPlayerSession === leaderSession && + Number(candidate.actor?.fetchId?.() || 0) === targetId + )); + if (!belongsToParty) return false; + const now = Date.now(); + const key = `${request.kind}:${target?.fetchId?.() || 0}:${skill?.fetchSelfId?.() || 0}`; + session.partySupportSocialCredit ??= new Map(); + const previousAt = Number(session.partySupportSocialCredit.get(key) || 0); + if (now - previousAt < 60000) return false; + session.partySupportSocialCredit.set(key, now); + if (session.partySupportSocialCredit.size > 100) { + for (const [entryKey, at] of session.partySupportSocialCredit) { + if (now - Number(at) >= 60000) session.partySupportSocialCredit.delete(entryKey); + } + } + invoke('GameServer/Bot/AI/BotSocialMemory').recordEvent( + leaderSession, + session, + 'supported_party', + `${request.kind} ${skill?.fetchSelfId?.() || 0} on ${target?.fetchId?.() || 0}` + ); + return true; +} + function resultEntry(request, target, skill) { const targetName = target.fetchName?.() || 'the party'; const skillName = skill.fetchName?.() || 'Support'; @@ -193,6 +224,7 @@ function confirmSkillResult(session, actor, target, skill, outcome) { session.pendingPartyChatResult = undefined; if (!didLand(outcome)) return false; + recordSupportCredit(session, request, target, skill); return announce(session, resultEntry(request, target, skill)); } diff --git a/src/GameServer/Bot/AI/BotSocialMemory.js b/src/GameServer/Bot/AI/BotSocialMemory.js index 67b8028c..f8a335c3 100644 --- a/src/GameServer/Bot/AI/BotSocialMemory.js +++ b/src/GameServer/Bot/AI/BotSocialMemory.js @@ -120,6 +120,9 @@ function applyEvent(record, eventName) { updated.trust += 3; updated.familiarity += 1; updated.helpedInCombat += 1; + } else if (eventName === 'supported_party') { + updated.trust += 1; + updated.familiarity += 1; } else if (eventName === 'trade_completed') { updated.trust += 1; updated.familiarity += 1; diff --git a/src/GameServer/Bot/AI/PartyCompanionService.js b/src/GameServer/Bot/AI/PartyCompanionService.js index 29e303fb..769c3e4b 100644 --- a/src/GameServer/Bot/AI/PartyCompanionService.js +++ b/src/GameServer/Bot/AI/PartyCompanionService.js @@ -13,6 +13,7 @@ const DEFAULT_PARTY_SETTINGS = { itemLastLootIndex: -1 }; const PARTY_LOOT_RADIUS = 2500; +const PARTY_GROUND_LOOT_LEASH_RADIUS = 1200; const GROUND_LOOT_SCAN_INTERVAL_MS = 500; const GROUND_PICKUP_FALLBACK_TIMEOUT_MS = 8000; const GROUND_PICKUP_TIMEOUT_GRACE_MS = 5000; @@ -214,7 +215,21 @@ function canPickGroundLoot(session, leaderSession, item) { if (actor?.storedPickup) { delete actor.storedPickup; } - return distance2d(actor, item) <= PARTY_LOOT_RADIUS; + return distance2d(actor, item) <= PARTY_GROUND_LOOT_LEASH_RADIUS; +} + +function partyGroundLootLeaderId(item) { + return Number(item?.model?.partyLootLeaderId ?? item?.partyLootLeaderId ?? 0); +} + +function isOwnedPartyGroundLoot(leaderSession, item) { + const leaderId = Number(leaderSession?.actor?.fetchId?.() || 0); + return leaderId > 0 && partyGroundLootLeaderId(item) === leaderId; +} + +function isInsidePartyGroundLootLeash(leaderSession, item) { + return !!leaderSession?.actor && + distance2d(leaderSession.actor, item) <= PARTY_GROUND_LOOT_LEASH_RADIUS; } function partyCombatInProgress(leaderSession) { @@ -237,7 +252,9 @@ function availableGroundLoot(leaderSession) { return (world().items?.spawns || []) .filter((item) => item?.fetchId && item?.fetchLocX && item?.fetchLocY) .filter((item) => !queuedIds.has(Number(item.fetchId()))) - .filter((item) => members.some((memberSession) => distance2d(memberSession.actor, item) <= PARTY_LOOT_RADIUS)) + .filter((item) => isOwnedPartyGroundLoot(leaderSession, item)) + .filter((item) => isInsidePartyGroundLootLeash(leaderSession, item)) + .filter((item) => members.some((memberSession) => distance2d(memberSession.actor, item) <= PARTY_GROUND_LOOT_LEASH_RADIUS)) .sort((a, b) => Number(a.fetchId()) - Number(b.fetchId())); } @@ -277,6 +294,7 @@ function reconcileGroundLoot(looterSession) { function nearestGroundLootPicker(looterSession, item) { const leaderSession = partyLeaderSession(looterSession); if (!leaderSession || !item || !AUTOMATED_LOOT_DISTRIBUTIONS.has(distributionForLeader(leaderSession))) return null; + if (!isOwnedPartyGroundLoot(leaderSession, item) || !isInsidePartyGroundLootLeash(leaderSession, item)) return null; return membersForLeader(leaderSession) .filter((memberSession) => canPickGroundLoot(memberSession, leaderSession, item)) @@ -308,9 +326,33 @@ function startQueuedGroundPickup(pickerSession) { const queue = pickerSession?.partyGroundPickupQueue; if (!picker || !queue?.length) return false; const now = Date.now(); + const leaderSession = partyLeaderSession(pickerSession); + const pullState = leaderSession?.partyPullState || {}; + const partyNeedsAttention = ( + [leaderSession, ...membersForLeader(leaderSession)].some((memberSession) => memberSession?.actor?.isDead?.()) || + ['getting_buffed', 'shopping', 'merchant'].includes(pickerSession.plan) || + ( + ['approach', 'aggro', 'return'].includes(pullState.phase) && + Number(picker.fetchId?.()) === Number(pullState.pullerId || 0) + ) || + partyCombatInProgress(leaderSession) || + hasCampThreat(leaderSession) + ); + const pickup = queue[0]; + const queuedItem = (world().items?.spawns || []) + .find((item) => Number(item?.fetchId?.()) === Number(pickup?.id)); + const invalidPickup = !queuedItem || + !isOwnedPartyGroundLoot(leaderSession, queuedItem) || + !isInsidePartyGroundLootLeash(leaderSession, queuedItem) || + distance2d(picker, queuedItem) > PARTY_GROUND_LOOT_LEASH_RADIUS; if (pickerSession.partyGroundPickupInProgress) { const deadlineAt = Number(pickerSession.partyGroundPickupDeadlineAt || 0); - if (!deadlineAt || now < deadlineAt) return false; + // This is a handled AI action. Falling through into FollowingState + // would issue a formation move, cancel the pickup timer and make the + // bot visibly run out and back without collecting anything. Combat, + // revival and a broken leash still outrank loot and must reclaim the + // current tick immediately. + if ((!deadlineAt || now < deadlineAt) && !partyNeedsAttention && !invalidPickup) return true; // A competing movement order can cancel Automation's pickup timer // without invoking PickupExec's completion callback. Do not leave the // whole FIFO permanently locked behind that stale action. @@ -318,8 +360,12 @@ function startQueuedGroundPickup(pickerSession) { picker.state?.setPickinUp?.(false); pickerSession.partyGroundPickupInProgress = false; pickerSession.partyGroundPickupDeadlineAt = 0; + // Invalidate a completion that was already queued before abortAll. + // The item stays queued across transient combat and can retry later. + pickerSession.partyGroundPickupAttempt = Number(pickerSession.partyGroundPickupAttempt || 0) + 1; + if (invalidPickup) queue.shift(); + if (partyNeedsAttention || invalidPickup) return false; } - const leaderSession = partyLeaderSession(pickerSession); // A queued drop is lower priority than a resurrection. This also // protects queues that were assigned before a companion died, rather // than letting the only living support bot run away from the corpse. @@ -330,7 +376,6 @@ function startQueuedGroundPickup(pickerSession) { // built while following and become stale after it starts a town/support // action. Merely assigning a bot as puller is not combat: when no pull // is in progress it may collect ground loot like every other companion. - const pullState = leaderSession?.partyPullState || {}; if ( ['getting_buffed', 'shopping', 'merchant'].includes(pickerSession.plan) || ( @@ -341,7 +386,14 @@ function startQueuedGroundPickup(pickerSession) { if (partyCombatInProgress(leaderSession) || hasCampThreat(leaderSession)) return false; if (picker.state?.fetchPickinUp?.()) return false; - const pickup = queue[0]; + if (!queuedItem || + !isOwnedPartyGroundLoot(leaderSession, queuedItem) || + !isInsidePartyGroundLootLeash(leaderSession, queuedItem) || + !canPickGroundLoot(pickerSession, leaderSession, queuedItem)) { + queue.shift(); + pickerSession.partyGroundPickupDeadlineAt = 0; + return startQueuedGroundPickup(pickerSession); + } pickerSession.partyGroundPickupInProgress = true; pickerSession.partyGroundPickupDeadlineAt = now + groundPickupTimeoutMs(picker, pickup); const attempt = Number(pickerSession.partyGroundPickupAttempt || 0) + 1; diff --git a/src/GameServer/Bot/AI/PartyPulling.js b/src/GameServer/Bot/AI/PartyPulling.js index 16aa7fd4..591f8ac3 100644 --- a/src/GameServer/Bot/AI/PartyPulling.js +++ b/src/GameServer/Bot/AI/PartyPulling.js @@ -18,6 +18,9 @@ const PULL_ABANDON_DISTANCE = 5000; // holding the entire party for coordinate-perfect overlap. const PULL_DELIVERY_MELEE_DISTANCE = 250; const PULL_DELIVERY_CAMP_DISTANCE = 350; +const PULL_UNREACHABLE_RETRY_MS = 30000; +const PULL_ROUTE_SEARCH_BATCH = 5; +const PULL_ROUTE_SEARCH_COOLDOWN_MS = 5000; function point(actor) { return { @@ -229,6 +232,14 @@ function targetDeliveredToCamp(leaderSession, target) { distance2d(point(leaderSession.actor), point(target)) <= PULL_DELIVERY_CAMP_DISTANCE; } +function travellingPullerAwayFromCamp(leaderSession, pulling) { + return pulling?.enabled === true && + pulling.puller?.kind === 'bot' && + ['approach', 'aggro', 'return'].includes(pulling.phase) && + !!leaderSession?.actor && + distance2d(point(pulling.puller.actor), point(leaderSession.actor)) > PULL_RETURN_DISTANCE; +} + function hasDeadPartyMember(leaderSession) { return (World.user?.sessions || []).some((memberSession) => ( PartyAwareness.isPartySession(memberSession, leaderSession) && @@ -236,13 +247,54 @@ function hasDeadPartyMember(leaderSession) { )); } -function nearestFreeMonster(bot) { +function rejectedTargetUntil(leaderSession, targetId) { + return Number(leaderSession.partyPullRejectedTargets?.[targetId] || 0); +} + +function rejectTarget(leaderSession, target) { + const now = Date.now(); + const rejected = leaderSession.partyPullRejectedTargets || {}; + Object.keys(rejected).forEach((targetId) => { + if (Number(rejected[targetId]) <= now) delete rejected[targetId]; + }); + rejected[target.fetchId()] = now + PULL_UNREACHABLE_RETRY_MS; + leaderSession.partyPullRejectedTargets = rejected; + leaderSession.partyPullState = {}; +} + +function freeMonsters(bot) { return World.fetchNpcsInRadius(bot.fetchLocX(), bot.fetchLocY(), PULL_SEARCH_RADIUS) .filter((npc) => npc.fetchAttackable?.() && !npc.isDead?.()) .filter((npc) => !npc.fetchDestId?.()) + .sort((a, b) => distance(point(bot), point(a)) - distance(point(bot), point(b))); +} + +function nearestFreeMonster(bot, leaderSession) { + const now = Date.now(); + return freeMonsters(bot) + .filter((npc) => rejectedTargetUntil(leaderSession, npc.fetchId()) <= now)[0] || null; +} + +function incomingMobOnPuller(bot) { + return (World.npc?.spawns || []) + .filter((npc) => npc.fetchAttackable?.() && !npc.isDead?.()) + .filter((npc) => Number(npc.fetchDestId?.()) === Number(bot.fetchId())) + .filter((npc) => distance(point(bot), point(npc)) <= PULL_ABANDON_DISTANCE) .sort((a, b) => distance(point(bot), point(a)) - distance(point(bot), point(b)))[0] || null; } +function announceNoReachableTargets(session) { + return BotPartyChat.announce(session, { + priority: 'coordination', + dedupeMs: 30000, + key: `pull-no-route:${session.actor.fetchId()}`, + templates: [ + "I can't find a safe route to the nearby mobs. Holding here.", + 'No reachable pull target from here. Waiting for a better route.' + ] + }); +} + function shouldKeepPullMove(session, bot, state, phase, target) { if (!state?.moveTarget || state.movePhase !== phase) return false; if (!(session.moveTimer || bot.state?.fetchTowards?.())) return false; @@ -264,13 +316,28 @@ function shouldKeepPullMove(session, bot, state, phase, target) { } function moveTo(session, bot, state, phase, target) { - if (shouldKeepPullMove(session, bot, state, phase, target)) return false; + if (shouldKeepPullMove(session, bot, state, phase, target)) { + return { started: false, unreachable: false }; + } // FollowingState leaves active pull movement to this coordinator. A new // route is only needed after the old one stopped or its target drifted. state.movePhase = phase; state.moveTarget = point(target); - bot.moveTo({ from: point(bot), to: point(target) }); - return true; + const previousDiagnostics = session.lastPathfinding; + if (phase === 'approach') { + bot.moveTo({ from: point(bot), to: point(target), previewOnly: true }); + } + const diagnostics = session.lastPathfinding; + const requestedDistance = distance(point(bot), point(target)); + const unreachable = phase === 'approach' && + requestedDistance > PULL_CONTACT_DISTANCE && + diagnostics && diagnostics !== previousDiagnostics && + diagnostics?.lowLodWarp !== true && + diagnostics?.routeUsable === false; + if (!unreachable) { + bot.moveTo({ from: point(bot), to: point(target) }); + } + return { started: true, unreachable }; } function clearPullMove(state) { @@ -286,13 +353,61 @@ function aggroActionInFlight(bot) { ); } -function tickBotPuller(session, bot, leaderSession, settings, Generics, BotAI) { +function tickBotPuller(session, bot, leaderSession, settings, Generics, BotAI, searchAttempts = 0) { const puller = resolvePuller(leaderSession, settings); if (!puller || puller.session !== session) return { handled: false, puller }; - const pause = pauseReason(leaderSession, puller); + let target = clearFinishedTarget(leaderSession); + let state = pullState(leaderSession); + // Any mob that has already committed to the travelling puller is a valid + // delivery. Adopt it and return immediately instead of treating its aggro + // as a reason to freeze beside the original target. + const incoming = ['approach', 'aggro'].includes(state.phase) + ? incomingMobOnPuller(bot) + : null; + if (incoming) { + const switchedTarget = Number(incoming.fetchId()) !== Number(target?.fetchId?.()); + if (switchedTarget) { + beginTarget(leaderSession, puller, incoming, 'bot'); + state = pullState(leaderSession); + target = incoming; + } + bot.attack?.abortCast?.(session, bot); + bot.attack?.clearTimers?.(); + bot.state?.setHits?.(false); + bot.state?.setCasts?.(false); + bot.automation?.abortAll?.(bot); + clearPullMove(state); + state.phase = 'return'; + if (!state.announced) { + state.announced = true; + BotPartyChat.announce(session, { + priority: 'coordination', + key: `pull-aggro:${incoming.fetchId()}`, + templates: [ + `${incoming.fetchName()} is on me. Bringing it back.`, + `Got aggro from ${incoming.fetchName()} — returning to camp.` + ] + }); + } + } + // A native basic attack repeats until explicitly stopped. Once the + // opening hit has transferred aggro to the puller, returning to camp must + // win over an unrelated add pause; otherwise the tank keeps auto-attacking + // at the pull spot while the rest of the party handles the add. + const aggroConfirmed = state.phase === 'aggro' && target && + Number(target.fetchDestId?.()) === Number(bot.fetchId()); + if (aggroConfirmed) { + bot.attack?.clearTimers?.(); + bot.state?.setHits?.(false); + clearPullMove(state); + state.phase = 'return'; + } + + // Recovery/add pauses may cancel an approach or an unconfirmed opening + // hit, but must never interrupt a confirmed return leg. + const pause = state.phase === 'return' ? null : pauseReason(leaderSession, puller); if (pause) { - const state = pullState(leaderSession); // A rest/buff pause can happen while the puller is still travelling // towards an untouched mob. Stop that movement immediately instead // of letting its existing automation carry it out of the group. @@ -319,7 +434,6 @@ function tickBotPuller(session, bot, leaderSession, settings, Generics, BotAI) { return { handled: true, puller, paused: pause }; } - let target = clearFinishedTarget(leaderSession); if (!target) { // Do not let the puller win the scheduler race immediately after a // resurrection. The next revival target must be selected before the @@ -328,15 +442,36 @@ function tickBotPuller(session, bot, leaderSession, settings, Generics, BotAI) { if (hasDeadPartyMember(leaderSession)) { return { handled: true, puller, action: 'party_revival' }; } - target = nearestFreeMonster(bot); - if (!target) return { handled: true, puller, idle: true }; + if (Number(leaderSession.partyPullSearchRetryAt || 0) > Date.now()) { + return { handled: true, puller, action: 'route_search_cooldown' }; + } + target = nearestFreeMonster(bot, leaderSession); + if (!target) { + const rejectedNearby = freeMonsters(bot) + .some((npc) => rejectedTargetUntil(leaderSession, npc.fetchId()) > Date.now()); + if (rejectedNearby) { + leaderSession.partyPullSearchRetryAt = Date.now() + PULL_ROUTE_SEARCH_COOLDOWN_MS; + announceNoReachableTargets(session); + return { handled: true, puller, action: 'no_reachable_targets' }; + } + return { handled: true, puller, idle: true }; + } + leaderSession.partyPullSearchRetryAt = undefined; beginTarget(leaderSession, puller, target, 'bot'); + state = pullState(leaderSession); } - const state = pullState(leaderSession); if (state.phase === 'approach') { if (distance(point(bot), point(target)) > PULL_CONTACT_DISTANCE) { - moveTo(session, bot, state, 'approach', target); + const movement = moveTo(session, bot, state, 'approach', target); + if (movement.unreachable) { + bot.automation?.abortAll?.(bot); + rejectTarget(leaderSession, target); + if (searchAttempts + 1 < PULL_ROUTE_SEARCH_BATCH) { + return tickBotPuller(session, bot, leaderSession, settings, Generics, BotAI, searchAttempts + 1); + } + return { handled: true, puller, action: 'searching_reachable_target', target }; + } return { handled: true, puller, action: 'approach', target }; } @@ -418,8 +553,14 @@ function current(leaderSession, settings) { // the camp. Once it is there, the party must attack immediately rather // than wait for the puller to complete a separate return tick: otherwise // the delivered mob can hit the leader before anyone reacts. + const pullerBackAtCamp = puller.actor && leaderSession?.actor && + distance2d(point(puller.actor), point(leaderSession.actor)) <= PULL_RETURN_DISTANCE; const engageable = state.source === 'bot' - ? ['return', 'engage'].includes(state.phase) && targetDeliveredToCamp(leaderSession, target) + ? state.phase === 'engage' || ( + state.phase === 'return' && + pullerBackAtCamp && + targetDeliveredToCamp(leaderSession, target) + ) : targetIsEngageable(leaderSession, target, puller); return { enabled: true, @@ -441,6 +582,7 @@ module.exports = { current, targetIsEngageable, targetDeliveredToCamp, + travellingPullerAwayFromCamp, actorCanEngage, canDeliverPull, hasDeadPartyMember, diff --git a/src/GameServer/Bot/AI/PartyRevivalService.js b/src/GameServer/Bot/AI/PartyRevivalService.js index c735b603..c0d42af5 100644 --- a/src/GameServer/Bot/AI/PartyRevivalService.js +++ b/src/GameServer/Bot/AI/PartyRevivalService.js @@ -4,6 +4,9 @@ const SkillModel = invoke('GameServer/Model/Skill'); const PartyCombatState = invoke('GameServer/Bot/AI/PartyCombatState'); const PARTY_REVIVE_TIMEOUT_MS = 60000; +const PARTY_DEATH_FRUSTRATION_WINDOW_MS = 10 * 60 * 1000; +const PARTY_DEATH_WARNING_COUNT = 2; +const PARTY_DEATH_LEAVE_COUNT = 3; const RESURRECTION_SCROLL_SKILL_ID = 2014; const PLAYER_RESURRECTION_SCROLLS = new Set([737, 3936, 3959]); @@ -31,10 +34,32 @@ function isAlive(session) { function deadMembers(leaderSession) { return partySessions(leaderSession).filter((session) => ( - session?.actor?.fetchIsOnline?.() === true && session.actor.isDead?.() + session?.actor?.fetchIsOnline?.() === true && + session.actor.isDead?.() && + session.partyLeaveAfterDeath !== true )); } +function noteCompanionDeath(leaderSession, deadSession, now = Date.now()) { + if (!isCompanionOf(deadSession, leaderSession)) return { count: 0, warning: false, leaving: false }; + const leaderId = Number(leaderSession.actor?.fetchId?.() || 0); + const previous = deadSession.partyDeathFrustration; + const sameLeader = Number(previous?.leaderId || 0) === leaderId; + const deaths = (sameLeader ? previous?.deaths || [] : []) + .map(Number) + .filter((at) => now - at <= PARTY_DEATH_FRUSTRATION_WINDOW_MS); + deaths.push(now); + deadSession.partyDeathFrustration = { leaderId, deaths }; + const count = deaths.length; + const leaving = count >= PARTY_DEATH_LEAVE_COUNT; + deadSession.partyLeaveAfterDeath = leaving; + return { + count, + warning: count === PARTY_DEATH_WARNING_COUNT, + leaving + }; +} + function partyCombatInProgress(leaderSession) { return PartyCombatState.isActive(leaderSession); } @@ -184,6 +209,7 @@ function tick(session, leaderSession, Generics) { function shouldTownRespawn(leaderSession, deadSession, now = Date.now()) { if (!isCompanionOf(deadSession, leaderSession) || !leaderSession?.actor?.fetchIsOnline?.()) return true; + if (deadSession.partyLeaveAfterDeath === true) return true; const members = partySessions(leaderSession); const living = members.filter(isAlive); @@ -195,6 +221,9 @@ function shouldTownRespawn(leaderSession, deadSession, now = Date.now()) { module.exports = { PARTY_REVIVE_TIMEOUT_MS, + PARTY_DEATH_FRUSTRATION_WINDOW_MS, + PARTY_DEATH_WARNING_COUNT, + PARTY_DEATH_LEAVE_COUNT, partySessions, deadMembers, partyCombatInProgress, @@ -202,5 +231,6 @@ module.exports = { resurrectionSkill, playerCanResurrect, tick, + noteCompanionDeath, shouldTownRespawn }; diff --git a/src/GameServer/Bot/AI/States/FollowingState.js b/src/GameServer/Bot/AI/States/FollowingState.js index 4c11e584..2e5de354 100644 --- a/src/GameServer/Bot/AI/States/FollowingState.js +++ b/src/GameServer/Bot/AI/States/FollowingState.js @@ -36,6 +36,9 @@ const STARTER_GUIDE_TOWN_RADIUS = 1500; const CRITICAL_COMBAT_HP_RATIO = 0.25; const PARTY_RETREAT_DISTANCE = 500; const PARTY_RETREAT_REPATH_MS = 1500; +const SUPPORT_APPROACH_TIMEOUT_MS = 10000; +const SUPPORT_APPROACH_REPATH_MS = 1500; +const SUPPORT_TARGET_DRIFT = 120; function ratio(value, max) { if (!max) return 0; @@ -283,6 +286,18 @@ function sitDown(session, bot) { return true; } +function roleDecisionSignature(decision) { + return [ + decision?.role, + decision?.action, + decision?.reason, + decision?.targetId, + decision?.protectedId, + decision?.threatSource, + decision?.phase + ].map((value) => value ?? '').join(':'); +} + function recordRoleDecision(session, bot, action, reason, extra = {}) { const role = BotRoles.inferRole(bot); const previous = session.roleDecision; @@ -296,14 +311,20 @@ function recordRoleDecision(session, bot, action, reason, extra = {}) { session.roleDecision = current; - const signature = `${role}:${action}:${reason}`; + const signature = roleDecisionSignature(current); const shouldLog = !previous || - `${previous.role}:${previous.action}:${previous.reason}` !== signature || + roleDecisionSignature(previous) !== signature || current.at - (session.lastRoleDecisionLogAt || 0) > 10000; if (shouldLog) { session.lastRoleDecisionLogAt = current.at; - console.info("BotRole :: %s %s/%s (%s)", bot.fetchName(), action, reason, role); + const details = [ + current.targetId !== undefined && `target=${current.targetId}`, + current.protectedId !== undefined && `protected=${current.protectedId}`, + current.threatSource && `source=${current.threatSource}`, + current.phase && `phase=${current.phase}` + ].filter(Boolean).join(' '); + console.info("BotRole :: %s %s/%s (%s)%s", bot.fetchName(), action, reason, role, details ? ` ${details}` : ''); } } @@ -320,6 +341,82 @@ function castSkillOn(session, bot, Generics, target, skill, ctrl, announcement = Generics.skillExec(session, bot, { id: target.fetchId(), selfId: skill.fetchSelfId(), ctrl }); } +function clearPendingSupportApproach(session, bot, { abortMove = false } = {}) { + if (!session.pendingSupportApproach) return false; + session.pendingSupportApproach = undefined; + if (abortMove && bot.state?.fetchTowards?.() && !bot.state?.fetchHits?.() && !bot.state?.fetchCasts?.()) { + bot.automation?.abortAll?.(bot); + } + return true; +} + +function queueSupportSkillOn(session, bot, Generics, target, skill, ctrl, kind, announcement = null) { + const castRange = Math.max(0, Number(skill.fetchDistance?.()) || 0); + if (point(bot).distance(point(target)) <= castRange) { + clearPendingSupportApproach(session, bot, { abortMove: true }); + castSkillOn(session, bot, Generics, target, skill, ctrl, announcement); + return 'cast'; + } + + const now = Date.now(); + const targetLoc = loc(target); + const pending = session.pendingSupportApproach; + const sameAction = pending && + Number(pending.targetId) === Number(target.fetchId()) && + Number(pending.skillId) === Number(skill.fetchSelfId()); + const targetDrift = sameAction && pending.targetLoc + ? distance2d(pending.targetLoc, targetLoc) + : Infinity; + const shouldRepath = !sameAction || + !session.moveTimer || + !bot.state?.fetchTowards?.() || + targetDrift > SUPPORT_TARGET_DRIFT || + now - Number(pending.lastMoveAt || 0) >= SUPPORT_APPROACH_REPATH_MS; + + session.pendingSupportApproach = { + targetId: target.fetchId(), + skillId: skill.fetchSelfId(), + ctrl, + kind, + announcement, + startedAt: sameAction ? pending.startedAt : now, + lastMoveAt: shouldRepath ? now : pending.lastMoveAt, + targetLoc + }; + + if (shouldRepath) { + bot.moveTo({ from: loc(bot), to: targetLoc }); + } + return 'approach'; +} + +function resumePendingSupportApproach(session, bot, Generics, leaderSession) { + const pending = session.pendingSupportApproach; + if (!pending) return null; + + const targetSession = PartyAwareness.partySessions(leaderSession).find((memberSession) => ( + Number(memberSession.actor?.fetchId?.()) === Number(pending.targetId) + )); + const target = targetSession?.actor; + const skill = bot.skillset?.fetchSkill?.(pending.skillId); + if (!target || target.isDead?.() || !skill || Date.now() - pending.startedAt > SUPPORT_APPROACH_TIMEOUT_MS) { + clearPendingSupportApproach(session, bot, { abortMove: true }); + return { handled: false, expired: true }; + } + + const result = queueSupportSkillOn( + session, + bot, + Generics, + target, + skill, + pending.ctrl, + pending.kind, + pending.announcement + ); + return { handled: true, action: result, target, skill, kind: pending.kind }; +} + function canAttemptAggression(session, target) { const previous = session.lastAggressionAttempt; const targetId = Number(target?.fetchId?.() || 0); @@ -338,6 +435,32 @@ function rememberAggressionAttempt(session, target) { }; } +function pendingSupportShouldYield(session, bot, leaderSession, pullerActor, partyThreat) { + const pending = session.pendingSupportApproach; + if (!pending) return false; + + const targetSession = PartyAwareness.partySessions(leaderSession).find((memberSession) => ( + Number(memberSession.actor?.fetchId?.()) === Number(pending.targetId) + )); + const target = targetSession?.actor; + if (!target || target.isDead?.()) return true; + + const targetHpRatio = ratio(target.fetchHp(), target.fetchMaxHp()); + const targetMpRatio = ratio(target.fetchMp(), target.fetchMaxMp()); + if (pending.kind === 'top_off' && targetHpRatio >= 0.70) return true; + if (pending.kind === 'restore_mp' && targetMpRatio >= 0.55) return true; + if (partyThreat && pending.kind === 'restore_mp') return true; + + if (BotRoles.inferRole(bot) !== 'healer') return false; + const emergency = weakestPartyMember(leaderSession, bot, pullerActor); + if (emergency?.hpRatio < 0.45) return ( + pending.kind !== 'emergency_heal' || + Number(pending.targetId) !== Number(emergency.actor.fetchId()) + ); + return ratio(bot.fetchHp(), bot.fetchMaxHp()) < 0.55 && + Number(pending.targetId) !== Number(bot.fetchId()); +} + function partyActorIds(leaderSession) { return new Set(PartyAwareness.partyActors(leaderSession) .map((actor) => actor.fetchId()) @@ -538,8 +661,10 @@ function activeBotPullTravel(session, pulling) { ['approach', 'return'].includes(pulling.phase); } -function pullBlockReason(session, botVitals, partyVitals, activeMobs) { - if (session.autoTaunt === false) return 'manual_pull_off'; +function pullBlockReason(session, botVitals, partyVitals, activeMobs, partySettings) { + // pullMode is authoritative. autoTaunt is only a per-session mirror and + // can briefly be stale after party/session lifecycle changes. + if (partySettings?.pullMode === 'off' || session.autoTaunt === false) return 'manual_pull_off'; if (session.botStay) return 'stay_order'; if (session.currentTargetId) return 'already_assisting'; if (partyVitals?.hpRatio < 0.65) return 'party_low_hp'; @@ -644,6 +769,14 @@ module.exports = { const holdingPulledTarget = pulling.target && !pulling.engageable; const rawThreatIsHeldPull = holdingPulledTarget && Number(rawPartyThreat?.actor?.fetchId?.()) === Number(pulling.target.fetchId()); + // While the puller is away from camp, mobs attacking only that puller + // are part of the delivery, not a signal for the whole formation to + // run out. Once the puller returns (or becomes critical), ranged adds + // remain normal threats and the party will go finish them. + const pullerAwayFromCamp = PartyPulling.travellingPullerAwayFromCamp(playerSession, pulling); + const rawThreatOnlyTargetsTravellingPuller = pullerAwayFromCamp && + Number(rawPartyThreat?.targetId || 0) === Number(pulling.puller?.actor?.fetchId?.() || 0) && + ratio(pulling.puller.actor.fetchHp(), pulling.puller.actor.fetchMaxHp()) >= CRITICAL_COMBAT_HP_RATIO; let partyThreat = pulling.engageable && pulling.target ? { type: 'npc', @@ -651,7 +784,7 @@ module.exports = { targetId: pulling.puller.actor.fetchId(), source: 'party_pull' } - : (rawThreatIsHeldPull || (combatMode === 'passive' && rawPartyThreat?.targetId !== bot.fetchId()) + : (rawThreatIsHeldPull || rawThreatOnlyTargetsTravellingPuller || (combatMode === 'passive' && rawPartyThreat?.targetId !== bot.fetchId()) ? null : rawPartyThreat); const leaderTargetId = pulling.enabled ? undefined : configuredLeaderTargetId; @@ -659,6 +792,7 @@ module.exports = { const impairments = EffectStore.impairments(bot); if (impairments.disabled) { + clearPendingSupportApproach(session, bot, { abortMove: true }); session.currentTargetId = undefined; bot.unselect(); recordRoleDecision(session, bot, 'disabled', 'debuff_control'); @@ -726,6 +860,21 @@ module.exports = { return; } + if (pendingSupportShouldYield(session, bot, playerSession, pulling.puller?.actor, partyThreat)) { + clearPendingSupportApproach(session, bot, { abortMove: true }); + } + const pendingSupport = resumePendingSupportApproach(session, bot, Generics, playerSession); + if (pendingSupport?.handled) { + recordRoleDecision( + session, + bot, + pendingSupport.action === 'cast' ? 'heal_party' : 'move_for_support', + pendingSupport.kind, + { targetId: pendingSupport.target.fetchId(), skillId: pendingSupport.skill.fetchSelfId() } + ); + return; + } + const botVitals = { hpRatio: ratio(bot.fetchHp(), bot.fetchMaxHp()), mpRatio: ratio(bot.fetchMp(), bot.fetchMaxMp()) @@ -946,12 +1095,16 @@ module.exports = { if (woundedPartyMember?.hpRatio < 0.45 && healerCanCast) { acted = true; recordRoleDecision(session, bot, 'heal_party', 'emergency_heal', { targetId: woundedPartyMember.actor.fetchId() }); - castSkillOn(session, bot, Generics, woundedPartyMember.actor, healerSkill, false, { kind: 'emergency_heal' }); + queueSupportSkillOn(session, bot, Generics, woundedPartyMember.actor, healerSkill, false, 'emergency_heal', { kind: 'emergency_heal' }); returnToPartyAfterSupport(session, bot, player, woundedPartyMember.actor); + } else if (botVitals.hpRatio < 0.55 && botVitals.mpRatio >= 0.25 && healerCanCast) { + acted = true; + recordRoleDecision(session, bot, 'heal_self', 'self_preservation', { targetId: bot.fetchId() }); + queueSupportSkillOn(session, bot, Generics, bot, healerSkill, false, 'self_preservation'); } else if (woundedPartyMember?.hpRatio < 0.70 && botVitals.mpRatio >= 0.35 && healerCanCast) { acted = true; recordRoleDecision(session, bot, 'heal_party', 'top_off', { targetId: woundedPartyMember.actor.fetchId() }); - castSkillOn(session, bot, Generics, woundedPartyMember.actor, healerSkill, false); + queueSupportSkillOn(session, bot, Generics, woundedPartyMember.actor, healerSkill, false, 'top_off'); returnToPartyAfterSupport(session, bot, player, woundedPartyMember.actor); } else if (woundedPartyMember?.hpRatio < 0.70 && botVitals.mpRatio < 0.35) { recordRoleDecision(session, bot, 'save_mp', woundedPartyMember.hpRatio < 0.45 ? 'low_mp_emergency' : 'party_not_critical'); @@ -959,10 +1112,6 @@ module.exports = { BotPartyChat.announceHealManaShortage(session, woundedPartyMember.actor); } keepRoleDecision = true; - } else if (botVitals.hpRatio < 0.55 && botVitals.mpRatio >= 0.25 && healerCanCast) { - acted = true; - recordRoleDecision(session, bot, 'heal_self', 'self_preservation', { targetId: bot.fetchId() }); - castSkillOn(session, bot, Generics, bot, healerSkill, false); } else if (impairments.silenced) { recordRoleDecision(session, bot, 'save_mp', 'silenced'); keepRoleDecision = true; @@ -976,7 +1125,7 @@ module.exports = { targetId: manaPartyMember.actor.fetchId(), skillId: rechargeSkill.fetchSelfId() }); - castSkillOn(session, bot, Generics, manaPartyMember.actor, rechargeSkill, false); + queueSupportSkillOn(session, bot, Generics, manaPartyMember.actor, rechargeSkill, false, 'restore_mp'); returnToPartyAfterSupport(session, bot, player, manaPartyMember.actor); } else if (!healerSkill && woundedPartyMember?.hpRatio < 0.70) { recordRoleDecision(session, bot, 'cannot_heal', 'no_learned_heal'); @@ -1048,7 +1197,7 @@ module.exports = { // The marked mob is intentionally not a chase target. Keep the // formation on the player, then join combat once this particular // companion can strike from its own position. - recordRoleDecision(session, bot, 'follow_leader', pulling.paused || 'hold_for_pull', { + recordRoleDecision(session, bot, 'follow_leader', rawThreatOnlyTargetsTravellingPuller ? 'hold_for_pull' : (pulling.paused || 'hold_for_pull'), { targetId: pulling.target.fetchId(), pullerId: pulling.puller?.actor?.fetchId?.() || null }); @@ -1082,11 +1231,14 @@ module.exports = { } } - if (!acted && role === 'tank' && !PartyPulling.enabled(partySettings)) { + // Auto mode retains the lightweight tank fallback. Explicit Off is a + // quiet order, not an "avoid overpull" failure that should overwrite + // the tank's otherwise useful role status every tick. + if (!acted && role === 'tank' && partySettings.pullMode === 'auto') { const activeMobs = partyAggroCount(playerSession); const blockReason = PartyPulling.hasDeadPartyMember(playerSession) ? 'party_revival' - : pullBlockReason(session, botVitals, partyVitals, activeMobs); + : pullBlockReason(session, botVitals, partyVitals, activeMobs, partySettings); if (blockReason) { recordRoleDecision(session, bot, 'avoid_overpull', blockReason, { activeMobs }); @@ -1123,23 +1275,34 @@ module.exports = { const target = partyThreat.actor; const targetId = target.fetchId(); const holdSupportLine = !supportCanMeleeAssist(bot, role); - if (holdSupportLine) { session.currentTargetId = undefined; bot.unselect(); recordRoleDecision(session, bot, BotRoles.partyRoleStance(role), 'hold_support_line', { targetId, targetType: partyThreat.type, - protectedId: partyThreat.targetId + protectedId: partyThreat.targetId, + threatSource: partyThreat.source || 'targeting_party' }); keepRoleDecision = true; - } else if (session.currentTargetId !== targetId) { + } else { + // A combat-capable party member owns this tick even if an + // earlier hit, cast, or approach is still in flight. Support + // roles that hold their line may still follow the formation. + acted = true; + } + + if (!holdSupportLine && session.currentTargetId !== targetId) { + if (bot.state?.fetchTowards?.() && !bot.state?.fetchHits?.() && !bot.state?.fetchCasts?.()) { + bot.automation?.abortAll?.(bot); + } session.currentTargetId = targetId; bot.select({ id: targetId }); recordRoleDecision(session, bot, assistActionForRole(role), 'party_under_attack', { targetId, targetType: partyThreat.type, - protectedId: partyThreat.targetId + protectedId: partyThreat.targetId, + threatSource: partyThreat.source || 'targeting_party' }); } @@ -1150,7 +1313,6 @@ module.exports = { } else { BotAI.executeCombat(session, bot, target, Generics, { basicAttackOnly }); } - acted = true; } } diff --git a/src/GameServer/Bot/AI/States/RestingState.js b/src/GameServer/Bot/AI/States/RestingState.js index b463a41e..d8372c16 100644 --- a/src/GameServer/Bot/AI/States/RestingState.js +++ b/src/GameServer/Bot/AI/States/RestingState.js @@ -164,12 +164,14 @@ module.exports = { return; } - // A recovering companion must stay seated even when its leader is - // far away. Otherwise RestingState stands it up to follow, then - // FollowingState immediately seats it again for low HP/MP. - const shouldFollowLeader = recovered && ( + // When the whole party rests, regroup first. FollowingState checks + // the seated leader before its low-resource branch, so the bot + // moves into formation and then sits there without oscillating. + // A companion resting alone still finishes recovery in place. + const shouldRegroupForPartyRest = leaderSeated && distance > 250; + const shouldFollowLeader = shouldRegroupForPartyRest || (recovered && ( distance > REST_FOLLOW_WAKE_DISTANCE || !leaderSeated - ); + )); if (combatTargetId || shouldFollowLeader) { session.plan = 'following'; session.currentTargetId = combatTargetId || undefined; diff --git a/src/GameServer/Bot/BotAI.js b/src/GameServer/Bot/BotAI.js index 8441120c..92ddffa6 100644 --- a/src/GameServer/Bot/BotAI.js +++ b/src/GameServer/Bot/BotAI.js @@ -364,13 +364,26 @@ const BotAI = { if (!session.deathTimerStart) { session.deathTimerStart = Date.now(); if (wasCompanion) { + const deathReaction = PartyRevivalService.noteCompanionDeath( + session.followPlayerSession, + session, + session.deathTimerStart + ); invoke('GameServer/Bot/AI/BotPartyChat').announce(session, { priority: 'critical', key: `party-death:${bot.fetchId()}`, - templates: [ - `${bot.fetchName()} is down — waiting for resurrection.`, - `Down at the camp. Waiting for a resurrection.` - ] + templates: deathReaction.leaving + ? [ + `Down again. That's enough — I'm returning to town and leaving the party.` + ] + : deathReaction.warning + ? [ + `Down again. I'm getting tired of dying — one more death soon and I'm leaving.` + ] + : [ + `${bot.fetchName()} is down — waiting for resurrection.`, + `Down at the camp. Waiting for a resurrection.` + ] }); } else { this.say(session, 'Oops... I died! Resurrecting shortly.'); @@ -389,6 +402,7 @@ const BotAI = { // normal town restart remains the escape hatch for a wipe, an // unsupported solo leader, or an unanswered corpse. if (!partyRescuePending && Date.now() - session.deathTimerStart > 12000) { + const deathStartedAt = session.deathTimerStart; // TeleportTo rejects actors that are still marked dead, so bot // respawns must complete before applying the new town location. Generics.revive(session, bot, { delayMs: 0, restoreFullVitals: true }); @@ -403,10 +417,20 @@ const BotAI = { spawnTarget = this.getDeathRespawnTarget(session, bot); } else { if (wasCompanion) { + if (session.partyLeaveAfterDeath !== true) { + invoke('GameServer/Bot/AI/BotPartyChat').announce(session, { + priority: 'critical', + key: `party-respawn-timeout:${bot.fetchId()}:${deathStartedAt}`, + templates: [ + `No resurrection came. I'm returning to town and leaving the party.` + ] + }); + } PartyCompanionService.clearCompanion(session, { plan: 'hunting', refreshPanel: false }); + session.partyLeaveAfterDeath = false; // A corpse that timed out of party resurrection has // just been sent to town. Keep the now-solo bot hot // long enough to complete that visible transition; diff --git a/src/GameServer/Network/Response/MoveToPawn.js b/src/GameServer/Network/Response/MoveToPawn.js index 5df678c2..d00dd33b 100644 --- a/src/GameServer/Network/Response/MoveToPawn.js +++ b/src/GameServer/Network/Response/MoveToPawn.js @@ -9,7 +9,10 @@ function moveToPawn(src, dst, distance) { .writeD(distance) .writeD(src.fetchLocX()) .writeD(src.fetchLocY()) - .writeD(src.fetchLocZ()); + .writeD(src.fetchLocZ()) + .writeD(dst.fetchLocX()) + .writeD(dst.fetchLocY()) + .writeD(dst.fetchLocZ()); return packet.fetchBuffer(); } diff --git a/src/GameServer/Network/Response/SpawnItem.js b/src/GameServer/Network/Response/SpawnItem.js index 0407d58d..dfb483db 100644 --- a/src/GameServer/Network/Response/SpawnItem.js +++ b/src/GameServer/Network/Response/SpawnItem.js @@ -10,7 +10,8 @@ function spawnItem(item) { .writeD(item.fetchLocY()) .writeD(item.fetchLocZ()) .writeD(item.fetchStackable()) - .writeD(item.fetchAmount()); + .writeD(item.fetchAmount()) + .writeD(0x00); // C2/C4 trailing field return packet.fetchBuffer(); } diff --git a/src/GameServer/World/Generics/NpcRewards.js b/src/GameServer/World/Generics/NpcRewards.js index d0feb7a6..a613d50d 100644 --- a/src/GameServer/World/Generics/NpcRewards.js +++ b/src/GameServer/World/Generics/NpcRewards.js @@ -44,8 +44,16 @@ function awardDirect(world, session, selfId, amount) { function spawnGroundDrop(world, session, npc, selfId, amount) { const point = new SpeckMath.Circle(npc.fetchLocX(), npc.fetchLocY(), 50).createPointWithin(); + const leaderSession = session?.partyCompanion === true && session.followPlayerSession + ? session.followPlayerSession + : session; world.spawnItem(session, selfId, amount, { - ...point.toCoords(), locZ: npc.fetchLocZ() - 10 + ...point.toCoords(), + locZ: npc.fetchLocZ() - 10, + // Ground items have no native owner metadata in this runtime. Keep a + // lightweight provenance marker so an idle party never treats another + // group's nearby drop as its own recovery work. + partyLootLeaderId: Number(leaderSession?.actor?.fetchId?.() || 0) }, (item) => { PartyCompanionService.queueRandomGroundPickup(session, item); }); diff --git a/tests/test_bot_party_chat.js b/tests/test_bot_party_chat.js index e4c6ef9e..3c571f5d 100644 --- a/tests/test_bot_party_chat.js +++ b/tests/test_bot_party_chat.js @@ -4,6 +4,7 @@ require('../src/Global'); const BotManager = invoke('GameServer/Bot/BotManager'); const BotPartyChat = invoke('GameServer/Bot/AI/BotPartyChat'); +const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); function actor(id, name) { return { @@ -14,7 +15,10 @@ function actor(id, name) { const originalPartySay = BotManager.botPartySay; const originalTell = BotManager.botTell; +const originalSessions = BotManager.sessions; +const originalRecordEvent = BotSocialMemory.recordEvent; const messages = []; +const socialEvents = []; try { BotManager.botPartySay = (_session, text) => { @@ -25,6 +29,10 @@ try { messages.push({ scope: 'tell', target: targetSession.actor.fetchName(), text }); return true; }; + BotSocialMemory.recordEvent = (leaderSession, botSession, eventName, detail) => { + socialEvents.push({ leaderSession, botSession, eventName, detail }); + return Promise.resolve(null); + }; const leaderSession = { actor: actor(1, 'Slava') }; const companionSession = { @@ -61,7 +69,12 @@ try { assert.match(messages.at(-1).text, /MP/, 'the mana warning should explain the actionable limitation'); const target = actor(3, 'Belen'); - const targetSession = { actor: target }; + const targetSession = { + actor: target, + partyCompanion: true, + followPlayerSession: leaderSession + }; + BotManager.sessions = [companionSession, targetSession]; const skill = { fetchSelfId: () => 1068, fetchName: () => 'Might' @@ -78,6 +91,8 @@ try { assert.deepStrictEqual(messages.at(-1), { scope: 'tell', target: 'Belen', text: 'Might is up on Belen.' }); + assert.strictEqual(socialEvents.length, 1, 'a confirmed party support result should earn social credit'); + assert.strictEqual(socialEvents[0].eventName, 'supported_party'); assert.strictEqual(BotPartyChat.expectSkillResult(companionSession, { target, @@ -119,4 +134,6 @@ try { } finally { BotManager.botPartySay = originalPartySay; BotManager.botTell = originalTell; + BotManager.sessions = originalSessions; + BotSocialMemory.recordEvent = originalRecordEvent; } diff --git a/tests/test_c4_protocol_packets.js b/tests/test_c4_protocol_packets.js index 96d60baa..c440a619 100644 --- a/tests/test_c4_protocol_packets.js +++ b/tests/test_c4_protocol_packets.js @@ -243,6 +243,33 @@ assert.strictEqual(chooseInventoryItem[0], 0x6f, 'C4 ChooseInventoryItem respons assert.strictEqual(chooseInventoryItem.readInt32LE(1), 731, 'C4 ChooseInventoryItem should send the selected scroll item id'); const actor = fakeActor(); +const movementTarget = { + fetchId: () => 3000001, + fetchLocX: () => 101, + fetchLocY: () => 202, + fetchLocZ: () => 303 +}; +const moveToPawn = ServerResponse.moveToPawn(actor, movementTarget, 80); +assert.strictEqual(moveToPawn[0], 0x60, 'C4 MoveToPawn opcode should be 0x60'); +assert.strictEqual(moveToPawn.readInt32LE(5), movementTarget.fetchId(), 'C4 MoveToPawn should include the target object id'); +assert.strictEqual(moveToPawn.readInt32LE(25), movementTarget.fetchLocX(), 'C4 MoveToPawn must include target X after source coordinates'); +assert.strictEqual(moveToPawn.readInt32LE(29), movementTarget.fetchLocY(), 'C4 MoveToPawn must include target Y after source coordinates'); +assert.strictEqual(moveToPawn.readInt32LE(33), movementTarget.fetchLocZ(), 'C4 MoveToPawn must include target Z after source coordinates'); + +const groundItem = { + fetchId: () => 1000002, + fetchSelfId: () => 57, + fetchLocX: () => 11, + fetchLocY: () => 22, + fetchLocZ: () => 33, + fetchStackable: () => 1, + fetchAmount: () => 1234 +}; +const spawnItem = ServerResponse.spawnItem(groundItem); +assert.strictEqual(spawnItem[0], 0x0b, 'C4 SpawnItem opcode should be 0x0b'); +assert.strictEqual(spawnItem.readInt32LE(25), groundItem.fetchAmount(), 'C4 SpawnItem should include the ground stack amount'); +assert.strictEqual(spawnItem.readInt32LE(29), 0, 'C4 SpawnItem must include its trailing protocol field'); + const etcStatusUpdate = ServerResponse.etcStatusUpdate(actor); assert.strictEqual(etcStatusUpdate[0], 0xf3, 'C4 EtcStatusUpdate response opcode should be 0xf3'); assert.strictEqual(etcStatusUpdate.readInt32LE(1), 3, 'C4 EtcStatusUpdate should send current charges first'); diff --git a/tests/test_party_bot_loot.js b/tests/test_party_bot_loot.js index 550531e4..e05b65ba 100644 --- a/tests/test_party_bot_loot.js +++ b/tests/test_party_bot_loot.js @@ -35,15 +35,21 @@ try { const spawned = []; const purchased = []; + World.items = { spawns: [] }; + const removeWorldItem = (id) => { + World.items.spawns = World.items.spawns.filter(item => Number(item.fetchId()) !== Number(id)); + }; const world = { spawnItem(session, selfId, amount, coords, onSpawn) { const item = { + model: { ...coords }, fetchId: () => 500001, fetchLocX: () => coords.locX, fetchLocY: () => coords.locY, fetchLocZ: () => coords.locZ }; spawned.push({ session, selfId, amount, coords }); + World.items.spawns.push(item); onSpawn(item); }, purchaseItem(session, selfId, amount) { purchased.push({ session, selfId, amount }); } @@ -116,9 +122,11 @@ try { assert.strictEqual(spawned.length, 1, 'a companion bot kill should create a visible ground drop for the party'); assert.strictEqual(spawned[0].selfId, 57); + assert.strictEqual(spawned[0].coords.partyLootLeaderId, leaderSession.actor.fetchId(), 'party ground drops should retain their leader provenance'); assert.strictEqual(purchased.length, 0, 'a companion bot kill must not silently route the drop into bot inventory'); assert.deepStrictEqual(pickupCalls.map(({ session, actor, data }) => ({ session, actor, data })), [{ session: botSession, actor: closestBot, data: { id: 500001 } }], 'with Random loot the closest active companion should immediately start normal server-side pickup'); assert.strictEqual(distantBot.storedPickup, undefined, 'only one nearest companion should receive the pickup order'); + removeWorldItem(500001); pickupCalls[0].onComplete(); const activeThreat = { @@ -131,25 +139,32 @@ try { }; World.npc = { spawns: [activeThreat] }; World.fetchNpcsInRadius = () => [activeThreat]; - PartyCompanionService.queueRandomGroundPickup(botSession, { + const queuedAdena = { + partyLootLeaderId: leaderSession.actor.fetchId(), fetchId: () => 500002, fetchLocX: () => 100, fetchLocY: () => 200, fetchLocZ: () => -310 - }); - PartyCompanionService.queueRandomGroundPickup(botSession, { + }; + const queuedItem = { + partyLootLeaderId: leaderSession.actor.fetchId(), fetchId: () => 500003, fetchLocX: () => 100, fetchLocY: () => 200, fetchLocZ: () => -310 - }); + }; + World.items.spawns.push(queuedAdena, queuedItem); + PartyCompanionService.queueRandomGroundPickup(botSession, queuedAdena); + PartyCompanionService.queueRandomGroundPickup(botSession, queuedItem); assert.strictEqual(pickupCalls.length, 1, 'a drop arriving while the party is in combat should wait instead of interrupting the fight'); World.npc = { spawns: [] }; World.fetchNpcsInRadius = () => []; PartyCompanionService.startQueuedGroundPickup(botSession); assert.deepStrictEqual(pickupCalls[1] && { session: pickupCalls[1].session, actor: pickupCalls[1].actor, data: pickupCalls[1].data }, { session: botSession, actor: closestBot, data: { id: 500002 } }, 'a queued hot-bot pickup should execute server-side after combat instead of waiting for a client position packet'); + removeWorldItem(500002); pickupCalls[1].onComplete(); assert.deepStrictEqual(pickupCalls[2] && { session: pickupCalls[2].session, actor: pickupCalls[2].actor, data: pickupCalls[2].data }, { session: botSession, actor: closestBot, data: { id: 500003 } }, 'multiple drops assigned to the same bot should be picked up in FIFO order'); + removeWorldItem(500003); pickupCalls[2].onComplete(); botSession.partyGroundPickupQueue = [{ id: 500007 }]; @@ -158,31 +173,51 @@ try { assert.strictEqual(pickupCalls.length, 3, 'a pending resurrection must preempt queued loot'); leaderSession.actor.isDead = () => false; leaderSession.partyCompanionSettings = { distribution: 1, pullMode: 'bot', pullerId: closestBot.fetchId() }; + World.items.spawns.push({ + partyLootLeaderId: leaderSession.actor.fetchId(), + fetchId: () => 500007, + fetchLocX: () => 100, + fetchLocY: () => 200, + fetchLocZ: () => -310 + }); assert.strictEqual(PartyCompanionService.startQueuedGroundPickup(botSession), true, 'an assigned puller should collect queued ground loot while no pull is active'); assert.deepStrictEqual(pickupCalls[3] && { session: pickupCalls[3].session, actor: pickupCalls[3].actor, data: pickupCalls[3].data }, { session: botSession, actor: closestBot, data: { id: 500007 } }, 'idle puller loot should use the normal server-side pickup path'); + removeWorldItem(500007); pickupCalls[3].onComplete(); botSession.partyGroundPickupQueue = []; leaderSession.partyCompanionSettings = { distribution: 1 }; leaderSession.partyPullState = {}; - // Loot reconciliation must not depend on the death that produced the - // item. A pre-existing drop is still party loot once the group is idle. + // Loot reconciliation may recover an older owned drop, but must ignore a + // nearby item created by another player or party. World.items = { - spawns: [{ - fetchId: () => 500004, - fetchLocX: () => 130, - fetchLocY: () => 200, - fetchLocZ: () => -310 - }] + spawns: [ + { + partyLootLeaderId: 999, + fetchId: () => 500012, + fetchLocX: () => 120, + fetchLocY: () => 200, + fetchLocZ: () => -310 + }, + { + partyLootLeaderId: leaderSession.actor.fetchId(), + fetchId: () => 500004, + fetchLocX: () => 130, + fetchLocY: () => 200, + fetchLocZ: () => -310 + } + ] }; leaderSession.lastGroundLootScanAt = 0; PartyCompanionService.reconcileGroundLoot(botSession); - assert.deepStrictEqual(pickupCalls[4] && { session: pickupCalls[4].session, actor: pickupCalls[4].actor, data: pickupCalls[4].data }, { session: botSession, actor: closestBot, data: { id: 500004 } }, 'an idle hot party should collect reachable loot that was already lying on the ground'); + assert.deepStrictEqual(pickupCalls[4] && { session: pickupCalls[4].session, actor: pickupCalls[4].actor, data: pickupCalls[4].data }, { session: botSession, actor: closestBot, data: { id: 500004 } }, 'an idle hot party should recover only its own reachable ground loot'); + removeWorldItem(500004); pickupCalls[4].onComplete(); closestBot.storedPickup = { id: 499999 }; World.items = { spawns: [{ + partyLootLeaderId: leaderSession.actor.fetchId(), fetchId: () => 500008, fetchLocX: () => 130, fetchLocY: () => 200, @@ -193,6 +228,7 @@ try { PartyCompanionService.reconcileGroundLoot(botSession); assert.deepStrictEqual(pickupCalls[5] && { session: pickupCalls[5].session, actor: pickupCalls[5].actor, data: pickupCalls[5].data }, { session: botSession, actor: closestBot, data: { id: 500008 } }, 'a stale client pickup must not block a companion from collecting later ground loot'); assert.strictEqual(closestBot.storedPickup, undefined, 'companion loot reconciliation should clear stale client pickup state'); + removeWorldItem(500008); pickupCalls[5].onComplete(); leaderSession.lastGroundLootScanAt = 0; @@ -211,6 +247,7 @@ try { leaderSession.partyPullState = { phase: 'return', pullerId: closestBot.fetchId() }; World.items = { spawns: [{ + partyLootLeaderId: leaderSession.actor.fetchId(), fetchId: () => 500005, fetchLocX: () => 130, fetchLocY: () => 200, @@ -220,6 +257,7 @@ try { leaderSession.lastGroundLootScanAt = 0; PartyCompanionService.reconcileGroundLoot(botSession); assert.deepStrictEqual(pickupCalls[6] && { session: pickupCalls[6].session, actor: pickupCalls[6].actor, data: pickupCalls[6].data }, { session: distantBotSession, actor: distantBot, data: { id: 500005 } }, 'a distant return pull should let another companion collect old loot without interrupting the puller'); + removeWorldItem(500005); pickupCalls[6].onComplete(); // An NPC already targeting the party is combat even before a companion @@ -238,6 +276,7 @@ try { World.fetchNpcsInRadius = () => [incomingThreat]; World.items = { spawns: [{ + partyLootLeaderId: leaderSession.actor.fetchId(), fetchId: () => 500006, fetchLocX: () => 130, fetchLocY: () => 200, @@ -254,16 +293,38 @@ try { // completion. World.npc = { spawns: [] }; World.fetchNpcsInRadius = () => []; - World.items = { spawns: [] }; + World.items = { spawns: [{ + partyLootLeaderId: leaderSession.actor.fetchId(), + fetchId: () => 500009, + fetchLocX: () => 130, + fetchLocY: () => 200, + fetchLocZ: () => -310 + }] }; botSession.partyGroundPickupQueue = [{ id: 500009 }]; botSession.partyGroundPickupInProgress = false; assert.strictEqual(PartyCompanionService.startQueuedGroundPickup(botSession), true, 'a queued pickup should start normally before a simulated cancellation'); - const cancelledPickup = pickupCalls[7]; + const interruptedPickup = pickupCalls[7]; + assert.strictEqual(PartyCompanionService.startQueuedGroundPickup(botSession), true, 'an active pickup must own the AI tick instead of falling through into follow movement'); + assert.strictEqual(pickupCalls.length, 8, 'an active pickup must not be scheduled twice before its deadline'); + + World.npc = { spawns: [incomingThreat] }; + World.fetchNpcsInRadius = () => [incomingThreat]; + assert.strictEqual(PartyCompanionService.startQueuedGroundPickup(botSession), false, 'a threat appearing during pickup must hand the current AI tick back to combat'); + assert.strictEqual(botSession.partyGroundPickupInProgress, false, 'combat should cancel the active pickup movement'); + assert.deepStrictEqual(botSession.partyGroundPickupQueue, [{ id: 500009 }], 'a combat interruption should preserve the item for a later retry'); + interruptedPickup.onComplete(); + assert.deepStrictEqual(botSession.partyGroundPickupQueue, [{ id: 500009 }], 'a stale completion from the interrupted pickup must not consume the preserved queue entry'); + + World.npc = { spawns: [] }; + World.fetchNpcsInRadius = () => []; + assert.strictEqual(PartyCompanionService.startQueuedGroundPickup(botSession), true, 'pickup should retry after the party is safe again'); + const cancelledPickup = pickupCalls[8]; botSession.partyGroundPickupDeadlineAt = Date.now() - 1; assert.strictEqual(PartyCompanionService.startQueuedGroundPickup(botSession), true, 'an expired pickup action should be retried instead of locking future loot'); - const retriedPickup = pickupCalls[8]; + const retriedPickup = pickupCalls[9]; cancelledPickup.onComplete(); assert.deepStrictEqual(botSession.partyGroundPickupQueue, [{ id: 500009 }], 'a stale completion must not remove the retried pickup from the queue'); + removeWorldItem(500009); retriedPickup.onComplete(); assert.deepStrictEqual(botSession.partyGroundPickupQueue, [], 'the current pickup completion should remove the recovered queue entry'); @@ -271,6 +332,7 @@ try { closestBot.automation.ticksToMove = () => 21000; World.items = { spawns: [{ + partyLootLeaderId: leaderSession.actor.fetchId(), fetchId: () => 500010, fetchLocX: () => 2500, fetchLocY: () => 200, @@ -279,15 +341,13 @@ try { }; botSession.partyGroundPickupQueue = [{ id: 500010 }]; botSession.partyGroundPickupInProgress = false; - assert.strictEqual(PartyCompanionService.startQueuedGroundPickup(botSession), true, 'a distant pickup should start normally'); - const longWalkPickup = pickupCalls[9]; - assert.strictEqual( - PartyCompanionService.startQueuedGroundPickup(botSession), - false, - 'a valid long walk should remain in progress instead of being cancelled by the short fallback timeout' - ); - longWalkPickup.onComplete(); - assert.deepStrictEqual(botSession.partyGroundPickupQueue, [], 'a completed long walk should still clear its queue entry'); + assert.strictEqual(PartyCompanionService.startQueuedGroundPickup(botSession), false, 'a queued drop outside the party leash must not send a companion on a long run'); + assert.deepStrictEqual(botSession.partyGroundPickupQueue, [], 'an out-of-leash drop should be removed from the companion queue'); + + botSession.partyGroundPickupQueue = [{ id: 599999 }]; + botSession.partyGroundPickupInProgress = false; + assert.strictEqual(PartyCompanionService.startQueuedGroundPickup(botSession), false, 'a ground item that no longer exists must not start a phantom pickup run'); + assert.deepStrictEqual(botSession.partyGroundPickupQueue, [], 'a missing ground item should be removed from the companion queue'); // By Turn and By Turn Including Spoil still require a physical companion // to collect the ground object before the normal distribution resolver @@ -295,6 +355,7 @@ try { leaderSession.partyCompanionSettings = { distribution: 3 }; World.items = { spawns: [{ + partyLootLeaderId: leaderSession.actor.fetchId(), fetchId: () => 500011, fetchLocX: () => 130, fetchLocY: () => 200, @@ -308,6 +369,7 @@ try { { session: botSession, actor: closestBot, data: { id: 500011 } }, 'By Turn loot should still be collected from the ground by an available companion' ); + removeWorldItem(500011); pickupCalls[10].onComplete(); } finally { DataCache.fetchNpcRewardsFromSelfId = originalRewards; diff --git a/tests/test_party_companion_rest_follow.js b/tests/test_party_companion_rest_follow.js index b38fa7c4..6f4e3fa6 100644 --- a/tests/test_party_companion_rest_follow.js +++ b/tests/test_party_companion_rest_follow.js @@ -138,7 +138,13 @@ function fakeActor(id, loc = {}) { fetchIsOnline() { return true; }, isDead() { return this.state.fetchDead(); }, isBlocked() { return this.state.isBlocked(); }, - moveTo(data) { this.moves.push(data); }, + moveTo(data) { + if (data.previewOnly) { + if (this.session) this.session.lastPathfinding = { pathLength: 2, lowLodWarp: false }; + return; + } + this.moves.push(data); + }, select(data) { this.destId = data.id; }, unselect() { this.destId = undefined; }, statusUpdateVitals() {}, @@ -530,6 +536,28 @@ try { assert.strictEqual(restingPullBot.state.fetchSeated(), true, 'pulling should stay paused while a companion is regenerating'); PartyCompanionService.updateSettings(leaderSession, { pullMode: 'auto' }); + const regroupingRestBot = fakeActor(2000055, { locX: 900, locY: 0, hp: 100, maxHp: 100, mp: 10, maxMp: 100 }); + regroupingRestBot.state.setSeated(true); + const regroupingRestSession = fakeSession('bot_regrouping_rest', regroupingRestBot); + regroupingRestSession.followPlayerSession = leaderSession; + regroupingRestSession.partyCompanion = true; + regroupingRestSession.plan = 'resting'; + leader.state.setSeated(true); + leader.destId = undefined; + World.user = { sessions: [leaderSession, regroupingRestSession] }; + World.npc = { spawns: [] }; + World.fetchNpcsInRadius = () => []; + + RestingState.tick(regroupingRestSession, regroupingRestBot, {}, { say() {} }); + + assert.strictEqual(regroupingRestSession.plan, 'following', 'a distant recovering companion should regroup when the leader sits'); + assert.strictEqual(regroupingRestBot.state.fetchSeated(), false, 'the recovering companion should stand before moving to the resting party'); + assert.strictEqual(regroupingRestSession.roleDecision.reason, 'leader_moved', 'rest regrouping should expose why the bot woke before recovery completed'); + FollowingState.tick(regroupingRestSession, regroupingRestBot, {}, { say() {}, executeCombat() {}, executePvPCombat() {} }); + assert.strictEqual(regroupingRestSession.roleDecision.reason, 'move_near_sitting_leader', 'the regrouping companion should move into the seated leader formation before sitting again'); + assert.strictEqual(regroupingRestBot.moves.length, 1, 'party rest regrouping should issue one formation move'); + leader.state.setSeated(false); + leader.destId = 1003; const assistingBot = fakeActor(2000006, { locX: 500, locY: 0 }); const assistingSession = fakeSession('bot_assisting', assistingBot); @@ -818,6 +846,62 @@ try { assert.deepStrictEqual(healerCasts, [{ id: woundedCompanion.fetchId(), selfId: 1011, ctrl: false }], 'an emergency heal must preempt a normal party buff instead of issuing two casts in one tick'); assert.strictEqual(healerSession.roleDecision.action, 'heal_party', 'healer role decision should be party-wide'); + healerCasts.length = 0; + healerBot.moves = []; + healerBot.locX = 0; + woundedCompanion.locX = 800; + FollowingState.tick(healerSession, healerBot, { + skillExec(session, bot, data) { healerCasts.push(data); } + }, { say() {}, executeCombat() {}, executePvPCombat() {} }); + assert.strictEqual(healerCasts.length, 0, 'an out-of-range emergency heal must not use direct action movement through geodata'); + assert.strictEqual(healerBot.moves.length, 1, 'an out-of-range healer should start a normal pathfinding approach'); + assert.strictEqual(healerSession.pendingSupportApproach.targetId, woundedCompanion.fetchId(), 'the healer should retain the pending emergency target while approaching'); + healerBot.locX = 250; + FollowingState.tick(healerSession, healerBot, { + skillExec(session, bot, data) { healerCasts.push(data); } + }, { say() {}, executeCombat() {}, executePvPCombat() {} }); + assert.strictEqual(healerCasts.length, 1, 'the pending emergency heal should cast as soon as pathfinding brings the healer into native range'); + assert.strictEqual(healerSession.pendingSupportApproach, undefined, 'a completed support approach must release its pending movement state'); + healerBot.locX = 80; + woundedCompanion.locX = 120; + + healerCasts.length = 0; + healerBot.locX = 0; + woundedCompanion.locX = 800; + woundedCompanion.hp = 60; + FollowingState.tick(healerSession, healerBot, { + skillExec(session, bot, data) { healerCasts.push(data); } + }, { say() {}, executeCombat() {}, executePvPCombat() {} }); + assert.strictEqual(healerSession.pendingSupportApproach.kind, 'top_off', 'a distant non-critical target should queue a normal top-off approach'); + const criticalCompanion = fakeActor(2000037, { locX: 120, locY: 0, hp: 20, maxHp: 100 }); + const criticalCompanionSession = fakeSession('bot_critical_party', criticalCompanion); + criticalCompanionSession.followPlayerSession = healerLeaderSession; + criticalCompanionSession.partyCompanion = true; + criticalCompanionSession.plan = 'following'; + World.user.sessions.push(criticalCompanionSession); + FollowingState.tick(healerSession, healerBot, { + skillExec(session, bot, data) { healerCasts.push(data); } + }, { say() {}, executeCombat() {}, executePvPCombat() {} }); + assert.deepStrictEqual(healerCasts, [{ id: criticalCompanion.fetchId(), selfId: 1011, ctrl: false }], 'a critical party member must preempt a pending top-off approach'); + assert.strictEqual(healerSession.pendingSupportApproach, undefined, 'preempting a stale support approach must release its pending state after the emergency cast'); + World.user.sessions.pop(); + + healerCasts.length = 0; + healerBot.hp = 100; + FollowingState.tick(healerSession, healerBot, { + skillExec(session, bot, data) { healerCasts.push(data); } + }, { say() {}, executeCombat() {}, executePvPCombat() {} }); + assert.strictEqual(healerSession.pendingSupportApproach.kind, 'top_off', 'the normal distant top-off should be queued again once the emergency is gone'); + healerBot.hp = 40; + FollowingState.tick(healerSession, healerBot, { + skillExec(session, bot, data) { healerCasts.push(data); } + }, { say() {}, executeCombat() {}, executePvPCombat() {} }); + assert.deepStrictEqual(healerCasts, [{ id: healerBot.fetchId(), selfId: 1011, ctrl: false }], 'a badly wounded healer must preempt a pending top-off and preserve itself'); + healerBot.hp = 100; + healerBot.locX = 80; + woundedCompanion.locX = 120; + woundedCompanion.hp = 25; + healerBot.mp = 20; healerBot.skillset.skills.find((skill) => skill.fetchSelfId() === 1011).model.mp = 30; const lowManaHealChat = []; @@ -873,6 +957,28 @@ try { executePvPCombat() {} }); assert.strictEqual(healerAssistOptions?.basicAttackOnly, true, 'a healer with a melee weapon may assist using only a basic attack'); + + const busyAssistBot = fakeActor(2000098, { locX: 650, locY: 0, classId: 0 }); + const busyAssistSession = fakeSession('bot_busy_party_assist', busyAssistBot); + busyAssistSession.followPlayerSession = healerLeaderSession; + busyAssistSession.partyCompanion = true; + busyAssistSession.plan = 'following'; + busyAssistBot.state.setTowards('move'); + let abortedOldFollow = 0; + busyAssistBot.automation.abortAll = () => { + abortedOldFollow++; + busyAssistBot.state.setTowards(false); + }; + let busyAssistTarget = null; + World.user = { sessions: [healerLeaderSession, busyAssistSession] }; + FollowingState.tick(busyAssistSession, busyAssistBot, {}, { + say() {}, + executeCombat(_session, _bot, npc) { busyAssistTarget = npc.fetchId(); }, + executePvPCombat() {} + }); + assert.strictEqual(abortedOldFollow, 1, 'acquiring a party threat should cancel an obsolete follow movement'); + assert.strictEqual(busyAssistTarget, healerAssistThreat.fetchId(), 'the bot should attack instead of falling through to ready after cancelling follow'); + assert.strictEqual(busyAssistSession.currentTargetId, healerAssistThreat.fetchId(), 'a busy combat tick must retain the shared threat target'); World.npc = { spawns: [] }; World.fetchNpcsInRadius = () => []; @@ -995,6 +1101,25 @@ try { assert.strictEqual(autoPullSkillCast, false, 'auto pull must not cast Aggression even when it is learned'); assert.strictEqual(autoPullOptions?.basicAttackOnly, true, 'auto pull should start with a basic attack'); assert.strictEqual(autoPullTankSession.roleDecision.reason, 'safe_pull', 'low MP should not disable a basic-attack pull'); + + // The shared party setting is authoritative. A stale per-session mirror + // must never revive the legacy tank fallback after Pull Off. + PartyCompanionService.updateSettings(healerLeaderSession, { pullMode: 'off', pullerId: null }); + autoPullTankSession.autoTaunt = true; + autoPullTankSession.currentTargetId = undefined; + autoPullTank.unselect(); + let disabledPullCombat = false; + FollowingState.tick(autoPullTankSession, autoPullTank, { + skillExec() { disabledPullCombat = true; } + }, { + say() {}, + executeCombat() { disabledPullCombat = true; }, + executePvPCombat() {} + }); + assert.strictEqual(disabledPullCombat, false, 'Pull Off must block tank safe-pull even when session.autoTaunt is stale'); + assert.notStrictEqual(autoPullTankSession.roleDecision.action, 'avoid_overpull', 'Pull Off should remain a quiet order instead of looking like a failed overpull check'); + PartyCompanionService.updateSettings(healerLeaderSession, { pullMode: 'auto', pullerId: null }); + autoPullTank.mp = 100; autoPullTargetId = autoPullTank.fetchId(); let engagedPulledTarget = null; @@ -1506,6 +1631,42 @@ try { assert.strictEqual(partyHudBotBSession.roleDecision.action, 'follow_leader', 'non-puller should keep following while the mob is outside its attack range'); assert.strictEqual(partyHudBotBSession.roleDecision.reason, 'hold_for_pull', 'following companion must not chase the marked pull target'); + const rangedPullAdd = { + ...pulledMob, + id: 3012, + locX: 1250, + destId: partyHudBotA.fetchId(), + fetchId() { return this.id; }, + fetchDestId() { return this.destId; }, + fetchName: () => 'ranged pull add' + }; + partyHudBotA.locX = 1100; + partyHudLeaderSession.partyPullState.phase = 'return'; + World.npc = { spawns: [pulledMob, rangedPullAdd] }; + World.fetchNpcsInRadius = () => [rangedPullAdd]; + let rangedAddAssistId = null; + FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { + say() {}, + executeCombat(_session, _bot, npc) { rangedAddAssistId = npc.fetchId(); }, + executePvPCombat() {} + }); + assert.strictEqual(rangedAddAssistId, null, 'the camp must not run toward a ranged add that targets only a travelling puller'); + assert.strictEqual(partyHudBotBSession.roleDecision.reason, 'hold_for_pull', 'a ranged add on the distant puller should preserve camp formation'); + partyHudBotA.locX = partyHudLeader.locX; + FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { + say() {}, + executeCombat(_session, _bot, npc) { rangedAddAssistId = npc.fetchId(); }, + executePvPCombat() {} + }); + assert.strictEqual(rangedAddAssistId, rangedPullAdd.fetchId(), 'once the puller returns, the party must chase a ranged add that keeps firing from outside camp'); + World.npc = { spawns: [pulledMob] }; + World.fetchNpcsInRadius = () => [pulledMob]; + partyHudBotA.locX = 40; + partyHudLeaderSession.partyPullState.phase = 'approach'; + pulledMob.destId = undefined; + partyHudBotBSession.currentTargetId = undefined; + partyHudBotB.unselect(); + partyHudLeader.locX = 600; partyHudBotB.moves = []; FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { @@ -1545,6 +1706,31 @@ try { partyHudBotA.state.casts = false; pulledMob.destId = partyHudBotA.fetchId(); + const pullAdd = { + fetchId: () => 3013, + fetchAttackable: () => true, + isDead: () => false, + fetchDestId: () => partyHudBotB.fetchId(), + fetchLocX: () => partyHudBotB.fetchLocX() + 100, + fetchLocY: () => partyHudBotB.fetchLocY(), + fetchLocZ: () => partyHudBotB.fetchLocZ(), + state: { fetchCombats: () => true }, + fetchStateAttack: () => true + }; + World.npc = { spawns: [pulledMob, pullAdd] }; + World.fetchNpcsInRadius = () => [pulledMob, pullAdd]; + let stoppedOpeningAttack = 0; + partyHudBotA.attack = { + clearTimers() { stoppedOpeningAttack++; }, + abortCast() {} + }; + FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { + say() {}, executeCombat() { throw new Error('confirmed aggro must return even while the camp handles an add'); }, executePvPCombat() {} + }); + assert.strictEqual(partyHudBotASession.roleDecision.reason, 'return', 'confirmed aggro must outrank an unrelated party-under-attack pause'); + assert.strictEqual(stoppedOpeningAttack, 1, 'confirmed pull aggro must stop the repeating opening basic attack'); + World.npc = { spawns: [pulledMob] }; + World.fetchNpcsInRadius = () => [pulledMob]; FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { say() {}, executeCombat() { throw new Error('non-puller must not chase a mob that has only just aggroed the distant puller'); }, executePvPCombat() {} }); @@ -1562,10 +1748,6 @@ try { assert.strictEqual(partyHudBotBSession.roleDecision.reason, 'hold_for_pull', 'an incoming pull should keep every non-puller in leader formation'); pulledMob.locX = 1200; - FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { - say() {}, executeCombat() { throw new Error('confirmed aggro should make the puller return before the party engages'); }, executePvPCombat() {} - }); - assert.strictEqual(partyHudBotASession.roleDecision.reason, 'return', 'puller should return to the leader after aggro is confirmed'); const returnMoves = partyHudBotA.moves.length; partyHudBotA.state.setTowards('move'); partyHudLeader.locX = 500; @@ -1638,6 +1820,15 @@ try { executePvPCombat() {} }); assert.strictEqual(pulledTargetId, pulledMob.fetchId(), 'puller should keep attacking after the delivered pull enters engage phase'); + pulledMob.locX = 900; + let rangedPullChaseId = null; + FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { + say() {}, + executeCombat(_session, _bot, npc) { rangedPullChaseId = npc.fetchId(); }, + executePvPCombat() {} + }); + assert.strictEqual(rangedPullChaseId, pulledMob.fetchId(), 'after the puller returns, melee companions should chase a ranged pull that refuses to enter camp'); + pulledMob.locX = partyHudBotB.locX + 160; partyHudLeaderSession.partyPullState.startedAt = Date.now() - 61000; assert.strictEqual(PartyPulling.current(partyHudLeaderSession, PartyCompanionService.getSettings(partyHudLeaderSession)).target, pulledMob, 'a living pulled mob must stay the party target after one minute'); let assistedPulledMobId = null; diff --git a/tests/test_party_pull_pause.js b/tests/test_party_pull_pause.js index ad7927e9..ef75cb7e 100644 --- a/tests/test_party_pull_pause.js +++ b/tests/test_party_pull_pause.js @@ -4,6 +4,7 @@ require('../src/Global'); const World = invoke('GameServer/World/World'); const PartyPulling = invoke('GameServer/Bot/AI/PartyPulling'); +const BotManager = invoke('GameServer/Bot/BotManager'); function actor(id, classId = 0) { return { @@ -125,4 +126,145 @@ assert.strictEqual( ); assert.deepStrictEqual(leaderSession.partyPullState, {}, 'clearing an abandoned pull must remove its stale target id'); +const unreachableTarget = { + fetchId: () => 3000101, + fetchName: () => 'unreachable target', + fetchAttackable: () => true, + isDead: () => false, + fetchLocX: () => 600, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchDestId: () => undefined +}; +const reachableTarget = { + ...unreachableTarget, + fetchId: () => 3000102, + fetchName: () => 'reachable target', + fetchLocX: () => 800 +}; +World.npc.spawns = [unreachableTarget, reachableTarget]; +World.fetchNpcsInRadius = () => [unreachableTarget, reachableTarget]; +leaderSession.partyPullState = {}; +let abortedUnreachableMove = 0; +pullerSession.actor.automation = { abortAll() { abortedUnreachableMove++; } }; +pullerSession.actor.moveTo = ({ to }) => { + pullerSession.lastPathfinding = { + pathLength: to.locX === unreachableTarget.fetchLocX() ? 1 : 2, + routeUsable: to.locX !== unreachableTarget.fetchLocX(), + lowLodWarp: false + }; +}; + +const reachablePull = PartyPulling.tickBotPuller( + pullerSession, + pullerSession.actor, + leaderSession, + settings, + {}, + { executeCombat() {} } +); +assert.strictEqual(reachablePull.action, 'approach', 'the puller should skip an unreachable candidate and begin the reachable route in the same tick'); +assert.strictEqual(reachablePull.target.fetchId(), reachableTarget.fetchId(), 'route selection should settle on the reachable target'); +assert.strictEqual(abortedUnreachableMove, 1, 'rejecting an unreachable pull target should stop its fallback movement'); +assert.strictEqual(leaderSession.partyPullState.targetId, reachableTarget.fetchId(), 'the reachable alternate should own the shared pull slot'); + +const directFallbackTarget = { + ...unreachableTarget, + fetchId: () => 3000104, + fetchName: () => 'direct fallback target', + fetchLocX: () => 700 +}; +leaderSession.partyPullState = {}; +let directFallbackMoves = 0; +pullerSession.actor.moveTo = () => { + directFallbackMoves++; + pullerSession.lastPathfinding = { + pathLength: 1, + routeUsable: true, + lowLodWarp: false + }; +}; +World.npc.spawns = [directFallbackTarget]; +World.fetchNpcsInRadius = () => [directFallbackTarget]; +const directFallbackPull = PartyPulling.tickBotPuller( + pullerSession, + pullerSession.actor, + leaderSession, + settings, + {}, + { executeCombat() {} } +); +assert.strictEqual(directFallbackPull.action, 'approach', 'a clear direct fallback must remain a usable pull route when bounded A* returns no path'); +assert.strictEqual(directFallbackPull.target.fetchId(), directFallbackTarget.fetchId(), 'the puller should keep the direct-fallback target'); +assert.strictEqual(directFallbackMoves, 2, 'a usable preview should be followed by the actual movement command'); + +const incomingAdd = { + ...reachableTarget, + fetchId: () => 3000103, + fetchName: () => 'incoming add', + fetchLocX: () => 900, + fetchDestId: () => pullerSession.actor.fetchId() +}; +pullerSession.actor.fetchLocX = () => 1000; +pullerSession.actor.attack = { abortCast() {}, clearTimers() {} }; +let returnDestination = null; +pullerSession.actor.moveTo = ({ to, previewOnly }) => { + if (!previewOnly) returnDestination = to; + pullerSession.lastPathfinding = { pathLength: 2, lowLodWarp: false }; +}; +World.npc.spawns = [reachableTarget, incomingAdd]; +World.fetchNpcsInRadius = () => [reachableTarget, incomingAdd]; +leaderSession.partyPullState = { + targetId: reachableTarget.fetchId(), + pullerId: pullerSession.actor.fetchId(), + source: 'bot', + phase: 'approach', + startedAt: Date.now() +}; +const adoptedPull = PartyPulling.tickBotPuller( + pullerSession, + pullerSession.actor, + leaderSession, + settings, + {}, + { executeCombat() {} } +); +assert.strictEqual(adoptedPull.action, 'return', 'aggro on a travelling puller must immediately become a return leg'); +assert.strictEqual(adoptedPull.target.fetchId(), incomingAdd.fetchId(), 'the mob already attacking the puller should replace the untouched target'); +assert.strictEqual(leaderSession.partyPullState.phase, 'return', 'opportunistic pull aggro should be persisted as return state'); +assert.strictEqual(returnDestination.locX, leaderSession.actor.fetchLocX(), 'the puller should route back to the leader after opportunistic aggro'); + +const originalBotPartySay = BotManager.botPartySay; +let noRouteMessage = null; +BotManager.botPartySay = (_session, text) => { noRouteMessage = text; return true; }; +try { + const blockedTargets = Array.from({ length: 5 }, (_, index) => ({ + ...unreachableTarget, + fetchId: () => 3000200 + index, + fetchName: () => `blocked_${index}`, + fetchLocX: () => 600 + index, + fetchDestId: () => undefined + })); + pullerSession.actor.fetchLocX = () => 0; + pullerSession.actor.moveTo = () => { + pullerSession.lastPathfinding = { pathLength: 0, routeUsable: false, lowLodWarp: false }; + }; + leaderSession.partyPullState = {}; + leaderSession.partyPullRejectedTargets = {}; + leaderSession.partyPullSearchRetryAt = undefined; + World.npc.spawns = blockedTargets; + World.fetchNpcsInRadius = () => blockedTargets; + const searchResult = PartyPulling.tickBotPuller( + pullerSession, pullerSession.actor, leaderSession, settings, {}, { executeCombat() {} } + ); + assert.strictEqual(searchResult.action, 'searching_reachable_target', 'route search should inspect only a bounded candidate batch per tick'); + const exhaustedResult = PartyPulling.tickBotPuller( + pullerSession, pullerSession.actor, leaderSession, settings, {}, { executeCombat() {} } + ); + assert.strictEqual(exhaustedResult.action, 'no_reachable_targets', 'exhausted geodata candidates should enter an explicit route cooldown'); + assert.match(noRouteMessage, /safe route|reachable pull target/, 'the puller should tell the party why it is holding position'); +} finally { + BotManager.botPartySay = originalBotPartySay; +} + console.info('party pull pause tests passed'); diff --git a/tests/test_party_revival.js b/tests/test_party_revival.js index 68202f4f..b4a1edab 100644 --- a/tests/test_party_revival.js +++ b/tests/test_party_revival.js @@ -7,6 +7,7 @@ const BotManager = invoke('GameServer/Bot/BotManager'); const PartyRevivalService = invoke('GameServer/Bot/AI/PartyRevivalService'); const FollowingState = invoke('GameServer/Bot/AI/States/FollowingState'); const C4SkillEffects = invoke('GameServer/Skills/C4SkillEffects'); +const Revive = invoke('GameServer/Actor/Generics/Revive'); function actor(id, { dead = false, skills = [], items = [] } = {}) { const state = { @@ -95,6 +96,36 @@ try { World.fetchNpcsInRadius = () => []; BotManager.sessions = [healerSession, fallenSession]; + const frustrationStart = 1_000_000; + assert.deepStrictEqual( + PartyRevivalService.noteCompanionDeath(leaderSession, fallenSession, frustrationStart), + { count: 1, warning: false, leaving: false }, + 'the first recent companion death should not threaten to leave' + ); + assert.deepStrictEqual( + PartyRevivalService.noteCompanionDeath(leaderSession, fallenSession, frustrationStart + 60_000), + { count: 2, warning: true, leaving: false }, + 'the second recent death should warn the party before any future departure' + ); + assert.deepStrictEqual( + PartyRevivalService.noteCompanionDeath(leaderSession, fallenSession, frustrationStart + 120_000), + { count: 3, warning: false, leaving: true }, + 'the third recent death should make the warned companion leave' + ); + assert.strictEqual(PartyRevivalService.shouldTownRespawn(leaderSession, fallenSession, frustrationStart + 120_000), true, 'a companion leaving after repeated deaths must not wait for resurrection'); + fallenSession.partyLeaveAfterDeath = false; + fallenSession.partyDeathFrustration = undefined; + fallenSession.deathTimerStart = frustrationStart; + Revive(fallenSession, fallen, { delayMs: 0 }); + assert.strictEqual(fallenSession.deathTimerStart, undefined, 'native resurrection must release the death lifecycle so a later death is counted'); + fallen.state.setDead(true); + assert.deepStrictEqual( + PartyRevivalService.noteCompanionDeath(leaderSession, fallenSession, frustrationStart + PartyRevivalService.PARTY_DEATH_FRUSTRATION_WINDOW_MS + 1), + { count: 1, warning: false, leaving: false }, + 'death frustration should cool off after ten quiet minutes' + ); + fallenSession.partyDeathFrustration = undefined; + World.npc.spawns = [{ fetchAttackable: () => true, isDead: () => false,