Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/GameServer/Bot/AI/PartyCompanionService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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);
Expand Down
65 changes: 65 additions & 0 deletions src/GameServer/Bot/AI/States/FollowingState.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Comment on lines +444 to +459

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Abort an active retreat when root is applied.

retreatInProgress returns before the rooted check. If the bot is rooted during the 1.5-second retreat window, its existing route remains active and moved is reported as true. Check rooted first, clear partyRetreatUntil, and abort automation before preserving a route.

Proposed fix
 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);
 
+    if (rooted) {
+        session.partyRetreatUntil = 0;
+        bot.automation?.abortAll?.(bot);
+        return false;
+    }
+
     if (retreatInProgress) return true;
 
     bot.automation?.abortAll?.(bot);
-    if (rooted) return false;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
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);
if (rooted) {
session.partyRetreatUntil = 0;
bot.automation?.abortAll?.(bot);
return 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);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/AI/States/FollowingState.js` around lines 444 - 459,
Update retreatFromThreat so the rooted case is handled before the
retreatInProgress early return: clear session.partyRetreatUntil, abort active
automation, and return false when rooted. Only preserve the existing retreat
route and return true for non-rooted retreatInProgress cases.


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;
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 10 additions & 4 deletions src/GameServer/Bot/BotManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}

Expand Down
30 changes: 29 additions & 1 deletion src/GameServer/Bot/Population/BotLifeState.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {});
Expand Down Expand Up @@ -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));
Expand Down
158 changes: 92 additions & 66 deletions src/GameServer/Bot/Population/HotActivation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
};
Comment on lines +124 to +134

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Cached-state fast path skips the groupedhunting normalization.

Line 126 returns refreshed as-is, so a cache entry with partyId cleared but activity === 'grouped' propagates into activationPlan and backgroundActivity, unlike the constructed state at Line 132. Normalize both paths.

♻️ Proposed fix
-            const refreshed = LifeState.cachedState(state.characterId);
-            if (refreshed && !refreshed.party?.partyId) return refreshed;
+            const refreshed = LifeState.cachedState(state.characterId);
+            if (refreshed && !refreshed.party?.partyId) {
+                return {
+                    ...refreshed,
+                    activity: refreshed.activity === 'grouped' ? 'hunting' : refreshed.activity
+                };
+            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.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 }
};
.then((cleared) => {
const refreshed = LifeState.cachedState(state.characterId);
if (refreshed && !refreshed.party?.partyId) {
return {
...refreshed,
activity: refreshed.activity === 'grouped' ? 'hunting' : refreshed.activity
};
}
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 }
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/Population/HotActivation.js` around lines 124 - 134,
Update the cached-state fast path in the promise callback to normalize
refreshed.activity from 'grouped' to 'hunting' before returning it, matching the
constructed state path while preserving all other refreshed state fields.

});
}
Comment on lines +118 to +136

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

No compensation when clearParty fails after the party is already marked dissolved.

setStatus(partyId, 'dissolved') is committed before clearParty. If the clear returns 0 or the cache still shows the link, the thrown error aborts activation but the background party stays dissolved while member rows still reference partyId. Those members are then orphaned at runtime — nothing reconciles them until recoverDissolvedPartyMembers() runs on the next restart. Consider restoring the party status (or enqueuing a retry) in a failure handler here.

🛠️ Sketch of a compensating path
     return BackgroundPartyState.setStatus(partyId, 'dissolved')
         .then(() => LifeState.clearParty(partyId, `hot_activation_${reason}`))
         .then((cleared) => {
@@
             return {
                 ...state,
                 activity: state.activity === 'grouped' ? 'hunting' : state.activity,
                 party: { ...(state.party || {}), partyId: null, leaderId: null }
             };
-        });
+        })
+        .catch((error) => BackgroundPartyState.setStatus(partyId, 'active')
+            .catch(() => null)
+            .then(() => { throw error; }));
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 }
};
});
}
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 }
};
})
.catch((error) => BackgroundPartyState.setStatus(partyId, 'active')
.catch(() => null)
.then(() => { throw error; }));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/Population/HotActivation.js` around lines 118 - 136,
Update releaseBackgroundParty to compensate when the post-setStatus clearParty
validation fails: restore the background party’s prior active status or enqueue
the established retry/reconciliation path before propagating the release error.
Keep the existing successful refresh and state-clearing behavior unchanged, and
ensure compensation covers both a zero clear count and a remaining cached party
link.


const HotActivation = {
activate(stateOrName, reason = 'activation', options = {}) {
const loadState = typeof stateOrName === 'string'
Expand All @@ -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 };
});
});
}
Expand Down
8 changes: 7 additions & 1 deletion src/GameServer/Bot/Population/PopulationService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading