diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 7e9195ea..6b2f26dc 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -37,9 +37,12 @@ const tests = [ 'tests/test_bot_craft_shop.js', 'tests/test_bot_warehouse.js', 'tests/test_bot_cold_market_trade_chat.js', + 'tests/test_bot_cold_combat.js', 'tests/test_bot_background_drops.js', 'tests/test_bot_party_gear_loot.js', 'tests/test_bot_background_respawn.js', + 'tests/test_bot_background_rest_scheduling.js', + 'tests/test_bot_party_wait.js', 'tests/test_bot_background_party_composition.js', 'tests/test_bot_background_party_recruitment.js', 'tests/test_bot_background_party_affinity.js', diff --git a/src/GameServer/Bot/Economy/ColdMarketListingService.js b/src/GameServer/Bot/Economy/ColdMarketListingService.js index 17590caa..01dd3ebe 100644 --- a/src/GameServer/Bot/Economy/ColdMarketListingService.js +++ b/src/GameServer/Bot/Economy/ColdMarketListingService.js @@ -536,7 +536,9 @@ function open(state, options = {}) { timing: { ...(state.timing || {}), activityStartedAt: timestamp, - nextResolveAt: timestamp + 60000 + // Sales settle through the market event path. A listed store only + // needs a scheduled wake-up when its offer expires. + nextResolveAt: timestamp + (Number(options.durationMs) || DEFAULT_LISTING_MS) } }; return LifeState.upsertState(nextState, 'cold_market_listing').then((saved) => { @@ -592,7 +594,9 @@ function resolve(state, timestamp = Date.now()) { return pricing; }, { ...(state.stats?.marketPricing || {}) }) : state.stats?.marketPricing || {} }, - timing: { ...(state.timing || {}), nextResolveAt: timestamp + 30000 } + // Closing a store is an event. The following shopping/return action + // should be available on the next scheduler pass, not after polling. + timing: { ...(state.timing || {}), nextResolveAt: timestamp } }; MarketOpportunity.removeColdStore(state.characterId); return (hasStock ? BotWarehouse.depositCold(nextState) : Promise.resolve({ state: nextState, count: 0 })) @@ -708,7 +712,7 @@ function reconcileInventory(state) { ...state, activity: 'shopping', stats: { ...(state.stats || {}), marketStore: null }, - timing: { ...(state.timing || {}), nextResolveAt: Date.now() + 30000 } + timing: { ...(state.timing || {}), nextResolveAt: Date.now() } }; MarketOpportunity.removeColdStore(state.characterId); return LifeState.upsertState(nextState, 'cold_market_inventory_empty').then((saved) => ({ @@ -724,7 +728,12 @@ function settle(offer, qty = 1) { const seller = LifeState.snapshot(offer.sourceId) || offer.sellerState; if (!seller) return Promise.resolve(null); return LifeState.applyMarketSale(seller, offer, qty).then((saved) => { - if (saved) MarketOpportunity.indexColdStore(saved); + if (!saved) return null; + const hasStock = (saved.stats?.marketStore?.items || []).some((item) => Number(item.count) > 0); + // Empty stock is a transaction event too; close immediately instead + // of keeping a dead merchant until the original listing expiry. + if (!hasStock) return resolve(saved).then((result) => result.state || saved); + MarketOpportunity.indexColdStore(saved); return saved; }); } diff --git a/src/GameServer/Bot/Goals/GoalExecutor.js b/src/GameServer/Bot/Goals/GoalExecutor.js index fc662924..fef116f9 100644 --- a/src/GameServer/Bot/Goals/GoalExecutor.js +++ b/src/GameServer/Bot/Goals/GoalExecutor.js @@ -53,7 +53,9 @@ function beginMarketTravel(state, goal, timestamp = Date.now()) { timing: { ...(state.timing || {}), activityStartedAt: timestamp, - nextResolveAt: timestamp + 30000 + // Travel is a finite transition. There is no state to simulate + // while a cold bot is casting SoE / waiting for gatekeeper travel. + nextResolveAt: timestamp + MARKET_TRAVEL_MS } }; } @@ -90,7 +92,7 @@ function finishMarketVisit(state, timestamp = Date.now()) { timing: { ...(state.timing || {}), activityStartedAt: timestamp, - nextResolveAt: timestamp + 30000 + nextResolveAt: timestamp + GATEKEEPER_SPOT_TRAVEL_MS } }; } diff --git a/src/GameServer/Bot/Population/BackgroundPartyResolver.js b/src/GameServer/Bot/Population/BackgroundPartyResolver.js index 708645bf..1c4779da 100644 --- a/src/GameServer/Bot/Population/BackgroundPartyResolver.js +++ b/src/GameServer/Bot/Population/BackgroundPartyResolver.js @@ -14,10 +14,6 @@ function randInt(rng, min, max) { return Math.floor(rng() * (max - min + 1)) + min; } -function roleCount(party, role) { - return Number(party.roleCoverage?.[role] || 0); -} - function memberVitals(state) { const level = Number(state.level || 1); const vitals = state.vitals || {}; @@ -38,26 +34,13 @@ function avgLevel(members) { } function estimateFightCount({ party, members, spot, elapsedMs }) { - const baseWindows = Math.max(1, Math.floor(elapsedMs / 10000)); - const memberFactor = Math.max(1, members.length * 0.75); + const baseWindows = Math.max(1, Math.floor(elapsedMs / 12000)); const densityFactor = clamp(Number(spot.density || 1) / 3, 0.7, 2.2); const cohesionFactor = clamp(Number(party.cohesion || 0.65), 0.35, 1.15); - return Math.max(1, Math.min(24, Math.round(baseWindows * memberFactor * densityFactor * cohesionFactor))); -} - -function estimateWinRate({ party, members, spot, pressure }) { - const partyLevel = avgLevel(members); - const levelDelta = partyLevel - Number(spot.avgLevel || partyLevel); - const tank = roleCount(party, 'tank') > 0; - const healer = roleCount(party, 'healer') > 0; - const buffer = roleCount(party, 'buffer') > 0; - const support = (tank ? 0.05 : 0) + (healer ? 0.07 : 0) + (buffer ? 0.06 : 0); - const size = clamp((members.length - 2) * 0.035, 0, 0.12); - const risk = Number(party.risk || 0.25) * 0.18 + Number(spot.risk || 0) * 0.025; - const pressureDeath = Number(pressure?.deathChanceMultiplier || 1); - - return clamp(0.63 + levelDelta * 0.035 + support + size - risk * pressureDeath, 0.18, 0.96); + // Party actions are individually simulated, so party size must not + // multiply work. Keep the same short active window as solo combat. + return Math.max(1, Math.min(4, Math.round(baseWindows * densityFactor * cohesionFactor))); } function distributeRewards({ members, spot, wins, pressure, rng }) { @@ -103,17 +86,42 @@ const BackgroundPartyResolver = { // pause the whole group: otherwise the resolver keeps granting fights // and draining the exhausted member on every cold tick. if (members.some((state) => state.activity === 'resting')) { + const partyRestUntil = Math.max( + Number(party.stats?.restUntil || 0), + ...members.map((state) => Number(state.stats?.restUntil || 0)) + ); const memberResults = members.map((state) => ({ state, result: BackgroundResolver.resolveRest({ ...state, - activity: 'resting' + activity: 'resting', + // A party sits down together. Members already ready do + // not wake and consume solo capacity while the healer is + // still recovering. + stats: { ...(state.stats || {}), restUntil: partyRestUntil || null } }, elapsedMs, timestamp) })); const resting = memberResults.filter(({ result }) => result.patch.activity === 'resting').length; + const nextRestUntil = resting + ? Math.max(...memberResults.map(({ result }) => Number(result.patch.stats?.restUntil || 0))) + : null; + const synchronizedMemberResults = resting + ? memberResults.map(({ state, result }) => ({ + state, + result: { + ...result, + patch: { + ...result.patch, + activity: 'resting', + stats: { ...(result.patch.stats || {}), restUntil: nextRestUntil } + }, + nextResolveAt: nextRestUntil + } + })) + : memberResults; return { - memberResults, + memberResults: synchronizedMemberResults, events: [], partyPatch: { cohesion: Number(party.cohesion || 0.65), @@ -121,10 +129,11 @@ const BackgroundPartyResolver = { stats: { ...(party.stats || {}), rests: Number(party.stats?.rests || 0) + 1, + restUntil: nextRestUntil, lastResolveAt: timestamp } }, - nextResolveAt: timestamp + (resting > 0 ? 30000 : 45000), + nextResolveAt: resting ? nextRestUntil : timestamp + 45000, debug: { activity: resting > 0 ? 'resting' : 'recovered', fights: 0, @@ -141,35 +150,45 @@ const BackgroundPartyResolver = { } const fights = estimateFightCount({ party, members, spot, elapsedMs }); - const winRate = estimateWinRate({ party, members, spot, pressure }); let wins = 0; + let losses = 0; + let combatActions = 0; + let skillUses = 0; + let heals = 0; + let combatMembers = members.map((state) => ({ ...state })); for (let i = 0; i < fights; i++) { - if (rng() <= winRate) wins += 1; + const encounter = BackgroundResolver.resolvePartyFight({ members: combatMembers, spot, 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); + combatMembers = encounter.members.map((member) => ({ + ...member.state, + vitals: { ...member.vitals }, + stats: { + ...(member.state.stats || {}), + coldCombat: { ...(member.state.stats?.coldCombat || member.profile), cooldowns: member.cooldowns } + } + })); + if (encounter.won) wins += 1; + else losses += 1; + if (!encounter.won || combatMembers.some((member) => Number(member.vitals?.hp || 0) <= 0)) break; } - const losses = fights - wins; - const hasTank = roleCount(party, 'tank') > 0; - const hasHealer = roleCount(party, 'healer') > 0; - const damageScale = clamp(0.18 + losses * 0.08 - (hasTank ? 0.05 : 0) - (hasHealer ? 0.06 : 0), 0.05, 0.75); - const deathChance = clamp((losses / Math.max(1, fights)) * (hasHealer ? 0.12 : 0.22) * (hasTank ? 0.75 : 1), 0, 0.45); const rewards = distributeRewards({ members, spot, wins, pressure, rng }); const memberResults = []; const events = []; let deaths = 0; let resting = 0; - rewards.forEach(({ state, exp, sp, adena, items }) => { - const vitals = memberVitals(state); - const role = state.party?.role || state.stats?.role || 'dps'; - const mpUse = role === 'healer' ? wins * 5 + losses * 4 : role === 'buffer' ? wins * 3 : wins * 2; - const hpLoss = Math.round(vitals.maxHp * damageScale * (0.65 + rng() * 0.45)); - let hp = Math.max(1, vitals.hp - hpLoss); - let mp = Math.max(0, vitals.mp - mpUse); + rewards.forEach(({ state, exp, sp, adena, items }, index) => { + const resolved = combatMembers[index] || state; + const vitals = resolved.vitals || memberVitals(state); + const hp = Math.max(0, Number(vitals.hp || 0)); + const mp = Math.max(0, Number(vitals.mp || 0)); let activity = 'grouped'; let deathCount = state.stats?.deaths || 0; - if (losses > 0 && rng() < deathChance) { - hp = 0; + if (hp <= 0) { activity = 'dead'; deathCount += 1; deaths += 1; @@ -203,12 +222,13 @@ const BackgroundPartyResolver = { maxMp: vitals.maxMp }, stats: { + ...(resolved.stats || {}), partyHistory: PartyAffinity.recordRun(state, members) } }, events: [], materialize: { exp, sp, adena, items }, - nextResolveAt: Date.now() + 45000 + Math.round(rng() * 90000), + nextResolveAt: timestamp + 45000 + Math.round(rng() * 90000), debug: { partyId: party.partyId, fights, @@ -225,7 +245,60 @@ const BackgroundPartyResolver = { }); const lootDistribution = PartyLootAllocator.transferGearDrops(memberResults); - const distributedMemberResults = lootDistribution.memberResults; + let distributedMemberResults = lootDistribution.memberResults; + let partyRestUntil = null; + + // The combat result can make one party member exhausted. Convert the + // whole group to one recovery event immediately, rather than letting + // its next ordinary hunt tick discover the rest state 45-135 seconds + // later and then poll all members every 30 seconds. + if (resting > 0) { + const restResults = distributedMemberResults.map(({ state, result }) => ({ + state, + result: result.patch.activity === 'dead' + ? result + : (() => { + const rest = BackgroundResolver.resolveRest({ + ...state, + activity: 'resting', + vitals: result.patch.vitals, + stats: { ...(state.stats || {}), ...(result.patch.stats || {}) } + }, 0, timestamp); + // The fights have already completed before the party + // decides to rest. Keep their rewards (and any gear + // redistribution) while replacing only the next + // lifecycle state with recovery. + return { + ...result, + patch: { + ...result.patch, + ...rest.patch, + stats: { ...(result.patch.stats || {}), ...(rest.patch.stats || {}) } + }, + events: [...(result.events || []), ...(rest.events || [])], + nextResolveAt: rest.nextResolveAt, + debug: { ...(result.debug || {}), rest: rest.debug } + }; + })() + })); + partyRestUntil = Math.max(...restResults + .filter(({ result }) => result.patch.activity !== 'dead') + .map(({ result }) => Number(result.patch.stats?.restUntil || 0))); + distributedMemberResults = restResults.map(({ state, result }) => ({ + state, + result: result.patch.activity === 'dead' + ? { ...result, nextResolveAt: partyRestUntil } + : { + ...result, + patch: { + ...result.patch, + activity: 'resting', + stats: { ...(result.patch.stats || {}), restUntil: partyRestUntil } + }, + nextResolveAt: partyRestUntil + } + })); + } if (wins > 0) { events.push({ @@ -263,10 +336,11 @@ const BackgroundPartyResolver = { fightsWon: Number(party.stats?.fightsWon || 0) + wins, deaths: Number(party.stats?.deaths || 0) + deaths, rests: Number(party.stats?.rests || 0) + resting, - lastResolveAt: timestamp + restUntil: partyRestUntil, + lastResolveAt: timestamp } }, - nextResolveAt: timestamp + 45000 + Math.round(rng() * 90000), + nextResolveAt: partyRestUntil || timestamp + 45000 + Math.round(rng() * 90000), debug: { fights, wins, @@ -276,7 +350,10 @@ const BackgroundPartyResolver = { dropsRolled: rewards.reduce((sum, reward) => sum + reward.items.length, 0), dropsAwarded: rewards.reduce((sum, reward) => sum + reward.items.reduce((itemSum, item) => itemSum + Number(item.amount || 0), 0), 0), spotId: spot.id, - route: spot.route || null + route: spot.route || null, + combatActions, + skillUses, + heals } }; } diff --git a/src/GameServer/Bot/Population/BackgroundResolver.js b/src/GameServer/Bot/Population/BackgroundResolver.js index 83ef82d1..868fc8c3 100644 --- a/src/GameServer/Bot/Population/BackgroundResolver.js +++ b/src/GameServer/Bot/Population/BackgroundResolver.js @@ -3,6 +3,7 @@ const BackgroundDropResolver = invoke('GameServer/Bot/Population/BackgroundDropR const DataCache = invoke('GameServer/DataCache'); const Formulas = invoke('GameServer/Formulas'); const C4SkillRules = invoke('GameServer/Skills/C4SkillRules'); +const ColdCombatProfile = invoke('GameServer/Bot/Population/ColdCombatProfile'); function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); @@ -19,56 +20,8 @@ function midpointBand(levelBand) { return Math.round((parts[0] + parts[1]) / 2); } -function roleProfile(state = {}) { - const role = state.party?.role || state.stats?.role || 'dps'; - const base = { - role, - damageMultiplier: 1, - defenseMultiplier: 1, - manaPerFight: 2, - deathMultiplier: 1 - }; - - if (role === 'tank') { - base.damageMultiplier = 0.85; - base.defenseMultiplier = 1.35; - base.deathMultiplier = 0.65; - } else if (role === 'healer') { - base.damageMultiplier = 0.72; - base.defenseMultiplier = 0.95; - base.manaPerFight = 7; - base.deathMultiplier = 0.75; - } else if (role === 'buffer') { - base.damageMultiplier = 0.85; - base.defenseMultiplier = 1.05; - base.manaPerFight = 5; - } else if (role === 'archer' || role === 'mage') { - base.damageMultiplier = 1.18; - base.defenseMultiplier = 0.85; - base.manaPerFight = role === 'mage' ? 9 : 3; - } else if (role === 'dagger') { - base.damageMultiplier = 1.12; - base.defenseMultiplier = 0.9; - base.manaPerFight = 4; - } - - return base; -} - -function botCombatStats(state, role) { - const level = midpointBand(state.levelBand); - const vitals = state.vitals || {}; - const maxHp = Number(vitals.maxHp || vitals.hp || 100 + level * 35); - const maxMp = Number(vitals.maxMp || vitals.mp || 50 + level * 18); - - return { - level, - maxHp, - maxMp, - damage: Math.max(4, Math.round((8 + level * 4.3) * role.damageMultiplier)), - defense: Math.max(1, (1 + level * 0.06) * role.defenseMultiplier), - manaPerFight: role.manaPerFight - }; +function botCombatStats(state, timestamp = Date.now()) { + return ColdCombatProfile.profileFor(state, timestamp); } function coldPassiveRegenAdd(state, skillId, stat) { @@ -112,7 +65,7 @@ function estimateRestMs(state, vitals) { } function resolveRest(state, elapsedMs, timestamp) { - const combat = botCombatStats(state, roleProfile(state)); + const combat = botCombatStats(state, timestamp); const vitals = { hp: Math.max(0, Number(state.vitals?.hp || 0)), maxHp: combat.maxHp, @@ -143,9 +96,10 @@ function resolveRest(state, elapsedMs, timestamp) { weight: 1 }], materialize: { exp: 0, sp: 0, adena: 0, items: [] }, - nextResolveAt: resting - ? timestamp + Math.max(3000, Math.min(30000, remainingMs || 30000)) - : timestamp + 30000, + // Sleeping is not an active simulation state. Persist the exact + // recovery deadline so the scheduler can leave this bot alone until + // HP/MP should have changed. + nextResolveAt: resting ? restUntil : timestamp + 30000, debug: { activity: resting ? 'resting' : 'recovered', regen, remainingMs } }; } @@ -199,7 +153,12 @@ function resolveTravel(state, timestamp = Date.now()) { meta: { townName: travel.townName || null, stationId: travel.stationId || null, reason: travel.reason || null } }] : [], materialize: { exp: 0, sp: 0, adena: 0, items: [] }, - nextResolveAt: timestamp + (arrived && arrivalActivity === 'shopping' ? 120000 : 30000), + // Until arrival nothing changes. On arrival, schedule the finite + // shopping/crafting transition for the next scheduler pass instead of + // parking the bot for another arbitrary polling interval. + nextResolveAt: arrived && ['shopping', 'crafting'].includes(arrivalActivity) + ? timestamp + : arrived ? timestamp + 30000 : arrivalAt, debug: { activity: 'traveling', arrived, progress, townName: travel.townName || null, arrivalActivity } }; } @@ -211,7 +170,7 @@ function staleShopping(state) { } function resolveDeathRecovery(state, timestamp = Date.now()) { - const combat = botCombatStats(state, roleProfile(state)); + const combat = botCombatStats(state, timestamp); const respawnDelayMs = 90000; return { @@ -241,50 +200,111 @@ function resolveDeathRecovery(state, timestamp = Date.now()) { }; } -function resolveFight({ state, spot, pressure, rng }) { - const role = roleProfile(state); - const bot = botCombatStats(state, role); +function hitSucceeds(accuracy, evasion, rng) { + const chance = clamp((80 + (2 * (Number(accuracy) - Number(evasion)))) / 100, 0.2, 0.98); + return rng() < chance; +} + +function actionDelayMs(profile, skill = null) { + if (skill?.spell) { + return Math.max(250, Formulas.calcRemoteAtkTime(Math.max(1, Number(skill.hitTime) || 1000), profile.castSpd)); + } + if (skill) { + return Math.max(250, Formulas.calcRemoteAtkTime(Math.max(1, Number(skill.hitTime) || 600), profile.atkSpd)); + } + return Math.max(250, Formulas.calcMeleeAtkTime(profile.atkSpd)); +} + +function chooseSkill(profile, mp, cooldowns, time) { + return ColdCombatProfile.offensiveSkills(profile) + .filter((skill) => Number(skill.mp || 0) <= mp && Number(cooldowns[skill.selfId] || 0) <= time) + .map((skill) => { + const magic = skill.spell === true; + const rawDamage = magic + ? Formulas.calcMagicDamage(profile.mAtk, Math.max(1, Number(skill.power) || 1), 1) + : Formulas.calcPhysicalDamage(profile.pAtk, profile.equipment.pAtkRnd, 1, Number(skill.power) || 0); + return { skill, magic, score: rawDamage / actionDelayMs(profile, skill) }; + }) + .sort((a, b) => b.score - a.score)[0] || null; +} + +function resolveFight({ state, spot, pressure, rng, timestamp = Date.now() }) { + const bot = botCombatStats(state, timestamp); + const mob = ColdCombatProfile.npcForSpot(spot, rng) || { + 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 + }; const vitals = { hp: Number(state.vitals?.hp || bot.maxHp), mp: Number(state.vitals?.mp || bot.maxMp), maxHp: bot.maxHp, maxMp: bot.maxMp }; - const mobHp = Math.max(1, spot.mob.hp); - const mobDamage = Math.max(1, Math.round(spot.mob.damage / bot.defense)); - const botHitsToKill = Math.ceil(mobHp / bot.damage); - const mobHitsToKill = Math.ceil(vitals.hp / mobDamage); - const pressureDeath = pressure?.deathChanceMultiplier || 1; - - if (mobHitsToKill < botHitsToKill) { - const deathChance = clamp(0.35 * role.deathMultiplier * pressureDeath, 0.05, 0.95); - if (rng() < deathChance) { - return { - won: false, - died: true, - hp: 0, - mp: Math.max(0, vitals.mp - bot.manaPerFight), - exp: 0, - sp: 0, - adena: 0, - loot: [] - }; + let botReadyAt = 0; + let mobReadyAt = 0; + let time = 0; + let mobHp = mob.maxHp; + let actions = 0; + let skillUses = 0; + const cooldowns = { ...(state.stats?.coldCombat?.cooldowns || {}) }; + const fightLimitMs = 12000; + + // A resolve contains only a handful of fights, and a fight itself is + // bounded by time and actions. This is deliberately cheaper than a live + // Actor while retaining its hit, critical, damage and speed formulas. + while (vitals.hp > 0 && mobHp > 0 && time < fightLimitMs && actions < 48) { + const botActs = botReadyAt <= mobReadyAt; + time = botActs ? botReadyAt : mobReadyAt; + if (time >= fightLimitMs) break; + actions += 1; + + if (botActs) { + const selected = chooseSkill(bot, vitals.mp, cooldowns, timestamp + time); + const skill = selected?.skill || null; + const magic = selected?.magic === true; + let damage = 0; + if (magic) { + const magicCritical = rng() < clamp(bot.critical / 1000, 0, 0.25); + damage = Formulas.calcMagicDamage(bot.mAtk, Math.max(1, Number(skill.power) || 1), mob.mDef, { magicCritical }); + } else if (hitSucceeds(bot.accur, mob.evasion, rng)) { + const critical = Formulas.rollCritical(bot.critical, rng); + damage = Formulas.calcPhysicalDamage(bot.pAtk, bot.equipment.pAtkRnd, mob.pDef, Number(skill?.power) || 0, { critical }); + } + mobHp -= Math.max(0, damage); + if (skill) { + vitals.mp = Math.max(0, vitals.mp - Number(skill.mp || 0)); + cooldowns[skill.selfId] = timestamp + time + Math.max(0, Number(skill.reuse || 0)); + skillUses += 1; + } + botReadyAt += actionDelayMs(bot, skill); + } else if (hitSucceeds(mob.accur, bot.evasion, rng)) { + const critical = Formulas.rollCritical(mob.critical, rng); + const damage = Formulas.calcMeleeDamage(mob.pAtk, mob.pAtkRnd, bot.pDef, { critical }); + vitals.hp -= Math.max(0, damage); + mobReadyAt += Math.max(250, Formulas.calcMeleeAtkTime(mob.atkSpd)); + } else { + mobReadyAt += Math.max(250, Formulas.calcMeleeAtkTime(mob.atkSpd)); } + } + const died = vitals.hp <= 0; + const won = mobHp <= 0; + if (!won) { return { won: false, - died: false, - hp: Math.max(1, Math.round(vitals.maxHp * 0.18)), - mp: Math.max(0, vitals.mp - bot.manaPerFight), + died, + hp: Math.max(0, Math.round(vitals.hp)), + mp: Math.max(0, Math.round(vitals.mp)), exp: 0, sp: 0, adena: 0, - loot: [] + loot: [], + cooldowns, + debug: { actions, skillUses, mobSelfId: mob.selfId || null, timedOut: !died } }; } - const hitsTaken = Math.max(0, botHitsToKill - 1); - const remainingHp = Math.max(1, vitals.hp - hitsTaken * mobDamage); const rewards = spot.rewards; const expMultiplier = pressure?.expMultiplier || 1; const rates = ProgressionRates.profile(); @@ -298,17 +318,126 @@ function resolveFight({ state, spot, pressure, rng }) { return { won: true, died: false, - hp: remainingHp, - mp: Math.max(0, vitals.mp - bot.manaPerFight), + hp: Math.max(1, Math.round(vitals.hp)), + mp: Math.max(0, Math.round(vitals.mp)), exp: Math.round(rewards.exp * expMultiplier * rates.exp), sp: Math.round(rewards.sp * expMultiplier * rates.sp), adena, - loot + loot, + cooldowns, + debug: { actions, skillUses, mobSelfId: mob.selfId || null, timedOut: false } + }; +} + +function chooseHeal(profile, allies, mp, cooldowns, time) { + const injured = allies.filter((ally) => ally.vitals.hp > 0 && ally.vitals.hp / Math.max(1, ally.vitals.maxHp) < 0.7) + .sort((a, b) => (a.vitals.hp / a.vitals.maxHp) - (b.vitals.hp / b.vitals.maxHp))[0]; + if (!injured) return null; + const skill = (profile.skills || []).filter((candidate) => { + if (candidate.passive || Number(candidate.mp || 0) > mp || Number(cooldowns[candidate.selfId] || 0) > time) return false; + const semantic = C4SkillRules.resolve(candidate); + return [C4SkillRules.HEAL, C4SkillRules.HEAL_PERCENT].includes(semantic.skillType) + && ['self', 'party', 'ally', 'friendly'].includes(semantic.target); + }).sort((a, b) => Number(b.power || 0) - Number(a.power || 0))[0]; + return skill ? { skill, target: injured } : null; +} + +function resolvePartyFight({ members, spot, rng = Math.random, timestamp = Date.now() }) { + const mob = ColdCombatProfile.npcForSpot(spot, rng) || { + 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 + }; + const fighters = members.map((state) => { + const profile = botCombatStats(state, timestamp); + return { + state, + profile, + role: state.party?.role || state.stats?.role || 'dps', + vitals: { + hp: Math.min(profile.maxHp, Math.max(0, Number(state.vitals?.hp || profile.maxHp))), + maxHp: profile.maxHp, + mp: Math.min(profile.maxMp, Math.max(0, Number(state.vitals?.mp || profile.maxMp))), + maxMp: profile.maxMp + }, + cooldowns: { ...(state.stats?.coldCombat?.cooldowns || {}) }, + readyAt: 0, + actions: 0, + skillUses: 0, + heals: 0 + }; + }); + let mobHp = mob.maxHp; + let mobReadyAt = 0; + let time = 0; + let actions = 0; + const fightLimitMs = 15000; + + while (mobHp > 0 && fighters.some((fighter) => fighter.vitals.hp > 0) && time < fightLimitMs && actions < 96) { + const alive = fighters.filter((fighter) => fighter.vitals.hp > 0); + const next = alive.sort((a, b) => a.readyAt - b.readyAt)[0]; + const botActs = next && next.readyAt <= mobReadyAt; + time = botActs ? next.readyAt : mobReadyAt; + if (time >= fightLimitMs) break; + actions += 1; + + if (botActs) { + next.actions += 1; + const heal = chooseHeal(next.profile, fighters, next.vitals.mp, next.cooldowns, timestamp + time); + if (heal) { + const amount = Formulas.calcHealAmount(heal.skill.power); + heal.target.vitals.hp = Math.min(heal.target.vitals.maxHp, heal.target.vitals.hp + amount); + next.vitals.mp = Math.max(0, next.vitals.mp - Number(heal.skill.mp || 0)); + next.cooldowns[heal.skill.selfId] = timestamp + time + Math.max(0, Number(heal.skill.reuse || 0)); + next.skillUses += 1; + next.heals += 1; + next.readyAt += actionDelayMs(next.profile, heal.skill); + continue; + } + + const selected = chooseSkill(next.profile, next.vitals.mp, next.cooldowns, timestamp + time); + const skill = selected?.skill || null; + let damage = 0; + if (selected?.magic) { + const magicCritical = rng() < clamp(next.profile.critical / 1000, 0, 0.25); + damage = Formulas.calcMagicDamage(next.profile.mAtk, Math.max(1, Number(skill.power) || 1), mob.mDef, { magicCritical }); + } else if (hitSucceeds(next.profile.accur, mob.evasion, rng)) { + damage = Formulas.calcPhysicalDamage(next.profile.pAtk, next.profile.equipment.pAtkRnd, mob.pDef, Number(skill?.power) || 0, { + critical: Formulas.rollCritical(next.profile.critical, rng) + }); + } + mobHp -= Math.max(0, damage); + if (skill) { + next.vitals.mp = Math.max(0, next.vitals.mp - Number(skill.mp || 0)); + next.cooldowns[skill.selfId] = timestamp + time + Math.max(0, Number(skill.reuse || 0)); + next.skillUses += 1; + } + next.readyAt += actionDelayMs(next.profile, skill); + } else { + const targets = fighters.filter((fighter) => fighter.vitals.hp > 0); + const tank = targets.find((fighter) => fighter.role === 'tank'); + const target = tank || targets[Math.floor(rng() * targets.length)]; + if (target && hitSucceeds(mob.accur, target.profile.evasion, rng)) { + const damage = Formulas.calcMeleeDamage(mob.pAtk, mob.pAtkRnd, target.profile.pDef, { + critical: Formulas.rollCritical(mob.critical, rng) + }); + target.vitals.hp = Math.max(0, target.vitals.hp - damage); + } + mobReadyAt += Math.max(250, Formulas.calcMeleeAtkTime(mob.atkSpd)); + } + } + + return { + won: mobHp <= 0, + timedOut: mobHp > 0 && fighters.some((fighter) => fighter.vitals.hp > 0), + members: fighters, + debug: { actions, mobSelfId: mob.selfId || null } }; } const BackgroundResolver = { resolveRest, + resolvePartyFight, resolveSolo({ state, spot, pressure = {}, elapsedMs = 60000, rng = Math.random, timestamp = Date.now() }) { if (!state) { return { @@ -321,7 +450,7 @@ const BackgroundResolver = { } if (state.activity === 'traveling') { - const travelResult = resolveTravel(state); + const travelResult = resolveTravel(state, timestamp); if (travelResult) return travelResult; } @@ -406,16 +535,27 @@ const BackgroundResolver = { let wins = 0; let died = false; + let combatActions = 0; + let skillUses = 0; for (let i = 0; i < fights; i++) { const fightState = { ...state, vitals: patch.vitals }; - const result = resolveFight({ state: fightState, spot, pressure, rng }); + const result = resolveFight({ state: fightState, spot, pressure, rng, timestamp }); patch.vitals.hp = result.hp; patch.vitals.mp = result.mp; + patch.stats = { + ...(patch.stats || state.stats || {}), + coldCombat: { + ...(state.stats?.coldCombat || ColdCombatProfile.profileFor(fightState, timestamp)), + cooldowns: result.cooldowns || {} + } + }; materialize.exp += result.exp; materialize.sp += result.sp; materialize.adena += result.adena; materialize.items.push(...result.loot); + combatActions += Number(result.debug?.actions || 0); + skillUses += Number(result.debug?.skillUses || 0); if (result.won) wins += 1; if (result.died) { @@ -462,7 +602,7 @@ const BackgroundResolver = { patch, events, materialize, - nextResolveAt: Date.now() + 30000 + Math.round(rng() * 90000), + nextResolveAt: patch.stats?.restUntil || timestamp + 30000 + Math.round(rng() * 90000), debug: { elapsedMs, fights, @@ -471,7 +611,9 @@ const BackgroundResolver = { dropsRolled: materialize.items.length, dropsAwarded: materialize.items.reduce((sum, item) => sum + Number(item.amount || 0), 0), spotId: spot.id, - route: spot.route || null + route: spot.route || null, + combatActions, + skillUses } }; } diff --git a/src/GameServer/Bot/Population/BotLifeState.js b/src/GameServer/Bot/Population/BotLifeState.js index 5c6c2c80..f112b85a 100644 --- a/src/GameServer/Bot/Population/BotLifeState.js +++ b/src/GameServer/Bot/Population/BotLifeState.js @@ -10,6 +10,7 @@ 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 ColdCombatProfile = invoke('GameServer/Bot/Population/ColdCombatProfile'); const cache = new Map(); const pendingWrites = new Map(); let initialized = false; @@ -235,6 +236,11 @@ function recordFromSession(session, phase, reason = '') { route: currentSpot?.route || null, build: GearSkillHints.forCharacter(actor, { role: session.botStatus?.role || null }), equipment: equipmentSummaryFromInventory(inventory), + // Cold combat must start from the exact same character model as the + // hot session: equipped item totals, learned skills and live effects. + // The resolver rebuilds those values deterministically after effects + // expire, rather than retaining a stale buffed total indefinitely. + coldCombat: ColdCombatProfile.capture(actor, timestamp), leaderId: session.followPlayerSession?.actor?.fetchId ? Number(session.followPlayerSession.actor.fetchId()) : null, newbieAnchor: !!session.newbieAnchor, lastReason: reason @@ -430,6 +436,19 @@ function classProgressionNeeded(state, classId, level) { return knownLevel < Number(level || 1) || knownClassId !== Number(classId); } +function refreshColdCombatProfile(state) { + return Database.fetchSkills(state.characterId).then((skills) => ({ + ...state, + stats: { + ...(state.stats || {}), + // Class progression writes skills directly to the character + // table. Keep the cold model in lockstep so its next fight uses + // the same ranks and newly learned abilities as a hot bot. + coldCombat: ColdCombatProfile.legacySnapshot(state, skills, now()) + } + })); +} + function applyClassProgression(state, profile = {}) { const level = Number(profile.level || state.level || 1); const currentClassId = Number(profile.classId ?? state.stats?.classId ?? 0); @@ -462,7 +481,7 @@ function applyClassProgression(state, profile = {}) { } }; if (resolved.transitions?.length) delete progressedState.stats.equipmentPlan; - return progressedState; + return refreshColdCombatProfile(progressedState); }); } @@ -586,6 +605,36 @@ function recoverStaleCraftWaits() { }); } +function migrateAcquisitionPartyWaits() { + const timestamp = now(); + const replanAt = timestamp + Config.partyWaitReplanMs; + return Database.execute([ + `UPDATE ${TABLE} + SET activity = 'party_wait', + activityStartedAt = ?, + nextResolveAt = ?, + statsJson = JSON_SET( + COALESCE(statsJson, '{}'), + '$.partyWaitUntil', CAST(? AS UNSIGNED), + '$.restUntil', NULL, + '$.lastReason', 'acquisition_party_wait' + ), + updatedAt = ? + WHERE phase = 'cold' + AND activity = 'resting' + AND (partyId IS NULL OR partyId = '') + AND JSON_UNQUOTE(JSON_EXTRACT(statsJson, '$.lastReason')) = 'acquisition_party_wait' + AND COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(statsJson, '$.restUntil')) AS UNSIGNED), 0) = 0`, + [timestamp, replanAt, replanAt, timestamp] + ]).then((result) => { + const migrated = Number(result?.affectedRows || 0); + if (migrated > 0) { + utils.infoWarn('BotLife', 'migrated %d acquisition party waits to event scheduling', migrated); + } + return migrated; + }); +} + const BotLifeState = { init() { if (initialized) return Promise.resolve(true); @@ -624,7 +673,7 @@ const BotLifeState = { INDEX accountName (accountName) )`, [] - ]).then(() => ensureColumns()).then(() => recoverStaleHotStates()).then(() => recoverStaleCraftWaits()).then(() => hydrateCache()).then((count) => { + ]).then(() => ensureColumns()).then(() => recoverStaleHotStates()).then(() => recoverStaleCraftWaits()).then(() => migrateAcquisitionPartyWaits()).then(() => hydrateCache()).then((count) => { const repairs = [...cache.values()] .map(recoverOrphanedGiranState) .filter((state) => state !== cache.get(state.characterId)); @@ -722,7 +771,11 @@ const BotLifeState = { phase: 'cold', activity: 'merchant', loc: { ...storeLoc }, - timing: { ...(marketState.timing || {}), activityStartedAt: timestamp, nextResolveAt: timestamp + 60000 }, + timing: { + ...(marketState.timing || {}), + activityStartedAt: timestamp, + nextResolveAt: Number(marketState.stats?.marketStore?.expiresAt || 0) || timestamp + 60000 + }, stats: { ...(marketState.stats || {}), marketStore: { @@ -755,7 +808,7 @@ const BotLifeState = { timing: { ...(craftState.timing || {}), activityStartedAt: now(), - nextResolveAt: now() + 60000 + nextResolveAt: null }, stats: { ...(craftState.stats || {}), lastReason: reason }, inventory: parseJson(row.inventorySummary, {}) @@ -895,6 +948,11 @@ const BotLifeState = { WHERE phase = 'cold' AND activity <> 'pk_hunting' AND (partyId IS NULL OR partyId = '') + -- Cold stores settle on trade/expiry events, and craft-service + -- stations are materialized on demand. Neither belongs in the + -- combat scheduler's periodic queue. + AND NOT (activity = 'merchant' AND JSON_EXTRACT(statsJson, '$.marketStore') IS NOT NULL) + AND NOT (activity = 'crafting' AND JSON_EXTRACT(statsJson, '$.craftShop') IS NOT NULL) AND (nextResolveAt IS NULL OR nextResolveAt <= ?) -- Travel and crafting are finite state transitions. They must -- outrank a large resting/hunting backlog, otherwise a bot can @@ -1014,6 +1072,42 @@ const BotLifeState = { )), Promise.resolve([])); }, + migrateLegacyColdCombatProfiles(limit = 5) { + if (!initialized) return Promise.resolve([]); + const safeLimit = Math.max(1, Math.min(20, Number(limit) || 5)); + const candidates = Array.from(cache.values()) + .filter((state) => state.phase === 'cold') + // Profiles made by the first cold resolver used the class tree + // before this migration existed. They have a profile but not an + // authoritative skill source, so replace their skills once too. + .filter((state) => ColdCombatProfile.needsDatabaseBackfill(state.stats?.coldCombat)) + .filter((state) => !pendingWrites.has(state.characterId)) + .sort((a, b) => Number(a.updatedAt || 0) - Number(b.updatedAt || 0)) + .slice(0, safeLimit); + + return candidates.reduce((chain, state) => chain.then((migrated) => ( + Database.fetchSkills(state.characterId).then((skills) => { + const nextState = { + ...state, + stats: { + ...(state.stats || {}), + coldCombat: ColdCombatProfile.legacySnapshot(state, skills, now()) + }, + updatedAt: now() + }; + const row = rowFromState(nextState); + return save(row).then(() => { + const snapshot = normalize(row); + cache.set(snapshot.characterId, snapshot); + return [...migrated, snapshot]; + }); + }).catch((err) => { + utils.infoWarn('BotLife', 'legacy cold combat profile failed for %s: %s', state.name, err.message); + return migrated; + }) + )), Promise.resolve([])); + }, + statesForParty(partyId) { if (!initialized || !partyId) return Promise.resolve([]); @@ -1033,9 +1127,12 @@ const BotLifeState = { }); }, - coldPartyCandidates(limit = 80) { + coldPartyCandidates(limit = 80, partyRequiredOnly = false) { if (!initialized) return Promise.resolve([]); const safeLimit = Math.max(1, Math.min(500, Number(limit) || 80)); + const activityClause = partyRequiredOnly + ? "activity = 'party_wait'" + : "activity IN ('hunting', 'resting', 'party_wait')"; return Database.execute([ `SELECT states.* FROM ${TABLE} states @@ -1045,13 +1142,13 @@ const BotLifeState = { WHERE phase = 'cold' AND (partyId IS NULL OR partyId = '') AND spotId IS NOT NULL - AND activity IN ('hunting', 'resting') + AND ${activityClause} GROUP BY spotId ) party_spots ON party_spots.spotId = states.spotId WHERE states.phase = 'cold' AND (states.partyId IS NULL OR states.partyId = '') AND states.spotId IS NOT NULL - AND states.activity IN ('hunting', 'resting') + AND states.${activityClause} ORDER BY party_spots.candidateCount DESC, party_spots.oldestAt ASC, states.level ASC, states.updatedAt ASC LIMIT ${safeLimit}`, [] @@ -1065,11 +1162,42 @@ const BotLifeState = { }); }, + partyRequirementCounts(partyIds = []) { + if (!initialized) return Promise.resolve([]); + const ids = Array.from(new Set((partyIds || []).map((partyId) => String(partyId || '')).filter(Boolean))); + if (!ids.length) return Promise.resolve([]); + const placeholders = ids.map(() => '?').join(', '); + + return Database.execute([ + `SELECT partyId, + COUNT(*) AS memberCount, + SUM(CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(statsJson, '$.equipmentPlan.requiresParty')) = 'true' THEN 1 ELSE 0 END) AS requiredMembers, + MIN(updatedAt) AS oldestAt + FROM ${TABLE} + WHERE phase = 'cold' + AND partyId IN (${placeholders}) + GROUP BY partyId`, + ids + ]).then((rows) => rows.map((row) => ({ + partyId: String(row.partyId || ''), + memberCount: Number(row.memberCount || 0), + requiredMembers: Number(row.requiredMembers || 0), + oldestAt: Number(row.oldestAt || 0) + }))).catch((err) => { + utils.infoWarn('BotLife', 'failed to count party requirements: %s', err.message); + return []; + }); + }, + assignParty(state, partyId, role = 'dps', leaderId = 0) { if (!state || !partyId) return Promise.resolve(null); + const wasWaitingForParty = state.activity === 'party_wait' + || state.stats?.lastReason === 'acquisition_party_wait'; + const timestamp = now(); const nextState = { ...state, + activity: wasWaitingForParty ? 'grouped' : state.activity, party: { ...(state.party || {}), partyId, @@ -1080,9 +1208,17 @@ const BotLifeState = { ...(state.stats || {}), role, leaderId, - backgroundPartyId: partyId + backgroundPartyId: partyId, + partyWaitUntil: wasWaitingForParty ? null : state.stats?.partyWaitUntil || null, + restUntil: wasWaitingForParty ? null : state.stats?.restUntil || null, + lastReason: wasWaitingForParty ? 'party_assigned' : state.stats?.lastReason }, - updatedAt: now() + timing: { + ...(state.timing || {}), + activityStartedAt: wasWaitingForParty ? timestamp : state.timing?.activityStartedAt, + nextResolveAt: wasWaitingForParty ? null : state.timing?.nextResolveAt + }, + updatedAt: timestamp }; const row = rowFromState(nextState); @@ -1237,20 +1373,25 @@ const BotLifeState = { } }; if (resolved.transitions?.length) delete progressedState.stats.equipmentPlan; - const row = rowFromState(progressedState); + const profileReady = needsClassProgression + ? refreshColdCombatProfile(progressedState) + : Promise.resolve(progressedState); - return save(row) - .then(() => Database.updateCharacterExperience(row.characterId, row.level, row.exp, row.sp)) - .then(() => Database.updateCharacterVitals(row.characterId, row.hp, row.maxHp, row.mp, row.maxMp)) - .then(() => syncInventorySummary(row.characterId, progressedState.inventory)) - .then(() => { - const snapshot = normalize(row); - cache.set(snapshot.characterId, snapshot); - return snapshot; - }) - .catch((err) => { - utils.infoWarn('BotLife', 'failed to apply resolve for %s: %s', state.name, err.message); - return null; + return profileReady.then((profiledState) => { + const row = rowFromState(profiledState); + return save(row) + .then(() => Database.updateCharacterExperience(row.characterId, row.level, row.exp, row.sp)) + .then(() => Database.updateCharacterVitals(row.characterId, row.hp, row.maxHp, row.mp, row.maxMp)) + .then(() => syncInventorySummary(row.characterId, profiledState.inventory)) + .then(() => { + const snapshot = normalize(row); + cache.set(snapshot.characterId, snapshot); + return snapshot; + }) + .catch((err) => { + utils.infoWarn('BotLife', 'failed to apply resolve for %s: %s', state.name, err.message); + return null; + }); }); }); }, diff --git a/src/GameServer/Bot/Population/ColdCombatProfile.js b/src/GameServer/Bot/Population/ColdCombatProfile.js new file mode 100644 index 00000000..e6d738ea --- /dev/null +++ b/src/GameServer/Bot/Population/ColdCombatProfile.js @@ -0,0 +1,316 @@ +const DataCache = invoke('GameServer/DataCache'); +const Formulas = invoke('GameServer/Formulas'); +const C4SkillRules = invoke('GameServer/Skills/C4SkillRules'); +const EffectStore = invoke('GameServer/Effects/EffectStore'); +const BuffCatalog = invoke('GameServer/Effects/BuffCatalog'); + +const PROFILE_VERSION = 3; + +const WEAPON_MASK_BY_KIND = Object.freeze({ + 'Weapon.Sword': 4, + 'Weapon.Blunt': 8, + 'Weapon.Knife': 16, + 'Weapon.Bow': 32, + 'Weapon.Pole': 64, + 'Weapon.Fist': 256, + 'Weapon.Dual': 512, + 'Weapon.DualFist': 1024, + 'Weapon.GreatSword': 2048, + 'Weapon.BigBlunt': 16384 +}); + +function number(value, fallback = 0) { + const resolved = Number(value); + return Number.isFinite(resolved) ? resolved : fallback; +} + +function classTemplate(classId) { + return (DataCache.classTemplates || []).find((entry) => Number(entry.classId) === Number(classId)) || {}; +} + +function itemTemplate(selfId) { + return (DataCache.items || []).find((entry) => Number(entry.selfId) === Number(selfId)) || null; +} + +function equippedTemplates(state = {}) { + return Object.values(state.inventory || {}) + .filter((item) => item?.equipped) + .map((item) => itemTemplate(item.selfId)) + .filter(Boolean); +} + +function equipmentFromTemplates(items = [], spellcaster = false, fallback = {}) { + const weapon = items.find((item) => String(item.template?.kind || '').startsWith('Weapon.')); + const armorAt = (slot) => items.find((item) => Number(item.etc?.slot) === slot && String(item.template?.kind || '').startsWith('Armor.')); + const armor = items.filter((item) => String(item.template?.kind || '').startsWith('Armor.')); + const fullBody = armorAt(15); + const torso = fullBody + ? number(fullBody.stats?.pDef) + : number(armorAt(10)?.stats?.pDef, spellcaster ? 15 : 31) + number(armorAt(11)?.stats?.pDef, spellcaster ? 8 : 18); + + return { + weaponKind: weapon?.template?.kind || fallback.weaponKind || '', + pAtk: number(weapon?.stats?.pAtk, fallback.pAtk), + pAtkRnd: number(weapon?.stats?.pAtkRnd, fallback.pAtkRnd), + mAtk: number(weapon?.stats?.mAtk, fallback.mAtk), + atkSpd: number(weapon?.stats?.atkSpd, fallback.atkSpd), + critical: number(weapon?.stats?.crit, fallback.critical), + accur: number(weapon?.stats?.accur, fallback.accur), + pDef: number(armorAt(0)?.stats?.pDef) + number(armorAt(6)?.stats?.pDef, 12) + torso + + number(armorAt(9)?.stats?.pDef, 8) + number(armorAt(12)?.stats?.pDef, 7) + number(armorAt(13)?.stats?.pDef), + mDef: number(armorAt(3)?.stats?.mDef, 13) + number(armorAt(1)?.stats?.mDef, 9) + + number(armorAt(2)?.stats?.mDef, 9) + number(armorAt(4)?.stats?.mDef, 5) + number(armorAt(5)?.stats?.mDef, 5), + evasion: armor.reduce((sum, item) => sum + number(item.stats?.evasion), 0), + bonusMp: armor.reduce((sum, item) => sum + number(item.stats?.maxMp), 0), + shieldPDef: number(armorAt(8)?.stats?.pDef), + shieldRate: number(armorAt(8)?.stats?.shieldRate, armorAt(8) ? 20 : 0), + armorKinds: armor.map((item) => item.template?.kind).filter(Boolean) + }; +} + +function activeEffects(effects = [], timestamp = Date.now()) { + return effects.filter((effect) => effect && effect.type !== 'debuff' && effect.toggle !== true + && (!effect.expiresAt || number(effect.expiresAt) > timestamp)); +} + +function effectStats(effect = {}) { + const structured = effect.stats || {}; + if (Object.keys(structured).length) return structured; + const legacy = BuffCatalog.byTypeOrKey(effect.key) + || Object.values(BuffCatalog.ALL_BUFFS || {}).find((buff) => Number(buff.id) === Number(effect.id)); + return legacy?.stats || structured; +} + +function statValues(profile, stat, timestamp) { + const effectValues = activeEffects(profile.effects, timestamp).map((effect) => number(effectStats(effect)?.[stat], NaN)); + const passiveValues = (profile.skills || []) + .filter((skill) => skill.passive) + .map((skill) => C4SkillRules.resolve({ selfId: skill.selfId, level: skill.level })) + .filter((semantic) => passiveRequirementsMatch(profile, semantic.requires)) + .map((semantic) => number(semantic.stats?.[stat], NaN)); + return [...effectValues, ...passiveValues].filter(Number.isFinite); +} + +function passiveRequirementsMatch(profile, requires = {}) { + if (!requires) return true; + const weaponMask = WEAPON_MASK_BY_KIND[profile.equipment?.weaponKind] || 0; + if (requires.weaponsAllowed && (number(requires.weaponsAllowed) & weaponMask) === 0) return false; + if (requires.weaponKinds && !requires.weaponKinds.includes(profile.equipment?.weaponKind)) return false; + if (requires.armorKind && !(profile.equipment?.armorKinds || []).includes(requires.armorKind)) return false; + if (requires.shield && number(profile.equipment?.shieldPDef) <= 0) return false; + return true; +} + +function add(profile, stat, timestamp) { + return statValues(profile, stat, timestamp).reduce((sum, value) => sum + value, 0); +} + +function multiplier(profile, stat, timestamp) { + return statValues(profile, stat, timestamp).reduce((total, value) => total * value, 1); +} + +function effectiveBase(profile, stat, timestamp) { + return Math.max(1, Math.round((number(profile.base?.[stat.toLowerCase()], 1) + add(profile, stat, timestamp)) + * multiplier(profile, `${stat}Mul`, timestamp))); +} + +function skillSnapshot(skill) { + return { + selfId: number(skill.fetchSelfId?.()), + level: number(skill.fetchLevel?.(), 1), + passive: skill.fetchPassive?.() === true, + spell: skill.fetchSpell?.() === true, + power: number(skill.fetchPower?.()), + mp: number(skill.fetchConsumedMp?.()), + hp: number(skill.fetchConsumedHp?.()), + hitTime: number(skill.fetchHitTime?.()), + reuse: number(skill.fetchReuseTime?.()), + buffTime: number(skill.fetchBuffTime?.()) + }; +} + +function skillsFromTree(classId, level) { + const tree = (DataCache.skillTree || []).find((entry) => Number(entry.classId) === Number(classId)); + return (tree?.skills || []).map((entry) => { + const learned = (entry.levels || []).filter((row) => number(row.pLevel) <= level).at(-1); + if (!learned) return null; + const skill = (DataCache.skills || []).find((candidate) => Number(candidate.selfId) === Number(entry.selfId)); + const definition = (skill?.levels || []).find((row) => number(row.level) === number(learned.level)) + || (skill?.levels || []).filter((row) => number(row.level) <= number(learned.level)).at(-1) || {}; + return { + selfId: number(entry.selfId), level: number(learned.level, 1), passive: skill?.template?.passive === true, + spell: definition.spell === true, power: number(definition.power), mp: number(definition.mp), hp: number(definition.hp), + hitTime: number(definition.hitTime), reuse: number(definition.reuse), buffTime: number(definition.buff) + }; + }).filter(Boolean); +} + +function skillSnapshotsFromRecords(records = []) { + return records.map((record) => { + const selfId = number(record.selfId); + const requestedLevel = number(record.level, 1); + const skill = (DataCache.skills || []).find((candidate) => Number(candidate.selfId) === selfId); + if (!skill) return null; + const definition = (skill.levels || []).find((row) => number(row.level) === requestedLevel) + || (skill.levels || []).filter((row) => number(row.level) <= requestedLevel).at(-1); + if (!definition) return null; + return { + selfId, + level: number(definition.level, requestedLevel), + passive: record.passive === true || skill.template?.passive === true, + spell: definition.spell === true, + power: number(definition.power), + mp: number(definition.mp), + hp: number(definition.hp), + hitTime: number(definition.hitTime), + reuse: number(definition.reuse), + buffTime: number(definition.buff) + }; + }).filter(Boolean); +} + +function legacySnapshot(state = {}, records = [], timestamp = Date.now()) { + const existing = state.stats?.coldCombat || {}; + return { + ...existing, + version: PROFILE_VERSION, + skillSource: 'database', + capturedAt: number(existing.capturedAt, timestamp), + classId: number(existing.classId, number(state.stats?.classId, number(state.classId))), + effects: existing.effects || [], + skills: skillSnapshotsFromRecords(records) + }; +} + +function capture(actor, timestamp = Date.now()) { + const backpack = actor.backpack; + const equipment = { + weaponKind: backpack?.fetchTotalWeaponKind?.() || '', + pAtk: number(backpack?.fetchTotalWeaponPAtk?.(), number(actor.fetchPAtk?.())), + pAtkRnd: number(backpack?.fetchTotalWeaponPAtkRnd?.()), + mAtk: number(backpack?.fetchTotalWeaponMAtk?.(), number(actor.fetchMAtk?.())), + atkSpd: number(backpack?.fetchTotalWeaponAtkSpd?.(), number(actor.fetchAtkSpd?.())), + critical: number(backpack?.fetchTotalWeaponCritical?.(), number(actor.fetchCritical?.())), + accur: number(backpack?.fetchTotalWeaponAccur?.(), number(actor.fetchAccur?.())), + pDef: number(backpack?.fetchTotalArmorPDef?.(actor.isSpellcaster?.()), number(actor.fetchPDef?.())), + mDef: number(backpack?.fetchTotalArmorMDef?.(), number(actor.fetchMDef?.())), + evasion: number(backpack?.fetchTotalArmorEvasion?.()), + bonusMp: number(backpack?.fetchTotalArmorBonusMp?.()), + shieldPDef: number(backpack?.fetchTotalShieldPDef?.()), + shieldRate: number(backpack?.fetchTotalShieldRate?.()), + armorKinds: (backpack?.fetchEquippedArmors?.() || []).map((item) => item.fetchKind?.()).filter(Boolean) + }; + return { + version: PROFILE_VERSION, + skillSource: 'hot', + capturedAt: timestamp, + classId: number(actor.fetchClassId?.()), + base: { + str: number(actor.fetchStr?.(), 1), dex: number(actor.fetchDex?.(), 1), con: number(actor.fetchCon?.(), 1), + int: number(actor.fetchInt?.(), 1), wit: number(actor.fetchWit?.(), 1), men: number(actor.fetchMen?.(), 1), + pAtk: number(actor.fetchPAtk?.()), mAtk: number(actor.fetchMAtk?.()), pDef: number(actor.fetchPDef?.()), + mDef: number(actor.fetchMDef?.()), accur: number(actor.fetchAccur?.()), evasion: number(actor.fetchEvasion?.()), + critical: number(actor.fetchCritical?.()), atkSpd: number(actor.fetchAtkSpd?.()), castSpd: number(actor.fetchCastSpd?.()) + }, + equipment, + effects: EffectStore.list(actor).filter((effect) => effect.type !== 'debuff' && effect.toggle !== true) + .map((effect) => ({ ...effect, stats: { ...(effect.stats || {}) } })), + skills: (actor.skillset?.fetchSkills?.() || []).map(skillSnapshot) + }; +} + +function needsDatabaseBackfill(snapshot = {}) { + return snapshot?.skillSource !== 'hot' + && (snapshot?.skillSource !== 'database' || number(snapshot?.version) < PROFILE_VERSION); +} + +function profileFor(state = {}, timestamp = Date.now()) { + const saved = state.stats?.coldCombat; + const classId = number(saved?.classId, number(state.stats?.classId, number(state.classId))); + const template = classTemplate(classId); + const level = Math.max(1, number(state.level, 1)); + const spellcaster = [10, 25, 38, 49].includes(Formulas.getParentClassId(classId)); + const equipped = equippedTemplates(state); + const legacyEquipment = equipmentFromTemplates(equipped, spellcaster, template.stats || {}); + const profile = { + version: 1, + capturedAt: number(saved?.capturedAt, timestamp), + classId, + base: { ...(template.base || {}), ...(template.stats || {}), ...(saved?.base || {}) }, + // Inventory is authoritative for a cold bot too: a completed market + // purchase or craft must alter its next fight without waiting for a + // hot materialisation. The hot snapshot fills only legacy states that + // have no persisted equipped items yet. + equipment: equipped.length + ? { ...(saved?.equipment || {}), ...legacyEquipment } + : { ...legacyEquipment, ...(saved?.equipment || {}) }, + effects: saved?.effects || [], + skills: Array.isArray(saved?.skills) && saved.skills.length ? saved.skills : skillsFromTree(classId, level) + }; + const equipment = profile.equipment; + const str = effectiveBase(profile, 'STR', timestamp); + const dex = effectiveBase(profile, 'DEX', timestamp); + const con = effectiveBase(profile, 'CON', timestamp); + const int = effectiveBase(profile, 'INT', timestamp); + const wit = effectiveBase(profile, 'WIT', timestamp); + const men = effectiveBase(profile, 'MEN', timestamp); + const classTransfer = level < 20 ? 0 : level < 40 ? 1 : 2; + const maxHp = (Formulas.calcHp(level, classId, con) * multiplier(profile, 'maxHpMul', timestamp)) + add(profile, 'maxHpAdd', timestamp); + const maxMp = ((Formulas.calcMp(level, spellcaster ? 1 : 0, classTransfer, men) + number(equipment.bonusMp)) + * multiplier(profile, 'maxMpMul', timestamp)) + add(profile, 'maxMpAdd', timestamp); + const pAtk = Math.round(Formulas.calcPAtk(level, str, number(equipment.pAtk, number(profile.base.pAtk))) + * multiplier(profile, 'pAtkMul', timestamp)) + add(profile, 'pAtkAdd', timestamp); + const mAtk = Math.round(Formulas.calcMAtk(level, int, number(equipment.mAtk, number(profile.base.mAtk))) + * multiplier(profile, 'mAtkMul', timestamp)) + add(profile, 'mAtkAdd', timestamp); + const pDef = Math.round(Formulas.calcPDef(level, number(equipment.pDef, number(profile.base.pDef))) + * multiplier(profile, 'pDefMul', timestamp)) + add(profile, 'pDefAdd', timestamp); + const mDef = Math.round(Formulas.calcMDef(level, men, number(equipment.mDef, number(profile.base.mDef))) + * multiplier(profile, 'mDefMul', timestamp)) + add(profile, 'mDefAdd', timestamp); + const accur = Formulas.calcAccur(level, dex, number(equipment.accur, number(profile.base.accur))) + add(profile, 'pAccuracyCombatAdd', timestamp); + const evasion = Math.round((Formulas.calcEvasion(level, dex, number(equipment.evasion, number(profile.base.evasion))) + + add(profile, 'pEvasionRateAdd', timestamp)) * multiplier(profile, 'pEvasionMul', timestamp)); + const critical = (Formulas.calcCritical(dex, number(equipment.critical, number(profile.base.critical))) + * multiplier(profile, 'pCritRateMul', timestamp)) + add(profile, 'pCritRateAdd', timestamp); + const atkSpd = Math.round(Formulas.calcAtkSpd(dex, number(equipment.atkSpd, number(profile.base.atkSpd))) + * multiplier(profile, 'pAtkSpdMul', timestamp)); + const castSpd = Math.round(Formulas.calcCastSpd(wit) * multiplier(profile, 'castSpdMul', timestamp)); + return { + ...profile, level, maxHp: Math.max(1, maxHp), maxMp: Math.max(1, maxMp), pAtk: Math.max(1, pAtk), mAtk: Math.max(1, mAtk), + pDef: Math.max(1, pDef), mDef: Math.max(1, mDef), accur: Math.max(1, accur), evasion: Math.max(0, evasion), + critical: Math.max(0, critical), atkSpd: Math.max(1, atkSpd), castSpd: Math.max(1, castSpd), + weaponMask: (WEAPON_MASK_BY_KIND[equipment.weaponKind] || 0) | (number(equipment.shieldPDef) > 0 ? 1048576 : 0) + }; +} + +function offensiveSkills(profile) { + const allowed = new Set([C4SkillRules.DAMAGE, C4SkillRules.DAMAGE_EFFECT, C4SkillRules.DEATH_LINK, C4SkillRules.DRAIN, C4SkillRules.BLOW, C4SkillRules.AGGRO_DAMAGE]); + return (profile.skills || []).filter((skill) => { + if (skill.passive) return false; + const semantic = C4SkillRules.resolve(skill); + const required = number(semantic.requires?.weaponsAllowed); + return semantic.target === 'enemy' && allowed.has(semantic.skillType) && !semantic.notUsedInC4 + && (!required || (required & profile.weaponMask) !== 0); + }); +} + +function npcForSpot(spot = {}, rng = Math.random) { + 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 npc = (DataCache.npcs || []).find((entry) => Number(entry.selfId) === Number(selected?.selfId)); + if (!npc) return null; + return { + selfId: number(npc.selfId), level: number(npc.template?.level, number(spot.avgLevel, 1)), + maxHp: Math.max(1, number(npc.vitals?.maxHp, spot.mob?.hp)), pAtk: Math.max(1, number(npc.stats?.pAtk, spot.mob?.damage)), + pAtkRnd: number(npc.stats?.pAtkRnd), pDef: Math.max(1, number(npc.stats?.pDef, 1)), mDef: Math.max(1, number(npc.stats?.mDef, 1)), + accur: Math.max(1, number(npc.stats?.accur, 1)), evasion: Math.max(0, number(npc.stats?.evasion)), + critical: Math.max(0, number(npc.stats?.crit)), atkSpd: Math.max(1, number(npc.stats?.atkSpd, 253)), + mAtk: Math.max(1, number(npc.stats?.mAtk)), castSpd: Math.max(1, number(npc.stats?.castSpd, 333)) + }; +} + +module.exports = { PROFILE_VERSION, capture, legacySnapshot, needsDatabaseBackfill, profileFor, offensiveSkills, npcForSpot, skillSnapshotsFromRecords }; diff --git a/src/GameServer/Bot/Population/GeneratedColdSeeder.js b/src/GameServer/Bot/Population/GeneratedColdSeeder.js index ea85a0b2..d8cd84d2 100644 --- a/src/GameServer/Bot/Population/GeneratedColdSeeder.js +++ b/src/GameServer/Bot/Population/GeneratedColdSeeder.js @@ -11,6 +11,7 @@ const BotClassProgression = invoke('GameServer/Bot/BotClassProgression'); const CraftShopService = invoke('GameServer/Bot/Economy/CraftShopService'); const SeedPlanner = invoke('GameServer/Bot/Population/PopulationSeedPlanner'); const BotNameGenerator = invoke('GameServer/Bot/Population/BotNameGenerator'); +const ColdCombatProfile = invoke('GameServer/Bot/Population/ColdCombatProfile'); const NAME_GENERATOR_VERSION = 2; @@ -381,6 +382,20 @@ function stateFor(character, index, seedMeta = {}) { }; } +function hydrateColdCombatProfile(state) { + return LifeState.refreshInventory(state, { equip: true }) + .then((refreshed) => Database.fetchSkills(refreshed.characterId).then((skills) => ({ + ...refreshed, + stats: { + ...(refreshed.stats || {}), + // A generated cold bot has no hot actor to snapshot. Its DB + // skills and equipped inventory are its authoritative model + // from the first resolve, not a class-tree approximation. + coldCombat: ColdCombatProfile.legacySnapshot(refreshed, skills) + } + }))); +} + function craftServiceSeedState(existingState, seedState) { if (existingState) { // Preserve lifecycle (especially an active hot actor), but never retain @@ -512,11 +527,13 @@ const GeneratedColdSeeder = { spot, loc: result.loc || randomNear(spot.center, index) }); - return LifeState.upsertState(state, 'population_wave_seed').then((saved) => { - if (saved && result.created) created += 1; - if (saved) seeded += 1; - return saved; - }); + return hydrateColdCombatProfile(state) + .then((profiledState) => LifeState.upsertState(profiledState, 'population_wave_seed')) + .then((saved) => { + if (saved && result.created) created += 1; + if (saved) seeded += 1; + return saved; + }); })); }); diff --git a/src/GameServer/Bot/Population/PopulationConfig.js b/src/GameServer/Bot/Population/PopulationConfig.js index 7b4d0448..589a2d91 100644 --- a/src/GameServer/Bot/Population/PopulationConfig.js +++ b/src/GameServer/Bot/Population/PopulationConfig.js @@ -10,6 +10,8 @@ const DEFAULTS = { // in small batches so restart never becomes a database migration spike. classProgressionMigrationIntervalMs: 10000, classProgressionMigrationBatchSize: 5, + coldCombatProfileMigrationIntervalMs: 10000, + coldCombatProfileMigrationBatchSize: 5, // One-off migration for stores created before market towns were split. // It is deliberately independent from the normal cold-resolve budget. marketTownMigrationIntervalMs: 10000, @@ -17,6 +19,10 @@ const DEFAULTS = { marketExpiryCleanupIntervalMs: 10000, marketExpiryCleanupBatchSize: 10, partyFormationIntervalMs: 45000, + // Waiting for a compatible party is not rest. Formation sees these + // candidates independently every 45 seconds; this is only the rare + // fallback that rebuilds a stale acquisition plan. + partyWaitReplanMs: 5 * 60 * 1000, phasePolicyIntervalMs: 10000, directorIntervalMs: 30000, // Start with every level-one hunting sector. Waves open every five levels @@ -32,10 +38,16 @@ const DEFAULTS = { maxPartyResolvesPerTick: 3, maxMarketGoalReconcilesPerTick: 8, partyFormationBatchSize: 3, - partyFormationCandidateLimit: 80, + // Forming is an infrequent event. Read enough waiting candidates to let + // the three available slots reach distinct crowded grounds instead of + // letting the two largest queues consume the whole selection window. + partyFormationCandidateLimit: 250, partyMinSize: 2, partyMaxSize: 5, - maxBackgroundParties: 20, + // At roughly one party resolve per 90 seconds, forty parties consume + // about 27 of the 36 bounded resolves available each minute. This opens + // enough party-wait capacity without increasing work in a scheduler tick. + maxBackgroundParties: 40, cooldownGraceMs: 120000, cooldownBatchSize: 20, cooldownRadius: 11000, diff --git a/src/GameServer/Bot/Population/PopulationMetrics.js b/src/GameServer/Bot/Population/PopulationMetrics.js index 7a504a2c..d8efc2d7 100644 --- a/src/GameServer/Bot/Population/PopulationMetrics.js +++ b/src/GameServer/Bot/Population/PopulationMetrics.js @@ -9,6 +9,9 @@ function emptyCounters() { hotTicks: 0, backgroundResolves: 0, partyResolves: 0, + combatActions: 0, + skillUses: 0, + heals: 0, skippedResolves: 0, activations: 0, cooldowns: 0, @@ -102,6 +105,12 @@ const PopulationMetrics = { this.counters.partyResolves += 1; }, + recordCombat(debug = {}) { + this.counters.combatActions += Math.max(0, Number(debug.combatActions) || 0); + this.counters.skillUses += Math.max(0, Number(debug.skillUses) || 0); + this.counters.heals += Math.max(0, Number(debug.heals) || 0); + }, + recordSkippedResolve() { this.counters.skippedResolves += 1; }, diff --git a/src/GameServer/Bot/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js index 5db494ed..abb72621 100644 --- a/src/GameServer/Bot/Population/PopulationService.js +++ b/src/GameServer/Bot/Population/PopulationService.js @@ -23,7 +23,7 @@ const GearAcquisitionPlanner = invoke('GameServer/Bot/AI/GearAcquisitionPlanner' const ColdCraftingService = invoke('GameServer/Bot/Economy/ColdCraftingService'); const CraftTelemetry = invoke('GameServer/Bot/Economy/CraftTelemetry'); -function groupBySpot(states) { +function groupBySpot(states, options = {}) { const grouped = new Map(); states.forEach((state) => { const planSpotId = !SpotProfiles.isProtectedStarterCohort(state) @@ -36,13 +36,30 @@ function groupBySpot(states) { grouped.get(spotId).push(state); }); - return Array.from(grouped.values()) - .map((group) => group.sort((a, b) => Number(a.level || 1) - Number(b.level || 1))) + const activePartiesBySpot = options.activePartiesBySpot || new Map(); + return Array.from(grouped.entries()) + .map(([spotId, group]) => ({ + spotId, + states: group.sort((a, b) => Number(a.level || 1) - Number(b.level || 1)), + partyWaiters: group.filter((state) => state.activity === 'party_wait').length, + oldestPartyWaitAt: Math.min(...group + .filter((state) => state.activity === 'party_wait') + .map((state) => Number(state.timing?.activityStartedAt || state.updatedAt || Date.now()))) + })) .sort((a, b) => { - const aPlanned = a.filter((state) => state.stats?.equipmentPlan?.status === 'active').length; - const bPlanned = b.filter((state) => state.stats?.equipmentPlan?.status === 'active').length; - return bPlanned - aPlanned || b.length - a.length; - }); + if (options.prioritizePartyWait) { + const aDeficit = a.partyWaiters / (1 + Number(activePartiesBySpot.get(a.spotId) || 0)); + const bDeficit = b.partyWaiters / (1 + Number(activePartiesBySpot.get(b.spotId) || 0)); + if (aDeficit !== bDeficit) return bDeficit - aDeficit; + if (a.oldestPartyWaitAt !== b.oldestPartyWaitAt) return a.oldestPartyWaitAt - b.oldestPartyWaitAt; + } + const aGroup = a.states; + const bGroup = b.states; + const aPlanned = aGroup.filter((state) => state.stats?.equipmentPlan?.status === 'active').length; + const bPlanned = bGroup.filter((state) => state.stats?.equipmentPlan?.status === 'active').length; + return bPlanned - aPlanned || bGroup.length - aGroup.length; + }) + .map((group) => group.states); } function partySpotForLeader(leader) { @@ -130,11 +147,13 @@ const PopulationService = { seedTimer: null, classProgressionMigrationTimer: null, marketTownMigrationTimer: null, + nextColdCombatProfileMigrationAt: 0, nextMarketTownMigrationAt: 0, marketExpiryCleanupTimer: null, nextMarketExpiryCleanupAt: 0, resolving: false, classProgressionMigrationRunning: false, + coldCombatProfileMigrationRunning: false, marketTownMigrationRunning: false, marketExpiryCleanupRunning: false, partyFormationRunning: false, @@ -263,6 +282,7 @@ const PopulationService = { clearInterval(this.classProgressionMigrationTimer); this.classProgressionMigrationTimer = null; } + this.nextColdCombatProfileMigrationAt = 0; if (this.marketTownMigrationTimer) { clearInterval(this.marketTownMigrationTimer); this.marketTownMigrationTimer = null; @@ -331,6 +351,35 @@ const PopulationService = { }); }, + migrateLegacyColdCombatProfiles() { + if (this.coldCombatProfileMigrationRunning || this.resolving || this.classProgressionMigrationRunning || Config.enabled === false) { + return Promise.resolve([]); + } + this.coldCombatProfileMigrationRunning = true; + return LifeState.migrateLegacyColdCombatProfiles(Config.coldCombatProfileMigrationBatchSize) + .then((migrated) => { + if (migrated.length) { + console.info('BotPopulation :: migrated cold combat profiles for %d bot(s)', migrated.length); + } + return migrated; + }) + .catch((err) => { + utils.infoWarn('BotPopulation', 'legacy cold combat profile migration failed: %s', err.message); + return []; + }) + .finally(() => { + this.coldCombatProfileMigrationRunning = false; + }); + }, + + maybeMigrateLegacyColdCombatProfiles(timestamp = Date.now()) { + if (this.coldCombatProfileMigrationRunning || timestamp < this.nextColdCombatProfileMigrationAt) { + return Promise.resolve([]); + } + this.nextColdCombatProfileMigrationAt = timestamp + Config.coldCombatProfileMigrationIntervalMs; + return this.migrateLegacyColdCombatProfiles(); + }, + migrateLegacyMarketTowns() { // This migration is deliberately bounded and serialized. Do not gate // it on `resolving`: both timers share a 10-second cadence, which can @@ -549,16 +598,27 @@ const PopulationService = { } this.partyFormationRunning = true; - return LifeState.coldPartyCandidates(Config.partyFormationCandidateLimit) - .then((states) => this.recruitBackgroundMembers(states).then((recruitedIds) => ({ - states: states.filter((state) => !recruitedIds.has(Number(state.characterId))) - }))) - .then(({ states }) => { + return LifeState.coldPartyCandidates(Config.partyFormationCandidateLimit, true) + .then((partyWaitStates) => (partyWaitStates.length + ? { states: partyWaitStates, partyWaitBacklog: true } + : LifeState.coldPartyCandidates(Config.partyFormationCandidateLimit) + .then((states) => ({ states, partyWaitBacklog: false })))) + .then(({ states, partyWaitBacklog }) => this.reclaimBackgroundPartyCapacity(partyWaitBacklog ? states : []) + .then(() => this.recruitBackgroundMembers(states)).then((recruitedIds) => ({ + states: states.filter((state) => !recruitedIds.has(Number(state.characterId))), + partyWaitBacklog + }))) + .then(({ states, partyWaitBacklog }) => { const activeParties = BackgroundPartyState.counts().active || 0; const slots = Math.max(0, Config.maxBackgroundParties - activeParties); if (slots <= 0) return []; const maxNewParties = Math.min(slots, Config.partyFormationBatchSize); - const groups = groupBySpot(states); + const activePartiesBySpot = BackgroundPartyState.active().reduce((counts, party) => { + const spotId = String(party.spotId || ''); + if (spotId) counts.set(spotId, Number(counts.get(spotId) || 0) + 1); + return counts; + }, new Map()); + const groups = this.groupPartyCandidatesBySpot(states, { prioritizePartyWait: partyWaitBacklog, activePartiesBySpot }); const created = []; return groups.reduce((chain, group) => chain.then(() => { @@ -634,6 +694,35 @@ const PopulationService = { }); }, + groupPartyCandidatesBySpot(states = [], options = {}) { + return groupBySpot(states, options); + }, + + reclaimBackgroundPartyCapacity(partyWaitStates = []) { + if (!partyWaitStates.length) return Promise.resolve([]); + const activeParties = BackgroundPartyState.active(); + const availableSlots = Math.max(0, Config.maxBackgroundParties - activeParties.length); + const wantedSlots = Math.min( + Config.partyFormationBatchSize, + Math.floor(partyWaitStates.length / Math.max(1, Config.partyMinSize)) + ); + const reclaimCount = Math.max(0, wantedSlots - availableSlots); + if (!reclaimCount || !activeParties.length) return Promise.resolve([]); + + return LifeState.partyRequirementCounts(activeParties.map((party) => party.partyId)) + .then((counts) => { + const countByPartyId = new Map(counts.map((count) => [count.partyId, count])); + return activeParties + .filter((party) => Number(countByPartyId.get(party.partyId)?.requiredMembers || 0) === 0) + .sort((a, b) => Number(a.startedAt || 0) - Number(b.startedAt || 0)) + .slice(0, reclaimCount); + }) + .then((parties) => parties.reduce((chain, party) => ( + chain.then((reclaimed) => dissolveBackgroundParty(party, 'party_capacity_reclaimed', party.memberIds?.length || 0) + .then(() => [...reclaimed, party])) + ), Promise.resolve([]))); + }, + recruitBackgroundMembers(candidates = []) { const claimed = new Set(); const parties = BackgroundPartyState.active() @@ -683,8 +772,8 @@ const PopulationService = { }, tickBudgeted() { - if (this.resolving || this.classProgressionMigrationRunning || Config.enabled === false || Config.backgroundResolverEnabled === false) { - if (this.resolving || this.classProgressionMigrationRunning) { + if (this.resolving || this.classProgressionMigrationRunning || this.coldCombatProfileMigrationRunning || Config.enabled === false || Config.backgroundResolverEnabled === false) { + if (this.resolving || this.classProgressionMigrationRunning || this.coldCombatProfileMigrationRunning) { Metrics.recordSchedulerSkip(); } return Promise.resolve([]); @@ -735,6 +824,10 @@ const PopulationService = { // use its post-resolve edge as a reliable fallback for the // bounded legacy-store transition timer. this.maybeMigrateLegacyMarketTowns(); + // Keep the bounded profile migration outside the active + // scheduler slot. An independent timer can otherwise make a + // normal five-second tick look busy and skip its resolves. + this.maybeMigrateLegacyColdCombatProfiles(); }); }, @@ -863,6 +956,7 @@ const PopulationService = { }); }).then((updatedParty) => { Metrics.recordPartyResolve(); + Metrics.recordCombat(result.debug); const recruitment = PartyRecruitmentChat.maybeAnnounce(updatedParty, members, spot); const persistedParty = recruitment.announced ? BackgroundPartyState.createOrUpdate(recruitment.party) @@ -891,11 +985,36 @@ const PopulationService = { resolveColdState(state) { const startedAt = Date.now(); + 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. + if (state.activity === 'traveling' || (state.activity === 'resting' && Number(state.stats?.restUntil || 0) > 0)) { + const result = BackgroundResolver.resolveSolo({ + state, + spot: null, + pressure: Director.pressureForState(state), + elapsedMs, + timestamp: startedAt + }); + return LifeState.applyResolve(state, result).then((updatedState) => { + if (!updatedState) { + Metrics.recordSkippedResolve(); + return { ok: false, reason: 'apply_failed', state }; + } + Metrics.recordBackgroundResolve(); + Metrics.recordCombat(result.debug); + return LifeEvents.recordMany(state.characterId, result.events).then(() => ({ + ok: true, + state: updatedState, + debug: result.debug + })); + }).finally(() => Metrics.recordResolveDuration(Date.now() - startedAt)); + } if (GearAcquisitionPlanner.isCraftService(state)) { const { equipmentPlan, ...serviceStats } = state.stats || {}; const serviceState = { ...state, - timing: { ...(state.timing || {}), nextResolveAt: Date.now() + 60000 }, + timing: { ...(state.timing || {}), nextResolveAt: null }, stats: serviceStats }; return LifeState.upsertState(serviceState, 'craft_service_idle') @@ -932,11 +1051,16 @@ const PopulationService = { }; const planEvents = CraftTelemetry.planEvents(state, previousPlan, acquisitionPlan); if (acquisitionPlan.requiresParty && !state.party?.partyId) { + const partyWaitUntil = Date.now() + Config.partyWaitReplanMs; const partyWaitState = { ...plannedState, - activity: 'resting', + // Party formation reads these candidates independently of the + // combat scheduler. Do not disguise the wait as recovery and + // consume a resolve every 30 seconds. + activity: 'party_wait', spotId: acquisitionPlan.next?.spotId || state.spotId, - timing: { ...(state.timing || {}), nextResolveAt: Date.now() + 30000 } + timing: { ...(state.timing || {}), nextResolveAt: partyWaitUntil }, + stats: { ...(plannedState.stats || {}), partyWaitUntil, restUntil: null } }; return LifeState.upsertState(partyWaitState, 'acquisition_party_wait') .then((saved) => Promise.all(planEvents.map((event) => ( @@ -1007,7 +1131,6 @@ const PopulationService = { return Promise.resolve({ ok: false, reason: 'missing_spot', state }); } - const elapsedMs = state.timing?.lastResolvedAt ? Math.max(1000, Date.now() - state.timing.lastResolvedAt) : 60000; const result = BackgroundResolver.resolveSolo({ state: travellingState, spot, @@ -1024,6 +1147,7 @@ const PopulationService = { } Metrics.recordBackgroundResolve(); + Metrics.recordCombat(result.debug); if (updatedState.activity === 'crafting') { return { ok: true, state: updatedState, debug: result.debug }; } diff --git a/src/GameServer/Bot/Population/PopulationStatus.js b/src/GameServer/Bot/Population/PopulationStatus.js index 022d4ed5..f4f86737 100644 --- a/src/GameServer/Bot/Population/PopulationStatus.js +++ b/src/GameServer/Bot/Population/PopulationStatus.js @@ -40,7 +40,7 @@ const PopulationStatus = { ...counts, metrics, director: Director.snapshot(), - line: `hot=${counts.hot} warm=${counts.warm} cold=${counts.cold} parties=${counts.parties} persisted=${counts.persisted} merchants=${counts.merchants} ticks=${metrics.delta.hotTicks} resolves=${metrics.delta.backgroundResolves} partyResolves=${metrics.delta.partyResolves} skipped=${metrics.delta.skippedResolves} activations=${metrics.delta.activations} cooldowns=${metrics.delta.cooldowns} partyForms=${metrics.delta.partyFormations} partyRecruits=${metrics.delta.partyRecruits} partyDissolves=${metrics.delta.partyDissolutions} dbFlushes=${metrics.delta.dbFlushes} resolveAvg=${resolve.avgMs || 0}ms resolveP95=${resolve.p95Ms || 0}ms schedulerP95=${scheduler.p95Ms || 0}ms schedulerSkips=${metrics.delta.schedulerSkips || 0} schedulerOverruns=${metrics.delta.schedulerOverruns || 0} slowResolves=${metrics.delta.slowResolves || 0} heap=${heapMb}MB lag=${lag}ms maxLag=${maxLag}ms ${Director.statusLine()}` + line: `hot=${counts.hot} warm=${counts.warm} cold=${counts.cold} parties=${counts.parties} persisted=${counts.persisted} merchants=${counts.merchants} ticks=${metrics.delta.hotTicks} resolves=${metrics.delta.backgroundResolves} partyResolves=${metrics.delta.partyResolves} combatActions=${metrics.delta.combatActions} skillUses=${metrics.delta.skillUses} heals=${metrics.delta.heals} skipped=${metrics.delta.skippedResolves} activations=${metrics.delta.activations} cooldowns=${metrics.delta.cooldowns} partyForms=${metrics.delta.partyFormations} partyRecruits=${metrics.delta.partyRecruits} partyDissolves=${metrics.delta.partyDissolutions} dbFlushes=${metrics.delta.dbFlushes} resolveAvg=${resolve.avgMs || 0}ms resolveP95=${resolve.p95Ms || 0}ms schedulerP95=${scheduler.p95Ms || 0}ms schedulerSkips=${metrics.delta.schedulerSkips || 0} schedulerOverruns=${metrics.delta.schedulerOverruns || 0} slowResolves=${metrics.delta.slowResolves || 0} heap=${heapMb}MB lag=${lag}ms maxLag=${maxLag}ms ${Director.statusLine()}` }; } }; diff --git a/tests/test_bot_background_party_recruitment.js b/tests/test_bot_background_party_recruitment.js index d2ab83cb..357fc022 100644 --- a/tests/test_bot_background_party_recruitment.js +++ b/tests/test_bot_background_party_recruitment.js @@ -12,10 +12,15 @@ const originals = { active: PartyState.active, statesForParty: LifeState.statesForParty, assignParty: LifeState.assignParty, + partyRequirementCounts: LifeState.partyRequirementCounts, + clearParty: LifeState.clearParty, createOrUpdate: PartyState.createOrUpdate, + setStatus: PartyState.setStatus, record: LifeEvents.record, partyMinSize: Config.partyMinSize, - partyMaxSize: Config.partyMaxSize + partyMaxSize: Config.partyMaxSize, + maxBackgroundParties: Config.maxBackgroundParties, + partyFormationBatchSize: Config.partyFormationBatchSize }; async function run() { @@ -57,6 +62,39 @@ async function run() { assert.deepStrictEqual(saved.memberIds, [1, 2, 3, 4]); assert.deepStrictEqual(saved.roleCoverage, { tank: 1, healer: 1, buffer: 1, dps: 1 }); assert.strictEqual(events.length, 1); + + const fairGroups = PopulationService.groupPartyCandidatesBySpot([ + { characterId: 101, level: 10, spotId: 'crowded', activity: 'party_wait', timing: { activityStartedAt: 20 } }, + { characterId: 102, level: 10, spotId: 'crowded', activity: 'party_wait', timing: { activityStartedAt: 20 } }, + { characterId: 103, level: 10, spotId: 'crowded', activity: 'party_wait', timing: { activityStartedAt: 20 } }, + { characterId: 104, level: 10, spotId: 'under_served', activity: 'party_wait', timing: { activityStartedAt: 10 } }, + { characterId: 105, level: 10, spotId: 'under_served', activity: 'party_wait', timing: { activityStartedAt: 10 } } + ], { + prioritizePartyWait: true, + activePartiesBySpot: new Map([['crowded', 5]]) + }); + assert.strictEqual(fairGroups[0][0].spotId, 'under_served', 'party-wait groups must prefer a ground with no existing party over a larger but already saturated queue'); + + const electiveParty = { partyId: 'bgp_elective', leaderId: 11, memberIds: [11, 12], spotId: 'cruma', startedAt: 1 }; + const requiredParty = { partyId: 'bgp_required', leaderId: 21, memberIds: [21, 22], spotId: 'dion', startedAt: 2 }; + const reclaimed = []; + PartyState.active = () => [electiveParty, requiredParty]; + PartyState.setStatus = (partyId, status) => { + reclaimed.push({ partyId, status }); + return Promise.resolve({ partyId, status }); + }; + LifeState.clearParty = () => Promise.resolve(2); + LifeState.partyRequirementCounts = () => Promise.resolve([ + { partyId: 'bgp_elective', requiredMembers: 0 }, + { partyId: 'bgp_required', requiredMembers: 2 } + ]); + Config.maxBackgroundParties = 2; + Config.partyFormationBatchSize = 2; + const released = await PopulationService.reclaimBackgroundPartyCapacity([ + { characterId: 31 }, { characterId: 32 }, { characterId: 33 }, { characterId: 34 } + ]); + assert.deepStrictEqual(released.map((party) => party.partyId), ['bgp_elective']); + assert.deepStrictEqual(reclaimed, [{ partyId: 'bgp_elective', status: 'dissolved' }]); console.log('Bot background party recruitment checks passed'); } @@ -67,8 +105,13 @@ run().catch((err) => { PartyState.active = originals.active; LifeState.statesForParty = originals.statesForParty; LifeState.assignParty = originals.assignParty; + LifeState.partyRequirementCounts = originals.partyRequirementCounts; + LifeState.clearParty = originals.clearParty; PartyState.createOrUpdate = originals.createOrUpdate; + PartyState.setStatus = originals.setStatus; LifeEvents.record = originals.record; Config.partyMinSize = originals.partyMinSize; Config.partyMaxSize = originals.partyMaxSize; + Config.maxBackgroundParties = originals.maxBackgroundParties; + Config.partyFormationBatchSize = originals.partyFormationBatchSize; }); diff --git a/tests/test_bot_background_rest_scheduling.js b/tests/test_bot_background_rest_scheduling.js new file mode 100644 index 00000000..a4e13b74 --- /dev/null +++ b/tests/test_bot_background_rest_scheduling.js @@ -0,0 +1,63 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BackgroundResolver = invoke('GameServer/Bot/Population/BackgroundResolver'); +const BackgroundPartyResolver = invoke('GameServer/Bot/Population/BackgroundPartyResolver'); + +const timestamp = 1_000_000; +const restUntil = timestamp + 24 * 60 * 60 * 1000; +const exhausted = { + characterId: 81, + name: 'TiredSolo', + level: 20, + levelBand: '18-22', + activity: 'resting', + vitals: { hp: 10, maxHp: 800, mp: 0, maxMp: 420 }, + stats: { restUntil }, + party: { role: 'dps' } +}; + +const solo = BackgroundResolver.resolveSolo({ state: exhausted, spot: null, elapsedMs: 0, timestamp }); +assert.strictEqual(solo.patch.activity, 'resting'); +assert.strictEqual(solo.nextResolveAt, restUntil, 'a resting solo bot must sleep until its persisted recovery deadline'); + +const party = { partyId: 'rest-scheduling', cohesion: 0.7, risk: 0.2, stats: { restUntil } }; +const spot = { id: 'test_spot', name: 'Test Spot', center: {}, rewards: { exp: 1, sp: 1, adenaMin: 1, adenaMax: 1 } }; +const restedParty = BackgroundPartyResolver.resolve({ + party, + members: [exhausted, { + ...exhausted, + characterId: 82, + name: 'ReadyMember', + vitals: { hp: 800, maxHp: 800, mp: 420, maxMp: 420 }, + stats: {} + }], + spot, + elapsedMs: 0, + timestamp +}); +assert.strictEqual(restedParty.nextResolveAt, restUntil, 'a resting party must share one recovery deadline'); +assert(restedParty.memberResults.every(({ result }) => result.patch.activity === 'resting'), 'ready members must remain seated with their recovering party'); +assert.strictEqual(restedParty.partyPatch.stats.restUntil, restUntil, 'the common party deadline must be persisted'); + +const combatRestParty = BackgroundPartyResolver.resolve({ + party: { partyId: 'combat-rest', cohesion: 0.7, risk: 0.2, roleCoverage: { dps: 2 }, stats: {} }, + members: [ + { ...exhausted, characterId: 83, activity: 'grouped', vitals: { hp: 800, maxHp: 800, mp: 1, maxMp: 420 }, stats: {}, party: { role: 'dps' } }, + { ...exhausted, characterId: 84, activity: 'grouped', vitals: { hp: 800, maxHp: 800, mp: 420, maxMp: 420 }, stats: {}, party: { role: 'dps' } } + ], + spot, + elapsedMs: 10_000, + timestamp, + rng: () => 0 +}); +assert(combatRestParty.partyPatch.stats.restUntil > timestamp, 'combat exhaustion must create a shared party recovery deadline immediately'); +assert.strictEqual(combatRestParty.nextResolveAt, combatRestParty.partyPatch.stats.restUntil); +assert(combatRestParty.memberResults.every(({ result }) => result.patch.activity === 'resting'), 'a party combat rest must seat every living member together'); +assert( + combatRestParty.memberResults.some(({ result }) => Number(result.materialize.exp || 0) > 0), + 'the combat window that triggered rest must still award its completed fight rewards' +); + +console.log('Bot background rest scheduling checks passed'); diff --git a/tests/test_bot_cold_combat.js b/tests/test_bot_cold_combat.js new file mode 100644 index 00000000..70fa05b4 --- /dev/null +++ b/tests/test_bot_cold_combat.js @@ -0,0 +1,162 @@ +const assert = require('assert'); + +require('../src/Global'); + +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 PopulationService = invoke('GameServer/Bot/Population/PopulationService'); +const PopulationMetrics = invoke('GameServer/Bot/Population/PopulationMetrics'); + +DataCache.init(); + +const timestamp = 1_750_000_000_000; +const fighter = { + characterId: 901, + name: 'ColdFighter', + level: 12, + activity: 'hunting', + vitals: { hp: 500, maxHp: 500, mp: 200, maxMp: 200 }, + stats: { + classId: 0, + coldCombat: { + version: 1, + classId: 0, + base: { str: 40, dex: 30, con: 43, int: 21, wit: 11, men: 25 }, + equipment: { weaponKind: 'Weapon.Sword', pAtk: 80, pAtkRnd: 10, mAtk: 40, atkSpd: 379, critical: 80, accur: 0, pDef: 120, mDef: 50, evasion: 0, bonusMp: 0, shieldPDef: 0 }, + effects: [{ key: 'might', id: 1, type: 'buff', expiresAt: timestamp + 60000, stats: { pAtkMul: 2 } }], + skills: [{ selfId: 3, level: 4, passive: false, spell: false, power: 51, mp: 7, hitTime: 0, reuse: 2000 }] + } + }, + inventory: {}, + party: { role: 'dps' } +}; + +const buffed = ColdCombatProfile.profileFor(fighter, timestamp); +const expired = ColdCombatProfile.profileFor(fighter, timestamp + 60001); +assert(buffed.pAtk > expired.pAtk, 'an active persisted buff must affect cold combat and expire by its real deadline'); +assert.strictEqual(ColdCombatProfile.offensiveSkills(buffed).length, 1, 'the profile must retain compatible learned combat skills'); +const legacyBuffed = ColdCombatProfile.profileFor({ + ...fighter, + stats: { ...fighter.stats, coldCombat: { ...fighter.stats.coldCombat, effects: [{ key: 'might', id: 1068, type: 'buff', expiresAt: timestamp + 60000, stats: {} }] } } +}, timestamp); +assert(legacyBuffed.pAtk > expired.pAtk, 'legacy catalog buffs with an empty effect payload must retain their C4 stat bonus'); +const persistedSkills = ColdCombatProfile.skillSnapshotsFromRecords([ + { selfId: 3, level: 4, passive: false }, + { selfId: 999999, level: 1, passive: false } +]); +assert.deepStrictEqual(persistedSkills.map((skill) => [skill.selfId, skill.level]), [[3, 4]], 'legacy migration must use persisted skill rows and ignore only unknown datapack entries'); +const migratedSnapshot = ColdCombatProfile.legacySnapshot(fighter, [{ selfId: 3, level: 4 }], timestamp); +assert.strictEqual(migratedSnapshot.skills[0].level, 4, 'legacy snapshot must preserve the stored skill level'); +assert.strictEqual(migratedSnapshot.skillSource, 'database', 'legacy snapshot must prevent a later fallback from replacing persisted skills'); +assert.strictEqual(migratedSnapshot.version, ColdCombatProfile.PROFILE_VERSION, 'a database snapshot must record the current completeness contract'); +assert.strictEqual(ColdCombatProfile.needsDatabaseBackfill({ skillSource: 'database', version: ColdCombatProfile.PROFILE_VERSION - 1 }), true, 'a previous-version database snapshot must be repaired once'); +assert.strictEqual(ColdCombatProfile.needsDatabaseBackfill(migratedSnapshot), false, 'a current database snapshot must not be rescanned on every migration tick'); +assert.strictEqual(ColdCombatProfile.needsDatabaseBackfill({ version: ColdCombatProfile.PROFILE_VERSION }), true, 'a class-tree fallback without a database source must still be migrated'); + +const changedGear = ColdCombatProfile.profileFor({ + ...fighter, + inventory: { '1': { selfId: 1, equipped: true } }, + stats: { ...fighter.stats, coldCombat: { ...fighter.stats.coldCombat, equipment: { ...fighter.stats.coldCombat.equipment, pAtk: 1 } } } +}, timestamp); +assert.strictEqual(changedGear.equipment.pAtk, 8, 'cold inventory equipment must override a stale hot snapshot after a market or craft upgrade'); + +const spot = { + id: 'gremlin_field', + name: 'Gremlin field', + avgLevel: 1, + density: 1, + npcSelfIds: [1], + rewards: { exp: 10, sp: 2, adenaMin: 1, adenaMax: 1 }, + // Deliberately contradictory placeholders: the resolver must take the + // real NPC profile from the datapack when npcSelfIds are available. + mob: { hp: 1, damage: 9999 } +}; +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 injuredTank = { + ...fighter, + characterId: 902, + vitals: { hp: 10, maxHp: 500, mp: 200, maxMp: 200 }, + party: { role: 'tank' }, + stats: { ...fighter.stats, coldCombat: { ...fighter.stats.coldCombat, equipment: { ...fighter.stats.coldCombat.equipment, pAtk: 1, critical: 0 } } } +}; +const healer = { + ...fighter, + characterId: 903, + party: { role: 'healer' }, + stats: { + ...fighter.stats, + coldCombat: { + ...fighter.stats.coldCombat, + skills: [{ selfId: 69, level: 1, passive: false, spell: true, power: 120, mp: 1, hitTime: 1000, reuse: 1000 }] + } + } +}; +const partyFight = BackgroundResolver.resolvePartyFight({ + members: [injuredTank, healer], + spot: { ...spot, npcSelfIds: [], mob: { hp: 10000, damage: 1 } }, + timestamp, + rng: () => 0.1 +}); +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 partyResult = BackgroundPartyResolver.resolve({ + party: { partyId: 'cold_combat_party', cohesion: 1, risk: 0, roleCoverage: { tank: 1, healer: 1 }, stats: {} }, + members: [injuredTank, healer], + spot: { ...spot, npcSelfIds: [], mob: { hp: 10000, damage: 1 } }, + elapsedMs: 12000, + timestamp, + rng: () => 0.1 +}); +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 originalMetrics = { + counters: PopulationMetrics.counters, + lastSummaryCounters: PopulationMetrics.lastSummaryCounters +}; +PopulationMetrics.counters = Object.fromEntries(Object.keys(PopulationMetrics.counters).map((key) => [key, 0])); +PopulationMetrics.lastSummaryCounters = { ...PopulationMetrics.counters }; +PopulationMetrics.recordCombat(result.debug); +PopulationMetrics.recordCombat(partyResult.debug); +const combatMetrics = PopulationMetrics.snapshot().delta; +assert.strictEqual(combatMetrics.combatActions, result.debug.combatActions + partyResult.debug.combatActions, 'population telemetry must aggregate solo and party combat actions'); +assert.strictEqual(combatMetrics.skillUses, result.debug.skillUses + partyResult.debug.skillUses, 'population telemetry must aggregate cold skill casts'); +assert.strictEqual(combatMetrics.heals, partyResult.debug.heals, 'population telemetry must aggregate party healing casts'); +PopulationMetrics.counters = originalMetrics.counters; +PopulationMetrics.lastSummaryCounters = originalMetrics.lastSummaryCounters; + +const originalMigrateLegacyColdCombatProfiles = PopulationService.migrateLegacyColdCombatProfiles; +const originalMigrationRunning = PopulationService.coldCombatProfileMigrationRunning; +const originalNextMigrationAt = PopulationService.nextColdCombatProfileMigrationAt; +let profileMigrationCalls = 0; +PopulationService.migrateLegacyColdCombatProfiles = () => { + profileMigrationCalls++; + return Promise.resolve([]); +}; +PopulationService.coldCombatProfileMigrationRunning = false; +PopulationService.nextColdCombatProfileMigrationAt = 0; +Promise.resolve() + .then(() => PopulationService.maybeMigrateLegacyColdCombatProfiles(1000)) + .then(() => PopulationService.maybeMigrateLegacyColdCombatProfiles(1001)) + .then(() => PopulationService.maybeMigrateLegacyColdCombatProfiles(11000)) + .then(() => { + assert.strictEqual(profileMigrationCalls, 2, 'the post-resolve cold-profile migration must run initially and respect its cadence'); + console.log('Cold combat profile checks passed'); + }) + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(() => { + PopulationService.migrateLegacyColdCombatProfiles = originalMigrateLegacyColdCombatProfiles; + PopulationService.coldCombatProfileMigrationRunning = originalMigrationRunning; + PopulationService.nextColdCombatProfileMigrationAt = originalNextMigrationAt; + }); diff --git a/tests/test_bot_cold_market_listing.js b/tests/test_bot_cold_market_listing.js index d1c821aa..0143e773 100644 --- a/tests/test_bot_cold_market_listing.js +++ b/tests/test_bot_cold_market_listing.js @@ -114,15 +114,11 @@ async function run() { const sold = await ListingService.settle(offer); assert.strictEqual(sold.adena, 500 + offer.price); assert.strictEqual(sold.inventory['1'].amount, 0); - assert.strictEqual(sold.stats.marketStore.items[0].count, 0); + assert.strictEqual(sold.activity, 'shopping', 'selling the final item must close the store as part of the trade event'); + assert.strictEqual(sold.stats.marketStore, null); assert(calls.some((call) => call.type === 'amount' && call.id === 21 && call.amount === 0)); assert(calls.some((call) => call.type === 'amount' && call.id === 20 && call.amount === 500 + offer.price)); - const closedResult = await ListingService.resolve(sold, 2000); - const closed = closedResult.state; - assert.strictEqual(closed.activity, 'shopping'); - assert.strictEqual(closed.stats.marketStore, null); - const phantom = { ...opened.state, inventory: { 57: { selfId: 57, name: 'Adena', amount: 500 } } diff --git a/tests/test_bot_cold_travel.js b/tests/test_bot_cold_travel.js index b63c950e..81b81046 100644 --- a/tests/test_bot_cold_travel.js +++ b/tests/test_bot_cold_travel.js @@ -21,6 +21,7 @@ assert.strictEqual(started.stats.travel.townName, 'Giran'); assert.strictEqual(started.stats.travel.method, 'soe_gatekeeper'); assert(started.stats.travel.viaTown, 'market travel should first use SoE to the regional town'); assert.strictEqual(started.stats.travel.arrivalAt - started.stats.travel.startedAt, GoalExecutor.MARKET_TRAVEL_MS); +assert.strictEqual(started.timing.nextResolveAt, started.stats.travel.arrivalAt, 'travel must sleep until its arrival event'); const nativeMidway = { ...started, @@ -33,9 +34,10 @@ const nativeMidway = { } } }; -const nativeTransit = BackgroundResolver.resolveSolo({ state: nativeMidway, spot: null }); +const nativeTransit = BackgroundResolver.resolveSolo({ state: nativeMidway, spot: null, timestamp: nativeMidway.stats.travel.startedAt + 1 }); assert.deepStrictEqual(nativeTransit.patch.loc, state.loc, 'SoE/gatekeeper transit must not visibly interpolate across the map'); assert.strictEqual(nativeTransit.patch.activity, 'traveling', 'native transit must remain traveling until its short cast/transit completes'); +assert.strictEqual(nativeTransit.nextResolveAt, nativeMidway.stats.travel.arrivalAt, 'in-flight travel must retain its exact arrival deadline'); const sellStarted = GoalExecutor.beginMarketTravel({ ...state, activity: 'hunting', stats: {} }, { type: 'sell_inventory', @@ -58,6 +60,7 @@ const arrivedState = { ...started, stats: { ...started.stats, travel: { ...start const arrived = BackgroundResolver.resolveSolo({ state: arrivedState, spot: null }); assert.strictEqual(arrived.patch.activity, 'shopping'); assert.strictEqual(arrived.events[0].type, 'arrived_town'); +assert(arrived.nextResolveAt <= Date.now(), 'arrival must make the shopping event due without another polling delay'); const shoppingState = { ...arrivedState, @@ -72,6 +75,7 @@ assert.strictEqual(returning.stats.travel.reason, 'return_after_market'); assert.strictEqual(returning.stats.travel.arrivalActivity, 'hunting'); assert.strictEqual(returning.stats.travel.method, 'gatekeeper_spot', 'returning from Giran must use the destination gatekeeper instead of walking across the world'); assert.strictEqual(returning.stats.travel.arrivalAt - returning.stats.travel.startedAt, GoalExecutor.GATEKEEPER_SPOT_TRAVEL_MS); +assert.strictEqual(returning.timing.nextResolveAt, returning.stats.travel.arrivalAt, 'return travel must sleep until its arrival event'); const ignoringRemoteLead = GoalExecutor.finishMarketVisit({ ...shoppingState, diff --git a/tests/test_bot_cold_travel_without_spot.js b/tests/test_bot_cold_travel_without_spot.js index 607a3136..d2600e99 100644 --- a/tests/test_bot_cold_travel_without_spot.js +++ b/tests/test_bot_cold_travel_without_spot.js @@ -74,7 +74,7 @@ async function run() { const result = await PopulationService.resolveColdState(state); assert.strictEqual(result.ok, true); assert.strictEqual(receivedSpot, null, 'travel must resolve without a hunting spot'); - assert.strictEqual(planningAtlasRequests, 1, 'travel must still retain the spot atlas while refreshing an equipment plan'); + assert.strictEqual(planningAtlasRequests, 0, 'in-flight travel must not build an equipment plan or load the spot atlas'); console.log('Bot cold travel without spot checks passed'); } diff --git a/tests/test_bot_craft_service_idle.js b/tests/test_bot_craft_service_idle.js index eaf32cd9..7ad5523f 100644 --- a/tests/test_bot_craft_service_idle.js +++ b/tests/test_bot_craft_service_idle.js @@ -38,7 +38,7 @@ async function run() { assert.strictEqual(result.debug.activity, 'craft_service_idle'); assert.strictEqual(saved.reason, 'craft_service_idle'); assert.strictEqual(saved.state.stats.equipmentPlan, undefined); - assert.ok(Number(saved.state.timing.nextResolveAt) > Date.now()); + assert.strictEqual(saved.state.timing.nextResolveAt, null, 'idle craft stations must not poll through the cold scheduler'); console.log('Bot craft service idle checks passed'); } diff --git a/tests/test_bot_party_wait.js b/tests/test_bot_party_wait.js new file mode 100644 index 00000000..0bd58a1f --- /dev/null +++ b/tests/test_bot_party_wait.js @@ -0,0 +1,63 @@ +const assert = require('assert'); + +require('../src/Global'); + +const Config = invoke('GameServer/Bot/Population/PopulationConfig'); +const LifeState = invoke('GameServer/Bot/Population/BotLifeState'); +const SpotProfiles = invoke('GameServer/Bot/Population/SpotProfiles'); +const GearPlanner = invoke('GameServer/Bot/AI/GearAcquisitionPlanner'); +const PopulationService = invoke('GameServer/Bot/Population/PopulationService'); + +const originals = { + ensure: SpotProfiles.ensure, + planFor: GearPlanner.planFor, + upsertState: LifeState.upsertState, + partyWaitReplanMs: Config.partyWaitReplanMs +}; + +async function run() { + Config.partyWaitReplanMs = 5 * 60 * 1000; + const state = { + characterId: 9101, + name: 'PartyWaitProbe', + phase: 'cold', + level: 30, + activity: 'hunting', + spotId: 'cruma', + timing: { nextResolveAt: Date.now() - 1 }, + stats: {}, + party: {}, + inventory: {} + }; + let saved = null; + SpotProfiles.ensure = () => []; + GearPlanner.planFor = () => ({ + status: 'active', + requiresParty: true, + next: { spotId: 'cruma' }, + strategy: 'farm' + }); + LifeState.upsertState = (next, reason) => { + saved = { state: next, reason }; + return Promise.resolve(next); + }; + + const result = await PopulationService.resolveColdState(state); + assert.strictEqual(result.ok, true); + assert.strictEqual(saved.reason, 'acquisition_party_wait'); + assert.strictEqual(saved.state.activity, 'party_wait'); + assert.strictEqual(saved.state.stats.restUntil, null, 'party wait must not pretend to be HP/MP recovery'); + assert(saved.state.stats.partyWaitUntil >= Date.now() + Config.partyWaitReplanMs - 1000); + assert.strictEqual(saved.state.timing.nextResolveAt, saved.state.stats.partyWaitUntil, 'only the rare replan deadline belongs to the cold queue'); + console.log('Bot party wait scheduling checks passed'); +} + +run().catch((err) => { + console.error(err); + process.exitCode = 1; +}).finally(() => { + SpotProfiles.ensure = originals.ensure; + GearPlanner.planFor = originals.planFor; + LifeState.upsertState = originals.upsertState; + Config.partyWaitReplanMs = originals.partyWaitReplanMs; +}); diff --git a/tests/test_bot_population_state.js b/tests/test_bot_population_state.js index da625b0b..c823aafe 100644 --- a/tests/test_bot_population_state.js +++ b/tests/test_bot_population_state.js @@ -39,6 +39,8 @@ try { assert(craftRecovery, 'bot life init must release stale craft waits after a restart'); assert(craftRecovery.sql.includes("AND activity = 'crafting'"), 'only stale station waits should be recovered as hunters'); assert.strictEqual(craftRecovery.params[1], craftRecovery.params[0], 'recovered craft waits must be due immediately for their replan'); + const partyWaitMigration = statements.find((entry) => entry.sql.includes("migrated %d acquisition party waits") || entry.sql.includes("activity = 'party_wait'")); + assert(partyWaitMigration, 'startup must move legacy acquisition waits out of the rest scheduler'); return BotLifeState.upsertState({ characterId: 42, name: 'PersistenceProbe', level: 42, phase: 'cold', activity: 'hunting', timing: { activityStartedAt: 1, nextResolveAt: 2, lastResolvedAt: 1 }, @@ -61,6 +63,28 @@ try { assert(due.sql.includes("WHEN activity IN ('traveling', 'crafting') THEN 0"), 'due cold states must promptly finish travel and crafting transitions'); assert(due.sql.includes("startup_craft_wait_recovery"), 'startup craft recovery must immediately replan before the ordinary hunting backlog'); assert(due.sql.includes('COALESCE(nextResolveAt, 0) ASC'), 'due cold states must remain fair by schedule within each lifecycle bucket'); + return BotLifeState.assignParty({ + characterId: 43, + name: 'PartyWaitAssignmentProbe', + phase: 'cold', + activity: 'party_wait', + timing: { nextResolveAt: 9000 }, + vitals: {}, + stats: { lastReason: 'acquisition_party_wait', partyWaitUntil: 9000 }, + inventory: {} + }, 'bgp_probe', 'healer', 42).then((assigned) => { + assert.strictEqual(assigned.activity, 'grouped', 'a formed party must release its waiting member into the group lifecycle'); + assert.strictEqual(assigned.stats.partyWaitUntil, null, 'assigned members must not retain an obsolete wait deadline'); + return BotLifeState.coldPartyCandidates(5); + }).then(() => { + const candidates = statements.find((entry) => entry.sql.includes("activity IN ('hunting', 'resting', 'party_wait')")); + assert(candidates, 'party formation must see event-scheduled party waits without making them combat-due'); + return BotLifeState.coldPartyCandidates(5, true); + }).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'); + }); + }).then(() => { console.log('Bot population state checks passed'); }); }).catch((err) => {