From 3d1a13473a3d8b5d4a226805cfb2f8495b346504 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:14:50 -0400 Subject: [PATCH] fix: harden hot bot party behavior --- .../Bot/AI/PartyCompanionService.js | 6 +- .../Bot/AI/States/FollowingState.js | 65 +++++++ src/GameServer/Bot/BotManager.js | 14 +- src/GameServer/Bot/Population/BotLifeState.js | 30 +++- .../Bot/Population/HotActivation.js | 158 ++++++++++-------- .../Bot/Population/PopulationService.js | 8 +- src/GameServer/World/World.js | 36 +++- .../test_bot_background_party_recruitment.js | 90 ++++++++++ tests/test_bot_population_state.js | 5 + tests/test_party_bot_loot.js | 21 +++ tests/test_party_companion_rest_follow.js | 68 +++++++- 11 files changed, 419 insertions(+), 82 deletions(-) diff --git a/src/GameServer/Bot/AI/PartyCompanionService.js b/src/GameServer/Bot/AI/PartyCompanionService.js index 1f22cef2..29e303fb 100644 --- a/src/GameServer/Bot/AI/PartyCompanionService.js +++ b/src/GameServer/Bot/AI/PartyCompanionService.js @@ -16,7 +16,7 @@ const PARTY_LOOT_RADIUS = 2500; const GROUND_LOOT_SCAN_INTERVAL_MS = 500; const GROUND_PICKUP_FALLBACK_TIMEOUT_MS = 8000; const GROUND_PICKUP_TIMEOUT_GRACE_MS = 5000; -const RANDOM_LOOT_DISTRIBUTIONS = new Set([1, 2]); +const AUTOMATED_LOOT_DISTRIBUTIONS = new Set([1, 2, 3, 4]); const MAX_PARTY_MEMBERS = 9; const MAX_COMPANIONS = MAX_PARTY_MEMBERS - 1; const PARTY_POSITION_UPDATE_DISTANCE = 150; @@ -276,7 +276,7 @@ function reconcileGroundLoot(looterSession) { function nearestGroundLootPicker(looterSession, item) { const leaderSession = partyLeaderSession(looterSession); - if (!leaderSession || !item || !RANDOM_LOOT_DISTRIBUTIONS.has(distributionForLeader(leaderSession))) return null; + if (!leaderSession || !item || !AUTOMATED_LOOT_DISTRIBUTIONS.has(distributionForLeader(leaderSession))) return null; return membersForLeader(leaderSession) .filter((memberSession) => canPickGroundLoot(memberSession, leaderSession, item)) @@ -661,7 +661,7 @@ const PartyCompanionService = { : distributionForLeader(leaderSession); if (options.sendJoin !== false) { - leaderSession.dataSendToMe(ServerResponse.joinParty(distribution)); + leaderSession.dataSendToMe(ServerResponse.joinParty(1)); } restoreJoiningCompanion(companionSession, bot); diff --git a/src/GameServer/Bot/AI/States/FollowingState.js b/src/GameServer/Bot/AI/States/FollowingState.js index be6845fc..4c11e584 100644 --- a/src/GameServer/Bot/AI/States/FollowingState.js +++ b/src/GameServer/Bot/AI/States/FollowingState.js @@ -33,6 +33,9 @@ const COMPANION_TOWN_ERRAND_RADIUS = 7500; const COMPANION_TOWN_ERRAND_COOLDOWN_MS = 60000; const TOWN_CENTER_FALLBACK_RADIUS = 1500; const STARTER_GUIDE_TOWN_RADIUS = 1500; +const CRITICAL_COMBAT_HP_RATIO = 0.25; +const PARTY_RETREAT_DISTANCE = 500; +const PARTY_RETREAT_REPATH_MS = 1500; function ratio(value, max) { if (!max) return 0; @@ -438,6 +441,48 @@ function moveToFollowTarget(session, bot, player) { return true; } +function retreatFromThreat(session, bot, threat, player, rooted) { + const retreatInProgress = Date.now() < Number(session.partyRetreatUntil || 0) && + (!!session.moveTimer || bot.state?.fetchTowards?.()); + session.currentTargetId = undefined; + bot.unselect(); + bot.attack?.abortCast?.(session, bot); + bot.attack?.clearTimers?.(); + bot.state?.setHits?.(false); + + // Damage wakeups can run this state several times before a 500-unit route + // completes. Keep the existing escape movement instead of cancelling it + // and returning without a replacement route on every cooldown tick. + if (retreatInProgress) return true; + + bot.automation?.abortAll?.(bot); + if (rooted) return false; + + let dx = bot.fetchLocX() - threat.fetchLocX(); + let dy = bot.fetchLocY() - threat.fetchLocY(); + let magnitude = Math.sqrt((dx * dx) + (dy * dy)); + if (magnitude < 1) { + dx = player.fetchLocX() - threat.fetchLocX(); + dy = player.fetchLocY() - threat.fetchLocY(); + magnitude = Math.sqrt((dx * dx) + (dy * dy)); + } + if (magnitude < 1) { + dx = 1; + dy = 0; + magnitude = 1; + } + + const retreatTarget = { + locX: Math.round(bot.fetchLocX() + ((dx / magnitude) * PARTY_RETREAT_DISTANCE)), + locY: Math.round(bot.fetchLocY() + ((dy / magnitude) * PARTY_RETREAT_DISTANCE)), + locZ: bot.fetchLocZ() + }; + session.partyRetreatUntil = Date.now() + PARTY_RETREAT_REPATH_MS; + session.lastFollowMoveTarget = retreatTarget; + bot.moveTo({ from: loc(bot), to: retreatTarget }); + return true; +} + function manaPriority(entry, pullerActor) { const role = BotRoles.inferRole(entry.actor); if (entry.actor === pullerActor) return 0; @@ -939,6 +984,26 @@ module.exports = { } } + // A critically wounded non-tank should stop feeding the attacker. + // Healers still get the first action slot for a self/party heal; once + // no cast was started, every fragile role creates distance while + // remaining attached to the party lifecycle. + if ( + !acted && + partyThreat?.actor && + role !== 'tank' && + botVitals.hpRatio < CRITICAL_COMBAT_HP_RATIO && + !bot.state.fetchCasts() + ) { + const moved = retreatFromThreat(session, bot, partyThreat.actor, player, impairments.rooted); + recordRoleDecision(session, bot, 'retreat', impairments.rooted ? 'critical_hp_rooted' : 'critical_hp_under_attack', { + targetId: partyThreat.actor.fetchId(), + hpRatio: botVitals.hpRatio, + moved + }); + return; + } + if (!acted && pulling.enabled && pulling.puller?.session === session && pulling.puller.kind === 'bot') { const pullAction = PartyPulling.tickBotPuller(session, bot, playerSession, partySettings, Generics, BotAI); pulling = PartyPulling.current(playerSession, partySettings); diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js index a61526cb..1980d678 100644 --- a/src/GameServer/Bot/BotManager.js +++ b/src/GameServer/Bot/BotManager.js @@ -20,6 +20,7 @@ const SimulationKernel = invoke('GameServer/Bot/Simulation/SimulationKernel'); const GoalService = invoke('GameServer/Bot/Goals/GoalService'); const BotConversation = invoke('GameServer/Bot/AI/BotConversation'); const BotSupportPlanner = invoke('GameServer/Bot/AI/BotSupportPlanner'); +const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); const BotClassProgression = invoke('GameServer/Bot/BotClassProgression'); const BOTS_TO_SPAWN = BotPopulation.buildStarterBots(); @@ -779,11 +780,16 @@ const BotManager = { setTimeout(() => { this.botSay(session, `Alright, returning to hunt keltirs!`, playerSession); if (session.followPlayerSession === playerSession && session.partyCompanion === true) { - BotSocialMemory.recordEvent(playerSession, session, 'party_dismissed', 'chat_hunt'); + PartyCompanionService.detach(playerSession, session, { + event: 'party_dismissed', + source: 'chat_hunt', + plan: 'hunting' + }); + } else { + session.plan = 'hunting'; + session.followPlayerSession = null; + session.partyCompanion = false; } - session.plan = 'hunting'; - session.followPlayerSession = null; - session.partyCompanion = false; }, 800 + Math.random() * 800); } diff --git a/src/GameServer/Bot/Population/BotLifeState.js b/src/GameServer/Bot/Population/BotLifeState.js index 0a256594..f13d37c6 100644 --- a/src/GameServer/Bot/Population/BotLifeState.js +++ b/src/GameServer/Bot/Population/BotLifeState.js @@ -505,6 +505,34 @@ function recoverStaleHotStates() { }); } +function recoverDissolvedPartyMembers() { + const timestamp = now(); + return Database.execute([ + `UPDATE ${TABLE} + SET partyId = NULL, + activity = CASE WHEN activity = 'grouped' THEN 'hunting' ELSE activity END, + activityStartedAt = ?, + nextResolveAt = ?, + statsJson = json_set( + COALESCE(statsJson, '{}'), + '$.backgroundPartyId', NULL, + '$.partyBreakReason', 'orphaned_dissolved_party', + '$.lastReason', 'orphaned_dissolved_party' + ), + updatedAt = ? + WHERE partyId IN ( + SELECT partyId FROM bot_background_parties WHERE status <> 'active' + )`, + [timestamp, timestamp, timestamp] + ]).then((result) => { + const recovered = Number(result?.affectedRows || 0); + if (recovered > 0) { + utils.infoWarn('BotLife', 'released %d bot(s) from dissolved background parties', recovered); + } + return recovered; + }); +} + function mergeSessionIntoLifeState(session, state, phase, reason = '', options = {}) { const observed = recordFromSession(session, phase, reason); const observedStats = parseJson(observed.statsJson, {}); @@ -654,7 +682,7 @@ const BotLifeState = { if (initStarted) return initPromise; initStarted = true; - initPromise = Database.execute(['SELECT 1', []], 'schema:bot-life').then(() => recoverStaleHotStates()).then(() => recoverStaleCraftWaits()).then(() => migrateAcquisitionPartyWaits()).then(() => discardInvalidEquipmentPlans()).then(() => hydrateCache()).then((count) => { + initPromise = Database.execute(['SELECT 1', []], 'schema:bot-life').then(() => recoverStaleHotStates()).then(() => recoverDissolvedPartyMembers()).then(() => recoverStaleCraftWaits()).then(() => migrateAcquisitionPartyWaits()).then(() => discardInvalidEquipmentPlans()).then(() => hydrateCache()).then((count) => { const repairs = [...cache.values()] .map(recoverOrphanedGiranState) .filter((state) => state !== cache.get(state.characterId)); diff --git a/src/GameServer/Bot/Population/HotActivation.js b/src/GameServer/Bot/Population/HotActivation.js index 955c7d81..37dca519 100644 --- a/src/GameServer/Bot/Population/HotActivation.js +++ b/src/GameServer/Bot/Population/HotActivation.js @@ -115,6 +115,26 @@ function activationDistance(placement, options) { return Number.isFinite(dist) ? String(Math.round(dist)) : 'n/a'; } +function releaseBackgroundParty(state, reason) { + const partyId = state?.party?.partyId; + if (!partyId) return Promise.resolve(state); + + return BackgroundPartyState.setStatus(partyId, 'dissolved') + .then(() => LifeState.clearParty(partyId, `hot_activation_${reason}`)) + .then((cleared) => { + const refreshed = LifeState.cachedState(state.characterId); + if (refreshed && !refreshed.party?.partyId) return refreshed; + if (Number(cleared || 0) <= 0 || refreshed?.party?.partyId) { + throw new Error(`background_party_release_failed:${partyId}`); + } + return { + ...state, + activity: state.activity === 'grouped' ? 'hunting' : state.activity, + party: { ...(state.party || {}), partyId: null, leaderId: null } + }; + }); +} + const HotActivation = { activate(stateOrName, reason = 'activation', options = {}) { const loadState = typeof stateOrName === 'string' @@ -134,77 +154,83 @@ const HotActivation = { if (pendingActivations.has(state.characterId)) { return { ok: false, reason: 'activation_pending', state }; } + // Reserve the character before party cleanup or recipe sync can + // yield. Otherwise two concurrent visibility/invite requests can + // both pass the guard and create independent hot AI sessions. + pendingActivations.add(state.characterId); const BotManager = invoke('GameServer/Bot/BotManager'); - if (state.party?.partyId) { - BackgroundPartyState.setStatus(state.party.partyId, 'dissolved'); - LifeState.clearParty(state.party.partyId); - } - - const craftShop = state.activity === 'crafting' && state.stats?.craftShop - ? CraftShopService.profileFor(state) - : null; - const plan = activationPlan(state, options); - const marketStore = state.activity === 'merchant' ? state.stats?.marketStore : null; - const placement = activationPlacement(state, { - ...options, - storeLoc: marketStore?.loc || craftShop?.loc || state.loc - }); - pendingActivations.add(state.characterId); - if (marketStore) MarketOpportunity.removeColdStore(state.characterId); - const recipesReady = craftShop - ? CraftShopService.ensureRecipes(state.characterId, craftShop) - : Promise.resolve(); - return recipesReady.then(() => { - BotManager.loadAndSpawnBot(state.accountName, { - name: state.name, - homeRegion: state.homeRegion, - newbieAnchor: !!state.stats?.newbieAnchor, - plan, - backgroundActivity: state.activity || 'hunting', - currentSpot: spotSnapshot(placement.spot), - spawnReady: true, - locX: placement.loc?.locX, - locY: placement.loc?.locY, - locZ: placement.loc?.locZ, - keepStoreLocation: !!marketStore || !!craftShop, - coldLifeState: !marketStore && !craftShop ? state : null, - coldMarketState: marketStore ? state : null, - coldCraftState: craftShop ? state : null, - privateStore: marketStore ? { - storeType: Number(marketStore.storeType || 1), - title: marketStore.autoTitle === false - ? marketStore.title - : marketStoreTitle(marketStore.items), - town: marketStore.town || state.currentRegion || null, - items: marketStore.items || [] - } : null, - manufactureShop: craftShop + let craftActivation = false; + return releaseBackgroundParty(state, reason).then((releasedState) => { + state = releasedState; + + const craftShop = state.activity === 'crafting' && state.stats?.craftShop + ? CraftShopService.profileFor(state) + : null; + craftActivation = !!craftShop; + const plan = activationPlan(state, options); + const marketStore = state.activity === 'merchant' ? state.stats?.marketStore : null; + const placement = activationPlacement(state, { + ...options, + storeLoc: marketStore?.loc || craftShop?.loc || state.loc + }); + if (marketStore) MarketOpportunity.removeColdStore(state.characterId); + const recipesReady = craftShop + ? CraftShopService.ensureRecipes(state.characterId, craftShop) + : Promise.resolve(); + return recipesReady.then(() => { + BotManager.loadAndSpawnBot(state.accountName, { + name: state.name, + homeRegion: state.homeRegion, + newbieAnchor: !!state.stats?.newbieAnchor, + plan, + backgroundActivity: state.activity || 'hunting', + currentSpot: spotSnapshot(placement.spot), + spawnReady: true, + locX: placement.loc?.locX, + locY: placement.loc?.locY, + locZ: placement.loc?.locZ, + keepStoreLocation: !!marketStore || !!craftShop, + coldLifeState: !marketStore && !craftShop ? state : null, + coldMarketState: marketStore ? state : null, + coldCraftState: craftShop ? state : null, + privateStore: marketStore ? { + storeType: Number(marketStore.storeType || 1), + title: marketStore.autoTitle === false + ? marketStore.title + : marketStoreTitle(marketStore.items), + town: marketStore.town || state.currentRegion || null, + items: marketStore.items || [] + } : null, + manufactureShop: craftShop + }); + + const pendingTimer = setTimeout(() => { + pendingActivations.delete(state.characterId); + }, 10000); + pendingTimer.unref?.(); + + console.info( + 'BotPopulation :: requested activation for %s reason=%s activity=%s plan=%s spot=%s loc=%d,%d,%d playerDist=%s ready=%s', + state.name, + reason, + state.activity || 'hunting', + plan, + placement.spot?.id || state.spotId || 'none', + placement.loc?.locX || 0, + placement.loc?.locY || 0, + placement.loc?.locZ || 0, + activationDistance(placement, options), + (options.recoverOnActivation || options.readyOnActivation) ? 'yes' : 'no' + ); + Metrics.recordActivation(); + return { ok: true, state, reason }; }); - - setTimeout(() => { - pendingActivations.delete(state.characterId); - }, 10000); - - console.info( - 'BotPopulation :: requested activation for %s reason=%s activity=%s plan=%s spot=%s loc=%d,%d,%d playerDist=%s ready=%s', - state.name, - reason, - state.activity || 'hunting', - plan, - placement.spot?.id || state.spotId || 'none', - placement.loc?.locX || 0, - placement.loc?.locY || 0, - placement.loc?.locZ || 0, - activationDistance(placement, options), - (options.recoverOnActivation || options.readyOnActivation) ? 'yes' : 'no' - ); - Metrics.recordActivation(); - return { ok: true, state, reason }; }).catch((error) => { pendingActivations.delete(state.characterId); - utils.infoWarn('BotPopulation', 'craft shop activation failed for %s: %s', state.name, error.message || error); - return { ok: false, reason: 'craft_recipe_sync_failed', state }; + const failureReason = craftActivation ? 'craft_recipe_sync_failed' : 'activation_prepare_failed'; + utils.infoWarn('BotPopulation', 'activation failed for %s: %s', state.name, error.message || error); + return { ok: false, reason: failureReason, state }; }); }); } diff --git a/src/GameServer/Bot/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js index df9c24c4..3e888dc0 100644 --- a/src/GameServer/Bot/Population/PopulationService.js +++ b/src/GameServer/Bot/Population/PopulationService.js @@ -534,7 +534,13 @@ const PopulationService = { // persisted route. Keep it cold until its resolver // reaches the destination, instead of spawning a // hunter/resting bot stranded on a road or plaza. - const available = states.filter((state) => !['pk_hunting', 'traveling'].includes(state.activity)); + // A persisted background party is one lifecycle unit. + // Ambient visibility must not materialize one member + // as a solo hot bot and silently dissolve the group. + const available = states.filter((state) => ( + !['pk_hunting', 'traveling'].includes(state.activity) && + !state.party?.partyId + )); const merchants = available.filter((state) => state.activity === 'merchant' && state.stats?.marketStore); const crafters = available.filter((state) => state.activity === 'crafting' && state.stats?.craftShop); const ambientRemaining = Math.min( diff --git a/src/GameServer/World/World.js b/src/GameServer/World/World.js index f211c4ea..e1906a01 100644 --- a/src/GameServer/World/World.js +++ b/src/GameServer/World/World.js @@ -121,7 +121,16 @@ const World = { const targetIsBot = targetSession && (targetSession.constructor.name === 'BotSession' || (targetSession.accountId && targetSession.accountId.startsWith('bot_'))); if (targetIsBot) { - this.inviteBotCompanion(session, actor, targetSession, data.distribution, 'invite'); + // Keep the native C4 request/answer lifecycle even though a + // SimPlayer has no client from which to send AnswerJoinParty. + // The server-side availability decision is the bot's answer. + targetSession.pendingPartyInvite = { + requestorSession: session, + requestorActor: actor, + distribution: data.distribution, + source: 'invite' + }; + this.answerForTeamUp(targetSession, user, { id: 1 }); } else { user.session.dataSendToMe(ServerResponse.askForTeamUp(actor.fetchName(), data.distribution)); } @@ -146,7 +155,7 @@ const World = { if (!availability.available) { BotSocialMemory.recordEvent(session, targetSession, 'party_refused', availability.reason); - session.dataSendToMe(ServerResponse.actionFailed()); + session.dataSendToMe(ServerResponse.joinParty(0)); BotManager.botTell(targetSession, session, `I can't join right now: ${availability.reasonText}.`); console.info( 'BotParty :: %s refused %s: %s distance=%s', @@ -165,7 +174,7 @@ const World = { if (!PartyCompanionService.attach(session, targetSession, attachOptions)) { BotSocialMemory.recordEvent(session, targetSession, 'party_refused', 'party_full'); - session.dataSendToMe(ServerResponse.actionFailed()); + session.dataSendToMe(ServerResponse.joinParty(0)); BotManager.botTell(targetSession, session, "Your party is full. Ask me again after making room."); return false; } @@ -305,7 +314,26 @@ const World = { }, answerForTeamUp(session, actor, data) { - console.info(data); + const pending = session.pendingPartyInvite; + session.pendingPartyInvite = null; + + if (!pending?.requestorSession || !pending?.requestorActor) { + session.dataSendToMe(ServerResponse.actionFailed()); + return false; + } + + if (Number(data?.id) !== 1) { + pending.requestorSession.dataSendToMe(ServerResponse.joinParty(0)); + return false; + } + + return this.inviteBotCompanion( + pending.requestorSession, + pending.requestorActor, + session, + pending.distribution, + pending.source || 'invite' + ); }, oustPartyMember(session, actor, data) { diff --git a/tests/test_bot_background_party_recruitment.js b/tests/test_bot_background_party_recruitment.js index 357fc022..08c041ba 100644 --- a/tests/test_bot_background_party_recruitment.js +++ b/tests/test_bot_background_party_recruitment.js @@ -7,6 +7,9 @@ const LifeState = invoke('GameServer/Bot/Population/BotLifeState'); const LifeEvents = invoke('GameServer/Bot/Population/BotLifeEvents'); const PartyState = invoke('GameServer/Bot/Population/BackgroundPartyState'); const PopulationService = invoke('GameServer/Bot/Population/PopulationService'); +const HotActivation = invoke('GameServer/Bot/Population/HotActivation'); +const BotManager = invoke('GameServer/Bot/BotManager'); +const SpotService = invoke('GameServer/Bot/AI/SpotService'); const originals = { active: PartyState.active, @@ -14,8 +17,11 @@ const originals = { assignParty: LifeState.assignParty, partyRequirementCounts: LifeState.partyRequirementCounts, clearParty: LifeState.clearParty, + cachedState: LifeState.cachedState, createOrUpdate: PartyState.createOrUpdate, setStatus: PartyState.setStatus, + loadAndSpawnBot: BotManager.loadAndSpawnBot, + findCurrentSpot: SpotService.findCurrentSpot, record: LifeEvents.record, partyMinSize: Config.partyMinSize, partyMaxSize: Config.partyMaxSize, @@ -95,6 +101,87 @@ async function run() { ]); assert.deepStrictEqual(released.map((party) => party.partyId), ['bgp_elective']); assert.deepStrictEqual(reclaimed, [{ partyId: 'bgp_elective', status: 'dissolved' }]); + + const activationOrder = []; + const groupedState = { + characterId: 91001, + accountName: 'bot_activation_probe', + name: 'ActivationProbe', + phase: 'cold', + activity: 'grouped', + homeRegion: 'human', + loc: { locX: -71300, locY: 258000, locZ: -3100 }, + stats: {}, + party: { partyId: 'bgp_activation_probe', leaderId: 91001 } + }; + const releasedState = { + ...groupedState, + activity: 'hunting', + party: { partyId: null, leaderId: null } + }; + PartyState.setStatus = async (partyId, status) => { + activationOrder.push(`status:${partyId}:${status}`); + }; + LifeState.clearParty = async (partyId, reason) => { + activationOrder.push(`clear:${partyId}:${reason}`); + return 2; + }; + LifeState.cachedState = (characterId) => characterId === groupedState.characterId ? releasedState : null; + SpotService.findCurrentSpot = () => null; + BotManager.loadAndSpawnBot = (_accountName, options) => { + activationOrder.push(`spawn:${options.coldLifeState?.party?.partyId || 'solo'}`); + }; + + const activated = await HotActivation.activate(groupedState, 'party_invite', { keepStoreLocation: true }); + assert.strictEqual(activated.ok, true, 'an explicitly requested grouped bot should still materialize'); + assert.deepStrictEqual( + activationOrder, + [ + 'status:bgp_activation_probe:dissolved', + 'clear:bgp_activation_probe:hot_activation_party_invite', + 'spawn:solo' + ], + 'background party cleanup must finish before a member spawns from the released solo snapshot' + ); + + activationOrder.length = 0; + const blockedState = { + ...groupedState, + characterId: 91002, + name: 'BlockedActivationProbe' + }; + LifeState.clearParty = async () => 0; + LifeState.cachedState = (characterId) => characterId === blockedState.characterId ? blockedState : null; + const blockedActivation = await HotActivation.activate(blockedState, 'party_invite', { keepStoreLocation: true }); + assert.strictEqual(blockedActivation.ok, false, 'a member must not spawn while its persisted party link is still present'); + assert.strictEqual(blockedActivation.reason, 'activation_prepare_failed'); + assert(!activationOrder.some((entry) => entry.startsWith('spawn:')), 'failed party cleanup must stop hot activation before spawning'); + + activationOrder.length = 0; + const concurrentState = { + ...groupedState, + characterId: 91003, + name: 'ConcurrentActivationProbe' + }; + const concurrentReleasedState = { + ...concurrentState, + activity: 'hunting', + party: { partyId: null, leaderId: null } + }; + let releaseStatus; + const releaseGate = new Promise((resolve) => { releaseStatus = resolve; }); + PartyState.setStatus = async () => releaseGate; + LifeState.clearParty = async () => 2; + LifeState.cachedState = (characterId) => characterId === concurrentState.characterId ? concurrentReleasedState : null; + + const firstActivation = HotActivation.activate(concurrentState, 'concurrent_first', { keepStoreLocation: true }); + const secondActivation = HotActivation.activate(concurrentState, 'concurrent_second', { keepStoreLocation: true }); + await Promise.resolve(); + releaseStatus(); + const concurrentResults = await Promise.all([firstActivation, secondActivation]); + assert.strictEqual(concurrentResults.filter((result) => result.ok).length, 1, 'only one concurrent request may activate a character'); + assert.strictEqual(concurrentResults.filter((result) => result.reason === 'activation_pending').length, 1, 'the competing request must observe the early activation reservation'); + assert.strictEqual(activationOrder.filter((entry) => entry.startsWith('spawn:')).length, 1, 'concurrent activation must create exactly one hot AI session'); console.log('Bot background party recruitment checks passed'); } @@ -107,8 +194,11 @@ run().catch((err) => { LifeState.assignParty = originals.assignParty; LifeState.partyRequirementCounts = originals.partyRequirementCounts; LifeState.clearParty = originals.clearParty; + LifeState.cachedState = originals.cachedState; PartyState.createOrUpdate = originals.createOrUpdate; PartyState.setStatus = originals.setStatus; + BotManager.loadAndSpawnBot = originals.loadAndSpawnBot; + SpotService.findCurrentSpot = originals.findCurrentSpot; LifeEvents.record = originals.record; Config.partyMinSize = originals.partyMinSize; Config.partyMaxSize = originals.partyMaxSize; diff --git a/tests/test_bot_population_state.js b/tests/test_bot_population_state.js index d4cf31d8..fd267b5c 100644 --- a/tests/test_bot_population_state.js +++ b/tests/test_bot_population_state.js @@ -58,6 +58,11 @@ try { assert(!recovery.sql.includes('activity <> \'crafting\''), 'craft services must recover as cold because they have no static startup owner'); assert(recovery.sql.includes("WHEN activity IN ('following', 'shopping', 'getting_buffed', 'fleeing', 'pk_fleeing') THEN 'hunting'")); assert.strictEqual(recovery.params.length, 2, 'recovery query should set next resolve and updated timestamps'); + const dissolvedPartyRecovery = statements.find((entry) => entry.sql.includes('orphaned_dissolved_party')); + assert(dissolvedPartyRecovery, 'startup must release members left behind by a dissolved background party'); + assert(dissolvedPartyRecovery.sql.includes('SET partyId = NULL'), 'orphan recovery must clear the persisted party id'); + assert(dissolvedPartyRecovery.sql.includes("WHEN activity = 'grouped' THEN 'hunting'"), 'orphan recovery must return grouped members to an actionable solo state'); + assert(dissolvedPartyRecovery.sql.includes("status <> 'active'"), 'active background parties must survive startup recovery'); const craftRecovery = statements.find((entry) => entry.sql.includes("startup_craft_wait_recovery")); assert(craftRecovery, 'bot life init must release stale craft waits after a restart'); assert(craftRecovery.sql.includes("AND activity = 'crafting'"), 'only stale station waits should be recovered as hunters'); diff --git a/tests/test_party_bot_loot.js b/tests/test_party_bot_loot.js index 7eddb210..550531e4 100644 --- a/tests/test_party_bot_loot.js +++ b/tests/test_party_bot_loot.js @@ -288,6 +288,27 @@ try { ); longWalkPickup.onComplete(); assert.deepStrictEqual(botSession.partyGroundPickupQueue, [], 'a completed long walk should still clear its queue entry'); + + // By Turn and By Turn Including Spoil still require a physical companion + // to collect the ground object before the normal distribution resolver + // assigns it to the current recipient. + leaderSession.partyCompanionSettings = { distribution: 3 }; + World.items = { + spawns: [{ + fetchId: () => 500011, + fetchLocX: () => 130, + fetchLocY: () => 200, + fetchLocZ: () => -310 + }] + }; + leaderSession.lastGroundLootScanAt = 0; + PartyCompanionService.reconcileGroundLoot(botSession); + assert.deepStrictEqual( + pickupCalls[10] && { session: pickupCalls[10].session, actor: pickupCalls[10].actor, data: pickupCalls[10].data }, + { session: botSession, actor: closestBot, data: { id: 500011 } }, + 'By Turn loot should still be collected from the ground by an available companion' + ); + pickupCalls[10].onComplete(); } finally { DataCache.fetchNpcRewardsFromSelfId = originalRewards; ProgressionRates.rollGroup = originalRollGroup; diff --git a/tests/test_party_companion_rest_follow.js b/tests/test_party_companion_rest_follow.js index 87f0ccde..b38fa7c4 100644 --- a/tests/test_party_companion_rest_follow.js +++ b/tests/test_party_companion_rest_follow.js @@ -288,13 +288,41 @@ try { BotSocialMemory.getSnapshot = () => ({ trust: 0, familiarity: 0, recentlyAbandonedAt: null }); BotSocialMemory.recordEvent = () => Promise.resolve(null); BotManager.botTell = (sourceSession, targetSession, text) => { - assert.strictEqual(sourceSession, inviteBotSession, 'invite acknowledgement should come from invited bot'); assert.strictEqual(targetSession, leaderSession, 'invite acknowledgement should target party leader'); - inviteTell = text; + if (sourceSession === inviteBotSession) inviteTell = text; }; - BotManager.sessions = [inviteBotSession]; + const nativeAnswerBot = fakeActor(2000045, { locX: 60, locY: 0 }); + const nativeAnswerSession = fakeSession('bot_native_party_answer', nativeAnswerBot); + const nativeDeclineBot = fakeActor(2000046, { locX: 70, locY: 0 }); + const nativeDeclineSession = fakeSession('bot_native_party_decline', nativeDeclineBot); + BotManager.sessions = [inviteBotSession, nativeAnswerSession, nativeDeclineSession]; assert.strictEqual(World.inviteBotCompanion(leaderSession, leader, inviteBotSession, 1, 'test_invite'), true, 'available resting bot should join the party'); + const acceptedPacket = [...leaderSession.packets].reverse().find((packet) => packet[0] === 0x3a); + assert.strictEqual(acceptedPacket.readInt32LE(1), 1, 'party success must send native JoinParty(1), not the loot distribution id'); + + nativeAnswerSession.pendingPartyInvite = { + requestorSession: leaderSession, + requestorActor: leader, + distribution: 3, + source: 'test_native_answer' + }; + assert.strictEqual(World.answerForTeamUp(nativeAnswerSession, nativeAnswerBot, { id: 1 }), true, 'a bot should be able to accept through the native answer lifecycle'); + assert.strictEqual(nativeAnswerSession.pendingPartyInvite, null, 'the accepted native invitation must be consumed once'); + assert.strictEqual(nativeAnswerSession.followPlayerSession, leaderSession, 'native acceptance should attach the bot to the requesting leader'); + const nativeAcceptedPacket = [...leaderSession.packets].reverse().find((packet) => packet[0] === 0x3a); + assert.strictEqual(nativeAcceptedPacket.readInt32LE(1), 1, 'native bot acceptance must return JoinParty success'); + + nativeDeclineSession.pendingPartyInvite = { + requestorSession: leaderSession, + requestorActor: leader, + distribution: 3, + source: 'test_native_decline' + }; + assert.strictEqual(World.answerForTeamUp(nativeDeclineSession, nativeDeclineBot, { id: 0 }), false, 'a bot invitation may be declined'); + assert.strictEqual(nativeDeclineSession.partyCompanion, undefined, 'declining a native invitation must not attach the bot'); + const declinedPacket = [...leaderSession.packets].reverse().find((packet) => packet[0] === 0x3a); + assert.strictEqual(declinedPacket.readInt32LE(1), 0, 'native refusal must return JoinParty failure rather than ActionFailed'); } finally { global.setTimeout = originalSetTimeout; BotManager.botTell = originalBotTell; @@ -711,6 +739,40 @@ try { assert.strictEqual(selfDefenseSession.currentTargetId, selfDefenseNpc.fetchId(), 'companion should defend itself against recent incoming mob'); assert.strictEqual(selfDefenseAssistId, selfDefenseNpc.fetchId(), 'companion should fight back when mob hits the bot'); + const criticalBot = fakeActor(2000044, { locX: 120, locY: 0, hp: 20, maxHp: 100 }); + const criticalSession = fakeSession('bot_critical_self_preservation', criticalBot); + criticalSession.followPlayerSession = leaderSession; + criticalSession.partyCompanion = true; + criticalSession.plan = 'following'; + criticalSession.incomingThreatId = selfDefenseNpc.fetchId(); + criticalSession.incomingThreatAt = Date.now(); + let criticalCombatStarted = false; + World.user = { sessions: [leaderSession, criticalSession] }; + FollowingState.tick(criticalSession, criticalBot, {}, { + say() {}, + executeCombat() { criticalCombatStarted = true; }, + executePvPCombat() { criticalCombatStarted = true; } + }); + assert.strictEqual(criticalCombatStarted, false, 'a critically wounded non-tank should not start another attack'); + assert.strictEqual(criticalSession.currentTargetId, undefined, 'critical self-preservation should clear the combat target'); + assert.strictEqual(criticalSession.roleDecision.action, 'retreat', 'critical self-preservation should be observable'); + assert.strictEqual(criticalSession.plan, 'following', 'retreat should keep the bot attached to the party'); + assert.strictEqual(criticalBot.moves.length, 1, 'a critically wounded companion should create distance from its attacker'); + assert(criticalBot.moves[0].to.locX < criticalBot.fetchLocX(), 'the retreat destination should lead away from the attacker and toward party safety'); + let retreatAborts = 0; + criticalBot.automation.abortAll = () => { + retreatAborts += 1; + criticalBot.state.setTowards(false); + }; + criticalBot.state.setTowards('move'); + FollowingState.tick(criticalSession, criticalBot, {}, { + say() {}, + executeCombat() { criticalCombatStarted = true; }, + executePvPCombat() { criticalCombatStarted = true; } + }); + assert.strictEqual(retreatAborts, 0, 'a repeated critical-HP tick must preserve the active retreat route'); + assert.strictEqual(criticalBot.moves.length, 1, 'an active retreat should not be cancelled and immediately reissued'); + const hostileBot = fakeActor(2000019, { locX: 140, locY: 0, pvpFlag: 1, destId: leader.fetchId() }); const hostileBotSession = fakeSession('bot_hostile_attacker', hostileBot); const pvpAssistBot = fakeActor(2000020, { locX: 120, locY: 0 });