diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 57a5512e..7e9195ea 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -38,6 +38,7 @@ const tests = [ 'tests/test_bot_warehouse.js', 'tests/test_bot_cold_market_trade_chat.js', 'tests/test_bot_background_drops.js', + 'tests/test_bot_party_gear_loot.js', 'tests/test_bot_background_respawn.js', 'tests/test_bot_background_party_composition.js', 'tests/test_bot_background_party_recruitment.js', diff --git a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js index a10f150f..34ffdee2 100644 --- a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js +++ b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js @@ -12,7 +12,7 @@ const RANKS = ['none', 'd', 'c', 'b', 'a', 's']; const WEAPON_SLOTS = new Set([7, 14]); 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 = 2; +const RATE_MODEL_VERSION = 3; function gradeForLevel(level) { const value = Number(level || 1); @@ -65,6 +65,15 @@ function inventoryItems(inventory = {}) { }); } +function equippedInventoryItems(inventory = {}) { + const rows = Array.isArray(inventory) ? inventory : Object.values(inventory); + return rows.flatMap((row) => { + if (!row?.equipped || Number(row.amount || 0) < 1) return []; + const item = (DataCache.items || []).find((entry) => Number(entry.selfId) === Number(row.selfId)); + return item ? [item] : []; + }); +} + function itemScore(item, role) { const stats = item.stats || {}; const slot = Number(item.etc?.slot || 0); @@ -73,13 +82,46 @@ function itemScore(item, role) { return Number(stats.pDef || 0) + Number(item.etc?.mp || 0); } +function rankIndex(rank) { + const index = RANKS.indexOf(String(rank || 'none').toLowerCase()); + return index < 0 ? 0 : index; +} + +function combatReadiness(state = {}) { + const role = roleFor(state); + const equipped = equippedInventoryItems(state.inventory); + const weapon = equipped.find((item) => WEAPON_SLOTS.has(Number(item.etc?.slot || 0))); + const armor = equipped.filter((item) => ARMOR_SLOTS.has(Number(item.etc?.slot || 0))); + const weaponRank = rankIndex(weapon?.etc?.rank); + const armorRank = armor.length + ? armor.reduce((sum, item) => sum + rankIndex(item.etc?.rank), 0) / armor.length + : 0; + const baseKit = (weapon ? 0.25 : 0) + Math.min(0.45, armor.length * 0.1); + const roleAdjustment = role === 'tank' ? 0.45 + : ['healer', 'buffer'].includes(role) ? -0.7 + : role === 'mage' ? -0.25 : 0; + + return { + role, + weaponRank, + armorRank, + effectiveLevel: Math.max(1, Number(state.level || 1)) + + weaponRank * 1.25 + + armorRank * 0.65 + + baseKit + + roleAdjustment + }; +} + function suitable(item, state, role, requiredRank = gradeForLevel(state.level)) { const rank = String(item.etc?.rank || 'none').toLowerCase(); if (rank !== requiredRank) return false; const kind = item.template?.kind || ''; const slot = Number(item.etc?.slot || 0); if (WEAPON_SLOTS.has(slot)) return weaponKindsFor(role, state.stats?.classId || state.classId).includes(kind); - if (ARMOR_SLOTS.has(slot)) return kind === armorKindFor(role) || (!['mage', 'healer', 'buffer', 'archer', 'dagger'].includes(role) && kind === 'Armor.Shield'); + if (slot === 8) return !['mage', 'healer', 'buffer', 'archer', 'dagger'].includes(role) && kind === 'Armor.Shield'; + if ([10, 11, 15].includes(slot)) return kind === armorKindFor(role); + if ([6, 9, 12].includes(slot)) return kind === 'Armor.Wear'; return JEWEL_SLOTS.has(slot) && kind === 'Armor.Jewel'; } @@ -99,12 +141,156 @@ function isSlotUpgrade(item, ownedItems, role) { )); } -function cGradePriceCap(level) { +function slotPriority(item) { + const slot = Number(item?.etc?.slot || 0); + if (WEAPON_SLOTS.has(slot)) return 8; + if (ARMOR_SLOTS.has(slot)) return 4; + return JEWEL_SLOTS.has(slot) ? 1 : 0; +} + +function currentSlotScore(item, ownedItems = [], role) { + const slot = WEAPON_SLOTS.has(Number(item?.etc?.slot || 0)) ? 'weapon' : Number(item?.etc?.slot || 0); + return ownedItems + .filter((owned) => ( + (WEAPON_SLOTS.has(Number(owned.etc?.slot || 0)) ? 'weapon' : Number(owned.etc?.slot || 0)) === slot + )) + .reduce((best, owned) => Math.max(best, itemScore(owned, role)), 0); +} + +function candidateEffort(candidate, state, options = {}) { + const item = candidate?.item; + if (!item) return Infinity; + const spots = options.spots || []; + const offer = marketOfferForTarget(item, state, options); + const availableAdena = Number(state.adena || state.inventory?.[57]?.amount || 0); + const marketEffortValue = offer + ? (availableAdena >= Number(offer.price || 0) + ? 4 + : marketEffort(offer, state)) + : Infinity; + // A few callers only ask for a deterministic preferred item (tests, + // 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 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 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); + return sum + (source ? material.missing / Math.max(Number(source.expectedYield || 0), 0.000001) : 1000000); + }, 8); + return Math.min(directEffort, marketEffortValue, materialEffort); +} + +function shortlistCandidates(candidates = [], options = {}) { + if (options.recipeId) return candidates; + const bySlot = candidates.reduce((groups, candidate) => { + const slot = WEAPON_SLOTS.has(Number(candidate.item.etc?.slot || 0)) + ? 'weapon' + : String(candidate.item.etc?.slot || 0); + groups[slot] = groups[slot] || []; + groups[slot].push(candidate); + return groups; + }, {}); + // Evaluate a few entry and mid-tier candidates per paperdoll slot. The + // later effort model decides between them, but excluding the long tail + // keeps cold population ticks bounded and stops a fresh character from + // treating the best-in-slot item as its default target. + return Object.values(bySlot).flatMap((entries) => entries + .sort((left, right) => Number(left.item.template?.price || 0) - Number(right.item.template?.price || 0) + || Number(left.item.selfId) - Number(right.item.selfId)) + .slice(0, 3)); +} + +function progressionPriceCap(rank, level) { const value = Number(level || 1); - if (value < 44) return 2290000; - if (value < 48) return 2870000; - if (value < 50) return 4300000; - return Infinity; + const caps = { + d: value < 24 ? 180000 : value < 30 ? 420000 : 800000, + c: value < 44 ? 2290000 : value < 48 ? 2870000 : 4300000, + b: value < 55 ? 9000000 : 15000000, + a: value < 66 ? 30000000 : 60000000 + }; + return caps[String(rank || '').toLowerCase()] ?? Infinity; +} + +function opportunityScore(candidate, state, options = {}) { + const role = roleFor(state); + const ownedItems = inventoryItems(state.inventory); + const improvement = Math.max(1, itemScore(candidate.item, role) - currentSlotScore(candidate.item, ownedItems, role) + 2); + const effort = candidateEffort(candidate, state, options); + // The fallback makes an unobservable route deterministic, while real + // market/drop/craft effort always wins over template price. + if (!Number.isFinite(effort) && !candidate.recipe) return 0; + const normalizedEffort = Number.isFinite(effort) + ? Math.max(1, effort) + : Math.max(1, Number(candidate.item.template?.price || 0) / 1000); + return (slotPriority(candidate.item) * improvement) / normalizedEffort; +} + +function equipInventoryUpgrades(state = {}, inventory = {}) { + const role = roleFor(state); + const allowedRank = rankIndex(gradeForLevel(state.level)); + const candidates = Object.values(inventory || {}).flatMap((entry) => { + if (Number(entry?.amount || 0) < 1) return []; + const item = (DataCache.items || []).find((candidate) => Number(candidate.selfId) === Number(entry.selfId)); + const rank = rankIndex(item?.etc?.rank); + return item && rank <= allowedRank && suitable(item, state, role, item.etc?.rank) ? [{ entry, item }] : []; + }); + const slotKey = (item) => WEAPON_SLOTS.has(Number(item.etc?.slot || 0)) ? 'weapon' : String(item.etc?.slot || 0); + const best = candidates.reduce((selected, candidate) => { + const key = slotKey(candidate.item); + const current = selected.get(key); + if (!current || itemScore(candidate.item, role) > itemScore(current.item, role) + || itemScore(candidate.item, role) === itemScore(current.item, role) + && Number(candidate.item.template?.price || 0) < Number(current.item.template?.price || 0)) { + selected.set(key, candidate); + } + return selected; + }, new Map()); + // A full body occupies both chest and legs. Decide that mutually-exclusive + // set before applying equipment so inventory key order cannot flip the + // result on every inventory refresh. + const fullBody = best.get('15'); + const chest = best.get('10'); + const legs = best.get('11'); + if (fullBody && (chest || legs)) { + const separatesScore = [chest, legs] + .filter(Boolean) + .reduce((sum, candidate) => sum + itemScore(candidate.item, role), 0); + if (separatesScore >= itemScore(fullBody.item, role)) { + best.delete('15'); + } else { + best.delete('10'); + best.delete('11'); + } + } + const next = Object.fromEntries(Object.entries(inventory || {}).map(([key, value]) => [key, { ...value }])); + best.forEach(({ entry, item }, key) => { + const slot = Number(item.etc?.slot || 0); + Object.values(next).forEach((owned) => { + const ownedItem = (DataCache.items || []).find((candidate) => Number(candidate.selfId) === Number(owned.selfId)); + const ownedKey = ownedItem ? slotKey(ownedItem) : String(owned.slot || 0); + if (ownedKey === key) owned.equipped = Number(owned.selfId) === Number(entry.selfId); + }); + if (slot === 15) { + [10, 11].forEach((blockedSlot) => Object.values(next).forEach((owned) => { + if (Number(owned.slot || 0) === blockedSlot) owned.equipped = false; + })); + } else if ([10, 11].includes(slot)) { + Object.values(next).forEach((owned) => { + if (Number(owned.slot || 0) === 15) owned.equipped = false; + }); + } + next[String(entry.selfId)] = { ...next[String(entry.selfId)], equipped: true, slot }; + }); + return next; } function preferredTarget(state = {}, options = {}) { @@ -123,28 +309,41 @@ function preferredTarget(state = {}, options = {}) { ? String((DataCache.items || []).find((item) => Number(item.selfId) === Number(recipes.find((recipe) => Number(recipe.recipeId) === Number(options.recipeId))?.productId))?.etc?.rank || '') : null; const recipesByProduct = new Map(recipes.map((recipe) => [Number(recipe.productId), recipe])); - const candidates = (DataCache.items || []) - .filter((item) => suitable(item, state, role, recipeRank || gradeForLevel(state.level)) && recipesByProduct.has(Number(item.selfId))) - .map((item) => ({ item, recipe: recipesByProduct.get(Number(item.selfId)) })) - .filter(({ recipe }) => !options.recipeId || Number(recipe.recipeId) === Number(options.recipeId)) + const allCandidates = (DataCache.items || []) + .filter((item) => suitable(item, state, role, recipeRank || gradeForLevel(state.level))) + .map((item) => ({ item, recipe: recipesByProduct.get(Number(item.selfId)) || null })) + .filter(({ recipe }) => !options.recipeId || Number(recipe?.recipeId) === Number(options.recipeId)) .filter(({ item }) => Number(owned.get(Number(item.selfId)) || 0) < 1) - .filter(({ item }) => isSlotUpgrade(item, ownedItems, role)) + .filter(({ item }) => isSlotUpgrade(item, ownedItems, role)); + const requiredRank = recipeRank || gradeForLevel(state.level); + const hasCurrentGradeWeapon = ownedItems.some((item) => ( + WEAPON_SLOTS.has(Number(item.etc?.slot || 0)) + && rankIndex(item.etc?.rank) >= rankIndex(requiredRank) + )); + // A viable weapon is the first milestone of a new grade. Once it is + // covered, fill the rest of the kit before considering another weapon of + // the same grade. + const weaponFirst = !hasCurrentGradeWeapon + ? allCandidates.filter(({ item }) => WEAPON_SLOTS.has(Number(item.etc?.slot || 0))) + : allCandidates.filter(({ item }) => !WEAPON_SLOTS.has(Number(item.etc?.slot || 0))); + const progressionCandidates = weaponFirst.length ? weaponFirst : allCandidates; + const cap = progressionPriceCap(requiredRank, state.level); + const affordable = progressionCandidates.filter(({ item }) => Number(item.template?.price || 0) <= cap); + // The entry weapon for some weapon families costs more than the early + // grade cap (for example, D bows and daggers). Retain the weapon-first + // milestone rather than declaring progression complete; shortlisting + // 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 candidates = shortlistCandidates(affordable.length ? affordable : progressionCandidates, options) .sort((a, b) => { - const aSlot = Number(a.item.etc?.slot || 0); - const bSlot = Number(b.item.etc?.slot || 0); - const priority = (slot) => WEAPON_SLOTS.has(slot) ? 3 : ARMOR_SLOTS.has(slot) ? 2 : 1; - return priority(bSlot) - priority(aSlot) || itemScore(b.item, role) - itemScore(a.item, role) || Number(b.item.template?.price || 0) - Number(a.item.template?.price || 0); + const scoreDelta = opportunityScore(b, state, options) - opportunityScore(a, state, options); + 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); }); - const requiredRank = recipeRank || gradeForLevel(state.level); - // A C-grade character should upgrade in affordable steps rather than - // commit all of its material gathering to the final C weapon immediately. - // Keep the top tier available from level 50, shortly before B-grade. - const affordable = requiredRank === 'c' - ? candidates.filter(({ item }) => Number(item.template?.price || 0) <= cGradePriceCap(state.level)) - : candidates; - // At a C-tier cap, wait for the next level band instead of silently - // falling through to a top-C weapon once the current band is complete. - return requiredRank === 'c' ? affordable[0] || null : candidates[0] || null; + return candidates[0] || null; } function preferredDropTarget(state = {}) { @@ -223,7 +422,7 @@ function itemDropYield(reward, itemId, kind = 'drop', context = {}) { } function soloSafeForSource(state = {}, source = {}) { - return Number(state.level || 1) >= Number(source.spotLevel || Infinity) + 2; + return combatReadiness(state).effectiveLevel >= Number(source.spotLevel || Infinity) + 2; } function bestSourceForState(sources = [], state = {}) { @@ -342,7 +541,7 @@ function planFor(state = {}, options = {}) { const spots = options.spots || []; const directSources = sourceForItem(target.item.selfId, spots, state); const direct = bestSourceForState(directSources, state); - const materials = missingMaterials(target.recipe, state.inventory); + const materials = target.recipe ? missingMaterials(target.recipe, state.inventory) : []; const allowedRecipeIds = stationRecipeIds(); const materialPlans = materials.map((material) => ({ ...material, @@ -355,11 +554,15 @@ function planFor(state = {}, options = {}) { (b.missing / Math.max(b.source?.expectedYield || 0.000001, 0.000001)) - (a.missing / Math.max(a.source?.expectedYield || 0.000001, 0.000001)) ))[0] || null; const directKills = direct ? 1 / Math.max(direct.expectedYield, 0.000001) : Infinity; - const craftKills = missingMaterialPlans.reduce((sum, material) => sum + material.missing / Math.max(material.source?.expectedYield || 0.000001, 0.000001), 0); + 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 buy = offer && marketEffort(offer, state) <= Math.min(directKills, craftKills); const soloSafe = direct && soloSafeForSource(state, direct); - const strategy = buy ? 'market' : soloSafe && directKills <= craftKills * 0.8 ? 'direct_drop' : 'craft'; + const strategy = buy ? 'market' + : direct && (!target.recipe || soloSafe && directKills <= craftKills * 0.8) ? 'direct_drop' + : target.recipe ? 'craft' : 'blocked'; const next = strategy === 'direct_drop' ? direct && { ...direct, itemId: Number(target.item.selfId) } : strategy === 'craft' ? nextMaterial?.source && { ...nextMaterial.source, itemId: Number(nextMaterial.selfId) } : null; @@ -384,7 +587,7 @@ function planFor(state = {}, options = {}) { role: roleFor(state), rateModelVersion: RATE_MODEL_VERSION, target: { selfId: Number(target.item.selfId), name: target.item.template?.name || `Item ${target.item.selfId}`, slot: Number(target.item.etc?.slot || 0) }, - recipeId: Number(target.recipe.recipeId), + recipeId: target.recipe ? Number(target.recipe.recipeId) : null, strategy, soloSafe, requiresParty, @@ -416,4 +619,4 @@ function sameObjective(left, right) { ); } -module.exports = { RATE_MODEL_VERSION, gradeForLevel, isCraftService, preferredTarget, preferredDropTarget, preferredNoGradeTarget, marketOfferForTarget, itemDropChance, itemDropYield, soloSafeForSource, bestSourceForState, sourceForItem, farmSourceForMaterial, missingMaterials, planFor, shouldFinishPreviousPlan, scoreSpot, sameObjective }; +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 }; diff --git a/src/GameServer/Bot/Economy/ColdCraftingService.js b/src/GameServer/Bot/Economy/ColdCraftingService.js index f24780c0..d5647c68 100644 --- a/src/GameServer/Bot/Economy/ColdCraftingService.js +++ b/src/GameServer/Bot/Economy/ColdCraftingService.js @@ -128,7 +128,7 @@ async function supplementMaterials(characterId, items, recipe, multiplier = 1) { // latter after either path, otherwise consumed inputs can linger in the // summary and send a bot back to a station with phantom materials. function refreshPhysicalInventory(state) { - return LifeState.refreshInventory({ ...state, inventory: {} }); + return LifeState.refreshInventory({ ...state, inventory: {} }, { equip: true }); } function craftableBatchCount(items, recipe, requested = 1) { diff --git a/src/GameServer/Bot/Population/BackgroundPartyResolver.js b/src/GameServer/Bot/Population/BackgroundPartyResolver.js index 3a20f195..708645bf 100644 --- a/src/GameServer/Bot/Population/BackgroundPartyResolver.js +++ b/src/GameServer/Bot/Population/BackgroundPartyResolver.js @@ -2,6 +2,7 @@ const ProgressionRates = invoke('GameServer/ProgressionRates'); const BackgroundDropResolver = invoke('GameServer/Bot/Population/BackgroundDropResolver'); const BackgroundResolver = invoke('GameServer/Bot/Population/BackgroundResolver'); const PartyAffinity = invoke('GameServer/Bot/Population/BackgroundPartyAffinity'); +const PartyLootAllocator = invoke('GameServer/Bot/Population/PartyLootAllocator'); const MAX_DROPS_PER_RESOLVE = 4; @@ -223,6 +224,9 @@ const BackgroundPartyResolver = { }); }); + const lootDistribution = PartyLootAllocator.transferGearDrops(memberResults); + const distributedMemberResults = lootDistribution.memberResults; + if (wins > 0) { events.push({ characterId: party.leaderId, @@ -232,12 +236,24 @@ const BackgroundPartyResolver = { meta: { partyId: party.partyId, spotId: spot.id, fights, wins, losses } }); } - + lootDistribution.transfers.forEach((transfer) => { + events.push({ + characterId: transfer.to.characterId, + type: 'party_gear_share', + summary: `${transfer.from.name || 'A party member'} gave ${transfer.item.name || `Item ${transfer.item.selfId}`} to ${transfer.to.name || 'a party member'} who needed it`, + weight: 2, + meta: { + partyId: party.partyId, + fromCharacterId: transfer.from.characterId, + itemId: transfer.item.selfId + } + }); + }); const cohesionDelta = wins >= losses ? 0.015 : -0.035; const riskDelta = deaths > 0 ? 0.05 : losses > wins ? 0.02 : -0.01; return { - memberResults, + memberResults: distributedMemberResults, events, partyPatch: { cohesion: clamp(Number(party.cohesion || 0.65) + cohesionDelta, 0.1, 1), diff --git a/src/GameServer/Bot/Population/BotLifeState.js b/src/GameServer/Bot/Population/BotLifeState.js index d7e27388..5c6c2c80 100644 --- a/src/GameServer/Bot/Population/BotLifeState.js +++ b/src/GameServer/Bot/Population/BotLifeState.js @@ -9,6 +9,7 @@ const TABLE = 'bot_life_state'; const GearSkillHints = invoke('GameServer/Bot/AI/GearSkillHints'); const BotClassProgression = invoke('GameServer/Bot/BotClassProgression'); const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); +const GearAcquisitionPlanner = invoke('GameServer/Bot/AI/GearAcquisitionPlanner'); const cache = new Map(); const pendingWrites = new Map(); let initialized = false; @@ -1164,6 +1165,11 @@ const BotLifeState = { }; } + const equippedInventory = GearAcquisitionPlanner.equipInventoryUpgrades({ + ...state, + level, + stats: { ...(state.stats || {}), ...(result.patch?.stats || {}) } + }, inventory); const nextActivity = result.patch?.activity || state.activity; const nextState = { ...state, @@ -1193,9 +1199,10 @@ const BotLifeState = { }, stats: { ...stats, - ...(result.patch?.stats || {}) + ...(result.patch?.stats || {}), + equipment: equipmentSummaryFromInventory(equippedInventory) }, - inventory, + inventory: equippedInventory, updatedAt: timestamp }; const knownProfileLevel = Number(nextState.stats?.classProgressionLevel || 0); @@ -1271,7 +1278,7 @@ const BotLifeState = { }); }, - refreshInventory(state) { + refreshInventory(state, options = {}) { if (!state?.characterId) return Promise.resolve(state || null); return Database.fetchItems(state.characterId).then((items) => { // Cold progression owns virtual item counts between hot @@ -1289,15 +1296,21 @@ const BotLifeState = { slot: Number(item.slot || previous.slot || 0) }; }); - return { + const equipped = options.equip === true + ? GearAcquisitionPlanner.equipInventoryUpgrades(state, inventory) + : inventory; + const refreshed = { ...state, - adena: Math.max(Number(state.adena || 0), inventoryAdena(inventory)), - inventory, + adena: Math.max(Number(state.adena || 0), inventoryAdena(equipped)), + inventory: equipped, stats: { ...(state.stats || {}), - equipment: equipmentSummaryFromInventory(inventory) + equipment: equipmentSummaryFromInventory(equipped) } }; + return options.equip === true + ? syncInventorySummary(state.characterId, equipped).then(() => refreshed) + : refreshed; }).catch((err) => { utils.infoWarn('BotLife', 'failed to refresh inventory for %s: %s', state.name, err.message); return state; diff --git a/src/GameServer/Bot/Population/PartyLootAllocator.js b/src/GameServer/Bot/Population/PartyLootAllocator.js new file mode 100644 index 00000000..fbcf0851 --- /dev/null +++ b/src/GameServer/Bot/Population/PartyLootAllocator.js @@ -0,0 +1,120 @@ +const DataCache = invoke('GameServer/DataCache'); +const GearAcquisitionPlanner = invoke('GameServer/Bot/AI/GearAcquisitionPlanner'); + +const WEAPON_SLOTS = new Set([7, 14]); +const ARMOR_SLOTS = new Set([6, 8, 9, 10, 11, 12, 15]); +const JEWEL_SLOTS = new Set([1, 2, 3, 4, 5]); + +function templateFor(item = {}) { + return (DataCache.items || []).find((entry) => Number(entry.selfId) === Number(item.selfId)) || null; +} + +function equipmentDrop(item = {}) { + const kind = item.kind || templateFor(item)?.template?.kind || ''; + return String(kind).startsWith('Weapon.') || String(kind).startsWith('Armor.'); +} + +function inventoryTemplates(inventory = {}) { + return Object.values(inventory || {}).flatMap((item) => { + if (Number(item?.amount || 0) < 1) return []; + const template = templateFor(item); + return template ? [template] : []; + }); +} + +function slotPriority(item = {}) { + const slot = Number(item.etc?.slot || 0); + if (WEAPON_SLOTS.has(slot)) return 8; + if (ARMOR_SLOTS.has(slot)) return 4; + return JEWEL_SLOTS.has(slot) ? 1 : 0; +} + +function projectedInventory(state = {}, projected = new Map()) { + return projected.get(Number(state.characterId)) || { ...(state.inventory || {}) }; +} + +function recipientScore(state, item, projected) { + const template = templateFor(item); + if (!template || !equipmentDrop(item)) return -Infinity; + const role = GearAcquisitionPlanner.roleFor(state); + if (!GearAcquisitionPlanner.suitable(template, state, role)) return -Infinity; + + const inventory = projectedInventory(state, projected); + const owned = inventoryTemplates(inventory); + if (!GearAcquisitionPlanner.isSlotUpgrade(template, owned, role)) return -Infinity; + + const targetId = Number(state.stats?.equipmentPlan?.target?.selfId || 0); + const targetBonus = Number(template.selfId) === targetId ? 100000 : 0; + const current = owned + .filter((ownedItem) => ( + (WEAPON_SLOTS.has(Number(ownedItem.etc?.slot || 0)) ? 'weapon' : Number(ownedItem.etc?.slot || 0)) + === (WEAPON_SLOTS.has(Number(template.etc?.slot || 0)) ? 'weapon' : Number(template.etc?.slot || 0)) + )) + .reduce((best, ownedItem) => Math.max(best, GearAcquisitionPlanner.itemScore(ownedItem, role)), 0); + const improvement = Math.max(1, GearAcquisitionPlanner.itemScore(template, role) - current + 2); + const fairness = -Number(state.stats?.partyGearReceived || 0) * 0.01; + return targetBonus + slotPriority(template) * improvement + fairness; +} + +function addProjectedItem(state, item, projected) { + const inventory = { ...projectedInventory(state, projected) }; + const key = String(item.selfId); + inventory[key] = { + ...(inventory[key] || {}), + selfId: Number(item.selfId), + name: item.name || inventory[key]?.name || '', + amount: Number(inventory[key]?.amount || 0) + Number(item.amount || 0), + kind: item.kind || inventory[key]?.kind || '', + rank: item.rank || inventory[key]?.rank || 'none' + }; + projected.set(Number(state.characterId), inventory); +} + +function transferGearDrops(memberResults = []) { + const copies = memberResults.map((entry) => ({ + ...entry, + result: { + ...entry.result, + patch: { ...(entry.result?.patch || {}) }, + materialize: { + ...(entry.result?.materialize || {}), + items: [...(entry.result?.materialize?.items || [])] + } + } + })); + const projected = new Map(copies.map(({ state }) => [Number(state.characterId), { ...(state.inventory || {}) }])); + const transfers = []; + + const originalDrops = copies.flatMap((source) => ( + source.result.materialize.items.map((item) => ({ source, item })) + )); + originalDrops.forEach(({ source, item }) => { + const sourceItems = source.result.materialize.items; + if (!equipmentDrop(item)) return; + const recipient = copies + .map((entry) => ({ entry, score: recipientScore(entry.state, item, projected) })) + .filter((candidate) => Number.isFinite(candidate.score)) + .sort((left, right) => right.score - left.score + || Number(left.entry.state.characterId) - Number(right.entry.state.characterId))[0]?.entry; + if (!recipient) return; + + addProjectedItem(recipient.state, item, projected); + if (Number(recipient.state.characterId) === Number(source.state.characterId)) return; + const index = sourceItems.indexOf(item); + if (index >= 0) sourceItems.splice(index, 1); + recipient.result.materialize.items.push(item); + recipient.result.patch.stats = { + ...(recipient.result.patch.stats || {}), + partyGearReceived: Number(recipient.result.patch.stats?.partyGearReceived ?? recipient.state.stats?.partyGearReceived ?? 0) + 1 + }; + transfers.push({ + from: source.state, + to: recipient.state, + item + }); + }); + + return { memberResults: copies, transfers }; +} + +module.exports = { equipmentDrop, recipientScore, transferGearDrops }; diff --git a/tests/test_bot_gear_acquisition.js b/tests/test_bot_gear_acquisition.js index 41cc2c9e..80210994 100644 --- a/tests/test_bot_gear_acquisition.js +++ b/tests/test_bot_gear_acquisition.js @@ -81,6 +81,49 @@ const dMarketPlan = GearAcquisitionPlanner.planFor({ ...mage, level: 20 }, { findMarketOffer: (item) => ({ selfId: item.selfId, price: 1, town: 'Giran', sourceType: 'npc' }) }); assert.strictEqual(dMarketPlan.strategy, 'market', 'D-grade bots must compare a ready market offer with crafting and drops'); +const atubaMace = DataCache.items.find((item) => item.template?.name === 'Atuba Mace'); +const entryDSword = DataCache.items.find((item) => String(item.etc?.rank).toLowerCase() === 'd' && item.template?.kind === 'Weapon.Sword'); +const noGradeSword = DataCache.items.find((item) => String(item.etc?.rank).toLowerCase() === 'none' && item.template?.kind === 'Weapon.Sword'); +const equippedUpgrade = GearAcquisitionPlanner.equipInventoryUpgrades({ level: 20, stats: { role: 'tank' } }, { + [noGradeSword.selfId]: { selfId: noGradeSword.selfId, amount: 1, equipped: true, slot: 7 }, + [entryDSword.selfId]: { selfId: entryDSword.selfId, amount: 1, equipped: false, slot: 7 } +}); +assert.strictEqual(equippedUpgrade[entryDSword.selfId].equipped, true, 'a useful D drop must equip immediately in the cold inventory'); +assert.strictEqual(equippedUpgrade[noGradeSword.selfId].equipped, false, 'the replaced no-grade weapon must be unequipped'); +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 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'); +assert(Number.isFinite(GearAcquisitionPlanner.progressionPriceCap('d', 39)), 'D-grade planning must retain an adequate-kit ceiling through the whole grade band'); +assert(Number.isFinite(GearAcquisitionPlanner.progressionPriceCap('c', 51)), 'C-grade planning must retain an adequate-kit ceiling through the whole grade band'); +const fullLeather = DataCache.items.find((item) => item.etc?.rank === 'd' && item.template?.kind === 'Armor.Leather' && Number(item.etc?.slot) === 15); +const leatherChest = DataCache.items.find((item) => item.etc?.rank === 'd' && item.template?.kind === 'Armor.Leather' && Number(item.etc?.slot) === 10); +const leatherLegs = DataCache.items.find((item) => item.etc?.rank === 'd' && item.template?.kind === 'Armor.Leather' && Number(item.etc?.slot) === 11); +assert(fullLeather && leatherChest && leatherLegs, 'the datapack must expose D leather full and separate body armour for equip arbitration'); +const equipInventory = (items) => GearAcquisitionPlanner.equipInventoryUpgrades( + { level: 20, stats: { role: 'archer' } }, + Object.fromEntries(items.map((item) => [item.selfId, { selfId: item.selfId, amount: 1, slot: item.etc.slot }])) +); +const equippedIds = (inventory) => Object.values(inventory) + .filter((item) => item.equipped) + .map((item) => Number(item.selfId)) + .sort((left, right) => left - right); +const fullFirst = equippedIds(equipInventory([fullLeather, leatherChest, leatherLegs])); +const separateFirst = equippedIds(equipInventory([leatherChest, leatherLegs, fullLeather])); +assert.deepStrictEqual(fullFirst, separateFirst, 'full-body and chest/legs equipment must resolve identically regardless of inventory insertion order'); +assert(!(fullFirst.includes(fullLeather.selfId) && (fullFirst.includes(leatherChest.selfId) || fullFirst.includes(leatherLegs.selfId))), 'a full-body item must never equip alongside a conflicting chest or legs item'); +const lowDSource = { spotLevel: 18 }; +const tankReadiness = GearAcquisitionPlanner.combatReadiness({ + level: 20, + stats: { role: 'tank' }, + inventory: { 1: { selfId: 1, amount: 1, equipped: true }, 10: { selfId: 10, amount: 1, equipped: true } } +}); +const healerReadiness = GearAcquisitionPlanner.combatReadiness({ level: 20, stats: { role: 'healer' }, inventory: {} }); +assert(tankReadiness.effectiveLevel > healerReadiness.effectiveLevel, 'readiness must recognise that a geared tank can take safer solo routes than an unprepared support'); +assert.strictEqual(GearAcquisitionPlanner.soloSafeForSource({ level: 20, stats: { role: 'tank' }, inventory: { 1: { selfId: 1, amount: 1, equipped: true } } }, lowDSource), true, 'a tank may solo an entry D route when its actual kit supports it'); +assert.strictEqual(GearAcquisitionPlanner.soloSafeForSource({ level: 20, stats: { role: 'healer' }, inventory: {} }, lowDSource), false, 'an unprepared support must wait for party help at the same route'); assert(Number(target.item.template.price) <= 2290000, 'a new C-grade bot must begin with an entry-tier weapon target'); const station = ColdCraftingService.stationForRecipe(target.recipe.recipeId); assert(station, 'a selected equipment recipe must be published by a Giran crafting station'); diff --git a/tests/test_bot_party_gear_loot.js b/tests/test_bot_party_gear_loot.js new file mode 100644 index 00000000..7515db2a --- /dev/null +++ b/tests/test_bot_party_gear_loot.js @@ -0,0 +1,62 @@ +const assert = require('assert'); + +require('../src/Global'); + +const DataCache = invoke('GameServer/DataCache'); +const PartyLootAllocator = invoke('GameServer/Bot/Population/PartyLootAllocator'); + +DataCache.init(); + +const sword = DataCache.items.find((item) => ( + String(item.etc?.rank).toLowerCase() === 'd' + && item.template?.kind === 'Weapon.Sword' + && Number(item.etc?.slot) === 7 +)); +const bow = DataCache.items.find((item) => ( + String(item.etc?.rank).toLowerCase() === 'd' + && item.template?.kind === 'Weapon.Bow' +)); +assert(sword && bow, 'the C4 datapack must expose representative D-grade party drops'); + +const tank = { + characterId: 101, + name: 'TankNeed', + level: 20, + stats: { role: 'tank', equipmentPlan: { target: { selfId: sword.selfId } } }, + inventory: {} +}; +const mage = { + characterId: 102, + name: 'MageHolder', + level: 20, + stats: { role: 'mage' }, + inventory: {} +}; +const result = PartyLootAllocator.transferGearDrops([ + { + state: mage, + result: { patch: {}, materialize: { items: [{ selfId: sword.selfId, name: sword.template.name, amount: 1, kind: sword.template.kind, rank: sword.etc.rank }] } } + }, + { + state: tank, + result: { patch: {}, materialize: { items: [] } } + } +]); + +assert.strictEqual(result.transfers.length, 1, 'a useful gear drop must be reassigned inside the party'); +assert.strictEqual(result.transfers[0].to.characterId, tank.characterId, 'the D sword must go to the tank who planned that upgrade'); +assert.strictEqual(result.memberResults[0].result.materialize.items.length, 0, 'the holder must not retain gear that is more useful to another member'); +assert.strictEqual(result.memberResults[1].result.materialize.items[0].selfId, sword.selfId, 'the intended recipient must materialize the item directly'); +assert.strictEqual(result.memberResults[1].result.patch.stats.partyGearReceived, 1, 'the recipient ledger must record the useful party drop'); + +const unsuitable = PartyLootAllocator.transferGearDrops([ + { + state: mage, + result: { patch: {}, materialize: { items: [{ selfId: bow.selfId, name: bow.template.name, amount: 1, kind: bow.template.kind, rank: bow.etc.rank }] } } + }, + { state: tank, result: { patch: {}, materialize: { items: [] } } } +]); +assert.strictEqual(unsuitable.transfers.length, 0, 'incompatible equipment must remain with the original loot recipient'); +assert.strictEqual(unsuitable.memberResults[0].result.materialize.items[0].selfId, bow.selfId); + +console.log('Bot party gear loot checks passed');