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
1 change: 1 addition & 0 deletions scripts/run-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
267 changes: 235 additions & 32 deletions src/GameServer/Bot/AI/GearAcquisitionPlanner.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/GameServer/Bot/Economy/ColdCraftingService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
20 changes: 18 additions & 2 deletions src/GameServer/Bot/Population/BackgroundPartyResolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -223,6 +224,9 @@ const BackgroundPartyResolver = {
});
});

const lootDistribution = PartyLootAllocator.transferGearDrops(memberResults);
const distributedMemberResults = lootDistribution.memberResults;

if (wins > 0) {
events.push({
characterId: party.leaderId,
Expand All @@ -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),
Expand Down
27 changes: 20 additions & 7 deletions src/GameServer/Bot/Population/BotLifeState.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down
120 changes: 120 additions & 0 deletions src/GameServer/Bot/Population/PartyLootAllocator.js
Original file line number Diff line number Diff line change
@@ -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 };
43 changes: 43 additions & 0 deletions tests/test_bot_gear_acquisition.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
62 changes: 62 additions & 0 deletions tests/test_bot_party_gear_loot.js
Original file line number Diff line number Diff line change
@@ -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');
Loading