From 61d5adb752d33a33dc1b94a20f7c92ecb7eb7fab Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:18:02 -0400 Subject: [PATCH] Validate cold bot equipment targets --- .../Bot/AI/GearAcquisitionPlanner.js | 18 +++++++++++++-- src/GameServer/Bot/Population/BotLifeState.js | 23 ++++++++++++++++++- tests/test_bot_gear_acquisition.js | 5 ++++ tests/test_bot_population_state.js | 3 +++ 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js index 34ffdee2..3be487e4 100644 --- a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js +++ b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js @@ -14,6 +14,19 @@ const ARMOR_SLOTS = new Set([6, 9, 10, 11, 12, 15]); const JEWEL_SLOTS = new Set([1, 2, 3, 4, 5]); const RATE_MODEL_VERSION = 3; +function isRealCatalogItem(item = {}) { + const selfId = Number(item.selfId || 0); + const name = String(item.template?.name || '').trim(); + // A loaded row is not automatically a usable game item. The datapack has + // legacy placeholder rows (for example, the D-grade weapon named "0"). + // Do not let an anonymous or malformed catalog record become a bot goal, + // party-loot candidate, or equipped item just because its combat stats are + // otherwise present. + return Number.isInteger(selfId) && selfId > 0 + && name.length > 0 + && name !== '0'; +} + function gradeForLevel(level) { const value = Number(level || 1); if (value >= 76) return 's'; @@ -114,6 +127,7 @@ function combatReadiness(state = {}) { } function suitable(item, state, role, requiredRank = gradeForLevel(state.level)) { + if (!isRealCatalogItem(item)) return false; const rank = String(item.etc?.rank || 'none').toLowerCase(); if (rank !== requiredRank) return false; const kind = item.template?.kind || ''; @@ -364,7 +378,7 @@ function preferredNoGradeTarget(state = {}) { return planned.items .map((desired) => (DataCache.items || []).find((item) => Number(item.selfId) === Number(desired.selfId))) - .filter(Boolean) + .filter(isRealCatalogItem) .filter((item) => { if (uniqueItems.has(Number(item.selfId))) return false; uniqueItems.add(Number(item.selfId)); @@ -619,4 +633,4 @@ function sameObjective(left, right) { ); } -module.exports = { RATE_MODEL_VERSION, gradeForLevel, isCraftService, roleFor, itemScore, suitable, isSlotUpgrade, combatReadiness, progressionPriceCap, equipInventoryUpgrades, preferredTarget, preferredDropTarget, preferredNoGradeTarget, marketOfferForTarget, itemDropChance, itemDropYield, soloSafeForSource, bestSourceForState, sourceForItem, farmSourceForMaterial, missingMaterials, planFor, shouldFinishPreviousPlan, scoreSpot, sameObjective }; +module.exports = { RATE_MODEL_VERSION, gradeForLevel, isCraftService, roleFor, itemScore, isRealCatalogItem, suitable, isSlotUpgrade, combatReadiness, progressionPriceCap, equipInventoryUpgrades, preferredTarget, preferredDropTarget, preferredNoGradeTarget, marketOfferForTarget, itemDropChance, itemDropYield, soloSafeForSource, bestSourceForState, sourceForItem, farmSourceForMaterial, missingMaterials, planFor, shouldFinishPreviousPlan, scoreSpot, sameObjective }; diff --git a/src/GameServer/Bot/Population/BotLifeState.js b/src/GameServer/Bot/Population/BotLifeState.js index 5bd02cd0..6550d624 100644 --- a/src/GameServer/Bot/Population/BotLifeState.js +++ b/src/GameServer/Bot/Population/BotLifeState.js @@ -669,6 +669,27 @@ function migrateAcquisitionPartyWaits() { }); } +function discardInvalidEquipmentPlans() { + const timestamp = now(); + return Database.execute([ + `UPDATE ${TABLE} + SET statsJson = JSON_REMOVE(COALESCE(statsJson, '{}'), '$.equipmentPlan'), + updatedAt = ? + WHERE JSON_EXTRACT(statsJson, '$.equipmentPlan.target') IS NOT NULL + AND ( + COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(statsJson, '$.equipmentPlan.target.selfId')) AS UNSIGNED), 0) <= 0 + OR TRIM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(statsJson, '$.equipmentPlan.target.name')), '')) IN ('', '0') + )`, + [timestamp] + ]).then((result) => { + const discarded = Number(result?.affectedRows || 0); + if (discarded > 0) { + utils.infoWarn('BotLife', 'discarded %d invalid equipment plans on startup', discarded); + } + return discarded; + }); +} + const BotLifeState = { init() { if (initialized) return Promise.resolve(true); @@ -707,7 +728,7 @@ const BotLifeState = { INDEX accountName (accountName) )`, [] - ]).then(() => ensureColumns()).then(() => recoverStaleHotStates()).then(() => recoverStaleCraftWaits()).then(() => migrateAcquisitionPartyWaits()).then(() => hydrateCache()).then((count) => { + ]).then(() => ensureColumns()).then(() => recoverStaleHotStates()).then(() => recoverStaleCraftWaits()).then(() => migrateAcquisitionPartyWaits()).then(() => discardInvalidEquipmentPlans()).then(() => hydrateCache()).then((count) => { const repairs = [...cache.values()] .map(recoverOrphanedGiranState) .filter((state) => state !== cache.get(state.characterId)); diff --git a/tests/test_bot_gear_acquisition.js b/tests/test_bot_gear_acquisition.js index 80210994..3e51c889 100644 --- a/tests/test_bot_gear_acquisition.js +++ b/tests/test_bot_gear_acquisition.js @@ -93,6 +93,11 @@ assert.strictEqual(equippedUpgrade[noGradeSword.selfId].equipped, false, 'the re const entryDTarget = GearAcquisitionPlanner.preferredTarget({ level: 20, stats: { classId: 0, role: 'dps' }, inventory: {} }); assert(entryDTarget, 'a new D-grade bot must receive an attainable equipment target'); assert(Number(entryDTarget.item.template.price) < Number(atubaMace.template.price), 'a fresh D-grade bot must not begin by chasing the top D weapon'); +const malformedCatalogWeapon = DataCache.items.find((item) => Number(item.selfId) === 749); +assert(malformedCatalogWeapon, 'the malformed legacy catalog row must remain covered by the target filter'); +assert.strictEqual(GearAcquisitionPlanner.isRealCatalogItem(malformedCatalogWeapon), false, 'an anonymous catalog row must never count as a real item'); +assert.strictEqual(GearAcquisitionPlanner.suitable(malformedCatalogWeapon, { level: 20, stats: { classId: 0, role: 'dps' } }, 'dps'), false, 'an anonymous catalog row must never enter bot equipment selection'); +assert.notStrictEqual(Number(entryDTarget.item.selfId), 749, 'a bot must not set an anonymous catalog row as its D-grade goal'); const entryDArcherTarget = GearAcquisitionPlanner.preferredTarget({ level: 20, stats: { classId: 3, role: 'archer' }, inventory: {} }); assert(entryDArcherTarget, 'an archer must retain a D-grade target when every entry bow is above the early cap'); assert.strictEqual(entryDArcherTarget.item.template.kind, 'Weapon.Bow', 'an archer must keep weapon-first progression even when its entry bow exceeds the cap'); diff --git a/tests/test_bot_population_state.js b/tests/test_bot_population_state.js index f0bce340..87c0aaa2 100644 --- a/tests/test_bot_population_state.js +++ b/tests/test_bot_population_state.js @@ -41,6 +41,9 @@ try { assert.strictEqual(craftRecovery.params[1], craftRecovery.params[0], 'recovered craft waits must be due immediately for their replan'); const partyWaitMigration = statements.find((entry) => entry.sql.includes("migrated %d acquisition party waits") || entry.sql.includes("activity = 'party_wait'")); assert(partyWaitMigration, 'startup must move legacy acquisition waits out of the rest scheduler'); + const invalidPlanMigration = statements.find((entry) => entry.sql.includes("JSON_REMOVE(COALESCE(statsJson, '{}'), '$.equipmentPlan')")); + assert(invalidPlanMigration, 'startup must discard malformed persisted equipment plans that passive bots would not otherwise replan'); + assert(invalidPlanMigration.sql.includes("'$.equipmentPlan.target.selfId'"), 'the invalid-plan migration must validate the persisted target identity'); return BotLifeState.upsertState({ characterId: 42, name: 'PersistenceProbe', level: 42, phase: 'cold', activity: 'hunting', timing: { activityStartedAt: 1, nextResolveAt: 2, lastResolvedAt: 1 },