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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions src/GameServer/Bot/AI/GearAcquisitionPlanner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 || '';
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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 };
23 changes: 22 additions & 1 deletion src/GameServer/Bot/Population/BotLifeState.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down
5 changes: 5 additions & 0 deletions tests/test_bot_gear_acquisition.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
3 changes: 3 additions & 0 deletions tests/test_bot_population_state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Loading