From 94df4ae4a050481b0c1f9d7c5824e9c21b501e61 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:14:32 -0400 Subject: [PATCH 1/6] Improve bot hunting ground scheduling --- scripts/run-tests.js | 1 + src/GameServer/Bot/AI/BotDecisionService.js | 5 +- src/GameServer/Bot/AI/BotSpotTravel.js | 82 ++++++++++++ src/GameServer/Bot/AI/BotStatus.js | 8 +- src/GameServer/Bot/AI/BotTargetScorer.js | 5 + .../Bot/AI/GearAcquisitionPlanner.js | 26 ++-- src/GameServer/Bot/AI/LevelingRoutes.js | 3 +- src/GameServer/Bot/AI/SpotService.js | 78 ++++++++++- src/GameServer/Bot/AI/States/HuntingState.js | 91 ++++++++++--- src/GameServer/Bot/Population/BotLifeState.js | 51 +++++++- .../Bot/Population/HotActivation.js | 8 +- .../Bot/Population/PopulationConfig.js | 24 +++- .../Bot/Population/PopulationMetrics.js | 30 ++++- .../Bot/Population/PopulationService.js | 104 +++++++++++++-- .../Bot/Population/PopulationStatus.js | 4 +- src/GameServer/Bot/Population/SpotProfiles.js | 45 ++++--- tests/test_bot_hunting_ground_rules.js | 122 ++++++++++++++++++ tests/test_bot_population_scheduler_slices.js | 27 ++++ tests/test_bot_population_state.js | 1 + 19 files changed, 641 insertions(+), 74 deletions(-) create mode 100644 src/GameServer/Bot/AI/BotSpotTravel.js create mode 100644 tests/test_bot_hunting_ground_rules.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 96bc66a3..850ad884 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -68,6 +68,7 @@ const tests = [ 'tests/test_generated_cold_skills.js', 'tests/test_population_seed_planner.js', 'tests/test_spot_profile_state_priority.js', + 'tests/test_bot_hunting_ground_rules.js', 'tests/test_population_starter_party_grouping.js', 'tests/test_bot_goal_state.js', 'tests/test_bot_persona.js', diff --git a/src/GameServer/Bot/AI/BotDecisionService.js b/src/GameServer/Bot/AI/BotDecisionService.js index c4c7fb30..0ed6cacf 100644 --- a/src/GameServer/Bot/AI/BotDecisionService.js +++ b/src/GameServer/Bot/AI/BotDecisionService.js @@ -71,7 +71,10 @@ const BotDecisionService = { }; } - if (status.mode === 'hunting' && status.nearby.attackableNpcs === 0) { + if (status.mode === 'hunting' && ( + status.nearby.attackableNpcs === 0 + || Number(status.nearby.eligibleAttackableNpcs ?? status.nearby.attackableNpcs) === 0 + )) { if (!canMoveToSpot(session)) { return { action: 'search_locally', diff --git a/src/GameServer/Bot/AI/BotSpotTravel.js b/src/GameServer/Bot/AI/BotSpotTravel.js new file mode 100644 index 00000000..6bccf7bb --- /dev/null +++ b/src/GameServer/Bot/AI/BotSpotTravel.js @@ -0,0 +1,82 @@ +const ServerResponse = invoke('GameServer/Network/Response'); +const SpotService = invoke('GameServer/Bot/AI/SpotService'); +const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); + +const SOE_SKILL_ID = 2013; +const SOE_CAST_MS = 20000; +const TELEPORT_SETTLE_MS = 1200; + +function active(session) { + return !!session?.spotRelocation; +} + +function cancel(session, bot, reason = 'cancelled') { + if (!session?.spotRelocation) return false; + if (session.spotRelocation.arrivalPending) return false; + session.spotRelocation = undefined; + bot?.state?.setCasts?.(false); + session.lastSpotRelocation = { reason, at: Date.now() }; + return true; +} + +function start(session, bot, spot, targetLoc = null) { + if (!session || !bot || !spot) return false; + if (session.spotRelocation) return session.spotRelocation.spotId === spot.id; + + const token = Symbol('spot-relocation'); + const destination = { ...(targetLoc || spot.center) }; + session.spotRelocation = { + token, + spotId: spot.id, + destination, + startedAt: Date.now(), + completesAt: Date.now() + SOE_CAST_MS, + method: 'soe_gatekeeper' + }; + bot.state.setCasts(true); + const skill = { + fetchSelfId: () => SOE_SKILL_ID, + fetchCalculatedHitTime: () => SOE_CAST_MS, + fetchReuseTime: () => 0 + }; + session.dataSendToMeAndOthers?.(ServerResponse.skillStarted(bot, bot.fetchId(), skill), bot); + + setTimeout(() => { + const relocation = session.spotRelocation; + if (!relocation || relocation.token !== token) return; + if (bot.isDead?.() || session.currentTargetId || session.incomingThreatId) { + cancel(session, bot, 'combat_interrupt'); + return; + } + + bot.state.setCasts(false); + const TeleportTo = invoke('GameServer/Actor/Generics/TeleportTo'); + TeleportTo(session, bot, destination); + const arrivedSpot = SpotService.findById(spot.id) || spot; + SpotService.assignSpot(session, arrivedSpot); + session.initialSpawnCoord = { ...arrivedSpot.center }; + session.townRoutePlan = null; + session.spotRelocation = { ...relocation, arrivalPending: true }; + setTimeout(() => { + if (session.spotRelocation?.token !== token) return; + session.spotRelocation = undefined; + session.lastSpotRelocation = { + spotId: arrivedSpot.id, + method: 'soe_gatekeeper', + at: Date.now() + }; + }, TELEPORT_SETTLE_MS); + Promise.resolve(BotEventJournal.record({ + botId: bot.fetchId(), + eventType: 'travel_complete', + summary: `${bot.fetchName?.() || 'Bot'} reached ${arrivedSpot.name || 'a hunting ground'} via SoE and gatekeeper.`, + weight: 2, + dedupeKey: `spot-travel:${bot.fetchId()}:${arrivedSpot.id}`, + coalesceWindowMs: 30000, + meta: { spotId: arrivedSpot.id, method: 'soe_gatekeeper' } + })).catch(() => {}); + }, SOE_CAST_MS); + return true; +} + +module.exports = { SOE_CAST_MS, TELEPORT_SETTLE_MS, active, cancel, start }; diff --git a/src/GameServer/Bot/AI/BotStatus.js b/src/GameServer/Bot/AI/BotStatus.js index 477564f0..090047ed 100644 --- a/src/GameServer/Bot/AI/BotStatus.js +++ b/src/GameServer/Bot/AI/BotStatus.js @@ -11,6 +11,7 @@ const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); const BotAmbientDirector = invoke('GameServer/Bot/AI/BotAmbientDirector'); const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); +const BotTargetScorer = invoke('GameServer/Bot/AI/BotTargetScorer'); function ratio(value, max) { if (!max) return 0; @@ -161,6 +162,7 @@ function nearbySnapshot(bot) { let friendlyBots = 0; let hostilePlayers = 0; let attackableNpcs = 0; + let eligibleAttackableNpcs = 0; World.user.sessions.forEach((session) => { const actor = session.actor; @@ -181,10 +183,14 @@ function nearbySnapshot(bot) { World.fetchNpcsInRadius(bot.fetchLocX(), bot.fetchLocY(), 1500).forEach((npc) => { if (npc.fetchAttackable() && !npc.isDead()) { attackableNpcs++; + const levelGap = Number(npc.fetchLevel?.() || bot.fetchLevel()) - Number(bot.fetchLevel() || 1); + if (levelGap >= BotTargetScorer.MIN_LEVEL_GAP && levelGap <= BotTargetScorer.MAX_LEVEL_ADVANTAGE) { + eligibleAttackableNpcs++; + } } }); - return { realPlayers, friendlyBots, hostilePlayers, attackableNpcs }; + return { realPlayers, friendlyBots, hostilePlayers, attackableNpcs, eligibleAttackableNpcs }; } function tradeSnapshot(session, bot) { diff --git a/src/GameServer/Bot/AI/BotTargetScorer.js b/src/GameServer/Bot/AI/BotTargetScorer.js index 78b4c82b..a817ee57 100644 --- a/src/GameServer/Bot/AI/BotTargetScorer.js +++ b/src/GameServer/Bot/AI/BotTargetScorer.js @@ -1,4 +1,5 @@ const MAX_LEVEL_ADVANTAGE = 8; +const MIN_LEVEL_GAP = -7; const MAX_VERTICAL_GAP = 1200; function number(value, fallback = 0) { @@ -23,6 +24,9 @@ function score(context = {}) { if (!context.incomingThreat && levelGap > MAX_LEVEL_ADVANTAGE) { return { eligible: false, score: -Infinity, reason: 'level_too_high', reasons: ['level_too_high'] }; } + if (!context.incomingThreat && levelGap < MIN_LEVEL_GAP) { + return { eligible: false, score: -Infinity, reason: 'level_too_low', reasons: ['level_too_low'] }; + } if (verticalGap > MAX_VERTICAL_GAP) { return { eligible: false, score: -Infinity, reason: 'vertical_gap', reasons: ['vertical_gap'] }; } @@ -98,6 +102,7 @@ function rank(candidates) { module.exports = { MAX_LEVEL_ADVANTAGE, + MIN_LEVEL_GAP, MAX_VERTICAL_GAP, rank, score diff --git a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js index f9ae81c8..c2e3cdd2 100644 --- a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js +++ b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js @@ -508,24 +508,34 @@ function sourceIndexFor(spots = []) { ])); const spotByNpc = new Map(); const spotByName = new Map(); + const appendSpot = (index, key, spot) => { + if (!key || !spot) return; + const existing = index.get(key) || []; + if (!existing.some((candidate) => candidate.id === spot.id)) existing.push(spot); + index.set(key, existing); + }; (spots || []).forEach((spot) => (spot.npcEntries || []).forEach((entry) => { - if (entry.selfId) spotByNpc.set(Number(entry.selfId), spot); - if (entry.name) spotByName.set(String(entry.name).trim().toLowerCase(), spot); + if (entry.selfId) appendSpot(spotByNpc, Number(entry.selfId), spot); + if (entry.name) appendSpot(spotByName, String(entry.name).trim().toLowerCase(), spot); })); const byItemId = new Map(); rewards.forEach((reward) => { - const spot = spotByNpc.get(Number(reward.selfId)) - || spotByName.get(String(reward.template?.name || '').trim().toLowerCase()); - if (!spot) return; + const spotsForNpc = [...new Map([ + ...(spotByNpc.get(Number(reward.selfId)) || []), + ...(spotByName.get(String(reward.template?.name || '').trim().toLowerCase()) || []) + ].map((spot) => [spot.id, spot])).values()]; + if (!spotsForNpc.length) return; const itemIds = new Set((reward.rewards || []).flatMap((group) => ( (group.items || []).map((item) => Number(item.selfId || 0)).filter(Boolean) ))); - itemIds.forEach((id) => { + spotsForNpc.forEach((spot) => itemIds.forEach((id) => { const entries = byItemId.get(id) || []; - entries.push({ reward, spot, npcLevel: npcLevels.get(Number(reward.selfId)) || 0 }); + if (!entries.some((entry) => entry.reward === reward && entry.spot.id === spot.id)) { + entries.push({ reward, spot, npcLevel: npcLevels.get(Number(reward.selfId)) || 0 }); + } byItemId.set(id, entries); - }); + })); }); sourceIndexCache = { spots, rewards, byItemId }; diff --git a/src/GameServer/Bot/AI/LevelingRoutes.js b/src/GameServer/Bot/AI/LevelingRoutes.js index 60b3c92d..ab62b9c7 100644 --- a/src/GameServer/Bot/AI/LevelingRoutes.js +++ b/src/GameServer/Bot/AI/LevelingRoutes.js @@ -15,6 +15,7 @@ const ROUTES = [ maxLevel: 20, modes: ['solo', 'duo', 'party'], roles: ['dps', 'tank', 'dagger', 'archer', 'mage', 'healer', 'buffer', 'spoiler', 'crafter'], + requiredTags: ['starter'], preferredTags: ['starter', 'local'], reason: 'starter_leveling' }, @@ -212,7 +213,7 @@ function tagsForSpot(spot = {}) { .filter(([, pattern]) => pattern.test(text)) .map(([tag]) => tag); - if (Number(spot.minLevel || 0) <= 18) tags.push('starter'); + if (Number(spot.minLevel || 0) <= 18 && Number(spot.maxLevel || 0) <= 20) tags.push('starter'); if (Number(spot.maxLevel || 0) - Number(spot.minLevel || 0) <= 5) tags.push('normal_hp'); return uniq(tags); diff --git a/src/GameServer/Bot/AI/SpotService.js b/src/GameServer/Bot/AI/SpotService.js index 26a2db62..70a934a0 100644 --- a/src/GameServer/Bot/AI/SpotService.js +++ b/src/GameServer/Bot/AI/SpotService.js @@ -1,5 +1,7 @@ const GRID_SIZE = 6000; const DEFAULT_LEVEL_RANGE = 3; +const DEFAULT_MIN_HUNT_LEVEL_GAP = -7; +const DEFAULT_MAX_HUNT_LEVEL_GAP = 3; const LevelingRoutes = invoke('GameServer/Bot/AI/LevelingRoutes'); function distance2d(a, b) { @@ -23,6 +25,59 @@ function spotName(spot) { return `${primary} fields`; } +function levelCount(spot, level) { + return Number(spot?.levelCounts?.[String(level)] || spot?.levelCounts?.[level] || 0); +} + +function huntBand(targetLevel, options = {}) { + const level = Math.max(1, Number(targetLevel || 1)); + return { + min: Math.max(1, level + Number(options.minLevelGap ?? DEFAULT_MIN_HUNT_LEVEL_GAP)), + max: Math.max(1, level + Number(options.maxLevelGap ?? DEFAULT_MAX_HUNT_LEVEL_GAP)) + }; +} + +function eligibleDensity(spot, targetLevel, options = {}) { + if (!spot) return 0; + const band = huntBand(targetLevel, options); + if (spot.levelCounts && Object.keys(spot.levelCounts).length > 0) { + let count = 0; + for (let level = band.min; level <= band.max; level++) count += levelCount(spot, level); + return count; + } + // Fixtures and older persisted profiles may only carry min/max metadata. + // Keep them usable, but do not pretend a mixed sector is fully eligible. + const min = Number(spot.minLevel || 1); + const max = Number(spot.maxLevel || min); + if (max < band.min || min > band.max) return 0; + return Math.min(Number(spot.density || 0), Math.max(1, Number(spot.density || 0) * 0.5)); +} + +function levelFit(spot, targetLevel, options = {}) { + const band = huntBand(targetLevel, options); + const eligible = eligibleDensity(spot, targetLevel, options); + const density = Math.max(1, Number(spot?.density || 1)); + const ratio = eligible / density; + const avgLevel = Number(spot?.avgLevel || spot?.minLevel || 1); + const target = Math.max(1, Number(targetLevel || 1)); + const dangerous = Math.max(0, Number(spot?.maxLevel || avgLevel) - (target + Number(options.maxLevelGap ?? DEFAULT_MAX_HUNT_LEVEL_GAP))); + return { + band, + eligibleDensity: eligible, + eligibleRatio: ratio, + averageGap: Math.abs(avgLevel - target), + dangerousLevelSpan: dangerous + }; +} + +function isSuitable(spot, targetLevel, options = {}) { + if (!spot) return false; + const fit = levelFit(spot, targetLevel, options); + const minDensity = Math.max(1, Number(options.minEligibleDensity ?? 3)); + const minRatio = Math.max(0, Math.min(1, Number(options.minEligibleRatio ?? 0.25))); + return fit.eligibleDensity >= minDensity && fit.eligibleRatio >= minRatio; +} + const SpotService = { spots: null, @@ -34,6 +89,7 @@ const SpotService = { if (this.spots) return this.spots; const World = invoke('GameServer/World/World'); + if (!World?.npc?.spawns || !Array.isArray(World.npc.spawns)) return []; const sectors = {}; World.npc.spawns.forEach((npc) => { @@ -106,8 +162,8 @@ const SpotService = { npcSelfIds: selfIdEntries.slice(0, 8).map((item) => item.selfId), npcEntries: Object.values(sector.npcs) .sort((a, b) => b.count - a.count) - .slice(0, 24) .map((entry) => ({ ...entry })), + levelCounts: { ...sector.levels }, dominantLevels: levelEntries.slice(0, 3) }; @@ -123,6 +179,7 @@ const SpotService = { }, findCurrentSpot(loc) { + if (!loc || !Number.isFinite(Number(loc.locX)) || !Number.isFinite(Number(loc.locY))) return null; const gx = Math.floor(loc.locX / GRID_SIZE); const gy = Math.floor(loc.locY / GRID_SIZE); return this.findById(`${gx}_${gy}`); @@ -139,21 +196,30 @@ const SpotService = { const candidates = this.ensureIndexed() .filter((spot) => spot.density >= (options.minDensity || 4)) .filter((spot) => spot.minLevel <= targetLevel + levelRange && spot.maxLevel >= targetLevel - levelRange) + .filter((spot) => isSuitable(spot, targetLevel, options)) .filter((spot) => { const dist = distance2d(loc, spot.center); return dist >= minDistance && dist <= maxDistance; }) .map((spot) => { - const levelGap = Math.abs(spot.avgLevel - targetLevel); + const fit = levelFit(spot, targetLevel, options); + const levelGap = fit.averageGap; const dist = distance2d(loc, spot.center); const sameSpotPenalty = currentSpotId && currentSpotId === spot.id ? 100 : 0; const peacePenalty = utils.isInPeaceZone(spot.center.locX, spot.center.locY) ? 40 : 0; return { spot, - score: (spot.density * 3) - (levelGap * 18) - (dist / 2500) - sameSpotPenalty - peacePenalty, + score: (fit.eligibleDensity * 5) + (fit.eligibleRatio * 30) + - (levelGap * 18) + - (fit.dangerousLevelSpan * 3) + - (dist / 2500) + - sameSpotPenalty + - peacePenalty, distance: dist, - levelGap + levelGap, + eligibleDensity: fit.eligibleDensity, + eligibleRatio: fit.eligibleRatio }; }) .map((candidate) => { @@ -217,6 +283,10 @@ const SpotService = { }, distance2d, + eligibleDensity, + huntBand, + isSuitable, + levelFit, locFromActor }; diff --git a/src/GameServer/Bot/AI/States/HuntingState.js b/src/GameServer/Bot/AI/States/HuntingState.js index 44576569..eeb17e96 100644 --- a/src/GameServer/Bot/AI/States/HuntingState.js +++ b/src/GameServer/Bot/AI/States/HuntingState.js @@ -11,6 +11,7 @@ const BotPvpRisk = invoke('GameServer/Bot/AI/BotPvpRisk'); const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); const ShotStock = invoke('GameServer/Inventory/ShotStock'); const BotTownTravel = invoke('GameServer/Bot/AI/BotTownTravel'); +const BotSpotTravel = invoke('GameServer/Bot/AI/BotSpotTravel'); const TARGET_STALL_TICKS = 5; const TARGET_RETRY_COOLDOWN_MS = 15000; @@ -20,6 +21,8 @@ const TARGET_GEODATA_CHECK_LIMIT = 4; const EMERGENCY_RETREAT_HP_RATIO = 0.35; const EMERGENCY_RETREAT_MP_RATIO = 0.20; const EMERGENCY_RETREAT_DISTANCE = 850; +const MAX_WALK_SPOT_DISTANCE = 12000; +const SPOT_ARRIVAL_RADIUS = 1000; function isSoloHunter(session) { return session.plan === 'hunting' && session.partyCompanion !== true && !session.followPlayerSession; @@ -125,6 +128,71 @@ function targetOnCooldown(session, targetId) { return false; } +function botLocation(bot) { + return { locX: bot.fetchLocX(), locY: bot.fetchLocY(), locZ: bot.fetchLocZ() }; +} + +function finishWalkRelocation(session, bot, spot) { + const arrivedSpot = SpotService.findById(spot.id) || spot; + SpotService.assignSpot(session, arrivedSpot); + session.initialSpawnCoord = { ...arrivedSpot.center }; + session.spotRelocation = undefined; + session.townRoutePlan = null; + session.lastSpotRelocation = { spotId: arrivedSpot.id, method: 'walk', at: Date.now() }; +} + +function issueWalkRelocation(session, bot, relocation) { + const from = botLocation(bot); + relocation.lastCommandAt = Date.now(); + bot.moveTo({ from, to: { ...relocation.destination } }); +} + +function tickSpotRelocation(session, bot) { + const relocation = session.spotRelocation; + if (!relocation) return false; + if (relocation.method === 'soe_gatekeeper') return true; + + const distance = SpotService.distance2d(botLocation(bot), relocation.destination); + if (distance <= SPOT_ARRIVAL_RADIUS) { + finishWalkRelocation(session, bot, SpotService.findById(relocation.spotId) || { id: relocation.spotId, center: relocation.destination }); + return false; + } + if (bot.state.fetchTowards() || session.moveTimer) return true; + if (Date.now() - Number(relocation.lastCommandAt || 0) >= 1000) issueWalkRelocation(session, bot, relocation); + return true; +} + +function beginSpotRelocation(session, bot, spot, BotAI) { + const destination = { ...spot.center }; + session.currentTargetId = undefined; + bot.unselect?.(); + session.noTargetTicks = 0; + session.lastSpotMoveAt = Date.now(); + + if (SpotService.distance2d(botLocation(bot), destination) > MAX_WALK_SPOT_DISTANCE) { + BotSpotTravel.start(session, bot, spot, destination); + if (Math.random() < 0.65) BotAI.say(session, `No good mobs here. Using a gatekeeper to reach ${SpotService.describe(spot)}.`); + return; + } + + session.spotRelocation = { + mode: 'walk', + method: 'walk', + spotId: spot.id, + destination, + startedAt: Date.now(), + lastCommandAt: 0 + }; + if (Math.random() < 0.65) BotAI.say(session, `No good mobs here. Moving to ${SpotService.describe(spot)}.`); + issueWalkRelocation(session, bot, session.spotRelocation); +} + +function reconcilePhysicalSpot(session, bot) { + if (session.spotRelocation) return; + const physical = SpotService.findCurrentSpot(botLocation(bot)); + if (physical && session.currentSpot?.id !== physical.id) SpotService.assignSpot(session, physical); +} + function assignTarget(session, bot, target) { const targetId = target.fetchId(); if (session.currentTargetId !== targetId) { @@ -204,6 +272,7 @@ function targetProgressing(session, bot, target) { module.exports = { tick(session, bot, Generics, BotAI) { + if (session.spotRelocation?.arrivalPending) return; if (session.pendingTownTrip) { const trip = startShopping(session, bot, BotAI, session.pendingTownTrip.reason); if (trip !== 'deferred') return; @@ -330,6 +399,7 @@ module.exports = { // actively hitting the bot only turns the recovery state into a death loop. const incomingMonster = PartyAwareness.recentIncomingNpc(session); if (incomingMonster) { + if (session.spotRelocation) BotSpotTravel.cancel(session, bot, 'incoming_threat'); if (needsEmergencyRetreat(bot)) { retreatFromThreat(session, bot, incomingMonster); return; @@ -343,6 +413,10 @@ module.exports = { return; } + // A hunting-ground relocation owns the movement/combat window. Do not + // attack starter mobs while walking or casting SoE to a better field. + if (tickSpotRelocation(session, bot)) return; + // 4. HP/MP resting check const hpRatio = bot.fetchHp() / bot.fetchMaxHp(); const mpRatio = bot.fetchMp() / bot.fetchMaxMp(); @@ -419,6 +493,7 @@ module.exports = { }); }); } else { + reconcilePhysicalSpot(session, bot); // Prefer unclaimed mobs so solo bots do not form accidental trains. const closestMonster = findPreferredMonster(session, bot, 2500); @@ -472,21 +547,7 @@ module.exports = { }; if (decision.action === 'move_to_spot' && decision.spot) { - const assignedSpot = SpotService.assignSpot(session, decision.spot); - const targetLoc = SpotService.randomPointNear(decision.spot); - - session.initialSpawnCoord = { ...assignedSpot.center }; - session.lastSpotMoveAt = Date.now(); - session.noTargetTicks = 0; - - if (Math.random() < 0.65) { - BotAI.say(session, `No good mobs here. Moving to ${SpotService.describe(decision.spot)}.`); - } - - bot.moveTo({ - from: { locX: bot.fetchLocX(), locY: bot.fetchLocY(), locZ: bot.fetchLocZ() }, - to: targetLoc - }); + beginSpotRelocation(session, bot, decision.spot, BotAI); return; } diff --git a/src/GameServer/Bot/Population/BotLifeState.js b/src/GameServer/Bot/Population/BotLifeState.js index 7a0678d3..32522d4e 100644 --- a/src/GameServer/Bot/Population/BotLifeState.js +++ b/src/GameServer/Bot/Population/BotLifeState.js @@ -1073,6 +1073,10 @@ const BotLifeState = { // they wait for their persisted deadline. const staleRateModelPlan = `json_extract(statsJson, '$.equipmentPlan.expectedKills') IS NOT NULL AND COALESCE(CAST(json_extract(statsJson, '$.equipmentPlan.rateModelVersion') AS INTEGER), 0) < ${GearAcquisitionPlanner.RATE_MODEL_VERSION}`; + const pendingEquipmentSpotReplan = `activity IN ('hunting', 'resting') + AND json_extract(statsJson, '$.equipmentPlan.status') = 'active' + AND json_extract(statsJson, '$.equipmentPlan.next.spotId') IS NOT NULL + AND json_extract(statsJson, '$.equipmentPlan.next.spotId') <> COALESCE(spotId, '')`; return Database.execute([ `SELECT * FROM ${TABLE} @@ -1097,12 +1101,16 @@ const BotLifeState = { -- target level or drop-rate estimate. WHEN ${staleRateModelPlan} THEN 0 WHEN activity IN ('traveling', 'shopping', 'crafting') THEN 1 + -- An active equipment plan whose next source is elsewhere + -- must get a chance to start gatekeeper travel before the + -- ordinary hunting backlog keeps resolving the old spot. + WHEN ${pendingEquipmentSpotReplan} THEN 2 -- Startup craft recovery is a one-shot replan. Serve it -- before the normal hunting backlog so a repaired station -- wait immediately selects its missing raw material. - WHEN json_extract(statsJson, '$.lastReason') = 'startup_craft_wait_recovery' THEN 2 - WHEN activity = 'dead' THEN 3 - ELSE 4 + WHEN json_extract(statsJson, '$.lastReason') = 'startup_craft_wait_recovery' THEN 3 + WHEN activity = 'dead' THEN 4 + ELSE 5 END ASC, COALESCE(nextResolveAt, 0) ASC LIMIT ${safeLimit}`, @@ -1962,6 +1970,43 @@ const BotLifeState = { return counts; }, + coldDueSummary(timestamp = now()) { + const summary = { + due: 0, + highLevel: 0, + replans: 0, + oldestAgeMs: 0 + }; + + cache.forEach((state) => { + if (state.phase !== 'cold' + || state.activity === 'pk_hunting' + || state.partyId + || state.party?.partyId + || (state.activity === 'merchant' && state.stats?.marketStore) + || (state.activity === 'crafting' && state.stats?.craftShop)) { + return; + } + + const nextResolveAt = Number(state.timing?.nextResolveAt || 0); + if (nextResolveAt > timestamp) return; + + summary.due += 1; + if (Number(state.level || 1) >= 16) summary.highLevel += 1; + const plan = state.stats?.equipmentPlan; + if (plan?.status === 'active' + && plan.next?.spotId + && plan.next.spotId !== state.spotId) { + summary.replans += 1; + } + + const dueAt = nextResolveAt > 0 ? nextResolveAt : Number(state.updatedAt || timestamp); + summary.oldestAgeMs = Math.max(summary.oldestAgeMs, Math.max(0, timestamp - dueAt)); + }); + + return summary; + }, + targetCombatSummary() { return Array.from(cache.values()).reduce((summary, state) => { const targets = state.stats?.targetCombat?.populationTargets || {}; diff --git a/src/GameServer/Bot/Population/HotActivation.js b/src/GameServer/Bot/Population/HotActivation.js index 6c933b4d..d625de5c 100644 --- a/src/GameServer/Bot/Population/HotActivation.js +++ b/src/GameServer/Bot/Population/HotActivation.js @@ -72,10 +72,14 @@ function activationPlacement(state, options = {}) { const loc = options.storeLoc || state.loc; return { loc: { ...loc }, spot: SpotService.findCurrentSpot(loc) || null }; } - const spot = state?.spotId ? SpotService.findById(state.spotId) : null; + const savedSpot = state?.spotId ? SpotService.findById(state.spotId) : null; + // Coordinates are authoritative for activation. A stale destination spot + // must not resurrect a bot on a remote field it never reached. + const physicalSpot = state?.loc ? SpotService.findCurrentSpot(state.loc) : null; + const spot = physicalSpot || savedSpot; const baseLoc = options.playerLoc ? (options.forceNearPlayer ? options.playerLoc : (state?.loc || spot?.center || { locX: 0, locY: 0, locZ: 0 })) - : (spot?.center || state?.loc || { locX: 0, locY: 0, locZ: 0 }); + : (state?.loc || spot?.center || { locX: 0, locY: 0, locZ: 0 }); let candidate = null; for (let i = 0; i < Config.activationPlacementAttempts; i++) { diff --git a/src/GameServer/Bot/Population/PopulationConfig.js b/src/GameServer/Bot/Population/PopulationConfig.js index 126a528e..98abed01 100644 --- a/src/GameServer/Bot/Population/PopulationConfig.js +++ b/src/GameServer/Bot/Population/PopulationConfig.js @@ -6,15 +6,19 @@ const DEFAULTS = { directorEnabled: true, summaryIntervalMs: 30000, schedulerIntervalMs: 5000, - // Cold simulation is invisible to players. Keep its total throughput, - // but let sockets and hot AI run between bounded pieces of work. + // Cold simulation is invisible to players. Keep its total throughput + // bounded, while allowing a larger catch-up slice when no real player is + // online. The player-aware budget remains intentionally conservative. schedulerSliceMs: 12, - // The scheduler is a background tenant. A count-only limit is unsafe - // because one party resolve can cost much more than one solo resolve. - schedulerBudgetMs: 750, + schedulerIdleBudgetMs: 2500, schedulerPlayerBudgetMs: 250, + schedulerIdleMaxResolvesPerTick: 100, + schedulerPlayerMaxResolvesPerTick: 25, + // Above this lag, taper background work before the hard stop below. A + // gradual throttle avoids turning one noisy sample into a long backlog. + schedulerLagThrottleMs: 40, schedulerLagAbortMs: 120, - partyFormationBudgetMs: 1500, + partyFormationIdleBudgetMs: 3000, partyFormationPlayerBudgetMs: 600, partyFormationSliceMs: 12, // Existing cold population predates full class progression. Reconcile it @@ -135,6 +139,14 @@ const ENV_KEYS = { nearPlayerHotTarget: 'BOT_NEAR_PLAYER_HOT_TARGET', maxActivationsPerScan: 'BOT_MAX_ACTIVATIONS_PER_SCAN', schedulerSliceMs: 'BOT_POPULATION_SCHEDULER_SLICE_MS', + schedulerIdleBudgetMs: 'BOT_POPULATION_SCHEDULER_IDLE_BUDGET_MS', + schedulerPlayerBudgetMs: 'BOT_POPULATION_SCHEDULER_PLAYER_BUDGET_MS', + schedulerIdleMaxResolvesPerTick: 'BOT_POPULATION_SCHEDULER_IDLE_MAX_RESOLVES', + schedulerPlayerMaxResolvesPerTick: 'BOT_POPULATION_SCHEDULER_PLAYER_MAX_RESOLVES', + schedulerLagThrottleMs: 'BOT_POPULATION_SCHEDULER_LAG_THROTTLE_MS', + schedulerLagAbortMs: 'BOT_POPULATION_SCHEDULER_LAG_ABORT_MS', + partyFormationIdleBudgetMs: 'BOT_POPULATION_PARTY_FORMATION_IDLE_BUDGET_MS', + partyFormationPlayerBudgetMs: 'BOT_POPULATION_PARTY_FORMATION_PLAYER_BUDGET_MS', partyInviteRange: 'BOT_PARTY_INVITE_RANGE', marketTradeChatEnabled: 'BOT_MARKET_TRADE_CHAT_ENABLED', marketTradeChatIntervalMs: 'BOT_MARKET_TRADE_CHAT_INTERVAL_MS', diff --git a/src/GameServer/Bot/Population/PopulationMetrics.js b/src/GameServer/Bot/Population/PopulationMetrics.js index fc04dcce..74e8b386 100644 --- a/src/GameServer/Bot/Population/PopulationMetrics.js +++ b/src/GameServer/Bot/Population/PopulationMetrics.js @@ -55,6 +55,14 @@ const PopulationMetrics = { samples: 0, slowSamples: 0 }, + schedulerState: { + budgetMs: 0, + mode: 'unknown', + lagMs: 0, + coldBatch: 0, + coldBatchLimit: 0, + coldQueueSaturated: false + }, interval: { resolveDurationsMs: [], schedulerDurationsMs: [], @@ -185,6 +193,26 @@ const PopulationMetrics = { this.counters.schedulerBudgetStops += 1; }, + recordSchedulerProfile(profile = {}) { + this.schedulerState = { + ...this.schedulerState, + budgetMs: Math.max(0, Number(profile.budgetMs) || 0), + mode: profile.idle ? 'idle' : 'player', + lagMs: Math.max(0, Number(profile.lagMs) || 0) + }; + }, + + recordColdBatch(count = 0, limit = 0) { + const batch = Math.max(0, Number(count) || 0); + const cap = Math.max(0, Number(limit) || 0); + this.schedulerState = { + ...this.schedulerState, + coldBatch: batch, + coldBatchLimit: cap, + coldQueueSaturated: cap > 0 && batch >= cap + }; + }, + recordPartyFormationBudgetStop() { this.counters.partyFormationBudgetStops += 1; }, @@ -236,7 +264,7 @@ const PopulationMetrics = { delta, eventLoop: { ...this.eventLoop }, resolve: resolveStats, - scheduler: schedulerStats, + scheduler: { ...schedulerStats, ...this.schedulerState }, schedulerSlice: schedulerSliceStats, partyFormation: partyFormationStats, partyFormationStages, diff --git a/src/GameServer/Bot/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js index 28b793e0..475ca72f 100644 --- a/src/GameServer/Bot/Population/PopulationService.js +++ b/src/GameServer/Bot/Population/PopulationService.js @@ -5,6 +5,7 @@ const Status = invoke('GameServer/Bot/Population/PopulationStatus'); const LifeState = invoke('GameServer/Bot/Population/BotLifeState'); const LifeEvents = invoke('GameServer/Bot/Population/BotLifeEvents'); const SpotProfiles = invoke('GameServer/Bot/Population/SpotProfiles'); +const SpotService = invoke('GameServer/Bot/AI/SpotService'); const BackgroundResolver = invoke('GameServer/Bot/Population/BackgroundResolver'); const BackgroundPartyResolver = invoke('GameServer/Bot/Population/BackgroundPartyResolver'); const BackgroundPartyState = invoke('GameServer/Bot/Population/BackgroundPartyState'); @@ -26,6 +27,46 @@ const CraftTelemetry = invoke('GameServer/Bot/Economy/CraftTelemetry'); const BotPersona = invoke('GameServer/Bot/AI/BotPersona'); const PersonaPartyPolicy = invoke('GameServer/Bot/Population/PersonaPartyPolicy'); +const HUNTING_TRAVEL_MS = 25000; + +function beginHuntingTravel(state, spot, timestamp = Date.now(), options = {}) { + if (!state || !spot || state.activity === 'traveling') return null; + const from = { ...(state.loc || {}) }; + const hasLocation = Number.isFinite(Number(from.locX)) && Number.isFinite(Number(from.locY)) + && (Object.prototype.hasOwnProperty.call(from, 'locX') || Object.prototype.hasOwnProperty.call(from, 'locY')); + if (!hasLocation) return null; + const physical = SpotService.findCurrentSpot(from); + const currentId = physical?.id || options.currentSpotId || state.spotId || null; + if (currentId === spot.id) return null; + + return { + ...state, + activity: 'traveling', + timing: { + ...(state.timing || {}), + activityStartedAt: timestamp, + nextResolveAt: timestamp + HUNTING_TRAVEL_MS + }, + stats: { + ...(state.stats || {}), + travel: { + from, + to: { ...spot.center }, + startedAt: timestamp, + arrivalAt: timestamp + HUNTING_TRAVEL_MS, + regionName: spot.name || state.currentRegion || 'Hunting Ground', + method: 'gatekeeper_spot', + spotId: spot.id, + arrivalActivity: 'hunting', + arrivalEvent: 'arrived_hunting_ground', + reason: state.stats?.equipmentPlan?.status === 'active' + ? 'equipment_source_replan' + : 'level_replan' + } + } + }; +} + function groupBySpot(states, options = {}) { const grouped = new Map(); states.forEach((state) => { @@ -1033,11 +1074,11 @@ const PopulationService = { } const configured = players > 0 ? Config.partyFormationPlayerBudgetMs - : Config.partyFormationBudgetMs; + : Config.partyFormationIdleBudgetMs; return Math.max(50, Number(configured) || 500); }, - schedulerBudgetMs() { + schedulerProfile() { let players = 0; try { players = this.realPlayerSessions().length; @@ -1046,12 +1087,36 @@ const PopulationService = { // where the world session registry is not available yet. players = 0; } - const configured = players > 0 ? Config.schedulerPlayerBudgetMs : Config.schedulerBudgetMs; - const budget = Math.max(25, Number(configured) || 250); + const idle = players === 0; + const lagMs = Math.max(0, Number(Metrics.currentEventLoopLag()) || 0); + const configured = idle ? Config.schedulerIdleBudgetMs : Config.schedulerPlayerBudgetMs; + const baseBudget = Math.max(25, Number(configured) || 250); + const lagThrottle = Math.max(0, Number(Config.schedulerLagThrottleMs) || 0); const lagAbort = Math.max(0, Number(Config.schedulerLagAbortMs) || 0); - return lagAbort > 0 && Metrics.currentEventLoopLag() >= lagAbort - ? 0 - : Math.min(budget, Math.max(25, Config.schedulerIntervalMs - 25)); + let budget = baseBudget; + + if (lagAbort > 0 && lagMs >= lagAbort) { + budget = 0; + } else if (lagAbort > lagThrottle && lagMs > lagThrottle) { + const pressure = Math.min(1, (lagMs - lagThrottle) / (lagAbort - lagThrottle)); + budget = Math.round(baseBudget * (1 - pressure)); + } + + return { + idle, + players, + lagMs, + budgetMs: budget > 0 + ? Math.min(budget, Math.max(25, Config.schedulerIntervalMs - 25)) + : 0, + maxResolvesPerTick: Math.max(1, Number(idle + ? Config.schedulerIdleMaxResolvesPerTick + : Config.schedulerPlayerMaxResolvesPerTick) || 25) + }; + }, + + schedulerBudgetMs() { + return this.schedulerProfile().budgetMs; }, groupPartyCandidatesByObjective(states = [], options = {}) { @@ -1281,7 +1346,9 @@ const PopulationService = { } const startedAt = Date.now(); - const budgetMs = this.schedulerBudgetMs(); + const profile = this.schedulerProfile(); + Metrics.recordSchedulerProfile(profile); + const budgetMs = profile.budgetMs; if (budgetMs <= 0) { Metrics.recordSchedulerBudgetStop(); return Promise.resolve([]); @@ -1290,8 +1357,10 @@ const PopulationService = { this.resolving = true; return Database.cooperatively(() => this.resolveDueParties(deadlineAt) .then(() => this.reconcileMarketGoals(deadlineAt)) - .then(() => LifeState.dueCold(Config.maxResolvesPerTick)) - .then((states) => this.runInSchedulerSlices(states, (state) => this.resolveColdState(state) + .then(() => LifeState.dueCold(profile.maxResolvesPerTick)) + .then((states) => { + Metrics.recordColdBatch(states.length, profile.maxResolvesPerTick); + return this.runInSchedulerSlices(states, (state) => this.resolveColdState(state) .catch((error) => { // A single bot may lose a race with a market or // craft transaction. It must not abort every @@ -1299,7 +1368,8 @@ const PopulationService = { utils.infoWarn('BotPopulation', 'cold resolve failed for %s: %s', state.name, error?.message || error); Metrics.recordSkippedResolve(); return { ok: false, reason: 'resolve_rejected', state }; - }), deadlineAt)) + }), deadlineAt); + }) .catch((err) => { utils.infoWarn('BotPopulation', 'background scheduler failed: %s', err.message); return []; @@ -1658,14 +1728,20 @@ const PopulationService = { const routedState = fallbackSpot ? { ...plannedState, activity: 'hunting', spotId: fallbackSpot.id } : plannedState; + const currentSpotId = plannedState.spotId || null; const travellingState = ColdCraftingService.beginTravel(routedState) || routedState; const travel = travellingState.stats?.travel; const travelEvents = travellingState !== plannedState && travel?.stationId ? [CraftTelemetry.stationTravelEvent(plannedState, travel)] : []; - const spot = passiveActivity + const selectedSpot = passiveActivity ? null : fallbackSpot || SpotProfiles.findForState(travellingState); + const huntingTravelState = selectedSpot && !passiveActivity + ? beginHuntingTravel(travellingState, selectedSpot, startedAt, { currentSpotId }) + : null; + const effectiveState = huntingTravelState || travellingState; + const spot = effectiveState.activity === 'traveling' ? null : selectedSpot; if (!spot && !passiveActivity) { Metrics.recordSkippedResolve(); Metrics.recordResolveDuration(Date.now() - startedAt); @@ -1673,7 +1749,7 @@ const PopulationService = { } const result = BackgroundResolver.resolveSolo({ - state: travellingState, + state: effectiveState, spot, pressure: Director.pressureForState(state), targetNpcId: requiredPartyRequest @@ -1687,7 +1763,7 @@ const PopulationService = { return Promise.resolve({ ok: false, reason: 'joined_party', state }); } - return LifeState.applyResolve(travellingState, result) + return LifeState.applyResolve(effectiveState, result) .then((updatedState) => { if (!updatedState) { Metrics.recordSkippedResolve(); diff --git a/src/GameServer/Bot/Population/PopulationStatus.js b/src/GameServer/Bot/Population/PopulationStatus.js index 7e6fbd62..3b8a5798 100644 --- a/src/GameServer/Bot/Population/PopulationStatus.js +++ b/src/GameServer/Bot/Population/PopulationStatus.js @@ -15,6 +15,7 @@ const PopulationStatus = { const hot = sessions.filter((session) => isBotSession(session) && session.actor).length; const merchants = sessions.filter((session) => isBotSession(session) && session.actor && session.plan === 'merchant').length; const lifeCounts = LifeState.counts(); + const coldQueue = LifeState.coldDueSummary(); const partyCounts = PartyState.counts(); const targetCombat = LifeState.targetCombatSummary(); const partyRequests = LifeState.partyRequestSummary(); @@ -27,6 +28,7 @@ const PopulationStatus = { merchants, total: Math.max(hot, lifeCounts.total || 0), persisted: lifeCounts.total || 0, + coldQueue, targetCombat, partyRequests }; @@ -53,7 +55,7 @@ const PopulationStatus = { metrics, director: Director.snapshot(), market, - line: `hot=${counts.hot} warm=${counts.warm} cold=${counts.cold} parties=${counts.parties} persisted=${counts.persisted} merchants=${counts.merchants} partyRequests=${counts.partyRequests.total} partyRequired=${counts.partyRequests.required} partyPreferred=${counts.partyRequests.preferred} partyBlocked=${counts.partyRequests.blocked} partyMaxAge=${Math.round(counts.partyRequests.maxAgeMs / 1000)}s partyRequiredReasons=${partyRequiredReasons} marketListings=${market.delta.listingsOpened} marketBuys=${market.delta.purchases} marketItems=${market.delta.itemsSold} marketAdena=${market.delta.adenaTraded} staticBuyerSales=${market.delta.staticBuyerSales} staticBuyerItems=${market.delta.staticBuyerItems} staticBuyerAdena=${market.delta.staticBuyerAdena} marketNoOffer=${market.delta.noOffer} marketSoldOut=${market.delta.soldOut} marketExpired=${market.delta.expired} ticks=${metrics.delta.hotTicks} resolves=${metrics.delta.backgroundResolves} partyResolves=${metrics.delta.partyResolves} combatActions=${metrics.delta.combatActions} skillUses=${metrics.delta.skillUses} heals=${metrics.delta.heals} skipped=${metrics.delta.skippedResolves} activations=${metrics.delta.activations} cooldowns=${metrics.delta.cooldowns} partyForms=${metrics.delta.partyFormations} partyRecruits=${metrics.delta.partyRecruits} partyDissolves=${metrics.delta.partyDissolutions} partyFormP95=${partyFormation.p95Ms || 0}ms partyFormBudgetStops=${metrics.delta.partyFormationBudgetStops || 0} partyFormStages=${Object.entries(partyFormationStages).map(([stage, value]) => `${stage}:${value.p95Ms || 0}`).join('|') || 'none'} dbFlushes=${metrics.delta.dbFlushes} resolveAvg=${resolve.avgMs || 0}ms resolveP95=${resolve.p95Ms || 0}ms schedulerP95=${scheduler.p95Ms || 0}ms sliceP95=${schedulerSlice.p95Ms || 0}ms schedulerYields=${metrics.delta.schedulerYields || 0} schedulerSkips=${metrics.delta.schedulerSkips || 0} schedulerBudgetStops=${metrics.delta.schedulerBudgetStops || 0} schedulerOverruns=${metrics.delta.schedulerOverruns || 0} slowResolves=${metrics.delta.slowResolves || 0} heap=${heapMb}MB lag=${lag}ms maxLag=${maxLag}ms ${Director.statusLine()}` + line: `hot=${counts.hot} warm=${counts.warm} cold=${counts.cold} parties=${counts.parties} persisted=${counts.persisted} merchants=${counts.merchants} coldDue=${counts.coldQueue.due} coldDueHigh=${counts.coldQueue.highLevel} coldReplans=${counts.coldQueue.replans} coldDueAge=${Math.round(counts.coldQueue.oldestAgeMs / 1000)}s partyRequests=${counts.partyRequests.total} partyRequired=${counts.partyRequests.required} partyPreferred=${counts.partyRequests.preferred} partyBlocked=${counts.partyRequests.blocked} partyMaxAge=${Math.round(counts.partyRequests.maxAgeMs / 1000)}s partyRequiredReasons=${partyRequiredReasons} marketListings=${market.delta.listingsOpened} marketBuys=${market.delta.purchases} marketItems=${market.delta.itemsSold} marketAdena=${market.delta.adenaTraded} staticBuyerSales=${market.delta.staticBuyerSales} staticBuyerItems=${market.delta.staticBuyerItems} staticBuyerAdena=${market.delta.staticBuyerAdena} marketNoOffer=${market.delta.noOffer} marketSoldOut=${market.delta.soldOut} marketExpired=${market.delta.expired} ticks=${metrics.delta.hotTicks} resolves=${metrics.delta.backgroundResolves} partyResolves=${metrics.delta.partyResolves} combatActions=${metrics.delta.combatActions} skillUses=${metrics.delta.skillUses} heals=${metrics.delta.heals} skipped=${metrics.delta.skippedResolves} activations=${metrics.delta.activations} cooldowns=${metrics.delta.cooldowns} partyForms=${metrics.delta.partyFormations} partyRecruits=${metrics.delta.partyRecruits} partyDissolves=${metrics.delta.partyDissolutions} partyFormP95=${partyFormation.p95Ms || 0}ms partyFormBudgetStops=${metrics.delta.partyFormationBudgetStops || 0} partyFormStages=${Object.entries(partyFormationStages).map(([stage, value]) => `${stage}:${value.p95Ms || 0}`).join('|') || 'none'} dbFlushes=${metrics.delta.dbFlushes} resolveAvg=${resolve.avgMs || 0}ms resolveP95=${resolve.p95Ms || 0}ms schedulerP95=${scheduler.p95Ms || 0}ms schedulerBudget=${scheduler.budgetMs || 0}ms schedulerMode=${scheduler.mode || 'unknown'} coldBatch=${scheduler.coldBatch || 0}/${scheduler.coldBatchLimit || 0} schedulerLag=${scheduler.lagMs || 0}ms schedulerYields=${metrics.delta.schedulerYields || 0} schedulerSkips=${metrics.delta.schedulerSkips || 0} schedulerBudgetStops=${metrics.delta.schedulerBudgetStops || 0} schedulerOverruns=${metrics.delta.schedulerOverruns || 0} slowResolves=${metrics.delta.slowResolves || 0} heap=${heapMb}MB lag=${lag}ms maxLag=${maxLag}ms ${Director.statusLine()}` }; } }; diff --git a/src/GameServer/Bot/Population/SpotProfiles.js b/src/GameServer/Bot/Population/SpotProfiles.js index ab409427..7284f896 100644 --- a/src/GameServer/Bot/Population/SpotProfiles.js +++ b/src/GameServer/Bot/Population/SpotProfiles.js @@ -35,6 +35,8 @@ function profileFromSpot(spot) { npcNames: [...(spot.npcNames || [])], npcSelfIds: [...(spot.npcSelfIds || [])], npcEntries: (spot.npcEntries || []).map((entry) => ({ ...entry })), + levelCounts: { ...(spot.levelCounts || {}) }, + dominantLevels: (spot.dominantLevels || []).map((entry) => ({ ...entry })), route: spot.route || null, rewards: rewardForLevel(avgLevel), mob: combatForLevel(avgLevel), @@ -48,6 +50,15 @@ function isProtectedStarterCohort(state) { && !!state?.stats?.starterRegion; } +function physicalSpotForState(state, profiles) { + const loc = state?.loc; + if (loc && Number.isFinite(Number(loc.locX)) && Number.isFinite(Number(loc.locY))) { + const physical = SpotService.findCurrentSpot(loc); + if (physical) return profiles.find((profile) => profile.id === physical.id) || physical; + } + return state?.spotId ? profiles.find((profile) => profile.id === state.spotId) || null : null; +} + const SpotProfiles = { cache: null, @@ -70,17 +81,20 @@ const SpotProfiles = { findForState(state, options = {}) { const acquisitionPlan = state?.stats?.equipmentPlan; const protectedStarterCohort = isProtectedStarterCohort(state); - const keepCurrentSpot = state?.spotId && (!acquisitionPlan || protectedStarterCohort); + const profiles = this.ensure(); + const physicalSpot = physicalSpotForState(state, profiles); + const savedSpot = state?.spotId ? this.findById(state.spotId) : null; + const currentSpot = physicalSpot || savedSpot; + const targetLevel = LevelingRoutes.targetLevelForState(state); + const keepCurrentSpot = currentSpot && (!acquisitionPlan || protectedStarterCohort) + && (protectedStarterCohort || SpotService.isSuitable(currentSpot, targetLevel, options)); // Fresh racial cohorts stay at their physical level-one spot until // they advance. A gear plan otherwise remains the normal route choice // for established bots. if (keepCurrentSpot) { - const existing = this.findById(state.spotId); - if (existing) { - const match = LevelingRoutes.scoreSpot(existing, state, options); - return LevelingRoutes.decorateSpot(existing, match); - } + const match = LevelingRoutes.scoreSpot(currentSpot, state, options); + return LevelingRoutes.decorateSpot(currentSpot, match); } if (acquisitionPlan?.status === 'active') { @@ -91,27 +105,24 @@ const SpotProfiles = { if (planned) return planned.spot; } - if (state?.spotId) { - const existing = this.findById(state.spotId); - if (existing) { - const match = LevelingRoutes.scoreSpot(existing, state, options); - return LevelingRoutes.decorateSpot(existing, match); - } + if (currentSpot && SpotService.isSuitable(currentSpot, targetLevel, options)) { + const match = LevelingRoutes.scoreSpot(currentSpot, state, options); + return LevelingRoutes.decorateSpot(currentSpot, match); } - const targetLevel = LevelingRoutes.targetLevelForState(state); - const profiles = this.ensure() + const candidates = profiles .filter((profile) => profile.minLevel <= targetLevel + 4 && profile.maxLevel >= targetLevel - 4); - const guided = LevelingRoutes.bestSpot(profiles, state, options); + const suitable = candidates.filter((profile) => SpotService.isSuitable(profile, targetLevel, options)); + const guided = LevelingRoutes.bestSpot(suitable.length ? suitable : candidates, state, options); if (guided?.spot) return guided.spot; - return profiles.sort((a, b) => { + return (suitable.length ? suitable : candidates).sort((a, b) => { const aGap = Math.abs(a.avgLevel - targetLevel); const bGap = Math.abs(b.avgLevel - targetLevel); if (aGap !== bGap) return aGap - bGap; return b.density - a.density; - })[0] || this.ensure()[0] || null; + })[0] || profiles[0] || null; } }; diff --git a/tests/test_bot_hunting_ground_rules.js b/tests/test_bot_hunting_ground_rules.js new file mode 100644 index 00000000..b0103621 --- /dev/null +++ b/tests/test_bot_hunting_ground_rules.js @@ -0,0 +1,122 @@ +const assert = require('assert'); + +require('../src/Global'); + +const DataCache = invoke('GameServer/DataCache'); +const SpotService = invoke('GameServer/Bot/AI/SpotService'); +const SpotProfiles = invoke('GameServer/Bot/Population/SpotProfiles'); +const LevelingRoutes = invoke('GameServer/Bot/AI/LevelingRoutes'); +const BotTargetScorer = invoke('GameServer/Bot/AI/BotTargetScorer'); +const GearAcquisitionPlanner = invoke('GameServer/Bot/AI/GearAcquisitionPlanner'); +const BackgroundResolver = invoke('GameServer/Bot/Population/BackgroundResolver'); + +const originalCache = SpotProfiles.cache; +const originalFindCurrentSpot = SpotService.findCurrentSpot; + +try { + const starter = { + id: 'starter_dwarf', + name: 'Elder Longtail Keltir fields', + minLevel: 1, + maxLevel: 3, + avgLevel: 2.1, + density: 16, + center: { locX: 115000, locY: -176000, locZ: -1000 }, + levelCounts: { 1: 3, 2: 8, 3: 5 } + }; + const mid = { + id: 'mid_level_field', + name: 'Mid-level fields', + minLevel: 12, + maxLevel: 19, + avgLevel: 15.5, + density: 32, + center: { locX: 125000, locY: -176000, locZ: -1000 }, + levelCounts: { 12: 4, 13: 4, 14: 4, 15: 4, 16: 4, 17: 4, 18: 4, 19: 4 } + }; + + SpotProfiles.cache = [starter, mid]; + SpotService.findCurrentSpot = () => starter; + const selected = SpotProfiles.findForState({ + level: 16, + spotId: 'remote_stale_destination', + loc: starter.center, + stats: {} + }); + assert.strictEqual(selected.id, 'mid_level_field', + 'a level-16 bot must replan away from a physical level-1-3 starter field'); + assert.strictEqual(SpotService.isSuitable(starter, 16), false, + 'starter mobs must not count as a suitable level-16 hunting ground'); + assert.strictEqual(SpotService.isSuitable(mid, 16), true, + 'a field with enough near-level mobs must remain suitable'); + assert.strictEqual( + LevelingRoutes.tagsForSpot({ minLevel: 10, maxLevel: 34, npcNames: ['Corpse Candle'] }).includes('starter'), + false, + 'a mixed level-10-34 sector must not masquerade as a starter field' + ); + + const tooLow = BotTargetScorer.score({ + attackable: true, + dead: false, + botLevel: 16, + npcLevel: 3, + distance: 100, + verticalGap: 0 + }); + assert.strictEqual(tooLow.eligible, false, 'voluntary targets seven-plus levels below the bot must be rejected'); + assert.strictEqual(tooLow.reason, 'level_too_low'); + const selfDefense = BotTargetScorer.score({ + attackable: true, + dead: false, + incomingThreat: true, + botLevel: 16, + npcLevel: 3, + distance: 100, + verticalGap: 0 + }); + assert.strictEqual(selfDefense.eligible, true, 'self-defense must remain possible against a weak incoming mob'); + + DataCache.init(); + const duplicateSources = GearAcquisitionPlanner.sourceForItem(1864, [ + { id: 'gremlin_field_a', avgLevel: 2, npcEntries: [{ selfId: 1, name: 'Gremlin', count: 4 }] }, + { id: 'gremlin_field_b', avgLevel: 2, npcEntries: [{ selfId: 1, name: 'Gremlin', count: 4 }] }, + { id: 'gremlin_name_only', avgLevel: 2, npcEntries: [{ name: 'Gremlin', count: 4 }] } + ], { level: 3 }); + assert.deepStrictEqual( + new Set(duplicateSources.map((source) => source.spotId)), + new Set(['gremlin_field_a', 'gremlin_field_b', 'gremlin_name_only']), + 'drop routing must retain every field containing the same dropper NPC' + ); + + const startedAt = 100000; + const travelState = { + name: 'leveling bot', + activity: 'traveling', + spotId: 'starter_dwarf', + loc: { locX: 115000, locY: -176000, locZ: -1000 }, + stats: { + travel: { + from: { locX: 115000, locY: -176000, locZ: -1000 }, + to: { locX: 125000, locY: -176000, locZ: -1000 }, + startedAt, + arrivalAt: startedAt + 25000, + regionName: 'Mid-level fields', + method: 'gatekeeper_spot', + spotId: 'mid_level_field', + arrivalActivity: 'hunting' + } + } + }; + const midway = BackgroundResolver.resolveSolo({ state: travelState, spot: null, timestamp: startedAt + 1000 }); + assert.strictEqual(midway.patch.spotId, 'starter_dwarf', 'destination must not become current spot before arrival'); + assert.strictEqual(midway.materialize.exp, 0, 'cold travel must not simulate combat before arrival'); + const arrived = BackgroundResolver.resolveSolo({ state: travelState, spot: null, timestamp: startedAt + 25000 }); + assert.strictEqual(arrived.patch.spotId, 'mid_level_field', 'arrival must commit the destination spot'); + assert.strictEqual(arrived.patch.activity, 'hunting'); + assert.deepStrictEqual(arrived.patch.loc, { locX: 125000, locY: -176000, locZ: -1000 }); +} finally { + SpotProfiles.cache = originalCache; + SpotService.findCurrentSpot = originalFindCurrentSpot; +} + +console.log('Bot hunting-ground rule checks passed'); diff --git a/tests/test_bot_population_scheduler_slices.js b/tests/test_bot_population_scheduler_slices.js index 2bc89394..59ed53cd 100644 --- a/tests/test_bot_population_scheduler_slices.js +++ b/tests/test_bot_population_scheduler_slices.js @@ -4,9 +4,12 @@ require('../src/Global'); const Config = invoke('GameServer/Bot/Population/PopulationConfig'); const PopulationService = invoke('GameServer/Bot/Population/PopulationService'); +const Metrics = invoke('GameServer/Bot/Population/PopulationMetrics'); const originalSliceMs = Config.schedulerSliceMs; const originalYield = PopulationService.yieldSchedulerSlice; +const originalRealPlayerSessions = PopulationService.realPlayerSessions; +const originalEventLoopLag = Metrics.currentEventLoopLag; async function run() { const values = []; @@ -25,6 +28,28 @@ async function run() { assert.deepStrictEqual(values, [1, 2, 3], 'scheduler work must stay ordered'); assert.deepStrictEqual(results, [2, 4, 6], 'scheduler work results must be retained'); assert.strictEqual(yields.length, 3, 'each over-budget scheduler slice must yield before more work'); + + PopulationService.realPlayerSessions = () => []; + Metrics.currentEventLoopLag = () => 0; + const idleProfile = PopulationService.schedulerProfile(); + assert.strictEqual(idleProfile.idle, true, 'no real players must select the idle scheduler profile'); + assert.strictEqual(idleProfile.budgetMs, Config.schedulerIdleBudgetMs, 'idle scheduler must use the larger background budget'); + assert.strictEqual(idleProfile.maxResolvesPerTick, Config.schedulerIdleMaxResolvesPerTick, 'idle scheduler must use the larger cold batch cap'); + assert.strictEqual(PopulationService.partyFormationBudgetMs(), Config.partyFormationIdleBudgetMs, 'idle party formation must use its larger budget'); + + PopulationService.realPlayerSessions = () => [{ actor: { fetchIsOnline: () => true }, accountId: 'player_1' }]; + const playerProfile = PopulationService.schedulerProfile(); + assert.strictEqual(playerProfile.idle, false, 'a real player must select the player scheduler profile'); + assert.strictEqual(playerProfile.budgetMs, Config.schedulerPlayerBudgetMs, 'player scheduler must use the conservative budget'); + assert.strictEqual(playerProfile.maxResolvesPerTick, Config.schedulerPlayerMaxResolvesPerTick, 'player scheduler must use the smaller cold batch cap'); + assert.strictEqual(PopulationService.partyFormationBudgetMs(), Config.partyFormationPlayerBudgetMs, 'player party formation must use its conservative budget'); + + PopulationService.realPlayerSessions = () => []; + Metrics.currentEventLoopLag = () => Config.schedulerLagThrottleMs + 40; + const throttledProfile = PopulationService.schedulerProfile(); + assert(throttledProfile.budgetMs > 0 && throttledProfile.budgetMs < idleProfile.budgetMs, 'event-loop lag must taper idle work before the hard stop'); + Metrics.currentEventLoopLag = () => Config.schedulerLagAbortMs; + assert.strictEqual(PopulationService.schedulerProfile().budgetMs, 0, 'critical event-loop lag must stop background work'); console.log('Bot population scheduler slice checks passed'); } @@ -36,4 +61,6 @@ run() .finally(() => { Config.schedulerSliceMs = originalSliceMs; PopulationService.yieldSchedulerSlice = originalYield; + PopulationService.realPlayerSessions = originalRealPlayerSessions; + Metrics.currentEventLoopLag = originalEventLoopLag; }); diff --git a/tests/test_bot_population_state.js b/tests/test_bot_population_state.js index 5d12612b..8093c74b 100644 --- a/tests/test_bot_population_state.js +++ b/tests/test_bot_population_state.js @@ -136,6 +136,7 @@ try { assert(due.sql.includes("OR (activity = 'hunting' AND (json_extract(statsJson, '$.equipmentPlan.expectedKills') IS NOT NULL"), 'a stale active combat plan must bypass its old next-resolve deadline for an immediate safety replan'); assert(due.sql.indexOf("WHEN json_extract(statsJson, '$.equipmentPlan.expectedKills') IS NOT NULL") < due.sql.indexOf("WHEN activity IN ('traveling', 'shopping', 'crafting') THEN 1"), 'a stale active plan must outrank ordinary market, travel, and crafting transitions'); assert(due.sql.includes("WHEN activity IN ('traveling', 'shopping', 'crafting') THEN 1"), 'due cold states must promptly finish market, travel, and crafting transitions after an urgent combat-safety replan'); + assert(due.sql.includes("json_extract(statsJson, '$.equipmentPlan.next.spotId')"), 'due cold states must prioritize active gear plans whose source spot differs from the saved spot'); assert(due.sql.includes("startup_craft_wait_recovery"), 'startup craft recovery must immediately replan before the ordinary hunting backlog'); assert(due.sql.includes('COALESCE(nextResolveAt, 0) ASC'), 'due cold states must remain fair by schedule within each lifecycle bucket'); return BotLifeState.assignParty({ From c91162e7932bacd3a156ec42f690900e2c61eda2 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:24:58 -0400 Subject: [PATCH 2/6] Optimize cold gear planning --- .../Bot/AI/GearAcquisitionPlanner.js | 67 +++++++++++++------ tests/test_bot_gear_acquisition.js | 17 ++++- 2 files changed, 62 insertions(+), 22 deletions(-) diff --git a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js index c2e3cdd2..626b04a4 100644 --- a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js +++ b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js @@ -4,7 +4,8 @@ const ProgressionRates = invoke('GameServer/ProgressionRates'); const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); const CraftShopService = invoke('GameServer/Bot/Economy/CraftShopService'); const CraftSupplementMaterials = invoke('GameServer/Bot/Economy/CraftSupplementMaterials'); -let sourceIndexCache = { spots: null, rewards: null, byItemId: new Map() }; +const MAX_RESOLVED_SOURCE_CACHE = 512; +let sourceIndexCache = { spots: null, rewards: null, byItemId: new Map(), resolved: new Map() }; const BotGear = invoke('GameServer/Bot/AI/BotGear'); const GearLifecycle = invoke('GameServer/Bot/AI/GearLifecycle'); const MarketOpportunity = invoke('GameServer/Bot/Economy/MarketOpportunity'); @@ -189,18 +190,18 @@ function candidateEffort(candidate, state, options = {}) { // diagnostics and a pre-route preview). Do not scan every NPC reward and // every component tree when no spot atlas is available. if (!spots.length) return marketEffortValue; - const direct = bestSourceForState(sourceForItem(item.selfId, spots, state), state); + const direct = bestSourceForState(sourceForItem(item.selfId, spots, state, options), state); const directEffort = direct ? (1 / Math.max(Number(direct.expectedYield || 0), 0.000001)) * (soloSafeForSource(state, direct) ? 1 : 1.35) : Infinity; if (!candidate.recipe) return Math.min(directEffort, marketEffortValue); - const allowedRecipeIds = stationRecipeIds(); + const allowedRecipeIds = options.allowedRecipeIds || stationRecipeIds(); const materialEffort = missingMaterials(candidate.recipe, state.inventory) .filter((material) => material.missing > 0 && !CraftSupplementMaterials.isSupplementalMaterial(material.selfId)) .reduce((sum, material) => { - const source = farmSourceForMaterial(material.selfId, state, spots, allowedRecipeIds, material.missing); + const source = farmSourceForMaterial(material.selfId, state, spots, allowedRecipeIds, material.missing, new Set(), options); return sum + (source ? material.missing / Math.max(Number(source.expectedYield || 0), 0.000001) : 1000000); }, 8); return Math.min(directEffort, marketEffortValue, materialEffort); @@ -363,15 +364,19 @@ function preferredTarget(state = {}, options = {}) { // still prevents a leap to a top-tier option. const entryWeaponFallback = !hasCurrentGradeWeapon && weaponFirst.length > 0; if (!options.recipeId && Number.isFinite(cap) && affordable.length === 0 && !entryWeaponFallback) return null; + const effortOptions = options.allowedRecipeIds + ? options + : { ...options, allowedRecipeIds: stationRecipeIds() }; const candidates = shortlistCandidates(affordable.length ? affordable : progressionCandidates, options) + .map((candidate) => ({ candidate, score: opportunityScore(candidate, state, effortOptions) })) .sort((a, b) => { - const scoreDelta = opportunityScore(b, state, options) - opportunityScore(a, state, options); + const scoreDelta = b.score - a.score; if (Math.abs(scoreDelta) > 0.000001) return scoreDelta; - return slotPriority(b.item) - slotPriority(a.item) - || Number(a.item.template?.price || 0) - Number(b.item.template?.price || 0) - || Number(a.item.selfId) - Number(b.item.selfId); + return slotPriority(b.candidate.item) - slotPriority(a.candidate.item) + || Number(a.candidate.item.template?.price || 0) - Number(b.candidate.item.template?.price || 0) + || Number(a.candidate.item.selfId) - Number(b.candidate.item.selfId); }); - return candidates[0] || null; + return candidates[0]?.candidate || null; } function preferredDropTarget(state = {}) { @@ -538,12 +543,23 @@ function sourceIndexFor(spots = []) { })); }); - sourceIndexCache = { spots, rewards, byItemId }; + sourceIndexCache = { spots, rewards, byItemId, resolved: new Map() }; return byItemId; } -function sourceForItem(itemId, spots = [], state = {}) { - return (sourceIndexFor(spots).get(Number(itemId)) || []).map(({ reward, spot, npcLevel }) => { +function sourceForItem(itemId, spots = [], state = {}, options = {}) { + const sourceCache = options.sourceCache; + const cacheKey = `${Number(itemId)}:${Number(state.level || 0)}`; + if (sourceCache?.has(cacheKey)) return sourceCache.get(cacheKey); + const sourceIndex = sourceIndexFor(spots); + const rates = ProgressionRates.profile(); + const resolvedKey = `${cacheKey}:${rates.drop}:${rates.adena}`; + if (sourceIndexCache.resolved.has(resolvedKey)) { + const cached = sourceIndexCache.resolved.get(resolvedKey); + sourceCache?.set(cacheKey, cached); + return cached; + } + const sources = (sourceIndex.get(Number(itemId)) || []).map(({ reward, spot, npcLevel }) => { const sourceLevel = Number(npcLevel || spot?.avgLevel || 1); const { chance, expectedYield } = itemDropYield(reward, itemId, 'drop', { npcLevel: sourceLevel, @@ -552,6 +568,12 @@ function sourceForItem(itemId, spots = [], state = {}) { if (!chance) return null; return { npcId: Number(reward.selfId), npcName: reward.template?.name || `NPC ${reward.selfId}`, kind: 'drop', chance, expectedYield, spotId: spot.id, spotLevel: Number(spot.avgLevel || 1), npcLevel: sourceLevel }; }).filter(Boolean).sort((a, b) => b.expectedYield - a.expectedYield); + if (sourceIndexCache.resolved.size >= MAX_RESOLVED_SOURCE_CACHE) { + sourceIndexCache.resolved.delete(sourceIndexCache.resolved.keys().next().value); + } + sourceIndexCache.resolved.set(resolvedKey, sources); + sourceCache?.set(cacheKey, sources); + return sources; } function stationRecipeIds() { @@ -562,8 +584,8 @@ function stationRecipeIds() { ))); } -function farmSourceForMaterial(itemId, state, spots, allowedRecipeIds, requiredAmount = 1, visited = new Set()) { - const direct = bestSourceForState(sourceForItem(itemId, spots, state), state); +function farmSourceForMaterial(itemId, state, spots, allowedRecipeIds, requiredAmount = 1, visited = new Set(), options = {}) { + const direct = bestSourceForState(sourceForItem(itemId, spots, state, options), state); if (direct) return { ...direct, itemId: Number(itemId) }; if (visited.has(Number(itemId))) return null; @@ -575,7 +597,7 @@ function farmSourceForMaterial(itemId, state, spots, allowedRecipeIds, requiredA const owned = Number(inventoryMap(state.inventory).get(Number(ingredient.selfId)) || 0); const required = Number(ingredient.amount || 0) * componentCrafts; if (owned >= required || CraftSupplementMaterials.isSupplementalMaterial(ingredient.selfId)) continue; - const source = farmSourceForMaterial(ingredient.selfId, state, spots, allowedRecipeIds, required - owned, nextVisited); + const source = farmSourceForMaterial(ingredient.selfId, state, spots, allowedRecipeIds, required - owned, nextVisited, options); if (source) return source; } return null; @@ -645,18 +667,23 @@ function planFor(state = {}, options = {}) { recipeId: null, materials: [], next: { ...source, itemId: Number(target.selfId) } } : { status: 'no_grade_drop_only', grade: 'none', role: roleFor(state), strategy: 'direct_drop', rateModelVersion: RATE_MODEL_VERSION, recipeId: null, materials: [], next: null }; } - const target = preferredTarget(state, options); + const planningOptions = { + ...options, + allowedRecipeIds: options.allowedRecipeIds || stationRecipeIds(), + sourceCache: options.sourceCache || new Map() + }; + const target = preferredTarget(state, planningOptions); if (!target) return { status: 'complete', reason: 'no_missing_craftable_upgrade' }; const spots = options.spots || []; - const directSources = sourceForItem(target.item.selfId, spots, state); + const directSources = sourceForItem(target.item.selfId, spots, state, planningOptions); const direct = bestSourceForState(directSources, state); const materials = target.recipe ? missingMaterials(target.recipe, state.inventory) : []; - const allowedRecipeIds = stationRecipeIds(); + const allowedRecipeIds = planningOptions.allowedRecipeIds; const materialPlans = materials.map((material) => ({ ...material, source: material.missing > 0 - ? farmSourceForMaterial(material.selfId, state, spots, allowedRecipeIds, material.missing) + ? farmSourceForMaterial(material.selfId, state, spots, allowedRecipeIds, material.missing, new Set(), planningOptions) : null })); const missingMaterialPlans = materialPlans.filter((material) => material.missing > 0 && !CraftSupplementMaterials.isSupplementalMaterial(material.selfId)); @@ -667,7 +694,7 @@ function planFor(state = {}, options = {}) { const craftKills = target.recipe ? missingMaterialPlans.reduce((sum, material) => sum + material.missing / Math.max(material.source?.expectedYield || 0.000001, 0.000001), 0) : Infinity; - const offer = marketOfferForTarget(target.item, state, options); + const offer = marketOfferForTarget(target.item, state, planningOptions); const buy = offer && marketEffort(offer, state) <= Math.min(directKills, craftKills); const directAssessment = direct ? partyNeedAssessmentForSource(state, direct) : null; const soloSafe = direct && directAssessment.need === 'solo_ok'; diff --git a/tests/test_bot_gear_acquisition.js b/tests/test_bot_gear_acquisition.js index 7098c1c1..828c5a24 100644 --- a/tests/test_bot_gear_acquisition.js +++ b/tests/test_bot_gear_acquisition.js @@ -53,15 +53,19 @@ assert.strictEqual( ); const previousProgressionRate = process.env.L2NODE_PROGRESSION_RATE; -process.env.L2NODE_PROGRESSION_RATE = 'x50'; const caveMaidenSpot = { id: 'cave-maiden-field', avgLevel: 59, npcEntries: [{ selfId: 134, name: 'Cave Maiden', count: 4 }] }; -const steelSourceAtX50 = GearAcquisitionPlanner.sourceForItem(1880, [caveMaidenSpot], { level: 52 })[0]; +const caveMaidenSpots = [caveMaidenSpot]; +process.env.L2NODE_PROGRESSION_RATE = 'x1'; +const steelSourceAtX1 = GearAcquisitionPlanner.sourceForItem(1880, caveMaidenSpots, { level: 52 })[0]; +process.env.L2NODE_PROGRESSION_RATE = 'x50'; +const steelSourceAtX50 = GearAcquisitionPlanner.sourceForItem(1880, caveMaidenSpots, { level: 52 })[0]; assert(steelSourceAtX50.expectedYield > 1, 'high-rate material plans must include the scaled drop quantity, not only the selection chance'); assert(Math.ceil(220 / steelSourceAtX50.expectedYield) < 300, '220 Steel from Cave Maiden at x50 must not be estimated as thousands of kills'); +assert(steelSourceAtX50.expectedYield > steelSourceAtX1.expectedYield, 'source cache keys must preserve progression-rate changes for the same atlas'); if (previousProgressionRate === undefined) delete process.env.L2NODE_PROGRESSION_RATE; else process.env.L2NODE_PROGRESSION_RATE = previousProgressionRate; @@ -97,6 +101,15 @@ const mage = { level: 40, stats: { classId: 10, role: 'mage' }, inventory: {} }; const target = GearAcquisitionPlanner.preferredTarget(mage); assert(target, 'a C-grade mage without gear must receive a craftable target'); assert(['Weapon.Sword', 'Weapon.Blunt'].includes(target.item.template.kind), 'mage target must use a caster weapon family'); +let targetOfferChecks = 0; +const scoredOnceTarget = GearAcquisitionPlanner.preferredTarget(mage, { + findMarketOffer: (item) => { + targetOfferChecks += 1; + return { selfId: item.selfId, price: Number(item.template?.price || 1), town: 'Giran', sourceType: 'npc' }; + } +}); +assert(scoredOnceTarget, 'memoized target scoring must retain a valid preferred item'); +assert(targetOfferChecks <= 3, 'each shortlisted target must be evaluated at most once instead of once per sort comparison'); const dMarketPlan = GearAcquisitionPlanner.planFor({ ...mage, level: 20 }, { spots: [], From a248d26fc04ea4231ede51302342903d9ac621e5 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:32:13 -0400 Subject: [PATCH 3/6] Fix cold travel state churn --- .../Bot/Population/PopulationMetrics.js | 10 ++++- .../Bot/Population/PopulationService.js | 20 ++++----- .../Bot/Population/PopulationStatus.js | 5 ++- tests/test_bot_cold_travel_without_spot.js | 41 +++++++++++++++++++ tests/test_bot_population_scheduler_slices.js | 6 +++ 5 files changed, 69 insertions(+), 13 deletions(-) diff --git a/src/GameServer/Bot/Population/PopulationMetrics.js b/src/GameServer/Bot/Population/PopulationMetrics.js index 74e8b386..8bbd2a36 100644 --- a/src/GameServer/Bot/Population/PopulationMetrics.js +++ b/src/GameServer/Bot/Population/PopulationMetrics.js @@ -68,7 +68,8 @@ const PopulationMetrics = { schedulerDurationsMs: [], schedulerSliceDurationsMs: [], partyFormationDurationsMs: [], - partyFormationStageDurationsMs: new Map() + partyFormationStageDurationsMs: new Map(), + skippedResolveReasons: new Map() }, timer: null, @@ -125,8 +126,10 @@ const PopulationMetrics = { this.counters.heals += Math.max(0, Number(debug.heals) || 0); }, - recordSkippedResolve() { + recordSkippedResolve(reason = 'unknown') { this.counters.skippedResolves += 1; + const key = String(reason || 'unknown'); + this.interval.skippedResolveReasons.set(key, Number(this.interval.skippedResolveReasons.get(key) || 0) + 1); }, recordActivation() { @@ -252,11 +255,13 @@ const PopulationMetrics = { const partyFormationStats = stats(this.interval.partyFormationDurationsMs); const partyFormationStages = Object.fromEntries(Array.from(this.interval.partyFormationStageDurationsMs.entries()) .map(([stage, values]) => [stage, stats(values)])); + const skippedResolveReasons = Object.fromEntries(this.interval.skippedResolveReasons.entries()); this.interval.resolveDurationsMs = []; this.interval.schedulerDurationsMs = []; this.interval.schedulerSliceDurationsMs = []; this.interval.partyFormationDurationsMs = []; this.interval.partyFormationStageDurationsMs = new Map(); + this.interval.skippedResolveReasons = new Map(); return { uptimeMs: elapsedMs, @@ -268,6 +273,7 @@ const PopulationMetrics = { schedulerSlice: schedulerSliceStats, partyFormation: partyFormationStats, partyFormationStages, + skippedResolveReasons, memory: process.memoryUsage ? process.memoryUsage() : null }; } diff --git a/src/GameServer/Bot/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js index 475ca72f..6360482a 100644 --- a/src/GameServer/Bot/Population/PopulationService.js +++ b/src/GameServer/Bot/Population/PopulationService.js @@ -1366,7 +1366,7 @@ const PopulationService = { // craft transaction. It must not abort every // remaining cold resolve in this scheduler tick. utils.infoWarn('BotPopulation', 'cold resolve failed for %s: %s', state.name, error?.message || error); - Metrics.recordSkippedResolve(); + Metrics.recordSkippedResolve('cold_resolve_rejected'); return { ok: false, reason: 'resolve_rejected', state }; }), deadlineAt); }) @@ -1488,7 +1488,7 @@ const PopulationService = { } }, { mode: 'party', role: PartyComposition.roleForState(leader) }) || SpotProfiles.findForState(leader); if (!spot) { - Metrics.recordSkippedResolve(); + Metrics.recordSkippedResolve('party_missing_spot'); return { ok: false, reason: 'missing_spot', party }; } @@ -1571,7 +1571,7 @@ const PopulationService = { }); }).catch((err) => { utils.infoWarn('BotPopulation', 'background party resolve failed for %s: %s', party.partyId, err.message); - Metrics.recordSkippedResolve(); + Metrics.recordSkippedResolve('party_resolve_failed'); return { ok: false, reason: 'resolve_failed', party }; }).finally(() => { Metrics.recordResolveDuration(Date.now() - startedAt); @@ -1581,7 +1581,7 @@ const PopulationService = { resolveColdState(state) { const startedAt = Date.now(); if (joinedBackgroundParty(state)) { - Metrics.recordSkippedResolve(); + Metrics.recordSkippedResolve('joined_party_before_resolve'); return Promise.resolve({ ok: false, reason: 'joined_party', state }); } const elapsedMs = state.timing?.lastResolvedAt ? Math.max(1000, startedAt - state.timing.lastResolvedAt) : 60000; @@ -1597,12 +1597,12 @@ const PopulationService = { timestamp: startedAt }); if (joinedBackgroundParty(state)) { - Metrics.recordSkippedResolve(); + Metrics.recordSkippedResolve('joined_party_during_transition'); return Promise.resolve({ ok: false, reason: 'joined_party', state }); } return LifeState.applyResolve(requestLifecycleState, result).then((updatedState) => { if (!updatedState) { - Metrics.recordSkippedResolve(); + Metrics.recordSkippedResolve('transition_apply_failed'); return { ok: false, reason: 'apply_failed', state }; } Metrics.recordBackgroundResolve(); @@ -1742,8 +1742,8 @@ const PopulationService = { : null; const effectiveState = huntingTravelState || travellingState; const spot = effectiveState.activity === 'traveling' ? null : selectedSpot; - if (!spot && !passiveActivity) { - Metrics.recordSkippedResolve(); + if (!spot && !passiveActivity && effectiveState.activity !== 'traveling') { + Metrics.recordSkippedResolve('missing_spot'); Metrics.recordResolveDuration(Date.now() - startedAt); return Promise.resolve({ ok: false, reason: 'missing_spot', state }); } @@ -1759,14 +1759,14 @@ const PopulationService = { }); if (joinedBackgroundParty(state)) { - Metrics.recordSkippedResolve(); + Metrics.recordSkippedResolve('joined_party_after_planning'); return Promise.resolve({ ok: false, reason: 'joined_party', state }); } return LifeState.applyResolve(effectiveState, result) .then((updatedState) => { if (!updatedState) { - Metrics.recordSkippedResolve(); + Metrics.recordSkippedResolve('cold_apply_failed'); return { ok: false, reason: 'apply_failed', state }; } diff --git a/src/GameServer/Bot/Population/PopulationStatus.js b/src/GameServer/Bot/Population/PopulationStatus.js index 3b8a5798..331c8800 100644 --- a/src/GameServer/Bot/Population/PopulationStatus.js +++ b/src/GameServer/Bot/Population/PopulationStatus.js @@ -45,6 +45,9 @@ const PopulationStatus = { const schedulerSlice = metrics.schedulerSlice || {}; const partyFormation = metrics.partyFormation || {}; const partyFormationStages = metrics.partyFormationStages || {}; + const skipReasons = Object.entries(metrics.skippedResolveReasons || {}) + .map(([reason, count]) => `${reason}:${count}`) + .join('|') || 'none'; const market = MarketTelemetry.snapshot(); const partyRequiredReasons = Object.entries(counts.partyRequests.requiredReasons || {}) .map(([reason, count]) => `${reason}:${count}`) @@ -55,7 +58,7 @@ const PopulationStatus = { metrics, director: Director.snapshot(), market, - line: `hot=${counts.hot} warm=${counts.warm} cold=${counts.cold} parties=${counts.parties} persisted=${counts.persisted} merchants=${counts.merchants} coldDue=${counts.coldQueue.due} coldDueHigh=${counts.coldQueue.highLevel} coldReplans=${counts.coldQueue.replans} coldDueAge=${Math.round(counts.coldQueue.oldestAgeMs / 1000)}s partyRequests=${counts.partyRequests.total} partyRequired=${counts.partyRequests.required} partyPreferred=${counts.partyRequests.preferred} partyBlocked=${counts.partyRequests.blocked} partyMaxAge=${Math.round(counts.partyRequests.maxAgeMs / 1000)}s partyRequiredReasons=${partyRequiredReasons} marketListings=${market.delta.listingsOpened} marketBuys=${market.delta.purchases} marketItems=${market.delta.itemsSold} marketAdena=${market.delta.adenaTraded} staticBuyerSales=${market.delta.staticBuyerSales} staticBuyerItems=${market.delta.staticBuyerItems} staticBuyerAdena=${market.delta.staticBuyerAdena} marketNoOffer=${market.delta.noOffer} marketSoldOut=${market.delta.soldOut} marketExpired=${market.delta.expired} ticks=${metrics.delta.hotTicks} resolves=${metrics.delta.backgroundResolves} partyResolves=${metrics.delta.partyResolves} combatActions=${metrics.delta.combatActions} skillUses=${metrics.delta.skillUses} heals=${metrics.delta.heals} skipped=${metrics.delta.skippedResolves} activations=${metrics.delta.activations} cooldowns=${metrics.delta.cooldowns} partyForms=${metrics.delta.partyFormations} partyRecruits=${metrics.delta.partyRecruits} partyDissolves=${metrics.delta.partyDissolutions} partyFormP95=${partyFormation.p95Ms || 0}ms partyFormBudgetStops=${metrics.delta.partyFormationBudgetStops || 0} partyFormStages=${Object.entries(partyFormationStages).map(([stage, value]) => `${stage}:${value.p95Ms || 0}`).join('|') || 'none'} dbFlushes=${metrics.delta.dbFlushes} resolveAvg=${resolve.avgMs || 0}ms resolveP95=${resolve.p95Ms || 0}ms schedulerP95=${scheduler.p95Ms || 0}ms schedulerBudget=${scheduler.budgetMs || 0}ms schedulerMode=${scheduler.mode || 'unknown'} coldBatch=${scheduler.coldBatch || 0}/${scheduler.coldBatchLimit || 0} schedulerLag=${scheduler.lagMs || 0}ms schedulerYields=${metrics.delta.schedulerYields || 0} schedulerSkips=${metrics.delta.schedulerSkips || 0} schedulerBudgetStops=${metrics.delta.schedulerBudgetStops || 0} schedulerOverruns=${metrics.delta.schedulerOverruns || 0} slowResolves=${metrics.delta.slowResolves || 0} heap=${heapMb}MB lag=${lag}ms maxLag=${maxLag}ms ${Director.statusLine()}` + line: `hot=${counts.hot} warm=${counts.warm} cold=${counts.cold} parties=${counts.parties} persisted=${counts.persisted} merchants=${counts.merchants} coldDue=${counts.coldQueue.due} coldDueHigh=${counts.coldQueue.highLevel} coldReplans=${counts.coldQueue.replans} coldDueAge=${Math.round(counts.coldQueue.oldestAgeMs / 1000)}s partyRequests=${counts.partyRequests.total} partyRequired=${counts.partyRequests.required} partyPreferred=${counts.partyRequests.preferred} partyBlocked=${counts.partyRequests.blocked} partyMaxAge=${Math.round(counts.partyRequests.maxAgeMs / 1000)}s partyRequiredReasons=${partyRequiredReasons} marketListings=${market.delta.listingsOpened} marketBuys=${market.delta.purchases} marketItems=${market.delta.itemsSold} marketAdena=${market.delta.adenaTraded} staticBuyerSales=${market.delta.staticBuyerSales} staticBuyerItems=${market.delta.staticBuyerItems} staticBuyerAdena=${market.delta.staticBuyerAdena} marketNoOffer=${market.delta.noOffer} marketSoldOut=${market.delta.soldOut} marketExpired=${market.delta.expired} ticks=${metrics.delta.hotTicks} resolves=${metrics.delta.backgroundResolves} partyResolves=${metrics.delta.partyResolves} combatActions=${metrics.delta.combatActions} skillUses=${metrics.delta.skillUses} heals=${metrics.delta.heals} skipped=${metrics.delta.skippedResolves} skipReasons=${skipReasons} activations=${metrics.delta.activations} cooldowns=${metrics.delta.cooldowns} partyForms=${metrics.delta.partyFormations} partyRecruits=${metrics.delta.partyRecruits} partyDissolves=${metrics.delta.partyDissolutions} partyFormP95=${partyFormation.p95Ms || 0}ms partyFormBudgetStops=${metrics.delta.partyFormationBudgetStops || 0} partyFormStages=${Object.entries(partyFormationStages).map(([stage, value]) => `${stage}:${value.p95Ms || 0}`).join('|') || 'none'} dbFlushes=${metrics.delta.dbFlushes} resolveAvg=${resolve.avgMs || 0}ms resolveP95=${resolve.p95Ms || 0}ms schedulerP95=${scheduler.p95Ms || 0}ms schedulerBudget=${scheduler.budgetMs || 0}ms schedulerMode=${scheduler.mode || 'unknown'} coldBatch=${scheduler.coldBatch || 0}/${scheduler.coldBatchLimit || 0} schedulerLag=${scheduler.lagMs || 0}ms schedulerYields=${metrics.delta.schedulerYields || 0} schedulerSkips=${metrics.delta.schedulerSkips || 0} schedulerBudgetStops=${metrics.delta.schedulerBudgetStops || 0} schedulerOverruns=${metrics.delta.schedulerOverruns || 0} slowResolves=${metrics.delta.slowResolves || 0} heap=${heapMb}MB lag=${lag}ms maxLag=${maxLag}ms ${Director.statusLine()}` }; } }; diff --git a/tests/test_bot_cold_travel_without_spot.js b/tests/test_bot_cold_travel_without_spot.js index f7b39927..92c90663 100644 --- a/tests/test_bot_cold_travel_without_spot.js +++ b/tests/test_bot_cold_travel_without_spot.js @@ -5,6 +5,8 @@ require('../src/Global'); const LifeState = invoke('GameServer/Bot/Population/BotLifeState'); const SpotProfiles = invoke('GameServer/Bot/Population/SpotProfiles'); const BackgroundResolver = invoke('GameServer/Bot/Population/BackgroundResolver'); +const GearPlanner = invoke('GameServer/Bot/AI/GearAcquisitionPlanner'); +const ColdCraftingService = invoke('GameServer/Bot/Economy/ColdCraftingService'); const ListingService = invoke('GameServer/Bot/Economy/ColdMarketListingService'); const MarketService = invoke('GameServer/Bot/Economy/ColdMarketService'); const TradeChat = invoke('GameServer/Bot/Economy/ColdMarketTradeChat'); @@ -18,6 +20,8 @@ const originals = { ensure: SpotProfiles.ensure, findForState: SpotProfiles.findForState, resolveSolo: BackgroundResolver.resolveSolo, + planFor: GearPlanner.planFor, + beginCraftTravel: ColdCraftingService.beginTravel, cachedState: LifeState.cachedState, applyResolve: LifeState.applyResolve, refreshInventory: LifeState.refreshInventory, @@ -78,6 +82,41 @@ async function run() { assert.strictEqual(receivedSpot, null, 'travel must resolve without a hunting spot'); assert.strictEqual(planningAtlasRequests, 0, 'in-flight travel must not build an equipment plan or load the spot atlas'); + const readyToTravel = { + ...state, + characterId: 73, + name: 'FreshCraftTraveler', + activity: 'hunting', + loc: { locX: 10, locY: 20, locZ: 30 }, + stats: { equipmentPlan: { status: 'ready_to_craft', strategy: 'craft', recipeId: 189 } } + }; + planningAtlasRequests = 0; + GearPlanner.planFor = () => readyToTravel.stats.equipmentPlan; + ColdCraftingService.beginTravel = (value) => ({ + ...value, + activity: 'traveling', + stats: { + ...(value.stats || {}), + travel: { + from: value.loc, + to: { locX: 100, locY: 200, locZ: 300 }, + startedAt: Date.now(), + arrivalAt: Date.now() + 25000, + arrivalActivity: 'crafting', + reason: 'equipment_craft' + } + } + }); + BackgroundResolver.resolveSolo = ({ state: value, spot }) => { + receivedSpot = spot; + assert.strictEqual(value.activity, 'traveling', 'a ready craft route must enter travel before resolving'); + return { patch: { activity: 'traveling', stats: value.stats }, events: [], materialize: { exp: 0, sp: 0, adena: 0, items: [] }, nextResolveAt: Date.now() + 25000, debug: { activity: 'traveling' } }; + }; + LifeState.applyResolve = (_value, _result) => Promise.resolve(readyToTravel); + const freshTravelResult = await PopulationService.resolveColdState(readyToTravel); + assert.strictEqual(freshTravelResult.ok, true, 'travel started during a cold resolve must not fail as a missing hunting spot'); + assert.strictEqual(receivedSpot, null, 'newly-started travel must resolve without a combat spot'); + let joinedDuringResolve = false; let applyCalled = false; BackgroundResolver.resolveSolo = () => { @@ -99,6 +138,8 @@ run().catch((err) => { console.error(err); process.exitCode = 1; }).finally(() = SpotProfiles.ensure = originals.ensure; SpotProfiles.findForState = originals.findForState; BackgroundResolver.resolveSolo = originals.resolveSolo; + GearPlanner.planFor = originals.planFor; + ColdCraftingService.beginTravel = originals.beginCraftTravel; LifeState.cachedState = originals.cachedState; LifeState.applyResolve = originals.applyResolve; LifeState.refreshInventory = originals.refreshInventory; diff --git a/tests/test_bot_population_scheduler_slices.js b/tests/test_bot_population_scheduler_slices.js index 59ed53cd..a2e26c5c 100644 --- a/tests/test_bot_population_scheduler_slices.js +++ b/tests/test_bot_population_scheduler_slices.js @@ -50,6 +50,12 @@ async function run() { assert(throttledProfile.budgetMs > 0 && throttledProfile.budgetMs < idleProfile.budgetMs, 'event-loop lag must taper idle work before the hard stop'); Metrics.currentEventLoopLag = () => Config.schedulerLagAbortMs; assert.strictEqual(PopulationService.schedulerProfile().budgetMs, 0, 'critical event-loop lag must stop background work'); + + Metrics.recordSkippedResolve('test_missing_spot'); + Metrics.recordSkippedResolve('test_missing_spot'); + Metrics.recordSkippedResolve('test_joined_party'); + const skipped = Metrics.snapshot().skippedResolveReasons; + assert.deepStrictEqual(skipped, { test_missing_spot: 2, test_joined_party: 1 }, 'scheduler telemetry must retain per-reason skipped resolve counts'); console.log('Bot population scheduler slice checks passed'); } From 2d6f14231a9508b55132c2cb8db0c2096110c9d6 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:50:58 -0400 Subject: [PATCH 4/6] Prioritize required party requests --- src/GameServer/Bot/Population/BotLifeState.js | 10 ++++- .../Bot/Population/PopulationService.js | 37 +++++++++++------- .../test_bot_background_party_recruitment.js | 38 +++++++++++++++++++ tests/test_bot_population_state.js | 19 +++++++--- 4 files changed, 83 insertions(+), 21 deletions(-) diff --git a/src/GameServer/Bot/Population/BotLifeState.js b/src/GameServer/Bot/Population/BotLifeState.js index 32522d4e..80b5b5ce 100644 --- a/src/GameServer/Bot/Population/BotLifeState.js +++ b/src/GameServer/Bot/Population/BotLifeState.js @@ -1296,7 +1296,15 @@ const BotLifeState = { AND (states.partyId IS NULL OR states.partyId = '') AND states.spotId IS NOT NULL AND ${stateActivityClause} - ORDER BY party_spots.candidateCount DESC, party_spots.oldestAt ASC, states.level ASC, states.updatedAt ASC + ORDER BY + CASE + WHEN json_extract(states.statsJson, '$.partyRequest.status') = 'open' + AND json_extract(states.statsJson, '$.partyRequest.priority') = 'required' THEN 0 + WHEN json_extract(states.statsJson, '$.partyRequest.status') = 'open' THEN 1 + ELSE 2 + END ASC, + party_spots.candidateCount DESC, party_spots.oldestAt ASC, + states.level ASC, states.updatedAt ASC LIMIT ${safeLimit}`, [] ]).then((rows) => rows.map((row) => { diff --git a/src/GameServer/Bot/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js index 6360482a..32d86cca 100644 --- a/src/GameServer/Bot/Population/PopulationService.js +++ b/src/GameServer/Bot/Population/PopulationService.js @@ -924,10 +924,14 @@ const PopulationService = { }) : Promise.resolve(0); return timedStage('cleanup', () => cleanup) - .then(() => timedStage('candidate_count', () => LifeState.coldPartyCandidateCount(false))) - .then((partyWaitCount) => timedStage('candidate_query', () => LifeState.coldPartyCandidates(Config.partyFormationCandidateLimit) - .then((states) => ({ states, partyWaitBacklog: partyWaitCount > 0 })) - .then(({ states, partyWaitBacklog }) => { + // Extra capacity and eviction are reserved for actionable required + // requests. The candidate query still includes preferred and + // elective bots, but orders open requests ahead of the bounded + // general pool so crowded solo grounds cannot starve them. + .then(() => timedStage('candidate_count', () => LifeState.coldPartyCandidateCount(true))) + .then((requiredPartyRequestCount) => timedStage('candidate_query', () => LifeState.coldPartyCandidates(Config.partyFormationCandidateLimit) + .then((states) => ({ states, requiredPartyRequestCount })) + .then(({ states, requiredPartyRequestCount }) => { const activeParties = BackgroundPartyState.active(); const recruitSpots = activeParties .filter((party) => (party.memberIds || []).length < Config.partyMaxSize) @@ -940,16 +944,21 @@ const PopulationService = { return fairCandidates.then((spotCandidates) => { const byId = new Map((states || []).map((state) => [Number(state.characterId), state])); spotCandidates.forEach((state) => byId.set(Number(state.characterId), state)); + const mergedStates = Array.from(byId.values()); return { - states: Array.from(byId.values()), - partyWaitBacklog, - partyWaitCount + states: mergedStates, + partyRequestBacklog: mergedStates.some((state) => state.stats?.partyRequest?.status === 'open'), + requiredPartyRequestCount }; }); }))) - .then(({ states, partyWaitBacklog, partyWaitCount }) => { + .then(({ states, partyRequestBacklog, requiredPartyRequestCount }) => { const willingStates = states.filter((state) => PersonaPartyPolicy.backgroundIntent(state).accept); - return this.reclaimBackgroundPartyCapacity(partyWaitBacklog ? willingStates : [], partyWaitCount, { + const requiredStates = willingStates.filter((state) => ( + state.stats?.partyRequest?.status === 'open' + && state.stats?.partyRequest?.priority === 'required' + )); + return this.reclaimBackgroundPartyCapacity(requiredStates, requiredPartyRequestCount, { deadlineAt, markBudgetStop: () => budgetReached() }) @@ -958,13 +967,13 @@ const PopulationService = { markBudgetStop: () => budgetReached() })).then((recruitedIds) => ({ states: willingStates.filter((state) => !recruitedIds.has(Number(state.characterId))), - partyWaitBacklog, - partyWaitCount + partyRequestBacklog, + requiredPartyRequestCount })); }) - .then(({ states, partyWaitBacklog, partyWaitCount }) => { + .then(({ states, partyRequestBacklog, requiredPartyRequestCount }) => { const activeParties = BackgroundPartyState.counts().active || 0; - const slots = Math.max(0, maxBackgroundPartiesForBacklog(partyWaitCount) - activeParties); + const slots = Math.max(0, maxBackgroundPartiesForBacklog(requiredPartyRequestCount) - activeParties); if (slots <= 0) return []; const maxNewParties = Math.min(slots, Config.partyFormationBatchSize); const activePartiesBySpot = BackgroundPartyState.active().reduce((counts, party) => { @@ -972,7 +981,7 @@ const PopulationService = { if (spotId) counts.set(spotId, Number(counts.get(spotId) || 0) + 1); return counts; }, new Map()); - const groups = this.groupPartyCandidatesByObjective(states, { prioritizePartyWait: partyWaitBacklog, activePartiesBySpot }); + const groups = this.groupPartyCandidatesByObjective(states, { prioritizePartyWait: partyRequestBacklog, activePartiesBySpot }); const created = []; return groups.reduce((chain, group) => chain.then(() => { diff --git a/tests/test_bot_background_party_recruitment.js b/tests/test_bot_background_party_recruitment.js index 628757fc..63c81ee9 100644 --- a/tests/test_bot_background_party_recruitment.js +++ b/tests/test_bot_background_party_recruitment.js @@ -13,8 +13,12 @@ const SpotService = invoke('GameServer/Bot/AI/SpotService'); const originals = { active: PartyState.active, + counts: PartyState.counts, statesForParty: LifeState.statesForParty, statesForParties: LifeState.statesForParties, + coldPartyCandidateCount: LifeState.coldPartyCandidateCount, + coldPartyCandidates: LifeState.coldPartyCandidates, + coldPartyCandidatesForSpots: LifeState.coldPartyCandidatesForSpots, assignParty: LifeState.assignParty, partyRequirementCounts: LifeState.partyRequirementCounts, clearParty: LifeState.clearParty, @@ -29,10 +33,39 @@ const originals = { maxBackgroundParties: Config.maxBackgroundParties, partyFormationBatchSize: Config.partyFormationBatchSize }; +const originalFormationState = { + resolving: PopulationService.resolving, + partyFormationRunning: PopulationService.partyFormationRunning, + partyFormationPending: PopulationService.partyFormationPending, + nextPartyRequestCleanupAt: PopulationService.nextPartyRequestCleanupAt +}; async function run() { Config.partyMinSize = 2; Config.partyMaxSize = 5; + let requiredOnly = null; + PartyState.active = () => []; + PartyState.counts = () => ({ active: 0 }); + LifeState.coldPartyCandidateCount = (value) => { + requiredOnly = value; + return Promise.resolve(0); + }; + LifeState.coldPartyCandidates = () => Promise.resolve([]); + LifeState.coldPartyCandidatesForSpots = () => Promise.resolve([]); + LifeState.statesForParties = () => Promise.resolve(new Map()); + PopulationService.resolving = false; + PopulationService.partyFormationRunning = false; + PopulationService.nextPartyRequestCleanupAt = Infinity; + await PopulationService.formBackgroundParties(); + assert.strictEqual(requiredOnly, true, 'extra party capacity must be driven by required requests, not every eligible solo bot'); + + PartyState.active = originals.active; + PartyState.counts = originals.counts; + LifeState.coldPartyCandidateCount = originals.coldPartyCandidateCount; + LifeState.coldPartyCandidates = originals.coldPartyCandidates; + LifeState.coldPartyCandidatesForSpots = originals.coldPartyCandidatesForSpots; + LifeState.statesForParties = originals.statesForParties; + const party = { partyId: 'bgp_1', leaderId: 1, memberIds: [1, 2], spotId: 'cruma', stats: {} }; const members = [ { characterId: 1, name: 'Tank', level: 15, party: { role: 'tank' } }, @@ -235,8 +268,12 @@ run().catch((err) => { process.exitCode = 1; }).finally(() => { PartyState.active = originals.active; + PartyState.counts = originals.counts; LifeState.statesForParty = originals.statesForParty; LifeState.statesForParties = originals.statesForParties; + LifeState.coldPartyCandidateCount = originals.coldPartyCandidateCount; + LifeState.coldPartyCandidates = originals.coldPartyCandidates; + LifeState.coldPartyCandidatesForSpots = originals.coldPartyCandidatesForSpots; LifeState.assignParty = originals.assignParty; LifeState.partyRequirementCounts = originals.partyRequirementCounts; LifeState.clearParty = originals.clearParty; @@ -250,4 +287,5 @@ run().catch((err) => { Config.partyMaxSize = originals.partyMaxSize; Config.maxBackgroundParties = originals.maxBackgroundParties; Config.partyFormationBatchSize = originals.partyFormationBatchSize; + Object.assign(PopulationService, originalFormationState); }); diff --git a/tests/test_bot_population_state.js b/tests/test_bot_population_state.js index 8093c74b..244f5173 100644 --- a/tests/test_bot_population_state.js +++ b/tests/test_bot_population_state.js @@ -167,13 +167,20 @@ try { }, 'bgp_probe', 'dps', 42).then((restingAssigned) => { assert.strictEqual(restingAssigned.activity, 'resting', 'assigning a resting requester must not wake it into combat'); assert(restingAssigned.stats.restUntil > Date.now(), 'assigning a resting requester must preserve its recovery deadline'); - return BotLifeState.coldPartyCandidates(5); + const candidateQueryStart = statements.length; + return BotLifeState.coldPartyCandidates(5).then(() => { + const candidates = statements.slice(candidateQueryStart) + .find((entry) => entry.sql.includes('party_spots.candidateCount')); + assert(candidates, 'party formation must see event-scheduled party waits without making them combat-due'); + const requiredRank = candidates.sql.indexOf("$.partyRequest.priority') = 'required' THEN 0"); + const preferredRank = candidates.sql.indexOf("$.partyRequest.status') = 'open' THEN 1"); + const generalRank = candidates.sql.indexOf('ELSE 2'); + const populationRank = candidates.sql.indexOf('party_spots.candidateCount DESC'); + assert(requiredRank >= 0 && requiredRank < preferredRank, 'required requests must rank ahead of preferred requests'); + assert(preferredRank < generalRank && generalRank < populationRank, 'all open requests must rank ahead of crowded general candidate grounds'); + }); }); - }).then(() => { - const candidates = statements.find((entry) => entry.sql.includes("activity IN ('hunting', 'resting', 'party_wait')")); - assert(candidates, 'party formation must see event-scheduled party waits without making them combat-due'); - return BotLifeState.coldPartyCandidates(5, true); - }).then(() => { + }).then(() => BotLifeState.coldPartyCandidates(5, true)).then(() => { const requiredCandidates = statements.find((entry) => entry.sql.includes("$.partyRequest.priority") && entry.sql.includes("'required'")); assert(requiredCandidates, 'required party requests must reserve formation capacity ahead of elective hunting parties'); return BotLifeState.coldPartyCandidateCount(true).then(() => { From 8ac8645b4967e0469ba43bd32a523e9fcaec130c Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:12:13 -0400 Subject: [PATCH 5/6] Address PR review feedback --- src/GameServer/Bot/AI/BotSpotTravel.js | 11 ++++++ src/GameServer/Bot/AI/SpotService.js | 11 ++++-- src/GameServer/Bot/AI/States/HuntingState.js | 36 ++++++++++++++++--- src/GameServer/Bot/Population/BotLifeState.js | 10 +++++- .../Bot/Population/HotActivation.js | 18 +++++++--- .../Bot/Population/PopulationService.js | 17 ++++++--- src/GameServer/Bot/Population/SpotProfiles.js | 2 +- tests/test_bot_cold_travel_without_spot.js | 3 +- tests/test_bot_hunting_ground_rules.js | 4 +++ tests/test_bot_population_scheduler_slices.js | 12 ++++++- tests/test_bot_population_state.js | 20 ++++++++++- 11 files changed, 123 insertions(+), 21 deletions(-) diff --git a/src/GameServer/Bot/AI/BotSpotTravel.js b/src/GameServer/Bot/AI/BotSpotTravel.js index 6bccf7bb..97f3613a 100644 --- a/src/GameServer/Bot/AI/BotSpotTravel.js +++ b/src/GameServer/Bot/AI/BotSpotTravel.js @@ -6,6 +6,13 @@ const SOE_SKILL_ID = 2013; const SOE_CAST_MS = 20000; const TELEPORT_SETTLE_MS = 1200; +function hasFiniteCoordinate(value) { + return value !== null + && value !== undefined + && String(value).trim() !== '' + && Number.isFinite(Number(value)); +} + function active(session) { return !!session?.spotRelocation; } @@ -25,6 +32,10 @@ function start(session, bot, spot, targetLoc = null) { const token = Symbol('spot-relocation'); const destination = { ...(targetLoc || spot.center) }; + if (!['locX', 'locY', 'locZ'].every((key) => hasFiniteCoordinate(destination[key]))) return false; + destination.locX = Number(destination.locX); + destination.locY = Number(destination.locY); + destination.locZ = Number(destination.locZ); session.spotRelocation = { token, spotId: spot.id, diff --git a/src/GameServer/Bot/AI/SpotService.js b/src/GameServer/Bot/AI/SpotService.js index 70a934a0..5c2ed84b 100644 --- a/src/GameServer/Bot/AI/SpotService.js +++ b/src/GameServer/Bot/AI/SpotService.js @@ -29,11 +29,16 @@ function levelCount(spot, level) { return Number(spot?.levelCounts?.[String(level)] || spot?.levelCounts?.[level] || 0); } +function finiteNumber(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + function huntBand(targetLevel, options = {}) { - const level = Math.max(1, Number(targetLevel || 1)); + const level = Math.max(1, finiteNumber(targetLevel, 1)); return { - min: Math.max(1, level + Number(options.minLevelGap ?? DEFAULT_MIN_HUNT_LEVEL_GAP)), - max: Math.max(1, level + Number(options.maxLevelGap ?? DEFAULT_MAX_HUNT_LEVEL_GAP)) + min: Math.max(1, level + finiteNumber(options.minLevelGap, DEFAULT_MIN_HUNT_LEVEL_GAP)), + max: Math.max(1, level + finiteNumber(options.maxLevelGap, DEFAULT_MAX_HUNT_LEVEL_GAP)) }; } diff --git a/src/GameServer/Bot/AI/States/HuntingState.js b/src/GameServer/Bot/AI/States/HuntingState.js index eeb17e96..53c67f99 100644 --- a/src/GameServer/Bot/AI/States/HuntingState.js +++ b/src/GameServer/Bot/AI/States/HuntingState.js @@ -23,6 +23,7 @@ const EMERGENCY_RETREAT_MP_RATIO = 0.20; const EMERGENCY_RETREAT_DISTANCE = 850; const MAX_WALK_SPOT_DISTANCE = 12000; const SPOT_ARRIVAL_RADIUS = 1000; +const MAX_SPOT_RELOCATION_MS = 120000; function isSoloHunter(session) { return session.plan === 'hunting' && session.partyCompanion !== true && !session.followPlayerSession; @@ -141,6 +142,25 @@ function finishWalkRelocation(session, bot, spot) { session.lastSpotRelocation = { spotId: arrivedSpot.id, method: 'walk', at: Date.now() }; } +function expireSpotRelocation(session, bot, relocation) { + session.spotRelocation = undefined; + bot?.state?.setCasts?.(false); + session.lastSpotRelocation = { + spotId: relocation.spotId, + method: `${relocation.method || 'walk'}_timeout`, + at: Date.now() + }; +} + +function expireTimedOutSpotRelocation(session, bot) { + const relocation = session.spotRelocation; + if (!relocation) return false; + const startedAt = Number(relocation.startedAt); + if (!Number.isFinite(startedAt) || Date.now() - startedAt < MAX_SPOT_RELOCATION_MS) return false; + expireSpotRelocation(session, bot, relocation); + return true; +} + function issueWalkRelocation(session, bot, relocation) { const from = botLocation(bot); relocation.lastCommandAt = Date.now(); @@ -150,6 +170,7 @@ function issueWalkRelocation(session, bot, relocation) { function tickSpotRelocation(session, bot) { const relocation = session.spotRelocation; if (!relocation) return false; + if (expireTimedOutSpotRelocation(session, bot)) return false; if (relocation.method === 'soe_gatekeeper') return true; const distance = SpotService.distance2d(botLocation(bot), relocation.destination); @@ -272,7 +293,6 @@ function targetProgressing(session, bot, target) { module.exports = { tick(session, bot, Generics, BotAI) { - if (session.spotRelocation?.arrivalPending) return; if (session.pendingTownTrip) { const trip = startShopping(session, bot, BotAI, session.pendingTownTrip.reason); if (trip !== 'deferred') return; @@ -338,6 +358,7 @@ module.exports = { }; if (pvpDecision.action === 'fight') { + if (session.spotRelocation) BotSpotTravel.cancel(session, bot, 'pk_combat'); // Fight back! if (session.currentTargetId !== spottedPk.fetchId()) { session.currentTargetId = spottedPk.fetchId(); @@ -352,6 +373,7 @@ module.exports = { } return; // Skip rest of AI tick while fighting back PK! } else { + if (session.spotRelocation) BotSpotTravel.cancel(session, bot, 'pk_flee'); // Flee in panic! if (session.plan !== 'fleeing') { session.plan = 'fleeing'; @@ -413,9 +435,10 @@ module.exports = { return; } - // A hunting-ground relocation owns the movement/combat window. Do not - // attack starter mobs while walking or casting SoE to a better field. - if (tickSpotRelocation(session, bot)) return; + // Expire a stuck walk/SoE transition even when the bot is too weak to + // leave its recovery state yet. The movement gate below still yields + // to HP/MP recovery for live relocations. + expireTimedOutSpotRelocation(session, bot); // 4. HP/MP resting check const hpRatio = bot.fetchHp() / bot.fetchMaxHp(); @@ -432,6 +455,11 @@ module.exports = { return; } + // A hunting-ground relocation owns the movement/combat window. Do not + // attack starter mobs while walking or casting SoE to a better field. + // Recovery above intentionally wins so a bot cannot walk while dying. + if (tickSpotRelocation(session, bot)) return; + if (isSoloHunter(session) && Math.random() < 0.005) { // ~0.5% chance per tick (~10 minutes) const closestTown = BotAI.getClosestTown(bot.fetchLocX(), bot.fetchLocY()); const trip = startShopping(session, bot, BotAI, `My bags are full of loot. Heading to ${closestTown.name} to sell and restock.`); diff --git a/src/GameServer/Bot/Population/BotLifeState.js b/src/GameServer/Bot/Population/BotLifeState.js index 80b5b5ce..0d54888a 100644 --- a/src/GameServer/Bot/Population/BotLifeState.js +++ b/src/GameServer/Bot/Population/BotLifeState.js @@ -21,6 +21,14 @@ function now() { return Date.now(); } +function hasStaleRateModelPlan(state) { + const plan = state?.stats?.equipmentPlan; + return state?.activity === 'hunting' + && plan?.expectedKills !== null + && plan?.expectedKills !== undefined + && Number(plan.rateModelVersion || 0) < GearAcquisitionPlanner.RATE_MODEL_VERSION; +} + function safeJson(value) { return JSON.stringify(value || {}); } @@ -1997,7 +2005,7 @@ const BotLifeState = { } const nextResolveAt = Number(state.timing?.nextResolveAt || 0); - if (nextResolveAt > timestamp) return; + if (nextResolveAt > timestamp && !hasStaleRateModelPlan(state)) return; summary.due += 1; if (Number(state.level || 1) >= 16) summary.highLevel += 1; diff --git a/src/GameServer/Bot/Population/HotActivation.js b/src/GameServer/Bot/Population/HotActivation.js index d625de5c..126b317f 100644 --- a/src/GameServer/Bot/Population/HotActivation.js +++ b/src/GameServer/Bot/Population/HotActivation.js @@ -67,6 +67,14 @@ function validPlayerPlacement(loc, playerLoc) { return dist >= Config.activationMinPlayerDistance && dist <= Config.activationRadius; } +function hasLocation(loc) { + return !!loc + && ['locX', 'locY'].every((key) => loc[key] !== null + && loc[key] !== undefined + && String(loc[key]).trim() !== '' + && Number.isFinite(Number(loc[key]))); +} + function activationPlacement(state, options = {}) { if (options.keepStoreLocation && (options.storeLoc || state?.loc)) { const loc = options.storeLoc || state.loc; @@ -75,11 +83,13 @@ function activationPlacement(state, options = {}) { const savedSpot = state?.spotId ? SpotService.findById(state.spotId) : null; // Coordinates are authoritative for activation. A stale destination spot // must not resurrect a bot on a remote field it never reached. - const physicalSpot = state?.loc ? SpotService.findCurrentSpot(state.loc) : null; - const spot = physicalSpot || savedSpot; + const hasStateLocation = hasLocation(state?.loc); + const physicalSpot = hasStateLocation ? SpotService.findCurrentSpot(state.loc) : null; + const spot = hasStateLocation ? physicalSpot : savedSpot; + const stateLocation = hasStateLocation ? state.loc : null; const baseLoc = options.playerLoc - ? (options.forceNearPlayer ? options.playerLoc : (state?.loc || spot?.center || { locX: 0, locY: 0, locZ: 0 })) - : (state?.loc || spot?.center || { locX: 0, locY: 0, locZ: 0 }); + ? (options.forceNearPlayer ? options.playerLoc : (stateLocation || spot?.center || { locX: 0, locY: 0, locZ: 0 })) + : (stateLocation || spot?.center || { locX: 0, locY: 0, locZ: 0 }); let candidate = null; for (let i = 0; i < Config.activationPlacementAttempts; i++) { diff --git a/src/GameServer/Bot/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js index 32d86cca..0c388277 100644 --- a/src/GameServer/Bot/Population/PopulationService.js +++ b/src/GameServer/Bot/Population/PopulationService.js @@ -29,11 +29,17 @@ const PersonaPartyPolicy = invoke('GameServer/Bot/Population/PersonaPartyPolicy' const HUNTING_TRAVEL_MS = 25000; +function hasFiniteCoordinate(value) { + return value !== null + && value !== undefined + && String(value).trim() !== '' + && Number.isFinite(Number(value)); +} + function beginHuntingTravel(state, spot, timestamp = Date.now(), options = {}) { if (!state || !spot || state.activity === 'traveling') return null; const from = { ...(state.loc || {}) }; - const hasLocation = Number.isFinite(Number(from.locX)) && Number.isFinite(Number(from.locY)) - && (Object.prototype.hasOwnProperty.call(from, 'locX') || Object.prototype.hasOwnProperty.call(from, 'locY')); + const hasLocation = hasFiniteCoordinate(from.locX) && hasFiniteCoordinate(from.locY); if (!hasLocation) return null; const physical = SpotService.findCurrentSpot(from); const currentId = physical?.id || options.currentSpotId || state.spotId || null; @@ -1109,6 +1115,9 @@ const PopulationService = { } else if (lagAbort > lagThrottle && lagMs > lagThrottle) { const pressure = Math.min(1, (lagMs - lagThrottle) / (lagAbort - lagThrottle)); budget = Math.round(baseBudget * (1 - pressure)); + } else if (lagAbort === 0 && lagThrottle > 0 && lagMs > lagThrottle) { + const pressure = Math.min(1, (lagMs - lagThrottle) / lagThrottle); + budget = Math.round(baseBudget * (1 - pressure)); } return { @@ -1118,9 +1127,9 @@ const PopulationService = { budgetMs: budget > 0 ? Math.min(budget, Math.max(25, Config.schedulerIntervalMs - 25)) : 0, - maxResolvesPerTick: Math.max(1, Number(idle + maxResolvesPerTick: Math.min(100, Math.max(1, Number(idle ? Config.schedulerIdleMaxResolvesPerTick - : Config.schedulerPlayerMaxResolvesPerTick) || 25) + : Config.schedulerPlayerMaxResolvesPerTick) || 25)) }; }, diff --git a/src/GameServer/Bot/Population/SpotProfiles.js b/src/GameServer/Bot/Population/SpotProfiles.js index 7284f896..8e1a7d98 100644 --- a/src/GameServer/Bot/Population/SpotProfiles.js +++ b/src/GameServer/Bot/Population/SpotProfiles.js @@ -122,7 +122,7 @@ const SpotProfiles = { const bGap = Math.abs(b.avgLevel - targetLevel); if (aGap !== bGap) return aGap - bGap; return b.density - a.density; - })[0] || profiles[0] || null; + })[0] || null; } }; diff --git a/tests/test_bot_cold_travel_without_spot.js b/tests/test_bot_cold_travel_without_spot.js index 92c90663..8e51fadd 100644 --- a/tests/test_bot_cold_travel_without_spot.js +++ b/tests/test_bot_cold_travel_without_spot.js @@ -90,7 +90,6 @@ async function run() { loc: { locX: 10, locY: 20, locZ: 30 }, stats: { equipmentPlan: { status: 'ready_to_craft', strategy: 'craft', recipeId: 189 } } }; - planningAtlasRequests = 0; GearPlanner.planFor = () => readyToTravel.stats.equipmentPlan; ColdCraftingService.beginTravel = (value) => ({ ...value, @@ -112,7 +111,7 @@ async function run() { assert.strictEqual(value.activity, 'traveling', 'a ready craft route must enter travel before resolving'); return { patch: { activity: 'traveling', stats: value.stats }, events: [], materialize: { exp: 0, sp: 0, adena: 0, items: [] }, nextResolveAt: Date.now() + 25000, debug: { activity: 'traveling' } }; }; - LifeState.applyResolve = (_value, _result) => Promise.resolve(readyToTravel); + LifeState.applyResolve = () => Promise.resolve(readyToTravel); const freshTravelResult = await PopulationService.resolveColdState(readyToTravel); assert.strictEqual(freshTravelResult.ok, true, 'travel started during a cold resolve must not fail as a missing hunting spot'); assert.strictEqual(receivedSpot, null, 'newly-started travel must resolve without a combat spot'); diff --git a/tests/test_bot_hunting_ground_rules.js b/tests/test_bot_hunting_ground_rules.js index b0103621..1f8793c0 100644 --- a/tests/test_bot_hunting_ground_rules.js +++ b/tests/test_bot_hunting_ground_rules.js @@ -45,6 +45,10 @@ try { }); assert.strictEqual(selected.id, 'mid_level_field', 'a level-16 bot must replan away from a physical level-1-3 starter field'); + assert.strictEqual(SpotProfiles.findForState({ level: 80, stats: {} }), null, + 'a bot without a level-aware candidate must report no spot instead of falling back to a starter field'); + assert.doesNotThrow(() => SpotService.isSuitable(mid, Infinity), + 'non-finite target levels must not create an unbounded eligibility scan'); assert.strictEqual(SpotService.isSuitable(starter, 16), false, 'starter mobs must not count as a suitable level-16 hunting ground'); assert.strictEqual(SpotService.isSuitable(mid, 16), true, diff --git a/tests/test_bot_population_scheduler_slices.js b/tests/test_bot_population_scheduler_slices.js index a2e26c5c..21ec044c 100644 --- a/tests/test_bot_population_scheduler_slices.js +++ b/tests/test_bot_population_scheduler_slices.js @@ -10,6 +10,8 @@ const originalSliceMs = Config.schedulerSliceMs; const originalYield = PopulationService.yieldSchedulerSlice; const originalRealPlayerSessions = PopulationService.realPlayerSessions; const originalEventLoopLag = Metrics.currentEventLoopLag; +const originalIdleMaxResolves = Config.schedulerIdleMaxResolvesPerTick; +const originalLagAbort = Config.schedulerLagAbortMs; async function run() { const values = []; @@ -31,10 +33,11 @@ async function run() { PopulationService.realPlayerSessions = () => []; Metrics.currentEventLoopLag = () => 0; + Config.schedulerIdleMaxResolvesPerTick = 1000; const idleProfile = PopulationService.schedulerProfile(); assert.strictEqual(idleProfile.idle, true, 'no real players must select the idle scheduler profile'); assert.strictEqual(idleProfile.budgetMs, Config.schedulerIdleBudgetMs, 'idle scheduler must use the larger background budget'); - assert.strictEqual(idleProfile.maxResolvesPerTick, Config.schedulerIdleMaxResolvesPerTick, 'idle scheduler must use the larger cold batch cap'); + assert.strictEqual(idleProfile.maxResolvesPerTick, 100, 'idle scheduler must not exceed the cold query cap'); assert.strictEqual(PopulationService.partyFormationBudgetMs(), Config.partyFormationIdleBudgetMs, 'idle party formation must use its larger budget'); PopulationService.realPlayerSessions = () => [{ actor: { fetchIsOnline: () => true }, accountId: 'player_1' }]; @@ -48,6 +51,11 @@ async function run() { Metrics.currentEventLoopLag = () => Config.schedulerLagThrottleMs + 40; const throttledProfile = PopulationService.schedulerProfile(); assert(throttledProfile.budgetMs > 0 && throttledProfile.budgetMs < idleProfile.budgetMs, 'event-loop lag must taper idle work before the hard stop'); + Config.schedulerLagAbortMs = 0; + const throttleOnlyProfile = PopulationService.schedulerProfile(); + assert(throttleOnlyProfile.budgetMs >= 0 && throttleOnlyProfile.budgetMs < idleProfile.budgetMs, + 'a throttle threshold must still reduce idle work when no abort threshold is configured'); + Config.schedulerLagAbortMs = originalLagAbort; Metrics.currentEventLoopLag = () => Config.schedulerLagAbortMs; assert.strictEqual(PopulationService.schedulerProfile().budgetMs, 0, 'critical event-loop lag must stop background work'); @@ -66,6 +74,8 @@ run() }) .finally(() => { Config.schedulerSliceMs = originalSliceMs; + Config.schedulerIdleMaxResolvesPerTick = originalIdleMaxResolves; + Config.schedulerLagAbortMs = originalLagAbort; PopulationService.yieldSchedulerSlice = originalYield; PopulationService.realPlayerSessions = originalRealPlayerSessions; Metrics.currentEventLoopLag = originalEventLoopLag; diff --git a/tests/test_bot_population_state.js b/tests/test_bot_population_state.js index 244f5173..edb5f6eb 100644 --- a/tests/test_bot_population_state.js +++ b/tests/test_bot_population_state.js @@ -248,7 +248,25 @@ try { assert.strictEqual(deadState.stats.partyRequest, null, 'dead bots must not retain open party requests'); }); }); - }).then(() => { + }).then(() => BotLifeState.upsertState({ + characterId: 99, + name: 'StaleSummaryProbe', + level: 20, + phase: 'cold', + activity: 'hunting', + timing: { nextResolveAt: 999999 }, + stats: { + equipmentPlan: { + expectedKills: 5, + rateModelVersion: GearPlanner.RATE_MODEL_VERSION - 1 + } + }, + vitals: {}, + inventory: {} + }, 'summary_probe').then(() => { + const summary = BotLifeState.coldDueSummary(1000); + assert(summary.due >= 1, 'cold due telemetry must include stale hunting plans before their persisted deadline'); + })).then(() => { console.log('Bot population state checks passed'); }); }).catch((err) => { From 366ba34e224f13fa5ad1f7ec215697ca3b949e5d Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:28:02 -0400 Subject: [PATCH 6/6] Guard stale plans by active status --- src/GameServer/Bot/Population/BotLifeState.js | 4 +++- tests/test_bot_population_state.js | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/GameServer/Bot/Population/BotLifeState.js b/src/GameServer/Bot/Population/BotLifeState.js index 0d54888a..38f75ea1 100644 --- a/src/GameServer/Bot/Population/BotLifeState.js +++ b/src/GameServer/Bot/Population/BotLifeState.js @@ -24,6 +24,7 @@ function now() { function hasStaleRateModelPlan(state) { const plan = state?.stats?.equipmentPlan; return state?.activity === 'hunting' + && plan?.status === 'active' && plan?.expectedKills !== null && plan?.expectedKills !== undefined && Number(plan.rateModelVersion || 0) < GearAcquisitionPlanner.RATE_MODEL_VERSION; @@ -1079,7 +1080,8 @@ const BotLifeState = { // Pull only fighting bots forward: resting and travelling states are // intentionally event-scheduled and cannot hurt themselves while // they wait for their persisted deadline. - const staleRateModelPlan = `json_extract(statsJson, '$.equipmentPlan.expectedKills') IS NOT NULL + const staleRateModelPlan = `json_extract(statsJson, '$.equipmentPlan.status') = 'active' + AND json_extract(statsJson, '$.equipmentPlan.expectedKills') IS NOT NULL AND COALESCE(CAST(json_extract(statsJson, '$.equipmentPlan.rateModelVersion') AS INTEGER), 0) < ${GearAcquisitionPlanner.RATE_MODEL_VERSION}`; const pendingEquipmentSpotReplan = `activity IN ('hunting', 'resting') AND json_extract(statsJson, '$.equipmentPlan.status') = 'active' diff --git a/tests/test_bot_population_state.js b/tests/test_bot_population_state.js index edb5f6eb..0a63f618 100644 --- a/tests/test_bot_population_state.js +++ b/tests/test_bot_population_state.js @@ -133,8 +133,9 @@ try { const due = statements.find((entry) => entry.sql.includes("WHEN activity IN ('traveling', 'shopping', 'crafting') THEN 1")); assert(due.sql.includes('rateModelVersion'), 'due cold states must prioritize persisted plans from an older drop-rate model'); assert(due.sql.includes(`< ${GearPlanner.RATE_MODEL_VERSION}`), 'due cold states must prioritize plans from the current model rollout rather than a stale hard-coded version'); - assert(due.sql.includes("OR (activity = 'hunting' AND (json_extract(statsJson, '$.equipmentPlan.expectedKills') IS NOT NULL"), 'a stale active combat plan must bypass its old next-resolve deadline for an immediate safety replan'); - assert(due.sql.indexOf("WHEN json_extract(statsJson, '$.equipmentPlan.expectedKills') IS NOT NULL") < due.sql.indexOf("WHEN activity IN ('traveling', 'shopping', 'crafting') THEN 1"), 'a stale active plan must outrank ordinary market, travel, and crafting transitions'); + assert(due.sql.includes("OR (activity = 'hunting' AND (json_extract(statsJson, '$.equipmentPlan.status') = 'active'"), 'only active stale combat plans must bypass their old next-resolve deadline for an immediate safety replan'); + const stalePlanOrder = due.sql.indexOf("WHEN json_extract(statsJson, '$.equipmentPlan.status') = 'active'"); + assert(stalePlanOrder >= 0 && stalePlanOrder < due.sql.indexOf("WHEN activity IN ('traveling', 'shopping', 'crafting') THEN 1"), 'a stale active plan must outrank ordinary market, travel, and crafting transitions'); assert(due.sql.includes("WHEN activity IN ('traveling', 'shopping', 'crafting') THEN 1"), 'due cold states must promptly finish market, travel, and crafting transitions after an urgent combat-safety replan'); assert(due.sql.includes("json_extract(statsJson, '$.equipmentPlan.next.spotId')"), 'due cold states must prioritize active gear plans whose source spot differs from the saved spot'); assert(due.sql.includes("startup_craft_wait_recovery"), 'startup craft recovery must immediately replan before the ordinary hunting backlog'); @@ -257,6 +258,7 @@ try { timing: { nextResolveAt: 999999 }, stats: { equipmentPlan: { + status: 'active', expectedKills: 5, rateModelVersion: GearPlanner.RATE_MODEL_VERSION - 1 }