From 4d9fde0bd4c34f105659496c70597732bf4ec6aa Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:18:23 -0400 Subject: [PATCH 1/2] Model targeted cold combat outcomes --- .../Bot/Population/BackgroundDropResolver.js | 25 +++++-- .../Bot/Population/BackgroundPartyResolver.js | 28 ++++++-- .../Bot/Population/BackgroundResolver.js | 23 +++--- src/GameServer/Bot/Population/BotLifeState.js | 63 +++++++++++++++- .../Bot/Population/ColdCombatProfile.js | 31 ++++++-- .../Bot/Population/PopulationStatus.js | 4 +- tests/test_bot_background_drops.js | 11 +++ tests/test_bot_cold_combat.js | 71 +++++++++++++++++++ tests/test_bot_population_state.js | 38 ++++++++++ 9 files changed, 263 insertions(+), 31 deletions(-) diff --git a/src/GameServer/Bot/Population/BackgroundDropResolver.js b/src/GameServer/Bot/Population/BackgroundDropResolver.js index 7e604111..db4c18f3 100644 --- a/src/GameServer/Bot/Population/BackgroundDropResolver.js +++ b/src/GameServer/Bot/Population/BackgroundDropResolver.js @@ -7,7 +7,7 @@ function randInt(rng, min, max) { return Math.floor(rng() * (high - low + 1)) + low; } -function rewardDataForSpot(spot, rng) { +function rewardDataForSpot(spot, rng, npcSelfId = 0) { const entries = spot?.npcEntries?.length ? spot.npcEntries : (spot?.npcSelfIds || []).map((selfId) => ({ selfId, count: 1 })); @@ -27,6 +27,16 @@ function rewardDataForSpot(spot, rng) { )).map((reward) => ({ reward, count: 1 })); const candidates = [...byId, ...byName]; if (!candidates.length) return null; + const defeatedNpcId = Number(npcSelfId || 0); + if (defeatedNpcId > 0) { + const exact = candidates.find((candidate) => Number(candidate.reward.selfId) === defeatedNpcId)?.reward; + if (exact) return exact; + const defeatedNpcName = String((DataCache.npcs || []).find((npc) => Number(npc.selfId) === defeatedNpcId)?.template?.name || '') + .trim().toLowerCase(); + return candidates.find((candidate) => ( + defeatedNpcName && String(candidate.reward.template?.name || '').trim().toLowerCase() === defeatedNpcName + ))?.reward || null; + } let roll = rng() * candidates.reduce((sum, candidate) => sum + candidate.count, 0); for (const candidate of candidates) { roll -= candidate.count; @@ -58,14 +68,15 @@ function itemSnapshot(item, amount, sourceMobLevel = 0) { }; } -function sourceMobLevel(rewardData, spot) { - const npc = (DataCache.npcs || []).find((entry) => Number(entry.selfId) === Number(rewardData?.selfId)); +function sourceMobLevel(rewardData, spot, npcSelfId = 0) { + const npc = (DataCache.npcs || []).find((entry) => Number(entry.selfId) === Number(npcSelfId || rewardData?.selfId)); return Math.max(0, Number(npc?.template?.level || spot?.avgLevel || 0)); } -function rollForFight({ spot, killerLevel, rng = Math.random, maxItems = 1 } = {}) { - const rewardData = rewardDataForSpot(spot, rng); +function rollForFight({ spot, killerLevel, npcSelfId = 0, rng = Math.random, maxItems = 1 } = {}) { + const rewardData = rewardDataForSpot(spot, rng, npcSelfId); if (!rewardData) return []; + const defeatedNpcLevel = sourceMobLevel(rewardData, spot, npcSelfId); const drops = []; for (const group of rewardData.rewards || []) { @@ -73,7 +84,7 @@ function rollForFight({ spot, killerLevel, rng = Math.random, maxItems = 1 } = { if ((group.items || []).every((item) => Number(item.selfId) === 57)) continue; const groupRoll = ProgressionRates.rewardGroupRoll(group, 'drop', { - npcLevel: Number(spot?.avgLevel || 0), + npcLevel: defeatedNpcLevel, killerLevel: Number(killerLevel || 0) }, rng); if (!groupRoll.hit) continue; @@ -81,7 +92,7 @@ function rollForFight({ spot, killerLevel, rng = Math.random, maxItems = 1 } = { const item = selectItem(group.items, rng); if (!item || Number(item.selfId) === 57) continue; const amount = ProgressionRates.scaleAmount(randInt(rng, item.min, item.max), groupRoll.amountMultiplier, rng); - const snapshot = itemSnapshot(item, amount, sourceMobLevel(rewardData, spot)); + const snapshot = itemSnapshot(item, amount, defeatedNpcLevel); if (snapshot) drops.push(snapshot); } return drops; diff --git a/src/GameServer/Bot/Population/BackgroundPartyResolver.js b/src/GameServer/Bot/Population/BackgroundPartyResolver.js index 1c4779da..965fd332 100644 --- a/src/GameServer/Bot/Population/BackgroundPartyResolver.js +++ b/src/GameServer/Bot/Population/BackgroundPartyResolver.js @@ -43,7 +43,7 @@ function estimateFightCount({ party, members, spot, elapsedMs }) { return Math.max(1, Math.min(4, Math.round(baseWindows * densityFactor * cohesionFactor))); } -function distributeRewards({ members, spot, wins, pressure, rng }) { +function distributeRewards({ members, spot, wins, defeatedNpcIds = [], pressure, rng }) { const rewards = spot.rewards; const expMultiplier = Number(pressure?.expMultiplier || 1); const rates = ProgressionRates.profile(); @@ -55,6 +55,7 @@ function distributeRewards({ members, spot, wins, pressure, rng }) { const drops = BackgroundDropResolver.rollForFight({ spot, killerLevel: Math.round(avgLevel(members)), + npcSelfId: defeatedNpcIds[win], rng }); if (!drops.length) continue; @@ -71,7 +72,7 @@ function distributeRewards({ members, spot, wins, pressure, rng }) { } const BackgroundPartyResolver = { - resolve({ party, members, spot, pressure = {}, elapsedMs = 60000, rng = Math.random, timestamp = Date.now() }) { + resolve({ party, members, spot, pressure = {}, targetNpcId = 0, elapsedMs = 60000, rng = Math.random, timestamp = Date.now() }) { if (!party || !members?.length || !spot) { return { memberResults: [], @@ -155,9 +156,10 @@ const BackgroundPartyResolver = { let combatActions = 0; let skillUses = 0; let heals = 0; + const defeatedNpcIds = []; let combatMembers = members.map((state) => ({ ...state })); for (let i = 0; i < fights; i++) { - const encounter = BackgroundResolver.resolvePartyFight({ members: combatMembers, spot, rng, timestamp }); + const encounter = BackgroundResolver.resolvePartyFight({ members: combatMembers, spot, targetNpcId, rng, timestamp }); combatActions += Number(encounter.debug?.actions || 0); skillUses += encounter.members.reduce((sum, member) => sum + Number(member.skillUses || 0), 0); heals += encounter.members.reduce((sum, member) => sum + Number(member.heals || 0), 0); @@ -169,12 +171,15 @@ const BackgroundPartyResolver = { coldCombat: { ...(member.state.stats?.coldCombat || member.profile), cooldowns: member.cooldowns } } })); - if (encounter.won) wins += 1; + if (encounter.won) { + wins += 1; + if (Number(encounter.debug?.mobSelfId) > 0) defeatedNpcIds.push(Number(encounter.debug.mobSelfId)); + } else losses += 1; if (!encounter.won || combatMembers.some((member) => Number(member.vitals?.hp || 0) <= 0)) break; } - const rewards = distributeRewards({ members, spot, wins, pressure, rng }); + const rewards = distributeRewards({ members, spot, wins, defeatedNpcIds, pressure, rng }); const memberResults = []; const events = []; let deaths = 0; @@ -238,7 +243,14 @@ const BackgroundPartyResolver = { dropsAwarded: items.reduce((sum, item) => sum + Number(item.amount || 0), 0), spotId: spot.id, route: spot.route || null, - aggregate: true + aggregate: true, + targetNpcId: Number(targetNpcId) || null, + defeatedNpcIds: [...defeatedNpcIds], + // A party resolve represents one shared encounter. Its + // leader may be replaced or leave while the resulting + // state updates are persisted, so use the stable local + // result order to nominate exactly one aggregate owner. + populationTelemetryOwner: index === 0 } } }); @@ -353,7 +365,9 @@ const BackgroundPartyResolver = { route: spot.route || null, combatActions, skillUses, - heals + heals, + targetNpcId: Number(targetNpcId) || null, + defeatedNpcIds } }; } diff --git a/src/GameServer/Bot/Population/BackgroundResolver.js b/src/GameServer/Bot/Population/BackgroundResolver.js index 868fc8c3..88f8f7e9 100644 --- a/src/GameServer/Bot/Population/BackgroundResolver.js +++ b/src/GameServer/Bot/Population/BackgroundResolver.js @@ -228,9 +228,9 @@ function chooseSkill(profile, mp, cooldowns, time) { .sort((a, b) => b.score - a.score)[0] || null; } -function resolveFight({ state, spot, pressure, rng, timestamp = Date.now() }) { +function resolveFight({ state, spot, pressure, targetNpcId = 0, rng, timestamp = Date.now() }) { const bot = botCombatStats(state, timestamp); - const mob = ColdCombatProfile.npcForSpot(spot, rng) || { + const mob = ColdCombatProfile.npcForSpot(spot, rng, { preferredNpcId: targetNpcId }) || { level: Number(spot.avgLevel || bot.level), maxHp: Math.max(1, Number(spot.mob?.hp || 1)), pAtk: Math.max(1, Number(spot.mob?.damage || 1)), pAtkRnd: 0, pDef: 1, mDef: 1, accur: 1, evasion: 0, critical: 0, atkSpd: 253, mAtk: 1, castSpd: 333 @@ -312,6 +312,7 @@ function resolveFight({ state, spot, pressure, rng, timestamp = Date.now() }) { const loot = BackgroundDropResolver.rollForFight({ spot, killerLevel: Number(state.level || bot.level), + npcSelfId: mob.selfId, rng }); @@ -342,8 +343,8 @@ function chooseHeal(profile, allies, mp, cooldowns, time) { return skill ? { skill, target: injured } : null; } -function resolvePartyFight({ members, spot, rng = Math.random, timestamp = Date.now() }) { - const mob = ColdCombatProfile.npcForSpot(spot, rng) || { +function resolvePartyFight({ members, spot, targetNpcId = 0, rng = Math.random, timestamp = Date.now() }) { + const mob = ColdCombatProfile.npcForSpot(spot, rng, { preferredNpcId: targetNpcId }) || { level: Number(spot.avgLevel || 1), maxHp: Math.max(1, Number(spot.mob?.hp || 1)), pAtk: Math.max(1, Number(spot.mob?.damage || 1)), pAtkRnd: 0, pDef: 1, mDef: 1, accur: 1, evasion: 0, critical: 0, atkSpd: 253 @@ -438,7 +439,7 @@ function resolvePartyFight({ members, spot, rng = Math.random, timestamp = Date. const BackgroundResolver = { resolveRest, resolvePartyFight, - resolveSolo({ state, spot, pressure = {}, elapsedMs = 60000, rng = Math.random, timestamp = Date.now() }) { + resolveSolo({ state, spot, pressure = {}, targetNpcId = 0, elapsedMs = 60000, rng = Math.random, timestamp = Date.now() }) { if (!state) { return { patch: {}, @@ -537,10 +538,11 @@ const BackgroundResolver = { let died = false; let combatActions = 0; let skillUses = 0; + const foughtNpcIds = []; for (let i = 0; i < fights; i++) { const fightState = { ...state, vitals: patch.vitals }; - const result = resolveFight({ state: fightState, spot, pressure, rng, timestamp }); + const result = resolveFight({ state: fightState, spot, pressure, targetNpcId, rng, timestamp }); patch.vitals.hp = result.hp; patch.vitals.mp = result.mp; patch.stats = { @@ -557,7 +559,10 @@ const BackgroundResolver = { combatActions += Number(result.debug?.actions || 0); skillUses += Number(result.debug?.skillUses || 0); - if (result.won) wins += 1; + if (result.won) { + wins += 1; + if (Number(result.debug?.mobSelfId) > 0) foughtNpcIds.push(Number(result.debug.mobSelfId)); + } if (result.died) { died = true; patch.activity = 'dead'; @@ -613,7 +618,9 @@ const BackgroundResolver = { spotId: spot.id, route: spot.route || null, combatActions, - skillUses + skillUses, + targetNpcId: Number(targetNpcId) || null, + foughtNpcIds } }; } diff --git a/src/GameServer/Bot/Population/BotLifeState.js b/src/GameServer/Bot/Population/BotLifeState.js index f112b85a..5bd02cd0 100644 --- a/src/GameServer/Bot/Population/BotLifeState.js +++ b/src/GameServer/Bot/Population/BotLifeState.js @@ -170,6 +170,40 @@ function syncInventorySummary(characterId, inventory) { )); } +function targetCombatTelemetry(previous = {}, debug = {}, timestamp = now()) { + const targetNpcId = Number(debug?.targetNpcId || 0); + if (targetNpcId <= 0) return null; + const targetKey = String(targetNpcId); + const defeatedNpcIds = (Array.isArray(debug.foughtNpcIds) ? debug.foughtNpcIds : debug.defeatedNpcIds || []) + .map(Number) + .filter((npcId) => npcId > 0); + const targetKills = defeatedNpcIds.filter((npcId) => npcId === targetNpcId).length; + const interruptions = defeatedNpcIds.length - targetKills; + const add = (current = {}) => ({ + resolves: Number(current.resolves || 0) + 1, + defeated: Number(current.defeated || 0) + defeatedNpcIds.length, + targetKills: Number(current.targetKills || 0) + targetKills, + interruptions: Number(current.interruptions || 0) + interruptions, + lastDefeatedNpcIds: defeatedNpcIds, + lastResolvedAt: timestamp + }); + const targets = { ...(previous.targets || {}) }; + const current = add(targets[targetKey]); + targets[targetKey] = current; + const populationTargets = { ...(previous.populationTargets || {}) }; + if (!debug.aggregate || debug.populationTelemetryOwner === true) { + populationTargets[targetKey] = add(populationTargets[targetKey]); + } + + return { + ...(previous || {}), + targetNpcId, + ...current, + targets, + populationTargets + }; +} + function normalize(row) { const stats = parseJson(row.statsJson, {}); const inventory = parseJson(row.inventorySummary, {}); @@ -1258,6 +1292,7 @@ const BotLifeState = { .filter((item) => Number(item.selfId) === 57) .reduce((sum, item) => sum + Number(item.amount || 0), 0); const adena = Number(state.adena || 0) + Number(result.materialize?.adena || 0) + materializedAdenaItems; + const targetCombat = targetCombatTelemetry(state.stats?.targetCombat, result.debug, timestamp); const stats = { ...(state.stats || {}), fightsWon: Number(state.stats?.fightsWon || 0) + Number(result.debug?.wins || 0), @@ -1267,7 +1302,8 @@ const BotLifeState = { spEarned: Number(state.stats?.spEarned || 0) + Number(result.materialize?.sp || 0), adenaEarned: Number(state.stats?.adenaEarned || 0) + Number(result.materialize?.adena || 0) + materializedAdenaItems, route: result.debug?.route || state.stats?.route || null, - lastResolveDebug: result.debug || null + lastResolveDebug: result.debug || null, + ...(targetCombat ? { targetCombat } : {}) }; const inventory = { ...(state.inventory || {}) }; materializedItems.filter((item) => Number(item.selfId) !== 57).forEach((item) => { @@ -1336,6 +1372,11 @@ const BotLifeState = { stats: { ...stats, ...(result.patch?.stats || {}), + // Party combat carries a projected combat snapshot in patch.stats. + // Keep lifecycle telemetry from this resolve authoritative over + // that snapshot, which still contains the previous tick's data. + ...(targetCombat ? { targetCombat } : {}), + lastResolveDebug: result.debug || null, equipment: equipmentSummaryFromInventory(equippedInventory) }, inventory: equippedInventory, @@ -1699,6 +1740,26 @@ const BotLifeState = { return counts; }, + targetCombatSummary() { + return Array.from(cache.values()).reduce((summary, state) => { + const targets = state.stats?.targetCombat?.populationTargets || {}; + const values = Object.values(targets); + if (!values.length) return summary; + summary.bots += 1; + values.forEach((telemetry) => { + summary.resolves += Number(telemetry.resolves || 0); + summary.defeated += Number(telemetry.defeated || 0); + summary.targetKills += Number(telemetry.targetKills || 0); + summary.interruptions += Number(telemetry.interruptions || 0); + }); + return summary; + }, { bots: 0, resolves: 0, defeated: 0, targetKills: 0, interruptions: 0 }); + }, + + cachedState(characterId) { + return cache.get(Number(characterId)) || null; + }, + allStates(limit = 500) { const safeLimit = Math.max(1, Math.min(2000, Number(limit) || 500)); return Array.from(cache.values()) diff --git a/src/GameServer/Bot/Population/ColdCombatProfile.js b/src/GameServer/Bot/Population/ColdCombatProfile.js index e6d738ea..f65cb8fc 100644 --- a/src/GameServer/Bot/Population/ColdCombatProfile.js +++ b/src/GameServer/Bot/Population/ColdCombatProfile.js @@ -293,14 +293,31 @@ function offensiveSkills(profile) { }); } -function npcForSpot(spot = {}, rng = Math.random) { +function npcForSpot(spot = {}, rng = Math.random, options = {}) { const entries = Array.isArray(spot.npcEntries) && spot.npcEntries.length ? spot.npcEntries : (spot.npcSelfIds || []).map((selfId) => ({ selfId, count: 1 })); - const total = entries.reduce((sum, entry) => sum + Math.max(1, number(entry.count, 1)), 0); - let needle = rng() * total; - const selected = entries.find((entry) => { - needle -= Math.max(1, number(entry.count, 1)); - return needle <= 0; - }) || entries[0]; + const pickEntry = (candidates) => { + const total = candidates.reduce((sum, entry) => sum + Math.max(1, number(entry.count, 1)), 0); + let needle = rng() * total; + return candidates.find((entry) => { + needle -= Math.max(1, number(entry.count, 1)); + return needle <= 0; + }) || candidates[0]; + }; + const preferredNpcId = number(options.preferredNpcId); + const preferred = preferredNpcId > 0 + ? entries.find((entry) => number(entry.selfId) === preferredNpcId) + : null; + // A direct-drop plan travels to the intended monster, but it is still a + // real hunting ground. Nearby aggressive mobs can engage first instead of + // making the bot immune to the rest of the encounter table. + const aggressive = preferred + ? entries.filter((entry) => number(entry.selfId) !== preferredNpcId + && (DataCache.npcs || []).find((npc) => number(npc.selfId) === number(entry.selfId))?.template?.hostile === true) + : []; + const interruptionChance = Math.max(0, Math.min(1, number(options.aggressiveInterruptionChance, 0.25))); + const selected = preferred && (!aggressive.length || rng() >= interruptionChance) + ? preferred + : pickEntry(aggressive.length ? aggressive : entries); const npc = (DataCache.npcs || []).find((entry) => Number(entry.selfId) === Number(selected?.selfId)); if (!npc) return null; return { diff --git a/src/GameServer/Bot/Population/PopulationStatus.js b/src/GameServer/Bot/Population/PopulationStatus.js index f4f86737..de139796 100644 --- a/src/GameServer/Bot/Population/PopulationStatus.js +++ b/src/GameServer/Bot/Population/PopulationStatus.js @@ -15,6 +15,7 @@ const PopulationStatus = { const merchants = sessions.filter((session) => isBotSession(session) && session.actor && session.plan === 'merchant').length; const lifeCounts = LifeState.counts(); const partyCounts = PartyState.counts(); + const targetCombat = LifeState.targetCombatSummary(); return { hot, @@ -23,7 +24,8 @@ const PopulationStatus = { parties: partyCounts.active || 0, merchants, total: Math.max(hot, lifeCounts.total || 0), - persisted: lifeCounts.total || 0 + persisted: lifeCounts.total || 0, + targetCombat }; }, diff --git a/tests/test_bot_background_drops.js b/tests/test_bot_background_drops.js index d0175f02..b789a6e5 100644 --- a/tests/test_bot_background_drops.js +++ b/tests/test_bot_background_drops.js @@ -3,6 +3,7 @@ const assert = require('assert'); require('../src/Global'); const DataCache = invoke('GameServer/DataCache'); +const ProgressionRates = invoke('GameServer/ProgressionRates'); const BackgroundDropResolver = invoke('GameServer/Bot/Population/BackgroundDropResolver'); const BackgroundResolver = invoke('GameServer/Bot/Population/BackgroundResolver'); const BackgroundPartyResolver = invoke('GameServer/Bot/Population/BackgroundPartyResolver'); @@ -25,6 +26,16 @@ assert.strictEqual(direct[0].selfId, 1121, 'the selected item must come from the assert.strictEqual(direct[0].kind, 'Armor.Wear'); assert.strictEqual(direct[0].sourceMobLevel, 1, 'background loot must retain the source-mob level for sale policy'); +const originalRewardGroupRoll = ProgressionRates.rewardGroupRoll; +let rolledNpcLevel = null; +ProgressionRates.rewardGroupRoll = (group, kind, context, rng) => { + rolledNpcLevel = context.npcLevel; + return originalRewardGroupRoll(group, kind, context, rng); +}; +BackgroundDropResolver.rollForFight({ spot: { ...spot, avgLevel: 99 }, killerLevel: 1, npcSelfId: 1, rng: () => 0 }); +ProgressionRates.rewardGroupRoll = originalRewardGroupRoll; +assert.strictEqual(rolledNpcLevel, 1, 'deep-blue rules must use the defeated NPC level, not the spot average'); + const nameOnly = BackgroundDropResolver.rollForFight({ spot: { ...spot, npcSelfIds: [], npcNames: ['Gremlin'] }, killerLevel: 1, diff --git a/tests/test_bot_cold_combat.js b/tests/test_bot_cold_combat.js index 70fa05b4..71a6bc2f 100644 --- a/tests/test_bot_cold_combat.js +++ b/tests/test_bot_cold_combat.js @@ -6,6 +6,7 @@ const DataCache = invoke('GameServer/DataCache'); const ColdCombatProfile = invoke('GameServer/Bot/Population/ColdCombatProfile'); const BackgroundResolver = invoke('GameServer/Bot/Population/BackgroundResolver'); const BackgroundPartyResolver = invoke('GameServer/Bot/Population/BackgroundPartyResolver'); +const BackgroundDropResolver = invoke('GameServer/Bot/Population/BackgroundDropResolver'); const PopulationService = invoke('GameServer/Bot/Population/PopulationService'); const PopulationMetrics = invoke('GameServer/Bot/Population/PopulationMetrics'); @@ -73,11 +74,51 @@ const spot = { // real NPC profile from the datapack when npcSelfIds are available. mob: { hp: 1, damage: 9999 } }; +const mixedSpot = { + ...spot, + npcEntries: [{ selfId: 1, count: 1 }, { selfId: 12, count: 1 }], + npcSelfIds: [1, 12] +}; +assert.strictEqual( + ColdCombatProfile.npcForSpot(mixedSpot, () => 0.9, { preferredNpcId: 1 }).selfId, + 1, + 'a direct-drop plan must prefer its intended NPC when the encounter is not interrupted' +); +assert.strictEqual( + ColdCombatProfile.npcForSpot(mixedSpot, () => 0, { preferredNpcId: 1 }).selfId, + 12, + 'a hostile NPC in the same spot must still be able to interrupt focused farming' +); +const focusedLoot = BackgroundDropResolver.rollForFight({ + spot: mixedSpot, + killerLevel: fighter.level, + npcSelfId: 1, + maxItems: 4, + rng: () => 0 +}); +assert(focusedLoot.length > 0 && focusedLoot.every((item) => item.sourceMobLevel === 1), 'focused farming must roll the killed target NPC loot table'); +const interruptedLoot = BackgroundDropResolver.rollForFight({ + spot: mixedSpot, + killerLevel: fighter.level, + npcSelfId: 12, + maxItems: 4, + rng: () => 0 +}); +assert(interruptedLoot.length > 0 && interruptedLoot.every((item) => item.sourceMobLevel === 16), 'an aggressive interruption must roll the interrupting NPC loot table'); const result = BackgroundResolver.resolveSolo({ state: fighter, spot, elapsedMs: 12000, timestamp, rng: () => 0.1 }); assert(result.debug.combatActions > 0, 'cold combat must execute bounded combat actions'); assert(result.debug.skillUses > 0, 'a usable learned skill must be cast during cold combat'); assert(result.patch.stats.coldCombat.cooldowns[3] > timestamp, 'skill reuse must survive a cold resolve'); assert(result.patch.vitals.hp > 0, 'datapack Gremlin damage must be used instead of the synthetic spot damage'); +const focusedResult = BackgroundResolver.resolveSolo({ + state: fighter, + spot: mixedSpot, + targetNpcId: 1, + elapsedMs: 12000, + timestamp, + rng: () => 0.9 +}); +assert.deepStrictEqual(focusedResult.debug.foughtNpcIds, [1], 'solo direct-drop farming must fight the requested NPC, not a random spot entry'); const injuredTank = { ...fighter, @@ -106,6 +147,14 @@ const partyFight = BackgroundResolver.resolvePartyFight({ }); assert(partyFight.debug.actions > 0, 'party cold combat must execute one shared NPC encounter'); assert(partyFight.members[1].heals > 0, 'a learned friendly heal must be applied to an injured party member'); +const focusedPartyFight = BackgroundResolver.resolvePartyFight({ + members: [injuredTank, healer], + spot: mixedSpot, + targetNpcId: 1, + timestamp, + rng: () => 0.9 +}); +assert.strictEqual(focusedPartyFight.debug.mobSelfId, 1, 'party direct-drop farming must use the party target NPC'); const partyResult = BackgroundPartyResolver.resolve({ party: { partyId: 'cold_combat_party', cohesion: 1, risk: 0, roleCoverage: { tank: 1, healer: 1 }, stats: {} }, @@ -117,6 +166,28 @@ const partyResult = BackgroundPartyResolver.resolve({ }); assert(partyResult.debug.combatActions > 0, 'the party lifecycle must use the cold action simulation'); assert(partyResult.debug.heals > 0, 'party support casts must be reflected in the persisted combat telemetry'); +const focusedPartyResult = BackgroundPartyResolver.resolve({ + party: { + partyId: 'focused_cold_combat_party', + leaderId: healer.characterId, + cohesion: 1, + risk: 0, + roleCoverage: { tank: 1, healer: 1 }, + stats: {} + }, + members: [injuredTank, healer], + spot: mixedSpot, + targetNpcId: 1, + elapsedMs: 12000, + timestamp, + rng: () => 0.9 +}); +assert.strictEqual(focusedPartyResult.debug.targetNpcId, 1, 'party aggregate telemetry must retain its focused NPC'); +assert.deepStrictEqual(focusedPartyResult.debug.defeatedNpcIds, [1], 'party aggregate telemetry must retain the NPC actually defeated'); +assert.strictEqual(focusedPartyResult.memberResults[0].result.debug.targetNpcId, 1, 'each party member must persist the shared target telemetry'); +assert.deepStrictEqual(focusedPartyResult.memberResults[0].result.debug.defeatedNpcIds, [1], 'each party member must persist the shared defeated NPC telemetry'); +assert.strictEqual(focusedPartyResult.memberResults[0].result.debug.populationTelemetryOwner, true, 'one stable party result must contribute the shared encounter to population totals'); +assert.strictEqual(focusedPartyResult.memberResults[1].result.debug.populationTelemetryOwner, false, 'other party members must retain personal telemetry without duplicating encounter totals'); const originalMetrics = { counters: PopulationMetrics.counters, diff --git a/tests/test_bot_population_state.js b/tests/test_bot_population_state.js index c823aafe..f0bce340 100644 --- a/tests/test_bot_population_state.js +++ b/tests/test_bot_population_state.js @@ -83,6 +83,44 @@ try { }).then(() => { const requiredCandidates = statements.find((entry) => entry.sql.includes("states.activity = 'party_wait'")); assert(requiredCandidates, 'a real party-wait backlog must reserve formation capacity ahead of elective hunting parties'); + const member = { + characterId: 44, + name: 'PartyTelemetryProbe', + level: 20, + phase: 'cold', + activity: 'grouped', + party: { partyId: 'bgp_probe' }, + timing: { nextResolveAt: 9000 }, + vitals: { hp: 400, maxHp: 400, mp: 200, maxMp: 200 }, + stats: { + lastResolveDebug: { targetNpcId: null }, + targetCombat: { targets: {}, populationTargets: {} } + }, + inventory: {} + }; + return BotLifeState.applyResolve(member, { + patch: { + activity: 'grouped', + vitals: member.vitals, + // This mirrors the projected snapshot that a party + // resolver returns after a fight. + stats: { ...member.stats, coldCombat: { cooldowns: {} } } + }, + materialize: { exp: 0, sp: 0, adena: 0, items: [] }, + nextResolveAt: 10000, + debug: { + partyId: 'bgp_probe', + aggregate: true, + populationTelemetryOwner: true, + targetNpcId: 93, + defeatedNpcIds: [93] + } + }); + }).then(() => { + const partySave = statements.filter((entry) => entry.sql.includes('ON DUPLICATE KEY UPDATE')).at(-1); + const persistedStats = JSON.parse(partySave.params[27]); + assert.strictEqual(persistedStats.lastResolveDebug.partyId, 'bgp_probe', 'a party result must not be replaced by its previous solo debug snapshot'); + assert.strictEqual(persistedStats.targetCombat.populationTargets['93'].targetKills, 1, 'a party result must retain its shared target telemetry'); }); }).then(() => { console.log('Bot population state checks passed'); From df43064fd4e8a66607f4c223ff12a78b9dfd2e12 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:18:30 -0400 Subject: [PATCH 2/2] Schedule cold party formation safely --- .../Bot/Population/PopulationService.js | 48 ++++++++++++++++++- tests/test_bot_cold_travel_without_spot.js | 18 +++++++ tests/test_bot_population_policy.js | 13 +++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/GameServer/Bot/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js index abb72621..a493e965 100644 --- a/src/GameServer/Bot/Population/PopulationService.js +++ b/src/GameServer/Bot/Population/PopulationService.js @@ -79,6 +79,20 @@ function partySpotForLeader(leader) { }, { mode: 'party', role: PartyComposition.roleForState(leader) }) || SpotProfiles.findById(leader.spotId); } +function directDropTargetNpcId(...plans) { + for (const plan of plans) { + if (plan?.status !== 'active' || plan?.strategy !== 'direct_drop') continue; + const npcId = Number(plan.next?.npcId || 0); + if (npcId > 0) return npcId; + } + return 0; +} + +function joinedBackgroundParty(state) { + const current = LifeState.cachedState(state?.characterId); + return !!current?.party?.partyId; +} + function canTakePartyMarketBreak(party, members, member, timestamp = Date.now()) { if (timestamp - Number(party.stats?.formedAt || party.startedAt || timestamp) < Config.partyMarketBreakMinSessionMs) return false; if (Number(party.stats?.fightsResolved || 0) < Config.partyMarketBreakMinFights) return false; @@ -157,6 +171,7 @@ const PopulationService = { marketTownMigrationRunning: false, marketExpiryCleanupRunning: false, partyFormationRunning: false, + partyFormationPending: false, phasePolicyRunning: false, init() { @@ -593,9 +608,18 @@ const PopulationService = { }, formBackgroundParties() { + // Formation rewrites party membership. It must not overlap with the + // scheduler after that scheduler has already selected solo candidates. if (this.partyFormationRunning || Config.enabled === false || Config.backgroundPartyEnabled === false) { return Promise.resolve([]); } + if (this.resolving) { + // The intervals are intentionally aligned (45s and 5s), so + // dropping this tick would starve party formation indefinitely. + // Let the scheduler launch it from its safe completion edge. + this.partyFormationPending = true; + return Promise.resolve([]); + } this.partyFormationRunning = true; return LifeState.coldPartyCandidates(Config.partyFormationCandidateLimit, true) @@ -772,8 +796,8 @@ const PopulationService = { }, tickBudgeted() { - if (this.resolving || this.classProgressionMigrationRunning || this.coldCombatProfileMigrationRunning || Config.enabled === false || Config.backgroundResolverEnabled === false) { - if (this.resolving || this.classProgressionMigrationRunning || this.coldCombatProfileMigrationRunning) { + if (this.resolving || this.partyFormationRunning || this.classProgressionMigrationRunning || this.coldCombatProfileMigrationRunning || Config.enabled === false || Config.backgroundResolverEnabled === false) { + if (this.resolving || this.partyFormationRunning || this.classProgressionMigrationRunning || this.coldCombatProfileMigrationRunning) { Metrics.recordSchedulerSkip(); } return Promise.resolve([]); @@ -828,6 +852,10 @@ const PopulationService = { // scheduler slot. An independent timer can otherwise make a // normal five-second tick look busy and skip its resolves. this.maybeMigrateLegacyColdCombatProfiles(); + if (this.partyFormationPending) { + this.partyFormationPending = false; + this.formBackgroundParties(); + } }); }, @@ -900,11 +928,13 @@ const PopulationService = { } const elapsedMs = party.stats?.lastResolveAt ? Math.max(1000, Date.now() - party.stats.lastResolveAt) : 60000; + const targetNpcId = directDropTargetNpcId(leader.stats?.equipmentPlan, party.stats?.acquisitionGoal); const result = BackgroundPartyResolver.resolve({ party, members, spot, pressure: Director.pressureForState(leader), + targetNpcId, elapsedMs }); const deadMemberIds = new Set(); @@ -985,6 +1015,10 @@ const PopulationService = { resolveColdState(state) { const startedAt = Date.now(); + if (joinedBackgroundParty(state)) { + Metrics.recordSkippedResolve(); + return Promise.resolve({ ok: false, reason: 'joined_party', state }); + } const elapsedMs = state.timing?.lastResolvedAt ? Math.max(1000, startedAt - state.timing.lastResolvedAt) : 60000; // These transitions have no planning, market search, or inventory work // between their persisted deadline and the next state change. @@ -996,6 +1030,10 @@ const PopulationService = { elapsedMs, timestamp: startedAt }); + if (joinedBackgroundParty(state)) { + Metrics.recordSkippedResolve(); + return Promise.resolve({ ok: false, reason: 'joined_party', state }); + } return LifeState.applyResolve(state, result).then((updatedState) => { if (!updatedState) { Metrics.recordSkippedResolve(); @@ -1135,9 +1173,15 @@ const PopulationService = { state: travellingState, spot, pressure: Director.pressureForState(state), + targetNpcId: directDropTargetNpcId(acquisitionPlan), elapsedMs }); + if (joinedBackgroundParty(state)) { + Metrics.recordSkippedResolve(); + return Promise.resolve({ ok: false, reason: 'joined_party', state }); + } + return LifeState.applyResolve(travellingState, result).then((updatedState) => LifeState.refreshInventory(updatedState) .then((refreshedState) => LifeState.upsertState(refreshedState, 'inventory_refresh').then((saved) => saved || refreshedState))) .then((updatedState) => { diff --git a/tests/test_bot_cold_travel_without_spot.js b/tests/test_bot_cold_travel_without_spot.js index d2600e99..f7b39927 100644 --- a/tests/test_bot_cold_travel_without_spot.js +++ b/tests/test_bot_cold_travel_without_spot.js @@ -18,6 +18,7 @@ const originals = { ensure: SpotProfiles.ensure, findForState: SpotProfiles.findForState, resolveSolo: BackgroundResolver.resolveSolo, + cachedState: LifeState.cachedState, applyResolve: LifeState.applyResolve, refreshInventory: LifeState.refreshInventory, upsertState: LifeState.upsertState, @@ -59,6 +60,7 @@ async function run() { receivedSpot = spot; return { patch: { activity: 'traveling' }, events: [], materialize: { exp: 0, sp: 0, adena: 0, items: [] }, nextResolveAt: Date.now() + 30000, debug: { activity: 'traveling' } }; }; + LifeState.cachedState = () => null; LifeState.applyResolve = () => Promise.resolve(state); LifeState.refreshInventory = (value) => Promise.resolve(value); LifeState.upsertState = (value) => Promise.resolve(value); @@ -75,6 +77,21 @@ async function run() { assert.strictEqual(result.ok, true); 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'); + + let joinedDuringResolve = false; + let applyCalled = false; + BackgroundResolver.resolveSolo = () => { + joinedDuringResolve = true; + return { patch: { activity: 'traveling' }, events: [], materialize: { exp: 0, sp: 0, adena: 0, items: [] }, nextResolveAt: Date.now() + 30000, debug: { activity: 'traveling' } }; + }; + LifeState.cachedState = () => (joinedDuringResolve ? { ...state, party: { partyId: 'fresh_party' } } : null); + LifeState.applyResolve = () => { + applyCalled = true; + return Promise.resolve(state); + }; + const joinedResult = await PopulationService.resolveColdState(state); + assert.strictEqual(joinedResult.reason, 'joined_party', 'a stale solo resolve must stop when the bot joins a party'); + assert.strictEqual(applyCalled, false, 'a stale solo resolve must not overwrite the new party state'); console.log('Bot cold travel without spot checks passed'); } @@ -82,6 +99,7 @@ run().catch((err) => { console.error(err); process.exitCode = 1; }).finally(() = SpotProfiles.ensure = originals.ensure; SpotProfiles.findForState = originals.findForState; BackgroundResolver.resolveSolo = originals.resolveSolo; + LifeState.cachedState = originals.cachedState; LifeState.applyResolve = originals.applyResolve; LifeState.refreshInventory = originals.refreshInventory; LifeState.upsertState = originals.upsertState; diff --git a/tests/test_bot_population_policy.js b/tests/test_bot_population_policy.js index 9b5de7a7..b6480178 100644 --- a/tests/test_bot_population_policy.js +++ b/tests/test_bot_population_policy.js @@ -45,6 +45,11 @@ const originalConfig = { cooldownRadius: Config.cooldownRadius, cooldownBatchSize: Config.cooldownBatchSize }; +const originalLifecycleFlags = { + resolving: PopulationService.resolving, + partyFormationRunning: PopulationService.partyFormationRunning, + partyFormationPending: PopulationService.partyFormationPending +}; async function run() { const playerSession = session('player_policy', actor(1, 0)); @@ -101,6 +106,13 @@ async function run() { }; await PopulationService.cooldownEligibleHot(); assert.deepStrictEqual(cooled, ['bot_far_craft', 'bot_far'], 'cooldown should park distant craft services along with normal cold-backed bots'); + + PopulationService.resolving = true; + assert.deepStrictEqual(await PopulationService.formBackgroundParties(), [], 'party formation must not overlap a cold scheduler pass'); + assert.strictEqual(PopulationService.partyFormationPending, true, 'a formation tick that collides with the scheduler must be queued rather than discarded'); + PopulationService.resolving = false; + PopulationService.partyFormationRunning = true; + assert.deepStrictEqual(await PopulationService.tickBudgeted(), [], 'the scheduler must wait for an in-flight party formation pass'); } run() @@ -115,5 +127,6 @@ run() LifeState.coldNear = originalColdNear; PopulationService.requestActivation = originalRequestActivation; PopulationService.cooldownSession = originalCooldownSession; + Object.assign(PopulationService, originalLifecycleFlags); Object.assign(Config, originalConfig); });