From be8b7ebb5213ebe9abf36a1910114ea41c400d22 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:13:04 -0400 Subject: [PATCH 01/13] Add companion town errands --- .../Bot/AI/States/FollowingState.js | 149 ++++++++++++++++++ src/GameServer/Bot/AI/States/ShoppingState.js | 99 +++++++++++- src/GameServer/Bot/TradeService.js | 3 +- tests/test_bot_travel_realism.js | 54 +++++++ tests/test_party_companion_rest_follow.js | 72 ++++++++- 5 files changed, 367 insertions(+), 10 deletions(-) diff --git a/src/GameServer/Bot/AI/States/FollowingState.js b/src/GameServer/Bot/AI/States/FollowingState.js index f44fcc04..10486deb 100644 --- a/src/GameServer/Bot/AI/States/FollowingState.js +++ b/src/GameServer/Bot/AI/States/FollowingState.js @@ -8,11 +8,19 @@ const BotSupportPlanner = invoke('GameServer/Bot/AI/BotSupportPlanner'); const PartyAwareness = invoke('GameServer/Bot/AI/PartyAwareness'); const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); const EffectStore = invoke('GameServer/Effects/EffectStore'); +const ShotStock = invoke('GameServer/Inventory/ShotStock'); +const TradeService = invoke('GameServer/Bot/TradeService'); +const MarketOpportunity = invoke('GameServer/Bot/Economy/MarketOpportunity'); const FOLLOW_RUN_DISTANCE = 250; const FOLLOW_RETARGET_DISTANCE = 900; const FOLLOW_TARGET_DRIFT = 650; const FOLLOW_TELEPORT_DISTANCE = 4500; +// Newbie Guides only exist in the starter villages. A companion should not +// abandon a player in the field just because its starter buffs have expired. +const NEWBIE_GUIDE_TOWN_RADIUS = 7500; +const COMPANION_TOWN_ERRAND_RADIUS = 7500; +const COMPANION_TOWN_ERRAND_COOLDOWN_MS = 60000; function ratio(value, max) { if (!max) return 0; @@ -37,6 +45,130 @@ function distance2d(a, b) { return Math.sqrt((dx * dx) + (dy * dy)); } +function isAtNewbieGuideTown(player, BotAI) { + const guide = BotAI.getClosestNewbieGuide?.(player.fetchLocX(), player.fetchLocY()); + if (!guide) return false; + + return distance2d( + { locX: player.fetchLocX(), locY: player.fetchLocY() }, + guide + ) <= NEWBIE_GUIDE_TOWN_RADIUS; +} + +function townForCompanionErrand(player, BotAI) { + const town = BotAI.getClosestTown?.(player.fetchLocX(), player.fetchLocY()); + if (!town) return null; + + return distance2d( + { locX: player.fetchLocX(), locY: player.fetchLocY() }, + { locX: town.x, locY: town.y } + ) <= COMPANION_TOWN_ERRAND_RADIUS ? town : null; +} + +function actorAdena(bot) { + const adena = bot.backpack?.fetchItemFromSelfId?.(57); + return Number(adena?.fetchAmount?.() || 0); +} + +function plannedMarketPurchase(session, bot, town) { + const plan = session.coldLifeState?.stats?.equipmentPlan; + const selfId = Number(plan?.strategy === 'market' ? plan.target?.selfId : 0); + if (!selfId) return null; + if (bot.backpack?.fetchItemFromSelfId?.(selfId)) return null; + + const offer = MarketOpportunity.findOffers(selfId, { + town: town.name, + buyerCharacterId: bot.fetchId() + }).find((candidate) => ( + Number(candidate.price) <= actorAdena(bot) && + candidate.sourceType === 'private_store' && + candidate.session?.actor && + String(candidate.session.accountId || '').startsWith('bot_') + )); + // Hot companions can transact only with a live bot merchant. Cold + // listings have no world actor to walk to, while player-store settlement + // still belongs to the native client request path. + if (!offer) return null; + + return { + kind: 'market_purchase', + itemId: selfId, + itemName: offer.itemName, + price: Number(offer.price), + target: { + actorId: offer.session.actor.fetchId(), + name: offer.session.actor.fetchName(), + locX: offer.session.actor.fetchLocX(), + locY: offer.session.actor.fetchLocY(), + locZ: offer.session.actor.fetchLocZ(), + town: offer.town || town.name + } + }; +} + +function companionTownErrand(session, bot, player, BotAI) { + if (Date.now() - Number(session.lastCompanionTownErrandAt || 0) < COMPANION_TOWN_ERRAND_COOLDOWN_MS) return null; + const town = townForCompanionErrand(player, BotAI); + if (!town) return null; + + const purchase = plannedMarketPurchase(session, bot, town); + if (purchase) return purchase; + + const buyer = TradeService.findBestBuyerForActor(bot, World.user?.sessions || [], { town }); + if (buyer) { + return { + kind: 'sell_resources', + target: { + actorId: buyer.actor.fetchId(), + name: buyer.actor.fetchName(), + locX: buyer.actor.fetchLocX(), + locY: buyer.actor.fetchLocY(), + locZ: buyer.actor.fetchLocZ(), + town: buyer.store.town || town.name + } + }; + } + + if (!ShotStock.needsActorRestock(bot, 0)) return null; + return { + kind: 'restock_shots', + target: { + actorId: null, + name: `${town.name} general shop`, + locX: town.x, + locY: town.y, + locZ: town.z, + town: town.name + } + }; +} + +function beginCompanionTownErrand(session, bot, playerSession, errand, BotAI) { + session.lastCompanionTownErrandAt = Date.now(); + session.preShopLocation = { locX: bot.fetchLocX(), locY: bot.fetchLocY(), locZ: bot.fetchLocZ() }; + session.resumeAfterShopping = { + plan: 'following', + followPlayerSession: playerSession, + partyCompanion: true, + botStay: session.botStay === true, + stayLocation: session.stayLocation ? { ...session.stayLocation } : null + }; + session.companionShopping = errand; + session.shoppingTarget = errand.target; + session.shoppingDoneAnnounced = false; + session.plan = 'shopping'; + session.currentTargetId = undefined; + bot.unselect(); + bot.automation.abortAll(bot); + + const detail = errand.kind === 'market_purchase' + ? `${errand.itemName} from ${errand.target.name}` + : errand.kind === 'sell_resources' + ? `sell these resources to ${errand.target.name}` + : 'restock my shots'; + BotAI.say(session, `I can ${detail} here. Give me a moment, then I'll return.`); +} + function shouldKeepCurrentFollowMove(session, bot, player, leaderDistance) { const isMoving = !!session.moveTimer || bot.state.fetchTowards(); if (!isMoving) return false; @@ -341,6 +473,11 @@ module.exports = { missingBuffs: BotBuffs.missingNewbieBuffs(bot, BotBuffs.REFRESH_THRESHOLD_MS) }); keepRoleDecision = true; + } else if (!isAtNewbieGuideTown(player, BotAI)) { + recordRoleDecision(session, bot, 'refresh_buffs', 'wait_for_newbie_guide_town', { + missingBuffs: BotBuffs.missingNewbieBuffs(bot, BotBuffs.REFRESH_THRESHOLD_MS) + }); + keepRoleDecision = true; } else { session.preBuffLocation = { locX: bot.fetchLocX(), locY: bot.fetchLocY(), locZ: bot.fetchLocZ() }; session.preBuffPlan = 'following'; @@ -364,6 +501,18 @@ module.exports = { } } + if (!partyThreat && !leaderTargetId && !isBusy(bot)) { + const errand = companionTownErrand(session, bot, player, BotAI); + if (errand) { + beginCompanionTownErrand(session, bot, playerSession, errand, BotAI); + recordRoleDecision(session, bot, 'town_errand', errand.kind, { + town: errand.target.town, + itemId: errand.itemId || null + }); + return; + } + } + const supportBuffTarget = BotSupportPlanner.nextAction( bot, partySupportMembers(playerSession), diff --git a/src/GameServer/Bot/AI/States/ShoppingState.js b/src/GameServer/Bot/AI/States/ShoppingState.js index d6302a1d..140a1e44 100644 --- a/src/GameServer/Bot/AI/States/ShoppingState.js +++ b/src/GameServer/Bot/AI/States/ShoppingState.js @@ -4,14 +4,46 @@ const TradeService = invoke('GameServer/Bot/TradeService'); const ShotStock = invoke('GameServer/Inventory/ShotStock'); const BotTownTravel = invoke('GameServer/Bot/AI/BotTownTravel'); const BotWarehouse = invoke('GameServer/Bot/Economy/BotWarehouseService'); +const BotEquipmentUpgrade = invoke('GameServer/Bot/AI/BotEquipmentUpgrade'); +const LifeState = invoke('GameServer/Bot/Population/BotLifeState'); +const GoalExecutor = invoke('GameServer/Bot/Goals/GoalExecutor'); +const Cooldown = invoke('GameServer/Bot/Population/Cooldown'); + +function findStoreSession(actorId) { + const BotManager = invoke('GameServer/Bot/BotManager'); + return BotManager.findSessionById(actorId) + || (invoke('GameServer/World/World').user?.sessions || []).find((session) => session.actor?.fetchId?.() === actorId) + || null; +} function formatAdena(value) { return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ','); } +function clearCompletedMarketPlan(session, bot, purchase) { + const state = session.coldLifeState; + if (!state) return; + + const { equipmentPlan, ...stats } = state.stats || {}; + session.coldLifeState = { + ...state, + adena: Number(bot.backpack?.fetchItemFromSelfId?.(57)?.fetchAmount?.() || state.adena || 0), + stats: { + ...stats, + lastMarketPurchase: { + selfId: Number(purchase.selfId), + price: Number(purchase.price), + sourceType: 'private_store', + sourceId: Number(purchase.sellerId), + at: Date.now() + } + } + }; +} + module.exports = { tick(session, bot, Generics, BotAI) { - if (session.partyCompanion === true && session.followPlayerSession) { + if (session.partyCompanion === true && session.followPlayerSession && !session.companionShopping) { session.plan = 'following'; session.shoppingTarget = undefined; session.shoppingDoneAnnounced = false; @@ -77,11 +109,58 @@ module.exports = { async sellAndRestock(session, bot, Generics, BotAI) { const NpcTalkResponse = invoke(path.world + 'NpcTalkResponse'); - const BotManager = invoke('GameServer/Bot/BotManager'); + const companionErrand = session.companionShopping; + + if (companionErrand?.kind === 'market_purchase') { + const sellerSession = findStoreSession(companionErrand.target.actorId); + const seller = sellerSession?.actor; + const store = seller?.fetchPrivateStore?.(); + try { + const bought = await TradeService.buyFromStore(bot, store, companionErrand.itemId, 1); + if (sellerSession?.coldMarketState) { + const updatedSeller = await LifeState.applyMarketSale(sellerSession.coldMarketState, { + selfId: companionErrand.itemId, + price: bought.totalAdena / bought.qty, + buyerCharacterId: bot.fetchId(), + storeItem: store.items.find((item) => Number(item.selfId) === Number(companionErrand.itemId)) + }, bought.qty); + if (updatedSeller) sellerSession.coldMarketState = updatedSeller; + } + clearCompletedMarketPlan(session, bot, { + selfId: companionErrand.itemId, + price: bought.totalAdena / bought.qty, + sellerId: seller.fetchId() + }); + BotEquipmentUpgrade.applyBestUpgrades(session); + session.lastTradeSummary = `bought ${bought.qty}x ${bought.name} from ${seller.fetchName()} for ${formatAdena(bought.totalAdena)}a`; + BotAI.say(session, `Bought ${bought.name} from ${seller.fetchName()}.`); + + if (!store.items.some((item) => Number(item.count || 0) > 0) && sellerSession?.coldMarketState) { + const returnState = GoalExecutor.finishMarketVisit(sellerSession.coldMarketState); + if (returnState) { + await Cooldown.transitionToColdState(sellerSession, { + ...returnState, + stats: { ...(returnState.stats || {}), marketStore: null } + }, 'market_sold_out'); + } + } + } catch (err) { + session.lastTradeSummary = `could not buy ${companionErrand.itemName || companionErrand.itemId}`; + BotAI.say(session, 'That market offer is gone already. I will keep looking later.'); + } + this.scheduleRestock(session, bot, Generics, BotAI); + return; + } + + if (companionErrand?.kind === 'restock_shots') { + this.scheduleRestock(session, bot, Generics, BotAI); + return; + } + let soldToBuyer = false; if (session.shoppingTarget?.actorId) { - const buyerSession = BotManager.findSessionById(session.shoppingTarget.actorId); + const buyerSession = findStoreSession(session.shoppingTarget.actorId); const buyer = buyerSession?.actor; const store = buyer && buyer.fetchPrivateStore ? buyer.fetchPrivateStore() : null; @@ -158,13 +237,22 @@ module.exports = { }, 4000); setTimeout(() => { - BotAI.say(session, "All stocked up! Returning to the hunting spot."); + const companionResume = session.resumeAfterShopping; + const returningToCompanion = session.partyCompanion === true && companionResume?.followPlayerSession?.actor?.fetchIsOnline?.(); + BotAI.say(session, returningToCompanion ? "All set. Returning to you." : "All stocked up! Returning to the hunting spot."); session.plan = session.partyCompanion === true && session.followPlayerSession ? 'following' : 'hunting'; session.shoppingDoneAnnounced = false; session.shoppingTarget = undefined; + session.companionShopping = undefined; let returnTarget = null; - if (session.partyCompanion === true && session.followPlayerSession) { + if (returningToCompanion) { + const leader = companionResume.followPlayerSession.actor; + returnTarget = { + locX: leader.fetchLocX(), + locY: leader.fetchLocY(), + locZ: leader.fetchLocZ() + }; session.preShopLocation = undefined; } else if (session.preShopLocation) { returnTarget = session.preShopLocation; @@ -174,6 +262,7 @@ module.exports = { } else { returnTarget = { locX: -81174, locY: 246037, locZ: -3719 }; } + session.resumeAfterShopping = undefined; if (returnTarget) { bot.moveTo({ diff --git a/src/GameServer/Bot/TradeService.js b/src/GameServer/Bot/TradeService.js index ef4eb3ba..8eeacd4e 100644 --- a/src/GameServer/Bot/TradeService.js +++ b/src/GameServer/Bot/TradeService.js @@ -264,7 +264,8 @@ function findBestBuyerForActor(actor, merchantSessions, options = {}) { let best = null; merchantSessions.forEach((session) => { const merchant = session.actor; - if (!merchant || session.plan !== 'merchant') return; + if (!merchant) return; + if (!String(session.accountId || '').startsWith('bot_') || session.plan !== 'merchant') return; const store = merchant.fetchPrivateStore && merchant.fetchPrivateStore(); if (!store || store.storeType !== 3 || !store.items.length) return; diff --git a/tests/test_bot_travel_realism.js b/tests/test_bot_travel_realism.js index 6891475f..a298527d 100644 --- a/tests/test_bot_travel_realism.js +++ b/tests/test_bot_travel_realism.js @@ -78,6 +78,60 @@ try { assert.deepStrictEqual(buffBot.moves[0].to, { locX: -83000, locY: 242000, locZ: -3700 }); assert.strictEqual(buffSession.plan, 'hunting'); + const companionLeader = { + fetchIsOnline: () => true, + fetchLocX: () => -84150, + fetchLocY: () => 243180, + fetchLocZ: () => -3723 + }; + const companionBot = bot({ locX: -84081, locY: 243227, locZ: -3723 }); + const companionSession = { + plan: 'getting_buffed', + partyCompanion: true, + resumeAfterBuff: { + plan: 'following', + followPlayerSession: { actor: companionLeader }, + partyCompanion: true, + botStay: false, + stayLocation: null, + role: 'dps' + } + }; + GettingBuffedState.tick(companionSession, companionBot, noTeleportGenerics, { + getClosestNewbieGuide: () => ({ locX: -84081, locY: 243227, locZ: -3723 }), + say() {} + }); + + assert.strictEqual(companionSession.plan, 'following', 'companion should resume following after the Newbie Guide buffs it'); + assert.strictEqual(companionBot.moves.length, 1, 'buffed companion should move back to the player'); + assert(Math.abs(companionBot.moves[0].to.locX - companionLeader.fetchLocX()) <= 60, 'companion return should target the player vicinity'); + assert(Math.abs(companionBot.moves[0].to.locY - companionLeader.fetchLocY()) <= 60, 'companion return should target the player vicinity'); + assert.strictEqual(companionBot.moves[0].to.locZ, companionLeader.fetchLocZ()); + + const shoppingLeader = { + fetchIsOnline: () => true, + fetchLocX: () => -84020, + fetchLocY: () => 243150, + fetchLocZ: () => -3723 + }; + const shoppingBot = bot({ locX: -84081, locY: 243227, locZ: -3723 }); + const shoppingCompanionSession = { + plan: 'shopping', + partyCompanion: true, + followPlayerSession: { actor: shoppingLeader }, + companionShopping: { kind: 'restock_shots' }, + resumeAfterShopping: { plan: 'following', followPlayerSession: { actor: shoppingLeader } }, + dataSendToOthers() {} + }; + ShoppingState.scheduleRestock(shoppingCompanionSession, shoppingBot, noTeleportGenerics, { say() {} }); + + assert.strictEqual(shoppingCompanionSession.plan, 'following', 'companion should resume following after its town errand'); + assert.strictEqual(shoppingCompanionSession.companionShopping, undefined, 'completed town errand should not leave a shopping state behind'); + assert.strictEqual(shoppingBot.moves.length, 1, 'companion should walk back to the player after its town errand'); + assert.deepStrictEqual(shoppingBot.moves[0].to, { + locX: shoppingLeader.fetchLocX(), locY: shoppingLeader.fetchLocY(), locZ: shoppingLeader.fetchLocZ() + }); + console.log('Bot travel realism checks passed'); } finally { global.setTimeout = originalSetTimeout; diff --git a/tests/test_party_companion_rest_follow.js b/tests/test_party_companion_rest_follow.js index 5a3bcf03..6b41df9b 100644 --- a/tests/test_party_companion_rest_follow.js +++ b/tests/test_party_companion_rest_follow.js @@ -14,6 +14,7 @@ const BotBuffs = invoke('GameServer/Bot/AI/BotBuffs'); const BotStatus = invoke('GameServer/Bot/AI/BotStatus'); const BotBrainContext = invoke('GameServer/Bot/AI/BotBrainContext'); const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); +const MarketOpportunity = invoke('GameServer/Bot/Economy/MarketOpportunity'); const CompanionControl = invoke('GameServer/World/Generics/NpcBypasses/CompanionControl'); const EffectStore = invoke('GameServer/Effects/EffectStore'); const NpcDied = invoke('GameServer/Actor/Generics/NpcDied'); @@ -196,6 +197,7 @@ const originalExperience = DataCache.experience; const originalRandom = Math.random; const originalBotSessions = BotManager.sessions; const originalApplySupportBuff = BotBuffs.applySupportBuff; +const originalFindOffers = MarketOpportunity.findOffers; function lastPartyAllPacket(session) { return [...session.packets].reverse().find((packet) => packet[0] === 0x4e); @@ -694,19 +696,80 @@ try { assert.strictEqual(buffedTargetId, unbuffedCompanion.fetchId(), 'buffer should refresh buffs on party companions'); assert.strictEqual(appliedBuffSkillId, 1040, 'buffer should cast its learned Shield skill'); - const refreshLeader = fakeActor(2000034, { locX: 0, locY: 0, level: 10 }); + const fieldRefreshLeader = fakeActor(2000033, { locX: 0, locY: 0, level: 10 }); + const fieldRefreshLeaderSession = fakeSession('player_field_refresh_party', fieldRefreshLeader); + const fieldRefreshBot = fakeActor(2000036, { locX: 80, locY: 0, level: 10 }); + Object.keys(fieldRefreshBot.activeBuffs).forEach((key) => { fieldRefreshBot.activeBuffs[key] = 0; }); + const fieldRefreshSession = fakeSession('bot_field_refresh_party', fieldRefreshBot); + fieldRefreshSession.followPlayerSession = fieldRefreshLeaderSession; + fieldRefreshSession.partyCompanion = true; + fieldRefreshSession.plan = 'following'; + World.user = { sessions: [fieldRefreshLeaderSession, fieldRefreshSession] }; + FollowingState.tick(fieldRefreshSession, fieldRefreshBot, {}, { + getClosestNewbieGuide: () => ({ locX: -84081, locY: 243227, locZ: -3723 }), + say() {}, executeCombat() {}, executePvPCombat() {} + }); + assert.notStrictEqual(fieldRefreshSession.plan, 'getting_buffed', 'companion should keep following until the player reaches a Newbie Guide town'); + assert.strictEqual(fieldRefreshSession.roleDecision.reason, 'wait_for_newbie_guide_town', 'field companion should explain why it did not leave for a distant Newbie Guide'); + + const refreshLeader = fakeActor(2000034, { locX: -84081, locY: 243227, locZ: -3723, level: 10 }); const refreshLeaderSession = fakeSession('player_refresh_party', refreshLeader); - const refreshBot = fakeActor(2000035, { locX: 80, locY: 0, level: 10 }); + const refreshBot = fakeActor(2000035, { locX: -84001, locY: 243227, locZ: -3723, level: 10 }); Object.keys(refreshBot.activeBuffs).forEach((key) => { refreshBot.activeBuffs[key] = 0; }); const refreshSession = fakeSession('bot_refresh_party', refreshBot); refreshSession.followPlayerSession = refreshLeaderSession; refreshSession.partyCompanion = true; refreshSession.plan = 'following'; World.user = { sessions: [refreshLeaderSession, refreshSession] }; - FollowingState.tick(refreshSession, refreshBot, {}, { say() {}, executeCombat() {}, executePvPCombat() {} }); - assert.strictEqual(refreshSession.plan, 'getting_buffed', 'safe companion should leave briefly to refresh expired newbie buffs'); + FollowingState.tick(refreshSession, refreshBot, {}, { + getClosestNewbieGuide: () => ({ locX: -84081, locY: 243227, locZ: -3723 }), + say() {}, executeCombat() {}, executePvPCombat() {} + }); + assert.strictEqual(refreshSession.plan, 'getting_buffed', 'safe companion should leave briefly for a Newbie Guide when the player is in its town'); assert.strictEqual(refreshSession.resumeAfterBuff?.plan, 'following', 'buff refresh should preserve the companion return plan'); + const errandLeader = fakeActor(2000037, { locX: 83396, locY: 147904, locZ: -3404 }); + const errandLeaderSession = fakeSession('player_town_errand_party', errandLeader); + const errandBot = fakeActor(2000038, { locX: 83436, locY: 147904, locZ: -3404 }); + const errandSession = fakeSession('bot_town_errand_party', errandBot); + errandSession.followPlayerSession = errandLeaderSession; + errandSession.partyCompanion = true; + errandSession.plan = 'following'; + const errandLines = []; + BotManager.sessions = []; + World.user = { sessions: [errandLeaderSession, errandSession] }; + FollowingState.tick(errandSession, errandBot, {}, { + getClosestNewbieGuide: () => ({ locX: -84081, locY: 243227, locZ: -3723 }), + getClosestTown: () => ({ name: 'Giran', x: 83396, y: 147904, z: -3404 }), + say(_session, text) { errandLines.push(text); }, executeCombat() {}, executePvPCombat() {} + }); + assert.strictEqual(errandSession.plan, 'shopping', 'companion with no shots should make a brief errand only after the party reaches town'); + assert.strictEqual(errandSession.companionShopping?.kind, 'restock_shots', 'town errand should describe the actual missing supply'); + assert.strictEqual(errandSession.shoppingTarget?.town, 'Giran', 'companion errand should stay in the player town'); + assert(errandLines.some((line) => line.includes("then I'll return")), 'companion should tell the player it will return before shopping'); + assert.strictEqual(errandBot.fetchPrivateStore?.(), undefined, 'companion errand must never create a private sale store'); + + const marketSeller = fakeActor(2000039, { locX: 83500, locY: 147904, locZ: -3404 }); + const marketBot = fakeActor(2000040, { locX: 83456, locY: 147904, locZ: -3404 }); + const marketSession = fakeSession('bot_market_errand_party', marketBot); + marketSession.followPlayerSession = errandLeaderSession; + marketSession.partyCompanion = true; + marketSession.plan = 'following'; + marketSession.coldLifeState = { stats: { equipmentPlan: { strategy: 'market', target: { selfId: 1 } } } }; + MarketOpportunity.findOffers = () => ([{ + sourceType: 'private_store', sourceId: marketSeller.fetchId(), itemName: 'Sword of Reflection', price: 0, + town: 'Giran', session: { accountId: 'bot_market_seller', actor: marketSeller } + }]); + World.user = { sessions: [errandLeaderSession, marketSession, { accountId: 'seller', actor: marketSeller }] }; + FollowingState.tick(marketSession, marketBot, {}, { + getClosestNewbieGuide: () => ({ locX: -84081, locY: 243227, locZ: -3723 }), + getClosestTown: () => ({ name: 'Giran', x: 83396, y: 147904, z: -3404 }), + say() {}, executeCombat() {}, executePvPCombat() {} + }); + assert.strictEqual(marketSession.companionShopping?.kind, 'market_purchase', 'companion should prefer an available planned market upgrade in town'); + assert.strictEqual(marketSession.shoppingTarget?.actorId, marketSeller.fetchId(), 'companion market errand should walk to the live seller'); + MarketOpportunity.findOffers = originalFindOffers; + World.user = { sessions: [bufferLeaderSession, bufferSession, unbuffedCompanionSession] }; const compactPartyStatus = BotBrainContext.compactStatus( @@ -1076,6 +1139,7 @@ try { DataCache.experience = originalExperience; BotManager.sessions = originalBotSessions; BotBuffs.applySupportBuff = originalApplySupportBuff; + MarketOpportunity.findOffers = originalFindOffers; } console.log('Party companion rest/follow regression checks passed'); From 2f4d218e50ecba78920cf101f098f2eea9821bc4 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:26:35 -0400 Subject: [PATCH 02/13] Use real NPC levels for cold bot gear plans --- .../Bot/AI/GearAcquisitionPlanner.js | 20 ++++++++---- src/GameServer/Bot/Population/BotLifeState.js | 32 +++++++++++-------- tests/test_bot_gear_acquisition.js | 11 +++++++ tests/test_bot_population_state.js | 8 +++-- 4 files changed, 49 insertions(+), 22 deletions(-) diff --git a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js index 33a36a68..b608fefa 100644 --- a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js +++ b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js @@ -13,7 +13,7 @@ const RANKS = ['none', 'd', 'c', 'b', 'a', 's']; const WEAPON_SLOTS = new Set([7, 14]); const ARMOR_SLOTS = new Set([6, 9, 10, 11, 12, 15]); const JEWEL_SLOTS = new Set([1, 2, 3, 4, 5]); -const RATE_MODEL_VERSION = 3; +const RATE_MODEL_VERSION = 4; function isRealCatalogItem(item = {}) { const selfId = Number(item.selfId || 0); @@ -437,7 +437,10 @@ function itemDropYield(reward, itemId, kind = 'drop', context = {}) { } function soloSafeForSource(state = {}, source = {}) { - return combatReadiness(state).effectiveLevel >= Number(source.spotLevel || Infinity) + 2; + // A target can be much stronger than the average of a mixed-level grid. + // Safety must be evaluated against the NPC that actually drops the item, + // not against incidental low-level mobs around it. + return combatReadiness(state).effectiveLevel >= Number(source.npcLevel || source.spotLevel || Infinity) + 2; } function bestSourceForState(sources = [], state = {}) { @@ -450,6 +453,10 @@ function sourceIndexFor(spots = []) { return sourceIndexCache.byItemId; } + const npcLevels = new Map((DataCache.npcs || []).map((npc) => [ + Number(npc.selfId), + Number(npc.template?.level || 0) + ])); const spotByNpc = new Map(); const spotByName = new Map(); (spots || []).forEach((spot) => (spot.npcEntries || []).forEach((entry) => { @@ -467,7 +474,7 @@ function sourceIndexFor(spots = []) { ))); itemIds.forEach((id) => { const entries = byItemId.get(id) || []; - entries.push({ reward, spot }); + entries.push({ reward, spot, npcLevel: npcLevels.get(Number(reward.selfId)) || 0 }); byItemId.set(id, entries); }); }); @@ -477,13 +484,14 @@ function sourceIndexFor(spots = []) { } function sourceForItem(itemId, spots = [], state = {}) { - return (sourceIndexFor(spots).get(Number(itemId)) || []).map(({ reward, spot }) => { + return (sourceIndexFor(spots).get(Number(itemId)) || []).map(({ reward, spot, npcLevel }) => { + const sourceLevel = Number(npcLevel || spot?.avgLevel || 1); const { chance, expectedYield } = itemDropYield(reward, itemId, 'drop', { - npcLevel: Number(spot?.avgLevel || 0), + npcLevel: sourceLevel, killerLevel: Number(state.level || 0) }); if (!chance) return null; - return { npcId: Number(reward.selfId), npcName: reward.template?.name || `NPC ${reward.selfId}`, kind: 'drop', chance, expectedYield, spotId: spot.id, spotLevel: Number(spot.avgLevel || 1) }; + return { npcId: Number(reward.selfId), npcName: reward.template?.name || `NPC ${reward.selfId}`, kind: 'drop', chance, expectedYield, spotId: spot.id, spotLevel: Number(spot.avgLevel || 1), npcLevel: sourceLevel }; }).filter(Boolean).sort((a, b) => b.expectedYield - a.expectedYield); } diff --git a/src/GameServer/Bot/Population/BotLifeState.js b/src/GameServer/Bot/Population/BotLifeState.js index b3012310..0a256594 100644 --- a/src/GameServer/Bot/Population/BotLifeState.js +++ b/src/GameServer/Bot/Population/BotLifeState.js @@ -923,6 +923,12 @@ const BotLifeState = { dueCold(limit = 10, at = now()) { if (!initialized) return Promise.resolve([]); const safeLimit = Math.max(1, Math.min(100, Number(limit) || 10)); + // A changed drop model can make an active direct-drop route unsafe. + // Pull only fighting bots forward: resting and travelling states are + // intentionally event-scheduled and cannot hurt themselves while + // they wait for their persisted deadline. + const staleRateModelPlan = `json_extract(statsJson, '$.equipmentPlan.expectedKills') IS NOT NULL + AND COALESCE(CAST(json_extract(statsJson, '$.equipmentPlan.rateModelVersion') AS INTEGER), 0) < ${GearAcquisitionPlanner.RATE_MODEL_VERSION}`; return Database.execute([ `SELECT * FROM ${TABLE} @@ -934,28 +940,26 @@ const BotLifeState = { -- 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 <= ?) + AND ( + nextResolveAt IS NULL OR nextResolveAt <= ? + OR (activity = 'hunting' AND (${staleRateModelPlan})) + ) -- Travel and crafting are finite state transitions. They must -- outrank a large resting/hunting backlog, otherwise a bot can -- remain on its way to a station forever after a restart. ORDER BY CASE - WHEN activity IN ('traveling', 'crafting') THEN 0 + -- Replan active combat before it can continue using a stale + -- target level or drop-rate estimate. + WHEN ${staleRateModelPlan} THEN 0 + WHEN activity IN ('traveling', 'crafting') THEN 1 -- Startup craft recovery is a one-shot replan. Serve it -- before the normal hunting backlog so a repaired station -- wait immediately selects its missing raw material. - WHEN json_extract(statsJson, '$.lastReason') = 'startup_craft_wait_recovery' THEN 1 - WHEN activity = 'dead' THEN 2 - ELSE 3 + WHEN json_extract(statsJson, '$.lastReason') = 'startup_craft_wait_recovery' THEN 2 + WHEN activity = 'dead' THEN 3 + ELSE 4 END ASC, - COALESCE(nextResolveAt, 0) ASC, - CASE - -- A rate-model rollout must promptly replace persisted kill estimates. - WHEN json_extract(statsJson, '$.equipmentPlan.expectedKills') IS NOT NULL - AND COALESCE(CAST(json_extract(statsJson, '$.equipmentPlan.rateModelVersion') AS INTEGER), 0) < 2 THEN 0 - WHEN activity = 'dead' THEN 1 - WHEN activity IN ('traveling', 'shopping', 'merchant', 'crafting') THEN 2 - ELSE 3 - END ASC + COALESCE(nextResolveAt, 0) ASC LIMIT ${safeLimit}`, [at] ]).then((rows) => rows.map((row) => { diff --git a/tests/test_bot_gear_acquisition.js b/tests/test_bot_gear_acquisition.js index 3e51c889..5897cb5f 100644 --- a/tests/test_bot_gear_acquisition.js +++ b/tests/test_bot_gear_acquisition.js @@ -22,6 +22,17 @@ const ironSources = GearAcquisitionPlanner.sourceForItem(1869, [stoneGolemSpot]) assert(ironSources.length > 0, 'known material drops must resolve to their real NPC source'); assert.strictEqual(ironSources[0].spotId, stoneGolemSpot.id, 'source lookup must retain the matching farming spot'); assert(ironSources[0].chance > 0, 'source lookup must retain an expected drop chance'); +const handAxe = DataCache.items.find((item) => item.template?.name === 'Hand Axe'); +const wereratChiefSpot = { + id: 'wererat-chief-field', + avgLevel: 19, + npcEntries: [{ selfId: 414, name: 'Sukar Wererat Chief', count: 1 }] +}; +const handAxeSource = GearAcquisitionPlanner.sourceForItem(handAxe.selfId, [wereratChiefSpot], { level: 20 }) + .find((source) => source.npcId === 414); +assert(handAxeSource, 'a direct equipment source must retain its real dropper'); +assert.strictEqual(handAxeSource.npcLevel, 28, 'a direct equipment source must retain its NPC level instead of its mixed-spot average'); +assert.strictEqual(GearAcquisitionPlanner.soloSafeForSource({ level: 20 }, handAxeSource), false, 'a level-20 bot must not solo a level-28 item target just because its grid also contains lower-level mobs'); assert.strictEqual(GearAcquisitionPlanner.soloSafeForSource({ level: 30 }, { spotLevel: 28 }), true, 'a bot should solo only sources below its combat safety margin'); assert.strictEqual(GearAcquisitionPlanner.soloSafeForSource({ level: 30 }, { spotLevel: 29 }), false, 'a bot must not call an equal-level source solo-safe'); assert.strictEqual( diff --git a/tests/test_bot_population_state.js b/tests/test_bot_population_state.js index 1651e4d2..d4cf31d8 100644 --- a/tests/test_bot_population_state.js +++ b/tests/test_bot_population_state.js @@ -4,6 +4,7 @@ require('../src/Global'); const Database = invoke('Database'); const DataCache = invoke('GameServer/DataCache'); +const GearPlanner = invoke('GameServer/Bot/AI/GearAcquisitionPlanner'); DataCache.init(); @@ -83,9 +84,12 @@ try { return BotLifeState.dueCold(5, 1000); }); }).then(() => { - const due = statements.find((entry) => entry.sql.includes("WHEN activity IN ('traveling', 'crafting') THEN 0")); + const due = statements.find((entry) => entry.sql.includes("WHEN activity IN ('traveling', 'crafting') THEN 1")); assert(due.sql.includes('rateModelVersion'), 'due cold states must prioritize persisted plans from an older drop-rate model'); - 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(`< ${GearPlanner.RATE_MODEL_VERSION}`), 'due cold states must prioritize plans from the current model rollout rather than a stale hard-coded version'); + assert(due.sql.includes("OR (activity = 'hunting' AND (json_extract(statsJson, '$.equipmentPlan.expectedKills') IS NOT NULL"), 'a stale active combat plan must bypass its old next-resolve deadline for an immediate safety replan'); + assert(due.sql.indexOf("WHEN json_extract(statsJson, '$.equipmentPlan.expectedKills') IS NOT NULL") < due.sql.indexOf("WHEN activity IN ('traveling', 'crafting') THEN 1"), 'a stale active plan must outrank ordinary travel and crafting transitions'); + assert(due.sql.includes("WHEN activity IN ('traveling', 'crafting') THEN 1"), 'due cold states must promptly finish travel and crafting transitions after an urgent combat-safety replan'); 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({ From bcf900d329dbc72e4a5e9d2a0b822eef5a1cb828 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:50:36 -0400 Subject: [PATCH 03/13] Add companion party pulling --- src/GameServer/Actor/Attack.js | 13 + src/GameServer/Bot/AI/BotBuffs.js | 29 +- src/GameServer/Bot/AI/BotStatus.js | 13 +- src/GameServer/Bot/AI/BotSupportPlanner.js | 143 +++++++- .../Bot/AI/PartyCompanionService.js | 30 +- src/GameServer/Bot/AI/PartyPulling.js | 328 ++++++++++++++++++ .../Bot/AI/States/FollowingState.js | 145 +++++--- src/GameServer/Bot/AI/States/RestingState.js | 25 +- .../Generics/NpcBypasses/CompanionControl.js | 104 +++++- tests/test_bot_support_planner.js | 95 ++++- tests/test_party_companion_rest_follow.js | 254 +++++++++++++- 11 files changed, 1098 insertions(+), 81 deletions(-) create mode 100644 src/GameServer/Bot/AI/PartyPulling.js diff --git a/src/GameServer/Actor/Attack.js b/src/GameServer/Actor/Attack.js index 63425891..25ab05f7 100644 --- a/src/GameServer/Actor/Attack.js +++ b/src/GameServer/Actor/Attack.js @@ -90,6 +90,7 @@ class Attack { this.resetQueuedEvent(); actor.state.setCasts(false); actor.storedSpell = undefined; + invoke('GameServer/Bot/AI/BotSupportPlanner').cancelSupportCast(session, actor); session?.dataSendToMeAndOthers?.( ServerResponse.magicSkillCanceld(actor.fetchId()), @@ -173,22 +174,26 @@ class Attack { const corpseTarget = skill.fetchTargetKind?.() === 'corpse_mob'; if (this.checkParticipants(actor, creature, { allowDeadTarget: corpseTarget })) { + invoke('GameServer/Bot/AI/BotSupportPlanner').cancelPendingSupportCast(session, actor, creature, skill); return; } if (actor.canUseSkill?.(skill) === false) { session.dataSendToMe?.(ServerResponse.actionFailed()); + invoke('GameServer/Bot/AI/BotSupportPlanner').cancelPendingSupportCast(session, actor, creature, skill); return; } if (actor.fetchMp() < skill.fetchConsumedMp()) { ConsoleText.transmit(session, ConsoleText.caption.depletedMp); + invoke('GameServer/Bot/AI/BotSupportPlanner').cancelPendingSupportCast(session, actor, creature, skill); return; } const conditionFailure = this.skillUseConditionFailure(actor, skill); if (conditionFailure) { this.rejectSkillUseCondition(session, actor, conditionFailure); + invoke('GameServer/Bot/AI/BotSupportPlanner').cancelPendingSupportCast(session, actor, creature, skill); return; } @@ -197,6 +202,11 @@ class Attack { const attackRate = magicSkill ? actor.fetchCollectiveCastSpd() : actor.fetchCollectiveAtkSpd(); skill.setCalculatedHitTime(Formulas.calcRemoteAtkTime(skill.fetchHitTime(), attackRate)); + // Companion support selection runs before a native cast is accepted. + // Only create its reservation at this point, after every rejection + // gate above has passed and the cast is about to begin. The calculated + // hit time is available here, so the reservation covers the full cast. + invoke('GameServer/Bot/AI/BotSupportPlanner').beginSupportCast(session, actor, creature, skill); actor.markSkillReuse?.(skill); session.dataSendToMeAndOthers(ServerResponse.skillStarted(actor, creature.fetchId(), skill), actor); session.dataSendToMe(ServerResponse.skillDurationBar(skill.fetchCalculatedHitTime())); @@ -204,6 +214,7 @@ class Attack { this.queueTimer(() => { if (this.checkParticipants(actor, creature, { allowDeadTarget: corpseTarget })) { + invoke('GameServer/Bot/AI/BotSupportPlanner').cancelSupportCast(session, actor); return; } @@ -211,6 +222,7 @@ class Attack { if (targets.length === 0) { actor.state.setCasts(false); + invoke('GameServer/Bot/AI/BotSupportPlanner').cancelSupportCast(session, actor); return; } @@ -250,6 +262,7 @@ class Attack { }); this.clearLoadedShot(actor, magicSkill); actor.state.setCasts(false); + invoke('GameServer/Bot/AI/BotSupportPlanner').finishSupportCast(session, actor, skill); // Start replenish actor.automation.replenishVitals(actor); diff --git a/src/GameServer/Bot/AI/BotBuffs.js b/src/GameServer/Bot/AI/BotBuffs.js index 6b08b9b3..80f23c5b 100644 --- a/src/GameServer/Bot/AI/BotBuffs.js +++ b/src/GameServer/Bot/AI/BotBuffs.js @@ -2,6 +2,7 @@ const ServerResponse = invoke('GameServer/Network/Response'); const EffectStore = invoke('GameServer/Effects/EffectStore'); const EffectTicker = invoke('GameServer/Effects/EffectTicker'); const BuffCatalog = invoke('GameServer/Effects/BuffCatalog'); +const C4SkillRules = invoke('GameServer/Skills/C4SkillRules'); const BUFF_DURATION_MS = 20 * 60 * 1000; const REFRESH_THRESHOLD_MS = 2 * 60 * 1000; @@ -23,6 +24,22 @@ function now() { return Date.now(); } +function effectData(buff) { + const semantic = C4SkillRules.resolve({ + selfId: buff.id, + level: buff.level + }); + return { + key: semantic.effect || buff.key, + id: buff.id, + level: buff.level, + name: buff.name, + type: semantic.effectType || 'buff', + category: semantic.effectTrait || semantic.trait || semantic.effect || buff.key, + stats: semantic.stats || {} + }; +} + function isNewbieEligible(actor) { return actor && actor.fetchLevel() <= 25 && actor.fetchKarma() === 0; } @@ -91,11 +108,7 @@ function applyBuff(session, actor, buffType, Generics, source = {}) { const store = ensureStore(actor); store[buff.key] = now() + BUFF_DURATION_MS; EffectStore.apply(actor, { - key: buff.key, - id: buff.id, - level: buff.level, - name: buff.name, - type: 'buff', + ...effectData(buff), durationMs: BUFF_DURATION_MS }); EffectTicker.scheduleExpiry(session, actor, actor.effects?.[buff.key]); @@ -127,11 +140,7 @@ function applyFullNewbieBlessing(session, actor, Generics) { NEWBIE_BUFF_TYPES.map((type) => ALL_BUFFS[type]).forEach((buff) => { store[buff.key] = expiresAt; EffectStore.apply(actor, { - key: buff.key, - id: buff.id, - level: buff.level, - name: buff.name, - type: 'buff', + ...effectData(buff), expiresAt }); EffectTicker.scheduleExpiry(session, actor, actor.effects?.[buff.key]); diff --git a/src/GameServer/Bot/AI/BotStatus.js b/src/GameServer/Bot/AI/BotStatus.js index 050fac77..ee496878 100644 --- a/src/GameServer/Bot/AI/BotStatus.js +++ b/src/GameServer/Bot/AI/BotStatus.js @@ -3,6 +3,7 @@ const BotBuffs = invoke('GameServer/Bot/AI/BotBuffs'); const TownPathfinder = invoke('GameServer/Bot/AI/TownPathfinder'); const PartyAwareness = invoke('GameServer/Bot/AI/PartyAwareness'); const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); +const PartyPulling = invoke('GameServer/Bot/AI/PartyPulling'); const EffectStore = invoke('GameServer/Effects/EffectStore'); const GearSkillHints = invoke('GameServer/Bot/AI/GearSkillHints'); @@ -229,13 +230,23 @@ const BotStatus = { const target = findTarget(session, bot); const leaderSession = session.followPlayerSession && session.partyCompanion === true ? session.followPlayerSession : null; const partySettings = leaderSession ? PartyCompanionService.getSettings(leaderSession) : null; + const pull = leaderSession ? PartyPulling.current(leaderSession, partySettings) : null; + const isAssignedPuller = partySettings?.pullMode === 'bot' && + Number(partySettings.pullerId || 0) === Number(bot.fetchId()); const party = leaderSession ? { leader: actorSummary(leaderSession.actor, bot), role, settings: partySettings, - stance: session.botStay ? 'stay' : 'follow', + stance: isAssignedPuller ? 'pulling' : (session.botStay ? 'stay' : 'follow'), roleStance: BotRoles.partyRoleStance(role), autoTaunt: session.autoTaunt !== false, + pull: pull?.enabled ? { + mode: partySettings.pullMode, + pullerId: pull.puller?.actor?.fetchId?.() || null, + targetId: pull.target?.fetchId?.() || null, + phase: pull.phase, + paused: pull.paused || null + } : null, decision: session.roleDecision || null, members: PartyAwareness.partySessions(leaderSession) .map((memberSession) => partyMemberSummary(memberSession, leaderSession, bot)) diff --git a/src/GameServer/Bot/AI/BotSupportPlanner.js b/src/GameServer/Bot/AI/BotSupportPlanner.js index ecf50644..270ad14c 100644 --- a/src/GameServer/Bot/AI/BotSupportPlanner.js +++ b/src/GameServer/Bot/AI/BotSupportPlanner.js @@ -3,6 +3,8 @@ const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); const REFRESH_THRESHOLD_MS = 2 * 60 * 1000; const CAST_RESERVATION_MS = 5000; +const PENDING_SUPPORT_CAST_TIMEOUT_MS = 30000; +const MIN_SUPPORT_MP_RATIO = 0.35; const PHYSICAL_ROLES = new Set(['tank', 'dagger', 'archer', 'dps']); const CASTER_ROLES = new Set(['mage', 'healer', 'buffer']); @@ -55,8 +57,14 @@ function isUsefulForTarget(target, skill) { function statKeys(skill) { const semantic = skill.fetchSemantic?.() || {}; - const keys = Object.keys(semantic.stats || {}); - return keys.length > 0 ? keys : [semantic.effect].filter(Boolean); + // Match both the C4 stat slot and the semantic effect key. Older saved + // newbie buffs had an empty stats object, but still carried their effect + // identity (for example `shield`). Treating only pDefMul/runSpdAdd as a + // match made the planner recast a buff it could never downgrade. + return [...new Set([ + ...Object.keys(semantic.stats || {}), + semantic.effect + ].filter(Boolean))]; } function overlaps(effect, keys) { @@ -86,6 +94,24 @@ function canCast(actor, skill) { return Number(actor?.fetchMp?.() || 0) >= Number(skill?.fetchConsumedMp?.() || 0); } +function isBusy(actor) { + return !!( + actor?.state?.fetchTowards?.() || + actor?.state?.fetchHits?.() || + actor?.state?.fetchCasts?.() + ); +} + +function canStartSupportCast(action) { + const actor = action?.provider; + const mp = Number(actor?.fetchMp?.() || 0); + const maxMp = Math.max(1, Number(actor?.fetchMaxMp?.() || mp || 1)); + return canCast(actor, action?.skill) && + mp / maxMp >= MIN_SUPPORT_MP_RATIO && + !EffectStore.impairments(actor).silenced && + !isBusy(actor); +} + function actorOrder(actor) { return Number(actor?.fetchId?.() || Number.MAX_SAFE_INTEGER); } @@ -102,9 +128,12 @@ function isReserved(target, skill) { function reserve(action) { if (!action?.target || !action?.skill) return; if (!action.target.supportReservations) action.target.supportReservations = {}; + const hitTime = Number(action.skill.fetchCalculatedHitTime?.() || action.skill.fetchHitTime?.() || 0); action.target.supportReservations[supportKey(action.skill)] = { casterId: actorOrder(action.provider), - expiresAt: Date.now() + CAST_RESERVATION_MS + // The effect can only exist after the native hit. A fixed five-second + // window was shorter than some C4 casts and let pulling resume early. + expiresAt: Date.now() + Math.max(CAST_RESERVATION_MS, hitTime + 1000) }; } @@ -112,6 +141,9 @@ function actionCompare(a, b) { const partyFirst = Number(b.skill.fetchTargetKind?.() === 'party') - Number(a.skill.fetchTargetKind?.() === 'party'); if (partyFirst) return partyFirst; + const pullerFirst = Number(b.puller) - Number(a.puller); + if (pullerFirst) return pullerFirst; + const leaderFirst = Number(b.leader) - Number(a.leader); if (leaderFirst) return leaderFirst; @@ -129,11 +161,104 @@ function allActions(members, providers, respectReservations = true) { .filter((member) => member?.actor && !member.actor.state?.fetchDead?.()) .flatMap((member) => providers.flatMap((provider) => supportSkills(provider) .filter((skill) => isUsefulForTarget(member.actor, skill) && canCast(provider, skill) && needsSkill(member.actor, skill) && (!respectReservations || !isReserved(member.actor, skill))) - .map((skill) => ({ provider, target: member.actor, leader: member.leader === true, skill, effect: skill.fetchSemantic().effect })))); + .map((skill) => ({ + provider, + target: member.actor, + leader: member.leader === true, + puller: member.puller === true, + skill, + effect: skill.fetchSemantic().effect + })))); +} + +function queueSupportCast(session, action) { + if (!session || !action?.provider || !action?.target || !action?.skill) return false; + session.pendingSupportCast = { + providerId: actorOrder(action.provider), + targetId: actorOrder(action.target), + skillId: Number(action.skill.fetchSelfId?.() || 0), + // skillExec can first walk into cast range. Keep the party's pull + // pause active through that approach instead of treating the cast as + // abandoned after the old fixed five-second window. + expiresAt: Date.now() + PENDING_SUPPORT_CAST_TIMEOUT_MS + }; + return true; +} + +function beginSupportCast(session, provider, target, skill) { + const pending = session?.pendingSupportCast; + if (!pending || Number(pending.expiresAt || 0) <= Date.now()) { + if (session) session.pendingSupportCast = undefined; + return false; + } + if ( + Number(pending.providerId) !== actorOrder(provider) || + Number(pending.targetId) !== actorOrder(target) || + Number(pending.skillId) !== Number(skill?.fetchSelfId?.() || 0) + ) { + return false; + } + + reserve({ provider, target, skill }); + session.pendingSupportCast = undefined; + session.activeSupportCast = { + targetId: actorOrder(target), + skillId: Number(skill.fetchSelfId()) + }; + return true; +} + +function cancelPendingSupportCast(session, provider, target, skill) { + const pending = session?.pendingSupportCast; + if (!pending || + Number(pending.providerId) !== actorOrder(provider) || + Number(pending.targetId) !== actorOrder(target) || + Number(pending.skillId) !== Number(skill?.fetchSelfId?.() || 0)) { + return false; + } + + session.pendingSupportCast = undefined; + return true; +} + +function finishSupportCast(session, provider, skill) { + const active = session?.activeSupportCast; + if (!active || Number(active.skillId) !== Number(skill?.fetchSelfId?.() || 0)) return false; + session.activeSupportCast = undefined; + if (Number(session.currentTargetId) === Number(active.targetId)) { + session.currentTargetId = undefined; + provider?.unselect?.(); + } + return true; +} + +function cancelSupportCast(session, provider) { + if (!session) return false; + const active = session.activeSupportCast; + const pending = session.pendingSupportCast; + session.pendingSupportCast = undefined; + session.activeSupportCast = undefined; + if (active && Number(session.currentTargetId) === Number(active.targetId)) { + session.currentTargetId = undefined; + provider?.unselect?.(); + } + return !!(active || pending); +} + +function hasPendingAction(members, providers = members.map((member) => member.actor).filter(Boolean)) { + // A reservation only prevents two casters from duplicating the same cast; + // it does not mean the buff has landed. Pulling must remain paused until + // the structured effect is actually present on the recipient. + const hasActiveReservation = members.some((member) => Object.values(member?.actor?.supportReservations || {}) + .some((reservation) => Number(reservation?.expiresAt || 0) > Date.now())); + const hasQueuedCast = providers.some((provider) => ( + Number(provider?.session?.pendingSupportCast?.expiresAt || 0) > Date.now() + )); + return hasActiveReservation || hasQueuedCast || allActions(members, providers, false).some(canStartSupportCast); } function nextAction(caster, members, providers = members.map((member) => member.actor).filter(Boolean)) { - const next = allActions(members, providers).sort(actionCompare)[0] || null; + const next = allActions(members, providers).filter(canStartSupportCast).sort(actionCompare)[0] || null; if (next?.provider !== caster) return null; return next; } @@ -152,11 +277,19 @@ function rebuffRequest(target, providers) { module.exports = { REFRESH_THRESHOLD_MS, CAST_RESERVATION_MS, + PENDING_SUPPORT_CAST_TIMEOUT_MS, + MIN_SUPPORT_MP_RATIO, supportSkills, isUsefulForTarget, needsSkill, actionCompare, + hasPendingAction, reserve, + queueSupportCast, + beginSupportCast, + cancelPendingSupportCast, + finishSupportCast, + cancelSupportCast, nextAction, rebuffRequest }; diff --git a/src/GameServer/Bot/AI/PartyCompanionService.js b/src/GameServer/Bot/AI/PartyCompanionService.js index 643baad8..66194573 100644 --- a/src/GameServer/Bot/AI/PartyCompanionService.js +++ b/src/GameServer/Bot/AI/PartyCompanionService.js @@ -6,6 +6,7 @@ const DEFAULT_PARTY_SETTINGS = { movementMode: 'follow', combatMode: 'assist', pullMode: 'auto', + pullerId: null, itemLastLootIndex: -1 }; const PARTY_LOOT_RADIUS = 2500; @@ -50,7 +51,7 @@ function getSettings(leaderSession) { function updateSettings(leaderSession, patch = {}) { const settings = settingsForLeader(leaderSession); Object.keys(patch).forEach((key) => { - if (patch[key] !== undefined && patch[key] !== null) { + if (patch[key] !== undefined && (patch[key] !== null || key === 'pullerId')) { settings[key] = patch[key]; } }); @@ -200,17 +201,41 @@ function refreshLeaderView(leaderSession, options = {}) { } } +function cancelCompanionAction(companionSession) { + const actor = companionSession?.actor; + if (!actor) return; + actor.attack?.abortCast?.(companionSession, actor); + actor.attack?.clearTimers?.(); + actor.state?.setHits?.(false); + actor.state?.setCasts?.(false); + actor.automation?.abortAll?.(actor); + invoke('GameServer/Bot/AI/BotSupportPlanner').cancelSupportCast(companionSession, actor); +} + function detachState(companionSession, plan = 'hunting') { + cancelCompanionAction(companionSession); companionSession.plan = plan; companionSession.followPlayerSession = null; companionSession.partyCompanion = false; companionSession.botStay = false; companionSession.stayLocation = null; companionSession.currentTargetId = undefined; + companionSession.partyPuller = false; companionSession.roleDecision = null; companionSession.actor?.unselect?.(); } +function clearPullerIfDetached(leaderSession, companionSession) { + const settings = settingsForLeader(leaderSession); + if (settings.pullMode !== 'bot' || Number(settings.pullerId || 0) !== Number(companionSession?.actor?.fetchId?.())) { + return false; + } + settings.pullMode = 'auto'; + settings.pullerId = null; + leaderSession.partyPullState = {}; + return true; +} + const PartyCompanionService = { membersForLeader, @@ -315,6 +340,7 @@ const PartyCompanionService = { companionSession.botStay = false; companionSession.stayLocation = null; companionSession.currentTargetId = undefined; + companionSession.partyPuller = false; companionSession.actor?.unselect?.(); companionSession.autoTaunt = settingsForLeader(leaderSession).pullMode !== 'off'; @@ -338,6 +364,7 @@ const PartyCompanionService = { BotSocialMemory.recordEvent(leaderSession, companionSession, event, source); } + clearPullerIfDetached(leaderSession, companionSession); detachState(companionSession, options.plan || 'hunting'); if (options.message) { @@ -364,6 +391,7 @@ const PartyCompanionService = { clearCompanion(companionSession, options = {}) { const leaderSession = companionSession?.followPlayerSession || null; if (!companionSession?.partyCompanion) return false; + if (leaderSession) clearPullerIfDetached(leaderSession, companionSession); detachState(companionSession, options.plan || 'hunting'); if (leaderSession) { refreshLeaderView(leaderSession, options); diff --git a/src/GameServer/Bot/AI/PartyPulling.js b/src/GameServer/Bot/AI/PartyPulling.js new file mode 100644 index 00000000..b66eb691 --- /dev/null +++ b/src/GameServer/Bot/AI/PartyPulling.js @@ -0,0 +1,328 @@ +const World = invoke('GameServer/World/World'); +const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); +const BotCombatUtility = invoke('GameServer/Bot/AI/BotCombatUtility'); +const BotSkillCapabilities = invoke('GameServer/Bot/AI/BotSkillCapabilities'); +const BotSupportPlanner = invoke('GameServer/Bot/AI/BotSupportPlanner'); +const PartyAwareness = invoke('GameServer/Bot/AI/PartyAwareness'); + +const PULL_SEARCH_RADIUS = 2200; +const PULL_CONTACT_DISTANCE = 260; +const PULL_RETURN_DISTANCE = 180; +const PULL_AGGRO_TIMEOUT_MS = 8000; + +function point(actor) { + return { + locX: actor.fetchLocX(), + locY: actor.fetchLocY(), + locZ: actor.fetchLocZ() + }; +} + +function distance(a, b) { + const dx = a.locX - b.locX; + const dy = a.locY - b.locY; + const dz = (a.locZ || 0) - (b.locZ || 0); + return Math.sqrt((dx * dx) + (dy * dy) + (dz * dz)); +} + +function enabled(settings) { + return settings?.pullMode === 'bot' || settings?.pullMode === 'leader'; +} + +function pullerRank(actor) { + const role = BotRoles.inferRole(actor); + if (role === 'tank') return 0; + if (role === 'dagger') return 1; + if (role === 'dps') return 2; + return Number.MAX_SAFE_INTEGER; +} + +function resolvePuller(leaderSession, settings) { + if (!enabled(settings) || !leaderSession?.actor) return null; + if (settings.pullMode === 'leader') { + return { session: leaderSession, actor: leaderSession.actor, kind: 'leader' }; + } + + const companions = PartyAwareness.partySessions(leaderSession) + .filter((memberSession) => memberSession !== leaderSession) + .filter((memberSession) => pullerRank(memberSession.actor) < Number.MAX_SAFE_INTEGER) + .sort((a, b) => ( + pullerRank(a.actor) - pullerRank(b.actor) || + Number(a.actor.fetchId()) - Number(b.actor.fetchId()) + )); + if (companions.length === 0) return null; + + const assignedId = Number(settings.pullerId || 0); + const assigned = assignedId + ? companions.find((memberSession) => Number(memberSession.actor.fetchId()) === assignedId) + : companions[0]; + // A player-selected puller is an explicit order. Do not silently hand it + // to another melee companion when that bot leaves or dies. + if (!assigned) return null; + const selected = assigned; + return { session: selected, actor: selected.actor, kind: 'bot' }; +} + +function pullState(leaderSession) { + if (!leaderSession.partyPullState) leaderSession.partyPullState = {}; + return leaderSession.partyPullState; +} + +function npcById(id) { + if (!id) return null; + return (World.npc?.spawns || []).find((npc) => Number(npc.fetchId?.()) === Number(id)) || null; +} + +function clearFinishedTarget(leaderSession) { + const state = pullState(leaderSession); + const npc = npcById(state.targetId); + if (!state.targetId) return null; + if (!npc || npc.isDead?.()) { + leaderSession.partyPullState = {}; + return null; + } + return npc; +} + +function beginTarget(leaderSession, puller, target, source) { + const state = pullState(leaderSession); + if (Number(state.targetId) === Number(target.fetchId())) return state; + + leaderSession.partyPullState = { + targetId: target.fetchId(), + pullerId: puller?.actor?.fetchId?.() || null, + source, + phase: source === 'bot' ? 'approach' : 'return', + startedAt: Date.now(), + announced: false + }; + return leaderSession.partyPullState; +} + +function observeLeaderTarget(leaderSession, settings, targetId) { + const puller = resolvePuller(leaderSession, settings); + if (!puller || puller.kind !== 'leader' || !targetId) return null; + const target = npcById(targetId); + if (!target || !target.fetchAttackable?.() || target.isDead?.()) return null; + beginTarget(leaderSession, puller, target, 'leader'); + return target; +} + +function supportMembers(leaderSession, puller) { + return PartyAwareness.partySessions(leaderSession).map((memberSession) => ({ + actor: memberSession.actor, + leader: memberSession === leaderSession, + puller: memberSession.actor === puller?.actor + })); +} + +// The human leader is a recipient, not an autonomous provider. They may +// know support skills, but only companion sessions run the support AI. +function supportProviders(leaderSession) { + return PartyAwareness.partySessions(leaderSession) + .filter((memberSession) => memberSession !== leaderSession) + .map((memberSession) => memberSession.actor) + .filter(Boolean); +} + +function pauseReason(leaderSession, puller) { + const members = PartyAwareness.partySessions(leaderSession); + if (members.some((memberSession) => ( + memberSession !== leaderSession && ( + memberSession.actor?.state?.fetchSeated?.() || + memberSession.plan === 'resting' || + memberSession.plan === 'getting_buffed' + ) + ))) { + return 'party_recovering'; + } + + const membersForSupport = supportMembers(leaderSession, puller); + if (BotSupportPlanner.hasPendingAction( + membersForSupport, + supportProviders(leaderSession) + )) { + return 'party_buffing'; + } + return null; +} + +function attackRange(actor, target) { + const role = BotRoles.inferRole(actor); + const combat = BotCombatUtility.select(actor, target, role); + if (Number.isFinite(Number(combat?.range))) return Number(combat.range); + + // This is the same fallback used by BotAI.executeCombat: archers pass a + // ranged basic attack, while every other role uses the native melee + // attack, whose scheduled range is zero. + return role === 'archer' ? 700 : 0; +} + +function targetIsEngageable(leaderSession, target, puller) { + if (!target) return false; + return PartyAwareness.partyActors(leaderSession) + // A leader pull has no return phase to synchronize. Release only when + // a companion can actually strike the player-designated target. + .filter((actor) => actor !== leaderSession.actor && actor !== puller?.actor) + .some((actor) => distance(point(actor), point(target)) <= attackRange(actor, target)); +} + +function nearestFreeMonster(bot) { + return World.fetchNpcsInRadius(bot.fetchLocX(), bot.fetchLocY(), PULL_SEARCH_RADIUS) + .filter((npc) => npc.fetchAttackable?.() && !npc.isDead?.()) + .filter((npc) => !npc.fetchDestId?.()) + .sort((a, b) => distance(point(bot), point(a)) - distance(point(bot), point(b)))[0] || null; +} + +function moveTo(session, bot, target) { + bot.moveTo({ from: point(bot), to: point(target) }); +} + +function aggroActionInFlight(bot) { + return !!( + bot.state?.fetchTowards?.() || + bot.state?.fetchHits?.() || + bot.state?.fetchCasts?.() + ); +} + +function tickBotPuller(session, bot, leaderSession, settings, Generics, BotAI) { + const puller = resolvePuller(leaderSession, settings); + if (!puller || puller.session !== session) return { handled: false, puller }; + + const pause = pauseReason(leaderSession, puller); + if (pause) { + const state = pullState(leaderSession); + // A rest/buff pause can happen while the puller is still travelling + // towards an untouched mob. Stop that movement immediately instead + // of letting its existing automation carry it out of the group. + if (state.phase === 'approach') { + bot.automation?.abortAll?.(bot); + } + // An aggro request has not landed yet, so it must not finish while the + // party is paused. Once it has landed, preserve the shared target and + // let the party resume its return/engage flow after recovery. + if (state.phase === 'aggro') { + const target = npcById(state.targetId); + const aggroConfirmed = Number(target?.fetchDestId?.()) === Number(bot.fetchId()); + if (!aggroConfirmed) { + bot.attack?.abortCast?.(session, bot); + bot.attack?.clearTimers?.(); + bot.state?.setHits?.(false); + bot.state?.setCasts?.(false); + bot.automation?.abortAll?.(bot); + state.phase = 'approach'; + state.aggroRequestedAt = undefined; + } + } + return { handled: true, puller, paused: pause }; + } + + let target = clearFinishedTarget(leaderSession); + if (!target) { + target = nearestFreeMonster(bot); + if (!target) return { handled: true, puller, idle: true }; + beginTarget(leaderSession, puller, target, 'bot'); + } + + const state = pullState(leaderSession); + if (state.phase === 'approach') { + if (distance(point(bot), point(target)) > PULL_CONTACT_DISTANCE) { + moveTo(session, bot, target); + return { handled: true, puller, action: 'approach', target }; + } + + // The approach movement has its own scheduled action. Stop it before + // starting the native attack; otherwise its later completion can race + // the cast and the return move would cancel the hit before it lands. + bot.automation?.abortAll?.(bot); + bot.select({ id: target.fetchId() }); + const aggression = BotRoles.inferRole(bot) === 'tank' ? BotSkillCapabilities.aggressionSkill(bot) : null; + if (aggression && bot.fetchMp() >= aggression.fetchConsumedMp()) { + Generics.skillExec(session, bot, { id: target.fetchId(), selfId: aggression.fetchSelfId(), ctrl: true }); + } else { + BotAI.executeCombat(session, bot, target, Generics); + } + // AttackExec/SkillExec schedules the native hit asynchronously. Do + // not issue moveTo yet: MoveTo aborts automation and would cancel the + // very hit that is supposed to put the mob into combat. + state.phase = 'aggro'; + state.aggroRequestedAt = Date.now(); + if (!state.announced) { + state.announced = true; + BotAI.say(session, `Pulling ${target.fetchName()} to the party!`); + } + return { handled: true, puller, action: 'aggro', target }; + } + + if (state.phase === 'aggro') { + const aggroConfirmed = Number(target.fetchDestId?.()) === Number(bot.fetchId()); + const waitingForHit = Date.now() - Number(state.aggroRequestedAt || 0) < PULL_AGGRO_TIMEOUT_MS; + if (!aggroConfirmed && (aggroActionInFlight(bot) || waitingForHit)) { + return { handled: true, puller, action: 'wait_for_aggro', target }; + } + if (!aggroConfirmed) { + // An interrupted/missed attempt must not make the puller abandon + // the mob. Re-enter approach and issue a new native attack. + state.phase = 'approach'; + return { handled: true, puller, action: 'retry_aggro', target }; + } + state.phase = 'return'; + } + + if (state.phase === 'return' && distance(point(bot), point(leaderSession.actor)) > PULL_RETURN_DISTANCE) { + moveTo(session, bot, leaderSession.actor); + return { handled: true, puller, action: 'return', target }; + } + + if (state.phase === 'return') { + // The puller is back at the camp. The mob is now delivered even when + // melee formation offsets put every companion just outside its first + // attack radius; normal assist movement can finish the engagement. + state.phase = 'engage'; + return { handled: false, puller, target }; + } + + if (state.phase === 'engage') { + // The shared target has been delivered. Fall through to the ordinary + // party combat logic on every subsequent tick so the puller keeps + // attacking and holding aggro until the mob dies. + return { handled: false, puller, target }; + } + + return { handled: true, puller, action: 'wait_for_mob', target }; +} + +function current(leaderSession, settings) { + const puller = resolvePuller(leaderSession, settings); + if (!puller) return { enabled: false, puller: null, target: null, paused: null }; + const target = clearFinishedTarget(leaderSession); + const state = pullState(leaderSession); + // A bot-pulled mob must not release the party merely because it is within + // the puller's own attack range. Only tickBotPuller may promote it to the + // engage phase after returning to the group. + const engageable = state.source === 'bot' + ? state.phase === 'engage' + : targetIsEngageable(leaderSession, target, puller); + return { + enabled: true, + puller, + target, + paused: pauseReason(leaderSession, puller), + engageable, + phase: state.phase || null + }; +} + +module.exports = { + enabled, + resolvePuller, + supportMembers, + supportProviders, + observeLeaderTarget, + tickBotPuller, + current, + targetIsEngageable, + attackRange, + PULL_AGGRO_TIMEOUT_MS +}; diff --git a/src/GameServer/Bot/AI/States/FollowingState.js b/src/GameServer/Bot/AI/States/FollowingState.js index 10486deb..514a4391 100644 --- a/src/GameServer/Bot/AI/States/FollowingState.js +++ b/src/GameServer/Bot/AI/States/FollowingState.js @@ -7,6 +7,7 @@ const BotSkillCapabilities = invoke('GameServer/Bot/AI/BotSkillCapabilities'); const BotSupportPlanner = invoke('GameServer/Bot/AI/BotSupportPlanner'); const PartyAwareness = invoke('GameServer/Bot/AI/PartyAwareness'); const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); +const PartyPulling = invoke('GameServer/Bot/AI/PartyPulling'); const EffectStore = invoke('GameServer/Effects/EffectStore'); const ShotStock = invoke('GameServer/Inventory/ShotStock'); const TradeService = invoke('GameServer/Bot/TradeService'); @@ -19,6 +20,7 @@ const FOLLOW_TELEPORT_DISTANCE = 4500; // Newbie Guides only exist in the starter villages. A companion should not // abandon a player in the field just because its starter buffs have expired. const NEWBIE_GUIDE_TOWN_RADIUS = 7500; +const NEWBIE_GUIDE_RECOVERY_MAX_LEVEL = 20; const COMPANION_TOWN_ERRAND_RADIUS = 7500; const COMPANION_TOWN_ERRAND_COOLDOWN_MS = 60000; @@ -55,6 +57,28 @@ function isAtNewbieGuideTown(player, BotAI) { ) <= NEWBIE_GUIDE_TOWN_RADIUS; } +function canRecoverAtNewbieGuide(bot, BotAI) { + return Number(bot?.fetchLevel?.() || 0) <= NEWBIE_GUIDE_RECOVERY_MAX_LEVEL && + isAtNewbieGuideTown(bot, BotAI); +} + +function beginNewbieGuideVisit(session, bot, playerSession, role) { + session.preBuffLocation = { locX: bot.fetchLocX(), locY: bot.fetchLocY(), locZ: bot.fetchLocZ() }; + session.preBuffPlan = 'following'; + session.resumeAfterBuff = { + plan: 'following', + followPlayerSession: playerSession, + partyCompanion: true, + botStay: session.botStay === true, + stayLocation: session.stayLocation ? { ...session.stayLocation } : null, + role + }; + session.plan = 'getting_buffed'; + session.currentTargetId = undefined; + bot.unselect(); + bot.automation.abortAll(bot); +} + function townForCompanionErrand(player, BotAI) { const town = BotAI.getClosestTown?.(player.fetchLocX(), player.fetchLocY()); if (!town) return null; @@ -248,11 +272,13 @@ function partyAggroCount(leaderSession) { .length; } -function unsafeSupportMoment(session, bot, target, activeMobs) { - return !!session.currentTargetId || - !!target.fetchDestId() || - activeMobs > 0 || - isBusy(bot); +function unsafeSupportMoment(bot, activeMobs) { + // A selected target is not combat. Both the leader and companions retain + // target ids after inspecting a creature or after a completed cast; using + // those ids here made an idle party wait forever to refresh its buffs. + // Only an NPC actually targeting the party, or this bot's active native + // action, is unsafe for support. + return activeMobs > 0 || isBusy(bot); } function partyMembersInSupportRange(leaderSession, bot, maxDistance = 900) { @@ -268,10 +294,12 @@ function partyMembersInSupportRange(leaderSession, bot, maxDistance = 900) { .filter((entry) => entry.distance <= maxDistance); } -function weakestPartyMember(leaderSession, bot, maxDistance = 900) { - return partyMembersInSupportRange(leaderSession, bot, maxDistance) +function weakestPartyMember(leaderSession, bot, preferredActor = null, maxDistance = 900) { + const members = partyMembersInSupportRange(leaderSession, bot, maxDistance) .filter((entry) => entry.actor !== bot) - .sort((a, b) => a.hpRatio - b.hpRatio)[0] || null; + .sort((a, b) => a.hpRatio - b.hpRatio); + const preferred = members.find((entry) => entry.actor === preferredActor); + return preferred?.hpRatio < 0.95 ? preferred : (members[0] || null); } function weakestPartyVitals(leaderSession, bot) { @@ -279,11 +307,8 @@ function weakestPartyVitals(leaderSession, bot) { .reduce((lowest, entry) => !lowest || entry.hpRatio < lowest.hpRatio ? entry : lowest, null); } -function partySupportMembers(leaderSession) { - return PartyAwareness.partySessions(leaderSession).map((memberSession) => ({ - actor: memberSession.actor, - leader: memberSession === leaderSession - })); +function partySupportMembers(leaderSession, puller) { + return PartyPulling.supportMembers(leaderSession, puller); } function pullBlockReason(session, botVitals, partyVitals, activeMobs) { @@ -343,13 +368,30 @@ module.exports = { const distance = point(bot).distance(point(player)); const partySettings = PartyCompanionService.getSettings(playerSession); const combatMode = partySettings.combatMode || 'assist'; + const selectedLeaderTargetId = PartyAwareness.leaderCombatTargetId(playerSession); + // A player-designated pull is intentional even when the ordinary + // combat posture is Protect or Passive. Those modes should not make + // the party ignore the leader's selected pull target. + const configuredLeaderTargetId = combatMode === 'assist' || partySettings.pullMode === 'leader' + ? selectedLeaderTargetId + : undefined; + PartyPulling.observeLeaderTarget(playerSession, partySettings, configuredLeaderTargetId); + let pulling = PartyPulling.current(playerSession, partySettings); const rawPartyThreat = PartyAwareness.findThreatTargetingParty(playerSession); - const partyThreat = combatMode === 'passive' && rawPartyThreat?.targetId !== bot.fetchId() + const holdingPulledTarget = pulling.target && !pulling.engageable; + const rawThreatIsHeldPull = holdingPulledTarget && + Number(rawPartyThreat?.actor?.fetchId?.()) === Number(pulling.target.fetchId()); + const partyThreat = pulling.engageable && pulling.target + ? { + type: 'npc', + actor: pulling.target, + targetId: pulling.puller.actor.fetchId(), + source: 'party_pull' + } + : (rawThreatIsHeldPull || (combatMode === 'passive' && rawPartyThreat?.targetId !== bot.fetchId()) ? null - : rawPartyThreat; - const leaderTargetId = combatMode === 'assist' - ? PartyAwareness.leaderCombatTargetId(playerSession) - : undefined; + : rawPartyThreat); + const leaderTargetId = pulling.enabled ? undefined : configuredLeaderTargetId; const impairments = EffectStore.impairments(bot); if (impairments.disabled) { @@ -452,6 +494,17 @@ module.exports = { } if (!partyThreat && !leaderTargetId && (botVitals.hpRatio < 0.30 || botVitals.mpRatio < 0.15)) { + // Do not leave a hunting field just to recover. This shortcut is + // available only when the companion is already in a starter town + // with a Newbie Guide, where characters through level 20 can + // recover and renew their blessing before returning to the party. + if (canRecoverAtNewbieGuide(bot, BotAI)) { + beginNewbieGuideVisit(session, bot, playerSession, role); + recordRoleDecision(session, bot, botVitals.hpRatio < 0.30 ? 'recover_hp' : 'save_mp', 'newbie_guide_recovery'); + BotAI.say(session, "I'm low on HP/MP. Recovering at the Newbie Guide, then I'll return."); + return; + } + session.plan = 'resting'; session.currentTargetId = undefined; bot.unselect(); @@ -466,7 +519,7 @@ module.exports = { const buffsNeedRefresh = BotBuffs.needsNewbieRefresh(bot); if (buffsNeedRefresh) { - const unsafeToRefresh = unsafeSupportMoment(session, bot, player, partyAggroCount(playerSession)); + const unsafeToRefresh = unsafeSupportMoment(bot, partyAggroCount(playerSession)); if (unsafeToRefresh) { recordRoleDecision(session, bot, 'refresh_buffs', 'wait_for_safe_moment', { @@ -479,20 +532,7 @@ module.exports = { }); keepRoleDecision = true; } else { - session.preBuffLocation = { locX: bot.fetchLocX(), locY: bot.fetchLocY(), locZ: bot.fetchLocZ() }; - session.preBuffPlan = 'following'; - session.resumeAfterBuff = { - plan: 'following', - followPlayerSession: playerSession, - partyCompanion: true, - botStay: session.botStay === true, - stayLocation: session.stayLocation ? { ...session.stayLocation } : null, - role - }; - session.plan = 'getting_buffed'; - session.currentTargetId = undefined; - bot.unselect(); - bot.automation.abortAll(bot); + beginNewbieGuideVisit(session, bot, playerSession, role); recordRoleDecision(session, bot, 'refresh_buffs', 'newbie_blessing', { missingBuffs: BotBuffs.missingNewbieBuffs(bot, BotBuffs.REFRESH_THRESHOLD_MS) }); @@ -515,11 +555,11 @@ module.exports = { const supportBuffTarget = BotSupportPlanner.nextAction( bot, - partySupportMembers(playerSession), - PartyAwareness.partyActors(playerSession) + partySupportMembers(playerSession, pulling.puller), + PartyPulling.supportProviders(playerSession) ); const rebuff = !partyThreat && !leaderTargetId && !isBusy(bot) - ? BotSupportPlanner.rebuffRequest(bot, PartyAwareness.partyActors(playerSession)) + ? BotSupportPlanner.rebuffRequest(bot, PartyPulling.supportProviders(playerSession)) : null; if (rebuff && rebuff.provider !== bot && Date.now() - (session.lastRebuffRequestAt || 0) > 90000) { session.lastRebuffRequestAt = Date.now(); @@ -527,7 +567,7 @@ module.exports = { } if (!acted && supportBuffTarget) { const activeMobs = partyAggroCount(playerSession); - if (unsafeSupportMoment(session, bot, supportBuffTarget.target, activeMobs)) { + if (unsafeSupportMoment(bot, activeMobs)) { recordRoleDecision(session, bot, 'buff_party', 'wait_for_safe_moment', { buff: supportBuffTarget.effect, targetId: supportBuffTarget.target.fetchId(), @@ -545,7 +585,9 @@ module.exports = { keepRoleDecision = true; } else { acted = true; - BotSupportPlanner.reserve(supportBuffTarget); + // A queued cast is not a buff yet. The reservation begins in + // Attack.remoteHit once the native cast has actually started. + BotSupportPlanner.queueSupportCast(session, supportBuffTarget); castSkillOn(session, bot, Generics, supportBuffTarget.target, supportBuffTarget.skill.fetchSelfId(), false); recordRoleDecision(session, bot, 'buff_party', supportBuffTarget.effect, { buff: supportBuffTarget.effect, @@ -599,7 +641,7 @@ module.exports = { if (role === 'healer') { const skill = BotSkillCapabilities.healSkill(bot); const canCast = !!skill && bot.fetchMp() >= skill.fetchConsumedMp() && !isBusy(bot) && !impairments.silenced; - const woundedPartyMember = weakestPartyMember(playerSession, bot); + const woundedPartyMember = weakestPartyMember(playerSession, bot, pulling.puller?.actor); if (woundedPartyMember?.hpRatio < 0.45 && canCast) { acted = true; @@ -637,6 +679,29 @@ module.exports = { } } + if (!acted && pulling.enabled && pulling.puller?.session === session && pulling.puller.kind === 'bot') { + const pullAction = PartyPulling.tickBotPuller(session, bot, playerSession, partySettings, Generics, BotAI); + pulling = PartyPulling.current(playerSession, partySettings); + if (pullAction.handled) { + const reason = pullAction.paused || pullAction.action || (pullAction.idle ? 'no_targets' : 'waiting'); + recordRoleDecision(session, bot, 'party_pull', reason, { + targetId: pullAction.target?.fetchId?.() || pulling.target?.fetchId?.() || null, + phase: pulling.phase || null + }); + return; + } + } + + if (!acted && pulling.enabled && pulling.target && !pulling.engageable) { + session.currentTargetId = undefined; + bot.unselect(); + recordRoleDecision(session, bot, 'hold_for_pull', pulling.paused || 'mob_not_in_range', { + targetId: pulling.target.fetchId(), + pullerId: pulling.puller?.actor?.fetchId?.() || null + }); + return; + } + if (!acted && role === 'tank') { const nearbyNpcs = World.fetchNpcsInRadius(bot.fetchLocX(), bot.fetchLocY(), 800); const monsterToAggro = partyThreat?.type === 'npc' @@ -662,7 +727,7 @@ module.exports = { } } - if (!acted && role === 'tank') { + if (!acted && role === 'tank' && !PartyPulling.enabled(partySettings)) { const activeMobs = partyAggroCount(playerSession); const blockReason = pullBlockReason(session, botVitals, partyVitals, activeMobs); diff --git a/src/GameServer/Bot/AI/States/RestingState.js b/src/GameServer/Bot/AI/States/RestingState.js index d53175b6..afff26bb 100644 --- a/src/GameServer/Bot/AI/States/RestingState.js +++ b/src/GameServer/Bot/AI/States/RestingState.js @@ -2,6 +2,8 @@ const ServerResponse = invoke('GameServer/Network/Response'); const SpeckMath = invoke('GameServer/SpeckMath'); const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); const PartyAwareness = invoke('GameServer/Bot/AI/PartyAwareness'); +const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); +const PartyPulling = invoke('GameServer/Bot/AI/PartyPulling'); const EffectStore = invoke('GameServer/Effects/EffectStore'); const REST_FOLLOW_WAKE_DISTANCE = 600; @@ -103,7 +105,18 @@ module.exports = { const distance = point(bot).distance(point(player)); const threat = PartyAwareness.findThreatTargetingParty(playerSession); + const partySettings = PartyCompanionService.getSettings(playerSession); const leaderTargetId = PartyAwareness.leaderCombatTargetId(playerSession); + PartyPulling.observeLeaderTarget(playerSession, partySettings, leaderTargetId); + const pulling = PartyPulling.current(playerSession, partySettings); + const heldPullTargetId = pulling.target && !pulling.engageable + ? pulling.target.fetchId() + : null; + const threatIsHeldPull = Number(threat?.actor?.fetchId?.()) === Number(heldPullTargetId); + const leaderTargetIsHeldPull = Number(leaderTargetId) === Number(heldPullTargetId); + const combatTargetId = !threatIsHeldPull && threat?.actor?.fetchId?.() + ? threat.actor.fetchId() + : (!leaderTargetIsHeldPull ? leaderTargetId : undefined); const leaderSeated = player.state?.fetchSeated?.() === true; const hpRatio = bot.fetchHp() / bot.fetchMaxHp(); const mpRatio = bot.fetchMp() / bot.fetchMaxMp(); @@ -115,17 +128,19 @@ module.exports = { const shouldFollowLeader = recovered && ( distance > REST_FOLLOW_WAKE_DISTANCE || !leaderSeated ); - if (threat || leaderTargetId || shouldFollowLeader) { + if (combatTargetId || shouldFollowLeader) { session.plan = 'following'; - session.currentTargetId = threat?.actor?.fetchId?.() || leaderTargetId || undefined; + session.currentTargetId = combatTargetId || undefined; session.townGossip = false; standUp(session, bot); recordWakeDecision( session, bot, - threat || leaderTargetId ? 'assist_party' : 'follow_leader', - threat ? 'party_under_attack' : (leaderTargetId ? 'leader_target' : (distance > REST_FOLLOW_WAKE_DISTANCE ? 'leader_moved' : 'leader_stood_ready')), - threat + combatTargetId ? 'assist_party' : 'follow_leader', + (threat && !threatIsHeldPull) + ? 'party_under_attack' + : (combatTargetId ? 'leader_target' : (distance > REST_FOLLOW_WAKE_DISTANCE ? 'leader_moved' : 'leader_stood_ready')), + threat && !threatIsHeldPull ? { targetId: session.currentTargetId, protectedId: threat.targetId } : { targetId: session.currentTargetId || null, distance: Math.round(distance) } ); diff --git a/src/GameServer/World/Generics/NpcBypasses/CompanionControl.js b/src/GameServer/World/Generics/NpcBypasses/CompanionControl.js index a72e19bb..b8fa20f5 100644 --- a/src/GameServer/World/Generics/NpcBypasses/CompanionControl.js +++ b/src/GameServer/World/Generics/NpcBypasses/CompanionControl.js @@ -34,6 +34,41 @@ function followLeader(targetSession) { targetSession.stayLocation = null; } +function isAssignedPuller(targetSession, settings = PartyCompanionService.getSettings(targetSession?.followPlayerSession)) { + return settings?.pullMode === 'bot' && + Number(settings.pullerId || 0) === Number(targetSession?.actor?.fetchId?.()); +} + +function stopPullAction(targetSession) { + const bot = targetSession?.actor; + if (!bot) return; + bot.attack?.abortCast?.(targetSession, bot); + bot.attack?.clearTimers?.(); + bot.state?.setHits?.(false); + bot.state?.setCasts?.(false); + bot.automation?.abortAll?.(bot); + targetSession.currentTargetId = undefined; + bot.unselect?.(); +} + +function assignedPullerSession(session) { + const settings = PartyCompanionService.getSettings(session); + return companionSessions(session).find((memberSession) => isAssignedPuller(memberSession, settings)) || null; +} + +function clearAssignedPuller(session) { + const settings = PartyCompanionService.getSettings(session); + if (settings.pullMode !== 'bot') return false; + + stopPullAction(assignedPullerSession(session)); + PartyCompanionService.updateSettings(session, { pullMode: 'auto', pullerId: null }); + session.partyPullState = {}; + companionSessions(session).forEach((memberSession) => { + memberSession.partyPuller = false; + }); + return true; +} + function summonNear(session, targetSession, offset = 60) { const actor = session.actor; const bot = targetSession.actor; @@ -50,6 +85,7 @@ function summonNear(session, targetSession, offset = 60) { function setMovementMode(session, mode) { const members = companionSessions(session); + if (mode === 'hold') clearAssignedPuller(session); PartyCompanionService.updateSettings(session, { movementMode: mode }); members.forEach((memberSession) => { if (mode === 'hold') { @@ -71,11 +107,37 @@ function setCombatMode(session, mode) { } function setPullMode(session, mode) { - const pullMode = mode === 'off' ? 'off' : 'auto'; - PartyCompanionService.updateSettings(session, { pullMode }); + const allowed = ['auto', 'leader', 'off']; + const pullMode = allowed.includes(mode) ? mode : 'auto'; + stopPullAction(assignedPullerSession(session)); + PartyCompanionService.updateSettings(session, { pullMode, pullerId: null }); + session.partyPullState = {}; companionSessions(session).forEach((memberSession) => { memberSession.autoTaunt = pullMode !== 'off'; + memberSession.partyPuller = false; + memberSession.currentTargetId = undefined; + memberSession.actor?.unselect?.(); + }); +} + +function setMemberPuller(session, targetSession) { + const role = BotRoles.inferRole(targetSession?.actor); + if (!['tank', 'dagger', 'dps'].includes(role)) return false; + + const previousPuller = assignedPullerSession(session); + if (previousPuller && previousPuller !== targetSession) stopPullAction(previousPuller); + PartyCompanionService.updateSettings(session, { + pullMode: 'bot', + pullerId: targetSession.actor.fetchId() + }); + session.partyPullState = {}; + companionSessions(session).forEach((memberSession) => { + memberSession.partyPuller = memberSession === targetSession; + memberSession.autoTaunt = true; + if (memberSession === targetSession) followLeader(memberSession); }); + BotManager.botSay(targetSession, "I'll pull the next mobs to the party."); + return true; } function regroup(session) { @@ -89,9 +151,11 @@ function handleMemberCommand(session, subCommand, botName) { if (!targetSession) return; if (subCommand === 'follow') { + if (isAssignedPuller(targetSession)) clearAssignedPuller(session); followLeader(targetSession); BotManager.botSay(targetSession, "Following you again!"); } else if (subCommand === 'stay') { + if (isAssignedPuller(targetSession)) clearAssignedPuller(session); stayHere(targetSession); BotManager.botSay(targetSession, "Holding this position."); } else if (subCommand === 'summon') { @@ -145,7 +209,7 @@ function renderModePanel(settings, count) { const summary = [ `Combat ${settings.combatMode}`, `Move ${settings.movementMode === 'hold' ? 'hold' : 'follow'}`, - `Pull ${settings.pullMode === 'off' ? 'off' : 'auto'}` + `Pull ${({ auto: 'auto', bot: 'bot', leader: 'player', off: 'off' })[settings.pullMode] || 'auto'}` ].join(' / '); return [ @@ -174,21 +238,22 @@ function renderModePanel(settings, count) { ]), Html.font('Pull', Html.COLOR.muted), actionRow([ - { label: 'Auto', active: settings.pullMode !== 'off', command: 'companion-control pull auto' }, - null, + { label: 'Auto', active: settings.pullMode === 'auto', command: 'companion-control pull auto' }, + { label: 'Player', active: settings.pullMode === 'leader', command: 'companion-control pull leader' }, { label: 'Off', active: settings.pullMode === 'off', command: 'companion-control pull off' } - ]), + ], { columns: 3 }), Html.font(`Loot: ${lootLabel(settings.distribution)}`, Html.COLOR.muted), '' ].join(''); } -function renderCompanionCard(companionSession) { +function renderCompanionCard(companionSession, settings) { const bot = companionSession.actor; + const isPuller = isAssignedPuller(companionSession, settings); const stayActive = companionSession.botStay === true; const status = BotManager.getBotStatus(companionSession); const role = status?.role || BotRoles.inferRole(bot); - const stance = stayActive ? 'hold' : 'follow'; + const stance = isPuller ? 'pulling' : (stayActive ? 'hold' : 'follow'); const intent = compactText(status?.intent || companionSession.plan, 'idle'); const tacticalDecision = status?.decisions?.pvp ? BotStatus.decisionSummary(status.decisions.pvp, 'pvp') @@ -211,9 +276,7 @@ function renderCompanionCard(companionSession) { : null; const note = blocker || debuff || buffWarning || targetEvaluation || target || 'ready'; const noteColor = blocker || debuff ? Html.COLOR.warn : Html.COLOR.muted; - const pullText = BotRoles.isTank(bot) - ? ` / pull ${companionSession.autoTaunt === false ? 'off' : 'auto'}` - : ''; + const canPull = ['tank', 'dagger', 'dps'].includes(role); const primaryAction = stayActive ? { label: 'Follow', command: `companion-control follow ${bot.fetchName()}`, color: Html.COLOR.ok } : { label: 'Hold', command: `companion-control stay ${bot.fetchName()}` }; @@ -229,16 +292,21 @@ function renderCompanionCard(companionSession) { ]), Html.row([ Html.cell(Html.font('State', Html.COLOR.muted), { width: 54 }), - Html.cell(`${Html.font(note, noteColor)}${Html.font(pullText, Html.COLOR.muted)}`, { width: 216, align: 'left' }) + Html.cell(Html.font(note, noteColor), { width: 216, align: 'left' }) ]) ]); const actions = actionRow([ primaryAction, + canPull + ? (isPuller + ? { label: 'Stop Pull', command: `companion-control member-pull off ${bot.fetchName()}`, color: Html.COLOR.warn } + : { label: 'Pull', command: `companion-control member-pull on ${bot.fetchName()}`, color: Html.COLOR.ok }) + : null, { label: 'Call', command: `companion-control summon ${bot.fetchName()}` }, { label: 'Info', command: `bot-status ${bot.fetchName()}` }, { label: 'Dismiss', command: `companion-control dismiss ${bot.fetchName()}`, color: Html.COLOR.warn } - ]); + ], { columns: 5 }); return `${Html.line(Html.TEXTURE.line, Html.WIDTH, 1)}${summary}${actions}`; } @@ -256,6 +324,14 @@ function companionControl(session, parts) { setCombatMode(session, value); } else if (subCommand === 'pull') { setPullMode(session, value); + } else if (subCommand === 'member-pull') { + const targetSession = findCompanion(session, parts[3]); + if (value === 'on' && targetSession) { + setMemberPuller(session, targetSession); + } else if (value === 'off' && targetSession && isAssignedPuller(targetSession)) { + clearAssignedPuller(session); + BotManager.botSay(targetSession, 'Stopping pull duty and staying with the party.'); + } } else if (subCommand === 'regroup') { regroup(session); } else if (subCommand && subCommand !== 'refresh') { @@ -288,7 +364,7 @@ function renderCompanionPanel(session) { body += Html.spacer(5); myCompanions.forEach((companionSession) => { - body += renderCompanionCard(companionSession); + body += renderCompanionCard(companionSession, settings); body += Html.spacer(4); }); diff --git a/tests/test_bot_support_planner.js b/tests/test_bot_support_planner.js index 66b82529..f4ce4426 100644 --- a/tests/test_bot_support_planner.js +++ b/tests/test_bot_support_planner.js @@ -18,15 +18,21 @@ function skill(id, name, level, effect, stats, target = 'friendly') { } let nextActorId = 1; -function actor(name, classId, skills = [], mp = 100) { +function actor(name, classId, skills = [], mp = 100, maxMp = 100, busy = false) { const id = nextActorId++; return { fetchId: () => id, fetchName: () => name, fetchClassId: () => classId, fetchMp: () => mp, + fetchMaxMp: () => maxMp, skillset: { fetchSkills: () => skills }, - state: { fetchDead: () => false } + state: { + fetchDead: () => false, + fetchTowards: () => busy, + fetchHits: () => false, + fetchCasts: () => false + } }; } @@ -43,6 +49,14 @@ assert.strictEqual( 'a legacy UI marker without a structured effect must not block a rebuff' ); +EffectStore.apply(target, { key: 'shield', id: 1040, level: 2, type: 'buff', durationMs: 10 * 60 * 1000 }); +assert.strictEqual( + BotSupportPlanner.needsSkill(target, shieldOne), + false, + 'a legacy structured newbie Shield without stats must still block a lower-level recast' +); +EffectStore.remove(target, 'shield'); + EffectStore.apply(target, { key: 'shield', id: 1040, level: 1, type: 'buff', stats: { pDefMul: 1.08 }, durationMs: 10 * 60 * 1000 }); assert.strictEqual(BotSupportPlanner.needsSkill(target, shieldOne), false, 'do not overwrite an equal-level active buff'); assert.strictEqual(BotSupportPlanner.needsSkill(target, soulShieldTwo), true, 'upgrade an active defensive buff when the party has a higher level'); @@ -143,4 +157,81 @@ action = BotSupportPlanner.nextAction(fullPackageBuffer, [ assert.strictEqual(action.skill.fetchSelfId(), 1078, 'after a successful cast, the autonomous buffer should advance to the next needed party buff without another request'); assert.strictEqual(action.target, packageMage, 'the next planned buff should target its eligible party member'); +const pullPriorityBuffer = actor('PullPriorityBuffer', 49, [sharedShield]); +const ordinaryLeader = actor('OrdinaryLeader', 0); +const designatedPuller = actor('DesignatedPuller', 4); +action = BotSupportPlanner.nextAction(pullPriorityBuffer, [ + { actor: ordinaryLeader, leader: true }, + { actor: designatedPuller, leader: false, puller: true } +], [pullPriorityBuffer]); +assert.strictEqual(action.target, designatedPuller, 'a designated puller should receive missing individual buffs before the party leader'); +assert.strictEqual( + BotSupportPlanner.hasPendingAction([{ actor: designatedPuller, puller: true }], [pullPriorityBuffer]), + true, + 'party pull should wait while a support action remains for its designated puller' +); + +const exhaustedPullBuffer = actor('ExhaustedPullBuffer', 49, [sharedShield], 30, 100); +const exhaustedPuller = actor('ExhaustedPuller', 4); +assert.strictEqual( + BotSupportPlanner.hasPendingAction([{ actor: exhaustedPuller, puller: true }], [exhaustedPullBuffer]), + false, + 'party pull should not wait forever for a buff the provider will decline below its support MP reserve' +); + +const silencedPullBuffer = actor('SilencedPullBuffer', 49, [sharedShield]); +const silencedPuller = actor('SilencedPuller', 4); +EffectStore.apply(silencedPullBuffer, { key: 'silence', id: 116, type: 'debuff', durationMs: 30000 }); +assert.strictEqual( + BotSupportPlanner.hasPendingAction([{ actor: silencedPuller, puller: true }], [silencedPullBuffer]), + false, + 'party pull should not wait for a support cast while its only provider is silenced' +); + +const travellingPullBuffer = actor('TravellingPullBuffer', 49, [sharedShield], 100, 100, true); +const travellingPuller = actor('TravellingPuller', 4); +assert.strictEqual( + BotSupportPlanner.hasPendingAction([{ actor: travellingPuller, puller: true }], [travellingPullBuffer]), + false, + 'party pull should not wait for a buff while its provider is still moving and cannot cast it' +); + +const queuedSupportSession = {}; +const queuedSupportTarget = actor('QueuedSupportTarget', 4); +const queuedSupportBuffer = actor('QueuedSupportBuffer', 49, [sharedShield]); +queuedSupportBuffer.session = queuedSupportSession; +action = BotSupportPlanner.nextAction(queuedSupportBuffer, [{ actor: queuedSupportTarget, leader: true }], [queuedSupportBuffer]); +assert.strictEqual(BotSupportPlanner.queueSupportCast(queuedSupportSession, action), true, 'support selection should queue the intended native cast'); +assert.strictEqual(queuedSupportTarget.supportReservations, undefined, 'a queued movement/action must not masquerade as an accepted support cast'); +queuedSupportBuffer.state.fetchTowards = () => true; +assert.strictEqual( + BotSupportPlanner.hasPendingAction([{ actor: queuedSupportTarget, puller: true }], [queuedSupportBuffer]), + true, + 'party pull should keep waiting while a selected support cast is walking into range' +); +queuedSupportBuffer.state.fetchTowards = () => false; +assert.strictEqual(BotSupportPlanner.beginSupportCast(queuedSupportSession, queuedSupportBuffer, queuedSupportTarget, action.skill), true, 'the reservation should begin only when the native cast starts'); +assert(queuedSupportTarget.supportReservations, 'an accepted cast should reserve its target effect'); +queuedSupportSession.currentTargetId = queuedSupportTarget.fetchId(); +assert.strictEqual(BotSupportPlanner.finishSupportCast(queuedSupportSession, queuedSupportBuffer, action.skill), true, 'support cast completion should clear its lifecycle marker'); +assert.strictEqual(queuedSupportSession.currentTargetId, undefined, 'a completed support cast must not leave a stale combat target behind'); +assert.strictEqual(BotSupportPlanner.queueSupportCast(queuedSupportSession, action), true, 'a subsequent support cast should be queued normally'); +assert.strictEqual( + BotSupportPlanner.cancelPendingSupportCast(queuedSupportSession, queuedSupportBuffer, queuedSupportTarget, partyShield), + false, + 'an unrelated rejected skill must not clear a queued support cast' +); +assert(queuedSupportSession.pendingSupportCast, 'an unrelated rejection should leave the selected support cast intact'); +assert.strictEqual( + BotSupportPlanner.cancelPendingSupportCast(queuedSupportSession, queuedSupportBuffer, queuedSupportTarget, action.skill), + true, + 'a native rejection of the queued support skill must release its pending marker immediately' +); +assert.strictEqual(queuedSupportSession.pendingSupportCast, undefined, 'a rejected queued support cast must not pause party pulling until timeout'); +assert.strictEqual(BotSupportPlanner.queueSupportCast(queuedSupportSession, action), true, 'a later retry should queue the support cast again'); +assert.strictEqual(BotSupportPlanner.beginSupportCast(queuedSupportSession, queuedSupportBuffer, queuedSupportTarget, action.skill), true, 'the subsequent cast should enter its active lifecycle'); +queuedSupportSession.currentTargetId = queuedSupportTarget.fetchId(); +assert.strictEqual(BotSupportPlanner.cancelSupportCast(queuedSupportSession, queuedSupportBuffer), true, 'an interrupted support cast should be cancellable'); +assert.strictEqual(queuedSupportSession.currentTargetId, undefined, 'a cancelled support cast must also clear its stale target'); + console.log('Bot support planner checks passed'); diff --git a/tests/test_party_companion_rest_follow.js b/tests/test_party_companion_rest_follow.js index 6b41df9b..ebc65d71 100644 --- a/tests/test_party_companion_rest_follow.js +++ b/tests/test_party_companion_rest_follow.js @@ -14,6 +14,7 @@ const BotBuffs = invoke('GameServer/Bot/AI/BotBuffs'); const BotStatus = invoke('GameServer/Bot/AI/BotStatus'); const BotBrainContext = invoke('GameServer/Bot/AI/BotBrainContext'); const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); +const PartyPulling = invoke('GameServer/Bot/AI/PartyPulling'); const MarketOpportunity = invoke('GameServer/Bot/Economy/MarketOpportunity'); const CompanionControl = invoke('GameServer/World/Generics/NpcBypasses/CompanionControl'); const EffectStore = invoke('GameServer/Effects/EffectStore'); @@ -243,6 +244,29 @@ try { assert.strictEqual(bot.moves.length, 1, 'companion should run after the leader at 1200 range'); assert.strictEqual(bot.fetchLocX(), 1200, 'companion should not teleport at 1200 range'); + const selectedTargetRefreshBot = fakeActor(2000042, { locX: 0, locY: 0, level: 1 }); + selectedTargetRefreshBot.activeBuffs = {}; + const selectedTargetRefreshSession = fakeSession('bot_selected_target_refresh', selectedTargetRefreshBot); + selectedTargetRefreshSession.followPlayerSession = leaderSession; + selectedTargetRefreshSession.partyCompanion = true; + selectedTargetRefreshSession.plan = 'following'; + // These can survive an inspection or an earlier completed cast. They are + // not proof that the party is in combat. + leader.destId = 1099; + selectedTargetRefreshSession.currentTargetId = 1099; + World.user = { sessions: [leaderSession, selectedTargetRefreshSession] }; + World.npc = { spawns: [] }; + World.fetchNpcsInRadius = () => []; + + FollowingState.tick(selectedTargetRefreshSession, selectedTargetRefreshBot, {}, { + getClosestNewbieGuide: () => ({ locX: 0, locY: 0 }), + say() {}, executeCombat() {}, executePvPCombat() {} + }); + + assert.strictEqual(selectedTargetRefreshSession.plan, 'getting_buffed', 'an idle companion should refresh newbie buffs even when it or the leader has a stale selected target'); + assert.strictEqual(selectedTargetRefreshSession.roleDecision.reason, 'newbie_blessing', 'stale target ids must not produce wait_for_safe_moment outside combat'); + leader.destId = undefined; + const inviteBot = fakeActor(2000033, { locX: 50, locY: 0 }); const inviteBotSession = fakeSession('bot_invite_resting', inviteBot); inviteBotSession.plan = 'resting'; @@ -415,6 +439,32 @@ try { assert.strictEqual(targetWakeBot.state.fetchSeated(), false, 'resting companion should stand when leader attacks'); assert.strictEqual(targetWakeSession.currentTargetId, 1005, 'resting companion should remember leader target'); + const restingPullBot = fakeActor(2000013, { locX: 80, locY: 0, hp: 20, maxHp: 100, mp: 10, maxMp: 100 }); + restingPullBot.state.setSeated(true); + const restingPullSession = fakeSession('bot_resting_pull_pause', restingPullBot); + restingPullSession.followPlayerSession = leaderSession; + restingPullSession.partyCompanion = true; + restingPullSession.plan = 'resting'; + const distantLeaderPull = { + fetchId: () => 1013, + fetchAttackable: () => true, + isDead: () => false, + fetchLocX: () => 1200, + fetchLocY: () => 0, + fetchLocZ: () => 0 + }; + PartyCompanionService.updateSettings(leaderSession, { pullMode: 'leader' }); + leader.destId = distantLeaderPull.fetchId(); + World.user = { sessions: [leaderSession, restingPullSession] }; + World.npc = { spawns: [distantLeaderPull] }; + World.fetchNpcsInRadius = () => []; + + RestingState.tick(restingPullSession, restingPullBot, {}, { say() {} }); + + assert.strictEqual(restingPullSession.plan, 'resting', 'recovering companion should not wake for a leader pull that has not reached the party'); + assert.strictEqual(restingPullBot.state.fetchSeated(), true, 'pulling should stay paused while a companion is regenerating'); + PartyCompanionService.updateSettings(leaderSession, { pullMode: 'auto' }); + leader.destId = 1003; const assistingBot = fakeActor(2000006, { locX: 500, locY: 0 }); const assistingSession = fakeSession('bot_assisting', assistingBot); @@ -728,6 +778,33 @@ try { assert.strictEqual(refreshSession.plan, 'getting_buffed', 'safe companion should leave briefly for a Newbie Guide when the player is in its town'); assert.strictEqual(refreshSession.resumeAfterBuff?.plan, 'following', 'buff refresh should preserve the companion return plan'); + const recoveryLeader = fakeActor(2000043, { locX: -84081, locY: 243227, locZ: -3723, level: 10 }); + const recoveryLeaderSession = fakeSession('player_newbie_recovery_party', recoveryLeader); + const recoveryBot = fakeActor(2000044, { locX: -84001, locY: 243227, locZ: -3723, level: 20, hp: 20, maxHp: 100, mp: 100, maxMp: 100 }); + const recoverySession = fakeSession('bot_newbie_recovery_party', recoveryBot); + recoverySession.followPlayerSession = recoveryLeaderSession; + recoverySession.partyCompanion = true; + recoverySession.plan = 'following'; + World.user = { sessions: [recoveryLeaderSession, recoverySession] }; + FollowingState.tick(recoverySession, recoveryBot, {}, { + getClosestNewbieGuide: () => ({ locX: -84081, locY: 243227, locZ: -3723 }), + say() {}, executeCombat() {}, executePvPCombat() {} + }); + assert.strictEqual(recoverySession.plan, 'getting_buffed', 'a low-level companion already in a Newbie Guide town should recover there instead of sitting'); + assert.strictEqual(recoverySession.roleDecision.reason, 'newbie_guide_recovery', 'Newbie Guide recovery should be visible in bot status'); + + const fieldRecoveryBot = fakeActor(2000045, { locX: 0, locY: 0, level: 20, hp: 20, maxHp: 100, mp: 100, maxMp: 100 }); + const fieldRecoverySession = fakeSession('bot_field_recovery_party', fieldRecoveryBot); + fieldRecoverySession.followPlayerSession = fieldRefreshLeaderSession; + fieldRecoverySession.partyCompanion = true; + fieldRecoverySession.plan = 'following'; + World.user = { sessions: [fieldRefreshLeaderSession, fieldRecoverySession] }; + FollowingState.tick(fieldRecoverySession, fieldRecoveryBot, {}, { + getClosestNewbieGuide: () => ({ locX: -84081, locY: 243227, locZ: -3723 }), + say() {}, executeCombat() {}, executePvPCombat() {} + }); + assert.strictEqual(fieldRecoverySession.plan, 'resting', 'a low-level companion must not travel from a farming field to recover at a Newbie Guide'); + const errandLeader = fakeActor(2000037, { locX: 83396, locY: 147904, locZ: -3404 }); const errandLeaderSession = fakeSession('player_town_errand_party', errandLeader); const errandBot = fakeActor(2000038, { locX: 83436, locY: 147904, locZ: -3404 }); @@ -807,7 +884,7 @@ try { const partyHudLeader = fakeActor(2000030, { locX: 0, locY: 0 }); const partyHudLeaderSession = fakeSession('player_party_hud', partyHudLeader); - const partyHudBotA = fakeActor(2000031, { locX: 40, locY: 0 }); + const partyHudBotA = fakeActor(2000031, { locX: 40, locY: 0, classId: 4 }); const partyHudBotASession = fakeSession('bot_party_hud_a', partyHudBotA); const partyHudBotB = fakeActor(2000032, { locX: 80, locY: 0 }); const partyHudBotBSession = fakeSession('bot_party_hud_b', partyHudBotB); @@ -828,6 +905,11 @@ try { World.user = { sessions: [partyHudLeaderSession, partyHudBotASession, partyHudBotBSession] }; World.fetchNpcsInRadius = () => []; + assert.deepStrictEqual( + PartyPulling.supportProviders(partyHudLeaderSession), + [partyHudBotA, partyHudBotB], + 'the human leader must be a buff recipient, not an autonomous support provider' + ); partyHudBotASession.lastTargetEvaluation = { targetId: 9001, targetName: 'Keltir', @@ -891,6 +973,11 @@ try { caster: casterBot }); assert(EffectStore.packetEffects(partyHudBotA).some((effect) => effect.id === 1040), 'support buff should be stored as a structured effect'); + assert.deepStrictEqual( + EffectStore.list(partyHudBotA).find((effect) => effect.key === 'shield').stats, + { pDefMul: 1.12 }, + 'newbie and support Shield must retain C4 stats so the planner recognises it as active' + ); const partyShieldPacket = lastPartySpelledPacket(partyHudLeaderSession, partyHudBotA.fetchId()); assert(partyShieldPacket, 'support buff should refresh native party effect icons'); assert.strictEqual(partyShieldPacket.readInt32LE(13), 1040, 'party effect packet should include shield skill id'); @@ -972,10 +1059,156 @@ try { assert.strictEqual(partyHudBotASession.botStay, false, 'follow mode should release held companions'); assert.strictEqual(partyHudBotBSession.botStay, false, 'follow mode should release the full group'); + const pulledMob = { + id: 3011, + locX: 1200, + locY: 0, + fetchId() { return this.id; }, + fetchAttackable: () => true, + isDead: () => false, + fetchLevel: () => 26, + destId: undefined, + fetchDestId() { return this.destId; }, + fetchLocX() { return this.locX; }, + fetchLocY() { return this.locY; }, + fetchLocZ: () => 0, + fetchName: () => 'pull target' + }; + World.npc = { spawns: [pulledMob] }; + World.fetchNpcsInRadius = () => [pulledMob]; + partyHudBotA.locX = 40; + partyHudBotB.locX = 80; + partyHudBotA.moves = []; + partyHudBotB.moves = []; + let pulledTargetId = null; + const pullChat = []; + + CompanionControl(partyHudLeaderSession, ['companion-control', 'member-pull', 'on', partyHudBotA.fetchName()]); + assert.strictEqual(PartyCompanionService.getSettings(partyHudLeaderSession).pullMode, 'bot', 'assigning a bot to pull should enable the dedicated bot pull mode'); + assert.strictEqual(PartyCompanionService.getSettings(partyHudLeaderSession).pullerId, partyHudBotA.fetchId(), 'party should use the bot explicitly selected by the player as puller'); + assert.strictEqual(BotStatus.getStatus(partyHudBotASession).party.stance, 'pulling', 'selected companion should expose pulling as its party stance'); + FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { + say(_session, text) { pullChat.push(text); }, + executeCombat(_session, _bot, npc) { pulledTargetId = npc.fetchId(); }, + executePvPCombat() {} + }); + assert.strictEqual(partyHudBotASession.roleDecision.action, 'party_pull', 'tank should become the assigned party puller before generic DPS'); + assert.strictEqual(partyHudBotASession.roleDecision.reason, 'approach', 'assigned puller should run to the nearest target first'); + assert.strictEqual(partyHudBotA.moves.length, 1, 'puller should walk to the selected mob before aggroing it'); + assert.strictEqual(partyHudLeaderSession.partyPullState.targetId, pulledMob.fetchId(), 'party should keep one shared pull target'); + + FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { + say() {}, executeCombat() { throw new Error('non-puller must wait for the incoming mob'); }, executePvPCombat() {} + }); + assert.strictEqual(partyHudBotBSession.roleDecision.action, 'hold_for_pull', 'non-puller should wait while the mob is outside party attack range'); + + partyHudBotA.locX = pulledMob.locX; + FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { + say(_session, text) { pullChat.push(text); }, + executeCombat(_session, _bot, npc) { pulledTargetId = npc.fetchId(); }, + executePvPCombat() {} + }); + assert.strictEqual(pulledTargetId, pulledMob.fetchId(), 'tank should aggro with a normal attack when Aggression is not learned'); + assert(pullChat.some((text) => text.includes('pull target')), 'puller should announce the specific mob in party chat'); + + partyHudLeaderSession.partyPullState.aggroRequestedAt = Date.now() - 3000; + partyHudBotA.state.casts = true; + FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { + say() {}, executeCombat() { throw new Error('puller must wait for its in-flight aggro cast instead of returning early'); }, executePvPCombat() {} + }); + assert.strictEqual(partyHudBotASession.roleDecision.reason, 'wait_for_aggro', 'puller should wait for a cast longer than the old fixed aggro timeout'); + partyHudBotA.state.casts = false; + + pulledMob.destId = partyHudBotA.fetchId(); + FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { + say() {}, executeCombat() { throw new Error('non-puller must not chase a mob that has only just aggroed the distant puller'); }, executePvPCombat() {} + }); + assert.strictEqual(partyHudBotBSession.roleDecision.action, 'hold_for_pull', 'party should remain held until the puller has returned to the camp'); + + FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { + say() {}, executeCombat() { throw new Error('confirmed aggro should make the puller return before the party engages'); }, executePvPCombat() {} + }); + assert.strictEqual(partyHudBotASession.roleDecision.reason, 'return', 'puller should return to the leader after aggro is confirmed'); + + pulledMob.locX = 700; + FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { + say() {}, executeCombat() { throw new Error('melee companion must not chase an incoming pull outside its attack range'); }, executePvPCombat() {} + }); + assert.strictEqual(partyHudBotBSession.roleDecision.action, 'hold_for_pull', 'melee companions should keep holding until the mob reaches their actual attack range'); + + partyHudBotA.locX = partyHudLeader.locX; + pulledMob.locX = partyHudBotB.locX; + FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { + say() {}, executeCombat() {}, executePvPCombat() {} + }); + pulledTargetId = null; + FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { + say() {}, + executeCombat(_session, _bot, npc) { pulledTargetId = npc.fetchId(); }, + executePvPCombat() {} + }); + assert.strictEqual(pulledTargetId, pulledMob.fetchId(), 'puller should keep attacking after the delivered pull enters engage phase'); + partyHudLeaderSession.partyPullState.startedAt = Date.now() - 61000; + assert.strictEqual(PartyPulling.current(partyHudLeaderSession, PartyCompanionService.getSettings(partyHudLeaderSession)).target, pulledMob, 'a living pulled mob must stay the party target after one minute'); + let assistedPulledMobId = null; + FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { + say() {}, + executeCombat(_session, _bot, npc) { assistedPulledMobId = npc.fetchId(); }, + executePvPCombat() {} + }); + assert.strictEqual(assistedPulledMobId, pulledMob.fetchId(), 'party should engage the marked mob once it reaches attack range'); + + CompanionControl(partyHudLeaderSession, ['companion-control', 'member-pull', 'on', partyHudBotA.fetchName()]); + partyHudBotB.state.setSeated(true); + partyHudBotA.locX = 40; + partyHudBotA.moves = []; + pulledMob.locX = 1200; + pulledMob.destId = undefined; + let abortedAggro = 0; + let clearedAggroTimers = 0; + partyHudBotA.attack = { + abortCast() { abortedAggro++; }, + clearTimers() { clearedAggroTimers++; } + }; + partyHudLeaderSession.partyPullState = { + targetId: pulledMob.fetchId(), + pullerId: partyHudBotA.fetchId(), + source: 'bot', + phase: 'aggro', + startedAt: Date.now(), + aggroRequestedAt: Date.now() + }; + FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { say() {}, executeCombat() {}, executePvPCombat() {} }); + assert.strictEqual(partyHudBotASession.roleDecision.reason, 'party_recovering', 'pulling should pause while any companion is regenerating'); + assert.strictEqual(partyHudBotA.moves.length, 0, 'puller should not leave the group during party recovery'); + assert.strictEqual(abortedAggro, 1, 'party recovery should cancel an aggro cast that has not landed'); + assert.strictEqual(clearedAggroTimers, 1, 'party recovery should cancel the scheduled aggro hit before it can land'); + assert.strictEqual(partyHudLeaderSession.partyPullState.phase, 'approach', 'an interrupted aggro request should retry only after the party resumes'); + partyHudBotB.state.setSeated(false); + + partyHudLeader.destId = pulledMob.fetchId(); + CompanionControl(partyHudLeaderSession, ['companion-control', 'pull', 'leader']); + FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { + say() {}, executeCombat() { throw new Error('party must wait for a leader pull outside range'); }, executePvPCombat() {} + }); + assert.strictEqual(partyHudBotBSession.roleDecision.action, 'hold_for_pull', 'leader pull should use the same hold gate as a bot pull'); + pulledMob.locX = partyHudBotB.locX; + assistedPulledMobId = null; + FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { + say() {}, + executeCombat(_session, _bot, npc) { assistedPulledMobId = npc.fetchId(); }, + executePvPCombat() {} + }); + assert.strictEqual(assistedPulledMobId, pulledMob.fetchId(), 'party should assist the leader-selected mob after it reaches the group'); + assert.strictEqual(BotStatus.getStatus(partyHudBotASession).party.pull.mode, 'leader', 'bot status should expose the active pull mode and state'); + partyHudLeader.destId = undefined; + CompanionControl(partyHudLeaderSession, ['companion-control', 'pull', 'off']); assert.strictEqual(PartyCompanionService.getSettings(partyHudLeaderSession).pullMode, 'off', 'party control should store pull mode'); + assert.strictEqual(PartyCompanionService.getSettings(partyHudLeaderSession).pullerId, null, 'leaving bot pull mode should clear the selected puller id'); assert.strictEqual(partyHudBotASession.autoTaunt, false, 'pull off should disable companion taunt'); assert.strictEqual(partyHudBotBSession.autoTaunt, false, 'pull off should apply to every companion'); + assert.strictEqual(partyHudBotASession.partyPuller, false, 'leaving party pull mode should clear the companion pulling stance'); const companionHtml = lastNpcHtml(partyHudLeaderSession); assert(companionHtml.includes('2 active'), 'party control panel should show active companion count'); assert(companionHtml.includes('Loot: Random+Spoil'), 'party control panel should show readable loot mode'); @@ -983,9 +1216,10 @@ try { assert(!companionHtml.includes('<\/td>[\s\S]*companion-control pull off/.test(companionHtml), - 'pull controls should keep Auto in the first column and Off in the third column' + /companion-control pull auto[\s\S]*companion-control pull leader[\s\S]*companion-control pull off/.test(companionHtml), + 'party pull controls should expose Auto, Player, and Off modes' ); + assert(companionHtml.includes('member-pull on'), 'eligible companion cards should expose a per-bot Pull order'); assert(companionHtml.includes('Call'), 'companion cards should expose summon as a compact call action'); assert(companionHtml.includes('Info'), 'companion cards should keep a compact status action'); assert(companionHtml.includes('Dismiss'), 'companion cards should expose a dismiss action'); @@ -995,7 +1229,21 @@ try { assert(!companionHtml.includes('bgcolor=222222'), 'party control panel should avoid the flat grey panel background'); assert(!companionHtml.includes('bgcolor=333333'), 'companion cards should avoid the flat grey card background'); + CompanionControl(partyHudLeaderSession, ['companion-control', 'member-pull', 'on', partyHudBotA.fetchName()]); + assert.strictEqual(PartyCompanionService.getSettings(partyHudLeaderSession).pullMode, 'bot', 'selected companion should remain the explicit puller until its status changes'); + let cancelledPullCast = 0; + let clearedPullTimers = 0; + partyHudBotA.attack = { + abortCast() { cancelledPullCast++; }, + clearTimers() { clearedPullTimers++; } + }; + CompanionControl(partyHudLeaderSession, ['companion-control', 'pull', 'off']); + assert.strictEqual(cancelledPullCast, 1, 'turning pull off should cancel an in-flight pull cast'); + assert.strictEqual(clearedPullTimers, 1, 'turning pull off should cancel scheduled pull attacks'); + CompanionControl(partyHudLeaderSession, ['companion-control', 'member-pull', 'on', partyHudBotA.fetchName()]); assert.strictEqual(PartyCompanionService.detach(partyHudLeaderSession, partyHudBotASession), true, 'dismiss should detach a companion'); + assert.strictEqual(clearedPullTimers, 2, 'dismissing the selected puller should also cancel its scheduled pull action'); + assert.strictEqual(PartyCompanionService.getSettings(partyHudLeaderSession).pullMode, 'auto', 'dismissing the selected puller should clear party pull instead of silently assigning another bot'); const oneMemberPacket = lastPartyAllPacket(partyHudLeaderSession); assert(oneMemberPacket, 'dismissing one companion should rebuild the party window'); assert.strictEqual(oneMemberPacket.readInt32LE(5), 2, 'party window should keep the stored loot distribution after detach'); From ec8d37f06270d3a2aa7e400544f4a11172a22ecc Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:41:21 -0400 Subject: [PATCH 04/13] Fix companion party pulling and loot flow --- src/GameServer/Actor/Attack.js | 20 +++- src/GameServer/Actor/Generics/PickupExec.js | 4 +- src/GameServer/Actor/Generics/ReceivedHit.js | 4 +- src/GameServer/Bot/AI/BotSupportPlanner.js | 8 +- .../Bot/AI/PartyCompanionService.js | 63 +++++++++++++ src/GameServer/Bot/AI/PartyPulling.js | 34 ++++++- .../Bot/AI/States/FollowingState.js | 22 ++++- src/GameServer/Bot/AI/States/RestingState.js | 42 +++++++++ src/GameServer/Bot/BotAI.js | 12 ++- src/GameServer/World/Generics/NpcRewards.js | 3 + src/GameServer/World/Generics/SpawnItem.js | 3 +- tests/test_bot_ai_visibility.js | 5 +- tests/test_bot_support_planner.js | 10 +- tests/test_party_bot_loot.js | 94 ++++++++++++++++++- tests/test_party_companion_rest_follow.js | 46 +++++++++ 15 files changed, 353 insertions(+), 17 deletions(-) diff --git a/src/GameServer/Actor/Attack.js b/src/GameServer/Actor/Attack.js index 25ab05f7..04d889db 100644 --- a/src/GameServer/Actor/Attack.js +++ b/src/GameServer/Actor/Attack.js @@ -57,7 +57,16 @@ class Attack { case 'move' : Generics.moveTo (session, actor, queue.data); break; case 'attack' : Generics.attackRequest(session, actor, queue.data); break; case 'skill' : Generics.skillRequest (session, actor, queue.data); break; - case 'pickup' : Generics.pickupRequest(session, actor, queue.data); break; + case 'pickup' : { + const isBot = session?.constructor?.name === 'BotSession' || String(session?.accountId || '').startsWith('bot_'); + // Hot bots move entirely on the server and never send the + // ValidatePosition that a player's pickupRequest waits for. + // A queued pickup commonly follows the killing hit, so it + // must use the server-side execution path as well. + if (isBot) Generics.pickupExec(session, actor, queue.data); + else Generics.pickupRequest(session, actor, queue.data); + break; + } case 'sit' : Generics.basicAction (session, actor, queue.data); break; } this.resetQueuedEvent(); @@ -159,6 +168,11 @@ class Attack { return; } + actor.state.setHits(false); + if (invoke('GameServer/Bot/AI/PartyCompanionService').startQueuedGroundPickup(session)) { + return; + } + if (this.queue.name) { this.dequeueEvent(session); return; @@ -267,6 +281,10 @@ class Attack { // Start replenish actor.automation.replenishVitals(actor); + if (invoke('GameServer/Bot/AI/PartyCompanionService').startQueuedGroundPickup(session)) { + return; + } + if (this.queue.name) { this.dequeueEvent(session); return; diff --git a/src/GameServer/Actor/Generics/PickupExec.js b/src/GameServer/Actor/Generics/PickupExec.js index 87e61c2d..5397eb60 100644 --- a/src/GameServer/Actor/Generics/PickupExec.js +++ b/src/GameServer/Actor/Generics/PickupExec.js @@ -1,7 +1,7 @@ const ServerResponse = invoke('GameServer/Network/Response'); const World = invoke('GameServer/World/World'); -function pickupExec(session, actor, data) { +function pickupExec(session, actor, data, onComplete) { World.fetchItem(data.id).then((item) => { actor.automation.schedulePickup(session, actor, item, () => { actor.state.setPickinUp(true); @@ -13,10 +13,12 @@ function pickupExec(session, actor, data) { setTimeout(() => { actor.state.setPickinUp(false); + onComplete?.(); }, 500); }); }).catch((err) => { utils.infoWarn('GameServer', 'Pickup -> ' + err); + onComplete?.(); }); } diff --git a/src/GameServer/Actor/Generics/ReceivedHit.js b/src/GameServer/Actor/Generics/ReceivedHit.js index 040a76f5..43bc8934 100644 --- a/src/GameServer/Actor/Generics/ReceivedHit.js +++ b/src/GameServer/Actor/Generics/ReceivedHit.js @@ -22,7 +22,9 @@ function wakeBotOnDamage(victimSession, attacker) { if (now - Number(victimSession.lastDamageWakeAt || 0) < BOT_WAKEUP_THROTTLE_MS) return; victimSession.lastDamageWakeAt = now; - invoke('GameServer/Bot/BotAI').wakeup(victimSession); + // Damage needs a prompt response even if a visibility refresh woke this + // bot a moment ago. Repeated damage is already rate-limited above. + invoke('GameServer/Bot/BotAI').wakeup(victimSession, { urgent: true }); } function shouldDamageCp(session, actor) { diff --git a/src/GameServer/Bot/AI/BotSupportPlanner.js b/src/GameServer/Bot/AI/BotSupportPlanner.js index 270ad14c..fd01ceb5 100644 --- a/src/GameServer/Bot/AI/BotSupportPlanner.js +++ b/src/GameServer/Bot/AI/BotSupportPlanner.js @@ -41,7 +41,13 @@ function supportSkills(actor) { .filter((skill) => skill && !skill.fetchPassive?.()) .filter((skill) => { const semantic = skill.fetchSemantic?.(); - return semantic?.effectType === 'buff' && ['friendly', 'ally', 'party'].includes(semantic.target); + // Heal-over-time effects are represented as temporary buffs for + // the effect engine, but they are not part of the persistent + // party-buff package. Treating a 15-second HoT as a rebuff makes + // the support planner request it continuously and pauses pulling. + const skillType = skill.fetchSkillType?.(); + const periodicHeal = skillType === 'hot' || skillType === 'healHot' || skillType === 'manaHot'; + return !periodicHeal && semantic?.effectType === 'buff' && ['friendly', 'ally', 'party'].includes(semantic.target); }); } diff --git a/src/GameServer/Bot/AI/PartyCompanionService.js b/src/GameServer/Bot/AI/PartyCompanionService.js index 66194573..5b476b9e 100644 --- a/src/GameServer/Bot/AI/PartyCompanionService.js +++ b/src/GameServer/Bot/AI/PartyCompanionService.js @@ -10,6 +10,7 @@ const DEFAULT_PARTY_SETTINGS = { itemLastLootIndex: -1 }; const PARTY_LOOT_RADIUS = 2500; +const RANDOM_LOOT_DISTRIBUTIONS = new Set([1, 2]); const FORMATION_OFFSETS = [ { locX: -90, locY: -70 }, { locX: -90, locY: 70 }, @@ -136,6 +137,50 @@ function nextTurnMember(leaderSession, members) { return members[nextIndex]; } +function canPickGroundLoot(session, leaderSession, item) { + const actor = session?.actor; + if (!isActiveCompanion(session, leaderSession) || !isAliveOnline(session)) return false; + if (['resting', 'getting_buffed', 'shopping', 'merchant'].includes(session.plan)) return false; + if (actor?.state?.fetchSeated?.() || actor?.state?.fetchPickinUp?.()) return false; + if (actor?.storedPickup) return false; + return distance2d(actor, item) <= PARTY_LOOT_RADIUS; +} + +function nearestGroundLootPicker(looterSession, item) { + const leaderSession = partyLeaderSession(looterSession); + if (!leaderSession || !item || !RANDOM_LOOT_DISTRIBUTIONS.has(distributionForLeader(leaderSession))) return null; + + return membersForLeader(leaderSession) + .filter((memberSession) => canPickGroundLoot(memberSession, leaderSession, item)) + .sort((a, b) => ( + distance2d(a.actor, item) - distance2d(b.actor, item) || + Number(a.actor.fetchId()) - Number(b.actor.fetchId()) + ))[0] || null; +} + +function startQueuedGroundPickup(pickerSession) { + const picker = pickerSession?.actor; + const queue = pickerSession?.partyGroundPickupQueue; + if (!picker || pickerSession.partyGroundPickupInProgress || !queue?.length) return false; + if (picker.state?.fetchHits?.() || picker.state?.fetchCasts?.() || picker.state?.fetchPickinUp?.()) return false; + + const pickup = queue[0]; + pickerSession.partyGroundPickupInProgress = true; + const Generics = invoke(path.actor); + Generics.stopAutomation(pickerSession, picker); + Generics.pickupExec(pickerSession, picker, pickup, () => { + if (queue[0]?.id === pickup.id) { + queue.shift(); + } else { + const index = queue.findIndex((entry) => entry.id === pickup.id); + if (index >= 0) queue.splice(index, 1); + } + pickerSession.partyGroundPickupInProgress = false; + startQueuedGroundPickup(pickerSession); + }); + return true; +} + function formationSlotFor(companionSession) { const leaderSession = companionSession?.followPlayerSession; const members = membersForLeader(leaderSession); @@ -319,6 +364,24 @@ const PartyCompanionService = { .filter((entry) => entry.amount > 0); }, + queueRandomGroundPickup(looterSession, item) { + const pickerSession = nearestGroundLootPicker(looterSession, item); + if (!pickerSession) return null; + + const pickup = { id: item.fetchId() }; + // Player pickup requests wait for the next client ValidatePosition. + // Hot bots update their location server-side, so leaving this in + // storedPickup makes the visible drop stay on the ground forever. + // Keep an independent FIFO because a mob can drop Adena and items in + // the same reward pass while Automation has only one pickup timer. + pickerSession.partyGroundPickupQueue ??= []; + pickerSession.partyGroundPickupQueue.push(pickup); + startQueuedGroundPickup(pickerSession); + return pickerSession; + }, + + startQueuedGroundPickup, + attach(leaderSession, companionSession, options = {}) { const leader = leaderSession?.actor; const bot = companionSession?.actor; diff --git a/src/GameServer/Bot/AI/PartyPulling.js b/src/GameServer/Bot/AI/PartyPulling.js index b66eb691..532143da 100644 --- a/src/GameServer/Bot/AI/PartyPulling.js +++ b/src/GameServer/Bot/AI/PartyPulling.js @@ -9,6 +9,7 @@ const PULL_SEARCH_RADIUS = 2200; const PULL_CONTACT_DISTANCE = 260; const PULL_RETURN_DISTANCE = 180; const PULL_AGGRO_TIMEOUT_MS = 8000; +const PULL_MOVE_TARGET_DRIFT = 200; function point(actor) { return { @@ -174,8 +175,31 @@ function nearestFreeMonster(bot) { .sort((a, b) => distance(point(bot), point(a)) - distance(point(bot), point(b)))[0] || null; } -function moveTo(session, bot, target) { +function shouldKeepPullMove(session, bot, state, phase, target) { + if (!state?.moveTarget || state.movePhase !== phase) return false; + if (!(session.moveTimer || bot.state?.fetchTowards?.())) return false; + if ((session.stuckTicks || 0) >= 2) return false; + return distance(state.moveTarget, point(target)) <= PULL_MOVE_TARGET_DRIFT; +} + +function moveTo(session, bot, state, phase, target) { + if (shouldKeepPullMove(session, bot, state, phase, target)) return false; + // FollowingState leaves active pull movement to this coordinator. When a + // route has genuinely stopped, start the replacement path with a fresh + // sample window instead of treating every later tick as still stuck. + if ((session.stuckTicks || 0) >= 2) { + session.stuckTicks = 0; + session.lastStuckSampleAt = Date.now(); + } + state.movePhase = phase; + state.moveTarget = point(target); bot.moveTo({ from: point(bot), to: point(target) }); + return true; +} + +function clearPullMove(state) { + delete state.movePhase; + delete state.moveTarget; } function aggroActionInFlight(bot) { @@ -198,6 +222,7 @@ function tickBotPuller(session, bot, leaderSession, settings, Generics, BotAI) { // of letting its existing automation carry it out of the group. if (state.phase === 'approach') { bot.automation?.abortAll?.(bot); + clearPullMove(state); } // An aggro request has not landed yet, so it must not finish while the // party is paused. Once it has landed, preserve the shared target and @@ -228,7 +253,7 @@ function tickBotPuller(session, bot, leaderSession, settings, Generics, BotAI) { const state = pullState(leaderSession); if (state.phase === 'approach') { if (distance(point(bot), point(target)) > PULL_CONTACT_DISTANCE) { - moveTo(session, bot, target); + moveTo(session, bot, state, 'approach', target); return { handled: true, puller, action: 'approach', target }; } @@ -236,6 +261,7 @@ function tickBotPuller(session, bot, leaderSession, settings, Generics, BotAI) { // starting the native attack; otherwise its later completion can race // the cast and the return move would cancel the hit before it lands. bot.automation?.abortAll?.(bot); + clearPullMove(state); bot.select({ id: target.fetchId() }); const aggression = BotRoles.inferRole(bot) === 'tank' ? BotSkillCapabilities.aggressionSkill(bot) : null; if (aggression && bot.fetchMp() >= aggression.fetchConsumedMp()) { @@ -265,13 +291,14 @@ function tickBotPuller(session, bot, leaderSession, settings, Generics, BotAI) { // An interrupted/missed attempt must not make the puller abandon // the mob. Re-enter approach and issue a new native attack. state.phase = 'approach'; + clearPullMove(state); return { handled: true, puller, action: 'retry_aggro', target }; } state.phase = 'return'; } if (state.phase === 'return' && distance(point(bot), point(leaderSession.actor)) > PULL_RETURN_DISTANCE) { - moveTo(session, bot, leaderSession.actor); + moveTo(session, bot, state, 'return', leaderSession.actor); return { handled: true, puller, action: 'return', target }; } @@ -279,6 +306,7 @@ function tickBotPuller(session, bot, leaderSession, settings, Generics, BotAI) { // The puller is back at the camp. The mob is now delivered even when // melee formation offsets put every companion just outside its first // attack radius; normal assist movement can finish the engagement. + clearPullMove(state); state.phase = 'engage'; return { handled: false, puller, target }; } diff --git a/src/GameServer/Bot/AI/States/FollowingState.js b/src/GameServer/Bot/AI/States/FollowingState.js index 514a4391..8661a130 100644 --- a/src/GameServer/Bot/AI/States/FollowingState.js +++ b/src/GameServer/Bot/AI/States/FollowingState.js @@ -17,6 +17,7 @@ const FOLLOW_RUN_DISTANCE = 250; const FOLLOW_RETARGET_DISTANCE = 900; const FOLLOW_TARGET_DRIFT = 650; const FOLLOW_TELEPORT_DISTANCE = 4500; +const STUCK_SAMPLE_INTERVAL_MS = 750; // Newbie Guides only exist in the starter villages. A companion should not // abandon a player in the field just because its starter buffs have expired. const NEWBIE_GUIDE_TOWN_RADIUS = 7500; @@ -311,6 +312,13 @@ function partySupportMembers(leaderSession, puller) { return PartyPulling.supportMembers(leaderSession, puller); } +function activeBotPullTravel(session, pulling) { + return pulling?.enabled === true && + pulling.puller?.session === session && + pulling.puller.kind === 'bot' && + ['approach', 'return'].includes(pulling.phase); +} + function pullBlockReason(session, botVitals, partyVitals, activeMobs) { if (session.autoTaunt === false) return 'manual_pull_off'; if (session.botStay) return 'stay_order'; @@ -411,10 +419,14 @@ module.exports = { session.lastTickLoc = currentLoc; const isMoving = !!session.moveTimer || bot.state.fetchTowards(); - if (isMoving && movedDist < 10) { + const now = Date.now(); + const canSampleStuck = now - Number(session.lastStuckSampleAt || 0) >= STUCK_SAMPLE_INTERVAL_MS; + if (isMoving && movedDist < 10 && canSampleStuck) { session.stuckTicks = (session.stuckTicks || 0) + 1; - } else { + session.lastStuckSampleAt = now; + } else if (!isMoving || movedDist >= 10) { session.stuckTicks = 0; + session.lastStuckSampleAt = now; } if (bot.state.fetchSeated() && (partyThreat || leaderTargetId || distance > FOLLOW_RUN_DISTANCE)) { @@ -435,7 +447,11 @@ module.exports = { return; } - if (session.stuckTicks >= 3 || distance > FOLLOW_TELEPORT_DISTANCE) { + // Pulling has its own route recovery. Teleporting an assigned + // puller back to the leader halfway through approach/return makes the + // client show it run forward, snap back, and start the route again. + const pullerTravelling = activeBotPullTravel(session, pulling); + if (!pullerTravelling && (session.stuckTicks >= 3 || distance > FOLLOW_TELEPORT_DISTANCE)) { session.stuckTicks = 0; recordRoleDecision(session, bot, 'follow_leader', distance > FOLLOW_TELEPORT_DISTANCE ? 'catch_up' : 'unstuck'); const TeleportTo = invoke('GameServer/Actor/Generics/TeleportTo'); diff --git a/src/GameServer/Bot/AI/States/RestingState.js b/src/GameServer/Bot/AI/States/RestingState.js index afff26bb..b463a41e 100644 --- a/src/GameServer/Bot/AI/States/RestingState.js +++ b/src/GameServer/Bot/AI/States/RestingState.js @@ -11,6 +11,8 @@ const RECOVERY_HP_RATIO = 0.35; const RECOVERY_MP_RATIO = 0.20; const EMERGENCY_RETREAT_DISTANCE = 850; const MANA_REGEN_CAST_RETRY_MS = 8000; +const NEWBIE_GUIDE_TOWN_RADIUS = 7500; +const NEWBIE_GUIDE_RECOVERY_MAX_LEVEL = 20; function point(actor) { return new SpeckMath.Point3D(actor.fetchLocX(), actor.fetchLocY(), actor.fetchLocZ()); @@ -63,6 +65,34 @@ function needsRecovery(bot) { || bot.fetchMp() / Math.max(1, bot.fetchMaxMp()) < RECOVERY_MP_RATIO; } +function canRecoverAtNewbieGuide(bot, BotAI) { + if (Number(bot?.fetchLevel?.() || 0) > NEWBIE_GUIDE_RECOVERY_MAX_LEVEL) return false; + const guide = BotAI.getClosestNewbieGuide?.(bot.fetchLocX(), bot.fetchLocY()); + if (!guide) return false; + + const dx = bot.fetchLocX() - guide.locX; + const dy = bot.fetchLocY() - guide.locY; + return Math.sqrt((dx * dx) + (dy * dy)) <= NEWBIE_GUIDE_TOWN_RADIUS; +} + +function beginNewbieGuideRecovery(session, bot, playerSession) { + session.preBuffLocation = { locX: bot.fetchLocX(), locY: bot.fetchLocY(), locZ: bot.fetchLocZ() }; + session.preBuffPlan = 'following'; + session.resumeAfterBuff = { + plan: 'following', + followPlayerSession: playerSession, + partyCompanion: true, + botStay: session.botStay === true, + stayLocation: session.stayLocation ? { ...session.stayLocation } : null, + role: BotRoles.inferRole(bot) + }; + session.plan = 'getting_buffed'; + session.currentTargetId = undefined; + bot.unselect?.(); + standUp(session, bot); + bot.automation?.abortAll?.(bot); +} + function retreatFromThreat(session, bot, threat) { const from = { locX: bot.fetchLocX(), locY: bot.fetchLocY(), locZ: bot.fetchLocZ() }; const dx = from.locX - threat.fetchLocX(); @@ -122,6 +152,18 @@ module.exports = { const mpRatio = bot.fetchMp() / bot.fetchMaxMp(); const recovered = hpRatio >= 0.95 && mpRatio >= 0.95; + // A companion can join while it is already sitting from a prior + // hunt. If it is in a starter town, send low-level bots to the + // Newbie Guide instead of making the new party wait for normal + // seated regeneration. This checks the bot's own position, so it + // never leaves a farming spot just because the leader is in town. + if (!combatTargetId && !recovered && canRecoverAtNewbieGuide(bot, BotAI)) { + beginNewbieGuideRecovery(session, bot, playerSession); + recordWakeDecision(session, bot, hpRatio < 0.95 ? 'recover_hp' : 'recover_mp', 'newbie_guide_recovery'); + BotAI.say(session, "I'm recovering at the Newbie Guide, then I'll return to you."); + return; + } + // A recovering companion must stay seated even when its leader is // far away. Otherwise RestingState stands it up to follow, then // FollowingState immediately seats it again for low HP/MP. diff --git a/src/GameServer/Bot/BotAI.js b/src/GameServer/Bot/BotAI.js index a21f498a..ff69f184 100644 --- a/src/GameServer/Bot/BotAI.js +++ b/src/GameServer/Bot/BotAI.js @@ -34,6 +34,11 @@ const CHAT_PHRASES = { "Ready to rumble!" ] }; +// Visibility refreshes and damage can request an immediate AI pass from +// several nearby actors at once. Without a small gate each request cancels +// and recreates the companion's normal timer, which can turn a group move +// into a tight synchronous AI loop. +const WAKEUP_THROTTLE_MS = 250; const REAL_PLAYER_CACHE_MS = 250; let realPlayerCache = { world: null, revision: -1, checkedAt: 0, sessions: [] }; @@ -135,8 +140,13 @@ const BotAI = { } }, - wakeup(session) { + wakeup(session, { urgent = false } = {}) { if (!session.actor || !session.aiActive) return; + + const now = Date.now(); + if (!urgent && now - Number(session.lastAiWakeAt || 0) < WAKEUP_THROTTLE_MS) return; + session.lastAiWakeAt = now; + if (session.aiTimeout) { clearTimeout(session.aiTimeout); session.aiTimeout = null; diff --git a/src/GameServer/World/Generics/NpcRewards.js b/src/GameServer/World/Generics/NpcRewards.js index 77fa4257..d0feb7a6 100644 --- a/src/GameServer/World/Generics/NpcRewards.js +++ b/src/GameServer/World/Generics/NpcRewards.js @@ -1,6 +1,7 @@ const DataCache = invoke('GameServer/DataCache'); const SpeckMath = invoke('GameServer/SpeckMath'); const BotLootEtiquette = invoke('GameServer/Bot/AI/BotLootEtiquette'); +const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); const ProgressionRates = invoke('GameServer/ProgressionRates'); function isBotSession(session) { @@ -45,6 +46,8 @@ function spawnGroundDrop(world, session, npc, selfId, amount) { const point = new SpeckMath.Circle(npc.fetchLocX(), npc.fetchLocY(), 50).createPointWithin(); world.spawnItem(session, selfId, amount, { ...point.toCoords(), locZ: npc.fetchLocZ() - 10 + }, (item) => { + PartyCompanionService.queueRandomGroundPickup(session, item); }); } diff --git a/src/GameServer/World/Generics/SpawnItem.js b/src/GameServer/World/Generics/SpawnItem.js index e1b900ce..021f55d8 100644 --- a/src/GameServer/World/Generics/SpawnItem.js +++ b/src/GameServer/World/Generics/SpawnItem.js @@ -2,12 +2,13 @@ const ServerResponse = invoke('GameServer/Network/Response'); const Item = invoke('GameServer/Item/Item'); const DataCache = invoke('GameServer/DataCache'); -function spawnItem(session, selfId, amount, coords) { +function spawnItem(session, selfId, amount, coords, onSpawn) { DataCache.fetchItemFromSelfId(selfId, (itemDetails) => { const item = new Item(this.items.nextId++, { ...utils.crushOb(itemDetails), ...coords }); item.setAmount(amount); this.items.spawns.push(item); session.dataSendToMeAndOthers(ServerResponse.spawnItem(item), item); + onSpawn?.(item); }); } diff --git a/tests/test_bot_ai_visibility.js b/tests/test_bot_ai_visibility.js index b6ebd10c..77c1c1d1 100644 --- a/tests/test_bot_ai_visibility.js +++ b/tests/test_bot_ai_visibility.js @@ -60,9 +60,11 @@ assert.deepStrictEqual( ); let wakeups = 0; +let urgentWakeup = false; const originalWakeup = BotAI.wakeup; -BotAI.wakeup = (session) => { +BotAI.wakeup = (session, options) => { wakeups += 1; + urgentWakeup = options?.urgent === true; assert.strictEqual(session.accountId, 'bot_hit_wakeup'); }; @@ -104,6 +106,7 @@ ReceivedHit(attackerSession, hitBotActor, 7); assert.strictEqual(hitBotActor.fetchHp(), 43, 'ReceivedHit should still apply damage'); assert.strictEqual(hitBotSession.incomingThreatId, 1001, 'bot victim should remember the fresh attacker'); assert.strictEqual(wakeups, 1, 'bot victim should wake immediately on incoming damage'); +assert.strictEqual(urgentWakeup, true, 'damage wakeups must bypass visibility wake coalescing'); BotAI.wakeup = originalWakeup; diff --git a/tests/test_bot_support_planner.js b/tests/test_bot_support_planner.js index f4ce4426..3326c141 100644 --- a/tests/test_bot_support_planner.js +++ b/tests/test_bot_support_planner.js @@ -5,7 +5,7 @@ require('../src/Global'); const BotSupportPlanner = invoke('GameServer/Bot/AI/BotSupportPlanner'); const EffectStore = invoke('GameServer/Effects/EffectStore'); -function skill(id, name, level, effect, stats, target = 'friendly') { +function skill(id, name, level, effect, stats, target = 'friendly', type = null) { return { fetchSelfId: () => id, fetchName: () => name, @@ -13,6 +13,7 @@ function skill(id, name, level, effect, stats, target = 'friendly') { fetchPassive: () => false, fetchConsumedMp: () => 5, fetchTargetKind: () => target, + fetchSkillType: () => type, fetchSemantic: () => ({ effectType: 'buff', effect, stats, target }) }; } @@ -37,11 +38,18 @@ function actor(name, classId, skills = [], mp = 100, maxMp = 100, busy = false) } const shieldOne = skill(1040, 'Shield', 1, 'shield', { pDefMul: 1.08 }); +const chantOfLife = skill(1229, 'Chant of Life', 1, 'chant_of_life', {}, 'friendly', 'hot'); const soulShieldTwo = skill(1010, 'Soul Shield', 2, 'soul_shield', { pDefMul: 1.12 }); const shaman = actor('Noren', 49, [soulShieldTwo]); const mage = actor('Saren', 25, [shieldOne]); const target = actor('Slava', 0); +assert.deepStrictEqual( + BotSupportPlanner.supportSkills(actor('ChantBuffer', 49, [chantOfLife])), + [], + 'a short heal-over-time effect must not enter the persistent party-buff planner' +); + target.activeBuffs = { shield: Date.now() + (10 * 60 * 1000) }; assert.strictEqual( BotSupportPlanner.needsSkill(target, shieldOne), diff --git a/tests/test_party_bot_loot.js b/tests/test_party_bot_loot.js index 1273957a..c5f498ac 100644 --- a/tests/test_party_bot_loot.js +++ b/tests/test_party_bot_loot.js @@ -5,12 +5,18 @@ require('../src/Global'); const NpcRewards = invoke('GameServer/World/Generics/NpcRewards'); const DataCache = invoke('GameServer/DataCache'); const ProgressionRates = invoke('GameServer/ProgressionRates'); +const BotManager = invoke('GameServer/Bot/BotManager'); +const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); +const ActorGenerics = require('../src/GameServer/Actor/Generics'); +const Attack = invoke('GameServer/Actor/Attack'); const originalRewards = DataCache.fetchNpcRewardsFromSelfId; const originalRollGroup = ProgressionRates.rollGroup; const originalGroupRate = ProgressionRates.groupRate; const originalScaleAmount = ProgressionRates.scaleAmount; const originalRandom = Math.random; +const originalBotSessions = BotManager.sessions; +const originalPickupExec = ActorGenerics.pickupExec; try { DataCache.fetchNpcRewardsFromSelfId = (_id, callback) => callback({ @@ -20,20 +26,77 @@ try { ProgressionRates.groupRate = () => 1; ProgressionRates.scaleAmount = (amount) => amount; Math.random = () => 0; + const pickupCalls = []; + ActorGenerics.pickupExec = (session, actor, data, onComplete) => pickupCalls.push({ session, actor, data, onComplete }); const spawned = []; const purchased = []; const world = { - spawnItem(session, selfId, amount, coords) { spawned.push({ session, selfId, amount, coords }); }, + spawnItem(session, selfId, amount, coords, onSpawn) { + const item = { + fetchId: () => 500001, + fetchLocX: () => coords.locX, + fetchLocY: () => coords.locY, + fetchLocZ: () => coords.locZ + }; + spawned.push({ session, selfId, amount, coords }); + onSpawn(item); + }, purchaseItem(session, selfId, amount) { purchased.push({ session, selfId, amount }); } }; - const leaderSession = { actor: { fetchId: () => 1 } }; + const leaderSession = { + partyCompanionSettings: { distribution: 1 }, + actor: { + fetchId: () => 1, + fetchLocX: () => 100, + fetchLocY: () => 200, + fetchIsOnline: () => true, + isDead: () => false + } + }; + function pickupBot(id, locX) { + return { + fetchId: () => id, + fetchLocX: () => locX, + fetchLocY: () => 200, + fetchLocZ: () => -310, + fetchIsOnline: () => true, + isDead: () => false, + isBlocked: () => false, + fetchHead: () => 0, + state: { + hit: false, + fetchSeated: () => false, + fetchPickinUp: () => false, + fetchTowards: () => false, + fetchHits() { return this.hit; }, + fetchCasts: () => false, + setTowards() {}, + setHits(value) { this.hit = value; } + }, + automation: { abortAll() {} } + }; + } + const closestBot = pickupBot(2, 110); + closestBot.attack = new Attack(); + const distantBot = pickupBot(3, 800); const botSession = { accountId: 'bot_looter', partyCompanion: true, followPlayerSession: leaderSession, - actor: { fetchId: () => 2 } + plan: 'following', + actor: closestBot, + dataSendToMeAndOthers() {} + }; + const distantBotSession = { + accountId: 'bot_far_looter', + partyCompanion: true, + followPlayerSession: leaderSession, + plan: 'following', + actor: distantBot, + dataSendToMeAndOthers() {} }; + BotManager.sessions = [botSession, distantBotSession]; const npc = { fetchSelfId: () => 999, fetchLocX: () => 100, @@ -46,12 +109,37 @@ try { assert.strictEqual(spawned.length, 1, 'a companion bot kill should create a visible ground drop for the party'); assert.strictEqual(spawned[0].selfId, 57); assert.strictEqual(purchased.length, 0, 'a companion bot kill must not silently route the drop into bot inventory'); + assert.deepStrictEqual(pickupCalls.map(({ session, actor, data }) => ({ session, actor, data })), [{ session: botSession, actor: closestBot, data: { id: 500001 } }], 'with Random loot the closest active companion should immediately start normal server-side pickup'); + assert.strictEqual(distantBot.storedPickup, undefined, 'only one nearest companion should receive the pickup order'); + pickupCalls[0].onComplete(); + + closestBot.state.hit = true; + PartyCompanionService.queueRandomGroundPickup(botSession, { + fetchId: () => 500002, + fetchLocX: () => 100, + fetchLocY: () => 200, + fetchLocZ: () => -310 + }); + PartyCompanionService.queueRandomGroundPickup(botSession, { + fetchId: () => 500003, + fetchLocX: () => 100, + fetchLocY: () => 200, + fetchLocZ: () => -310 + }); + assert.strictEqual(pickupCalls.length, 1, 'a drop arriving during the killing hit should wait for that hit to finish'); + closestBot.state.hit = false; + PartyCompanionService.startQueuedGroundPickup(botSession); + assert.deepStrictEqual(pickupCalls[1] && { session: pickupCalls[1].session, actor: pickupCalls[1].actor, data: pickupCalls[1].data }, { session: botSession, actor: closestBot, data: { id: 500002 } }, 'a queued hot-bot pickup should execute server-side after the hit instead of waiting for a client position packet'); + pickupCalls[1].onComplete(); + assert.deepStrictEqual(pickupCalls[2] && { session: pickupCalls[2].session, actor: pickupCalls[2].actor, data: pickupCalls[2].data }, { session: botSession, actor: closestBot, data: { id: 500003 } }, 'multiple drops assigned to the same bot should be picked up in FIFO order'); } finally { DataCache.fetchNpcRewardsFromSelfId = originalRewards; ProgressionRates.rollGroup = originalRollGroup; ProgressionRates.groupRate = originalGroupRate; ProgressionRates.scaleAmount = originalScaleAmount; Math.random = originalRandom; + BotManager.sessions = originalBotSessions; + ActorGenerics.pickupExec = originalPickupExec; } console.log('Party bot loot checks passed'); diff --git a/tests/test_party_companion_rest_follow.js b/tests/test_party_companion_rest_follow.js index ebc65d71..771a47e0 100644 --- a/tests/test_party_companion_rest_follow.js +++ b/tests/test_party_companion_rest_follow.js @@ -300,6 +300,20 @@ try { } assert.strictEqual(inviteTell, `I'll join you, just need a moment to recover.`, 'resting invite acknowledgement should survive PartyCompanionService.attach'); assert.strictEqual(inviteBotSession.plan, 'resting', 'attaching a resting bot should preserve resting state'); + inviteBot.level = 17; + inviteBot.hp = 40; + inviteBot.mp = 20; + inviteBot.state.setSeated(true); + World.user = { sessions: [leaderSession, inviteBotSession] }; + World.npc = { spawns: [] }; + World.fetchNpcsInRadius = () => []; + RestingState.tick(inviteBotSession, inviteBot, {}, { + getClosestNewbieGuide: () => ({ locX: 0, locY: 0, locZ: 0 }), + say() {} + }); + assert.strictEqual(inviteBotSession.plan, 'getting_buffed', 'a low-level companion accepted while resting in town should recover at the Newbie Guide'); + assert.strictEqual(inviteBot.state.fetchSeated(), false, 'Newbie Guide recovery should stand the invited companion up'); + assert.strictEqual(inviteBotSession.roleDecision.reason, 'newbie_guide_recovery', 'the city recovery transition should be visible in companion status'); const movingBot = fakeActor(2000007, { locX: 500, locY: 0 }); movingBot.state.setTowards('move'); @@ -314,6 +328,18 @@ try { FollowingState.tick(movingSession, movingBot, {}, { say() {}, executeCombat() {}, executePvPCombat() {} }); assert.strictEqual(movingBot.moves.length, 0, 'companion should not restart follow movement while the existing waypoint is still useful'); + + const arrivedBot = fakeActor(2000009, { locX: 0, locY: 0 }); + const arrivedSession = fakeSession('bot_arrived_follow', arrivedBot); + arrivedSession.followPlayerSession = leaderSession; + arrivedSession.partyCompanion = true; + arrivedSession.plan = 'following'; + arrivedSession.lastTickLoc = { x: 0, y: 0 }; + arrivedSession.lastStuckSampleAt = Date.now(); + arrivedSession.stuckTicks = 2; + World.user = { sessions: [leaderSession, arrivedSession] }; + FollowingState.tick(arrivedSession, arrivedBot, {}, { say() {}, executeCombat() {}, executePvPCombat() {} }); + assert.strictEqual(arrivedSession.stuckTicks, 0, 'arriving must clear stale stuck state before the next movement command'); assert(movingSession.lastFollowMoveHeldAt, 'companion should record that a follow retarget was held'); leader.state.setSeated(true); @@ -1097,6 +1123,19 @@ try { assert.strictEqual(partyHudBotA.moves.length, 1, 'puller should walk to the selected mob before aggroing it'); assert.strictEqual(partyHudLeaderSession.partyPullState.targetId, pulledMob.fetchId(), 'party should keep one shared pull target'); + partyHudBotA.state.setTowards('move'); + FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { + say() {}, executeCombat() {}, executePvPCombat() {} + }); + assert.strictEqual(partyHudBotA.moves.length, 1, 'an active pull approach must keep its current route instead of restarting pathfinding every AI tick'); + + partyHudBotASession.stuckTicks = 3; + FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { + say() {}, executeCombat() {}, executePvPCombat() {} + }); + assert.strictEqual(partyHudBotA.moves.length, 2, 'a stalled pull route should be replanned by the puller instead of using generic follow teleport recovery'); + assert.strictEqual(partyHudBotASession.stuckTicks, 0, 'a pull-route replan should begin with a fresh stuck sample window'); + FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { say() {}, executeCombat() { throw new Error('non-puller must wait for the incoming mob'); }, executePvPCombat() {} }); @@ -1129,6 +1168,13 @@ try { say() {}, executeCombat() { throw new Error('confirmed aggro should make the puller return before the party engages'); }, executePvPCombat() {} }); assert.strictEqual(partyHudBotASession.roleDecision.reason, 'return', 'puller should return to the leader after aggro is confirmed'); + const returnMoves = partyHudBotA.moves.length; + partyHudBotA.state.setTowards('move'); + FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { + say() {}, executeCombat() {}, executePvPCombat() {} + }); + assert.strictEqual(partyHudBotA.moves.length, returnMoves, 'an active pull return must keep its current route instead of restarting pathfinding every AI tick'); + partyHudBotA.state.setTowards(false); pulledMob.locX = 700; FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { From 8224588ba011fcfc9a0518a6e0b7b23345140510 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:59:42 -0400 Subject: [PATCH 05/13] Fix party loot recovery and bot auto shots --- src/GameServer/Bot/AI/BotEquipmentUpgrade.js | 8 ++++++++ .../Bot/AI/PartyCompanionService.js | 18 ++++++++++++++++-- src/GameServer/Bot/AI/PartyPulling.js | 19 +++++++++++-------- .../Bot/AI/States/FollowingState.js | 15 ++++++++++++--- src/GameServer/Bot/BotManager.js | 4 ++++ src/GameServer/Inventory/ShotStock.js | 18 ++++++++++++++++++ tests/test_auto_soulshots.js | 18 ++++++++++++++++++ tests/test_party_bot_loot.js | 10 ++++++---- tests/test_party_companion_rest_follow.js | 4 ++-- 9 files changed, 95 insertions(+), 19 deletions(-) diff --git a/src/GameServer/Bot/AI/BotEquipmentUpgrade.js b/src/GameServer/Bot/AI/BotEquipmentUpgrade.js index bb416410..5a6c9066 100644 --- a/src/GameServer/Bot/AI/BotEquipmentUpgrade.js +++ b/src/GameServer/Bot/AI/BotEquipmentUpgrade.js @@ -1,5 +1,6 @@ const ServerResponse = invoke('GameServer/Network/Response'); const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); +const ShotStock = invoke('GameServer/Inventory/ShotStock'); const ARMOR_SLOTS = { earringRight: 1, @@ -234,6 +235,13 @@ function applyBestUpgrades(session, options = {}) { actor.backpack.equipGear(session, item); }); + // A weapon upgrade can change both the grade and the compatible shot kind. + // Restock and re-enable after equipping; bots cannot send a client hotbar + // toggle themselves. + ShotStock.ensureActorStock(actor) + .then(() => ShotStock.enableAutoShot(actor)) + .catch((error) => utils.infoWarn('BotGear', 'failed to refresh shots for %s: %s', actor.fetchName(), error.message)); + session.lastEquipmentUpgradeAt = Date.now(); if (session.dataSendToOthers) { diff --git a/src/GameServer/Bot/AI/PartyCompanionService.js b/src/GameServer/Bot/AI/PartyCompanionService.js index 5b476b9e..5a02c48a 100644 --- a/src/GameServer/Bot/AI/PartyCompanionService.js +++ b/src/GameServer/Bot/AI/PartyCompanionService.js @@ -141,11 +141,23 @@ function canPickGroundLoot(session, leaderSession, item) { const actor = session?.actor; if (!isActiveCompanion(session, leaderSession) || !isAliveOnline(session)) return false; if (['resting', 'getting_buffed', 'shopping', 'merchant'].includes(session.plan)) return false; - if (actor?.state?.fetchSeated?.() || actor?.state?.fetchPickinUp?.()) return false; + if (actor?.state?.fetchSeated?.()) return false; if (actor?.storedPickup) return false; return distance2d(actor, item) <= PARTY_LOOT_RADIUS; } +function partyCombatInProgress(leaderSession) { + return [leaderSession, ...membersForLeader(leaderSession)] + .some((memberSession) => { + const state = memberSession?.actor?.state; + return !!( + state?.fetchCombats?.() || + state?.fetchHits?.() || + state?.fetchCasts?.() + ); + }); +} + function nearestGroundLootPicker(looterSession, item) { const leaderSession = partyLeaderSession(looterSession); if (!leaderSession || !item || !RANDOM_LOOT_DISTRIBUTIONS.has(distributionForLeader(leaderSession))) return null; @@ -162,7 +174,9 @@ function startQueuedGroundPickup(pickerSession) { const picker = pickerSession?.actor; const queue = pickerSession?.partyGroundPickupQueue; if (!picker || pickerSession.partyGroundPickupInProgress || !queue?.length) return false; - if (picker.state?.fetchHits?.() || picker.state?.fetchCasts?.() || picker.state?.fetchPickinUp?.()) return false; + const leaderSession = partyLeaderSession(pickerSession); + if (partyCombatInProgress(leaderSession)) return false; + if (picker.state?.fetchPickinUp?.()) return false; const pickup = queue[0]; pickerSession.partyGroundPickupInProgress = true; diff --git a/src/GameServer/Bot/AI/PartyPulling.js b/src/GameServer/Bot/AI/PartyPulling.js index 532143da..43ae54cf 100644 --- a/src/GameServer/Bot/AI/PartyPulling.js +++ b/src/GameServer/Bot/AI/PartyPulling.js @@ -178,19 +178,22 @@ function nearestFreeMonster(bot) { function shouldKeepPullMove(session, bot, state, phase, target) { if (!state?.moveTarget || state.movePhase !== phase) return false; if (!(session.moveTimer || bot.state?.fetchTowards?.())) return false; - if ((session.stuckTicks || 0) >= 2) return false; + // Follow-state samples are intentionally coarse and can report a moving + // puller as "stuck" between path waypoints. Replanning from that stale + // sample aborts the current route and makes the client snap backwards. + // Pull movement is server-stepped, so retain it until it stops or the + // target has materially moved. + if (session.stuckTicks) { + session.stuckTicks = 0; + session.lastStuckSampleAt = Date.now(); + } return distance(state.moveTarget, point(target)) <= PULL_MOVE_TARGET_DRIFT; } function moveTo(session, bot, state, phase, target) { if (shouldKeepPullMove(session, bot, state, phase, target)) return false; - // FollowingState leaves active pull movement to this coordinator. When a - // route has genuinely stopped, start the replacement path with a fresh - // sample window instead of treating every later tick as still stuck. - if ((session.stuckTicks || 0) >= 2) { - session.stuckTicks = 0; - session.lastStuckSampleAt = Date.now(); - } + // FollowingState leaves active pull movement to this coordinator. A new + // route is only needed after the old one stopped or its target drifted. state.movePhase = phase; state.moveTarget = point(target); bot.moveTo({ from: point(bot), to: point(target) }); diff --git a/src/GameServer/Bot/AI/States/FollowingState.js b/src/GameServer/Bot/AI/States/FollowingState.js index 8661a130..d8dd80e2 100644 --- a/src/GameServer/Bot/AI/States/FollowingState.js +++ b/src/GameServer/Bot/AI/States/FollowingState.js @@ -447,9 +447,10 @@ module.exports = { return; } - // Pulling has its own route recovery. Teleporting an assigned - // puller back to the leader halfway through approach/return makes the - // client show it run forward, snap back, and start the route again. + // Pulling has its own route recovery. Generic stuck samples must not + // teleport the assigned puller: between geodata waypoints they can + // report no movement even though its server-stepped route is healthy. + // A truly distant puller may still use the normal catch-up teleport. const pullerTravelling = activeBotPullTravel(session, pulling); if (!pullerTravelling && (session.stuckTicks >= 3 || distance > FOLLOW_TELEPORT_DISTANCE)) { session.stuckTicks = 0; @@ -467,6 +468,14 @@ module.exports = { return; } + // A drop can be assigned while this companion or another party member + // is still finishing combat. Retry the FIFO only after the whole party + // is clear, so it does not lie on the ground forever or interrupt an + // active fight between attack swings. + if (PartyCompanionService.startQueuedGroundPickup(session)) { + return; + } + const botVitals = { hpRatio: ratio(bot.fetchHp(), bot.fetchMaxHp()), mpRatio: ratio(bot.fetchMp(), bot.fetchMaxMp()) diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js index 8f2eb03f..54faa856 100644 --- a/src/GameServer/Bot/BotManager.js +++ b/src/GameServer/Bot/BotManager.js @@ -544,6 +544,10 @@ const BotManager = { session.setActor({ ...character, ...utils.crushOb(classInfo) }); + // Hot bots do not have a client hotbar request to enable + // shots. Their stock is prepared before actor creation, so + // enable the compatible C4 auto-shot explicitly. + ShotStock.enableAutoShot(session.actor); // PK seeds must remain red across restarts. Existing bot // records predate the seed list, so update both runtime diff --git a/src/GameServer/Inventory/ShotStock.js b/src/GameServer/Inventory/ShotStock.js index 1ff46c3b..5031fb42 100644 --- a/src/GameServer/Inventory/ShotStock.js +++ b/src/GameServer/Inventory/ShotStock.js @@ -145,6 +145,23 @@ function isCompatibleWithActor(kind, selfId, actor) { ); } +function enableAutoShot(actor) { + if (!actor?.backpack) return null; + + const plan = planForActor(actor); + if (!actor.backpack.fetchItemFromSelfId?.(plan.selfId)) return null; + + const enabled = actor.autoSoulshots instanceof Set + ? actor.autoSoulshots + : new Set(actor.autoSoulshots || []); + // A bot has one combat profile. Remove an old grade/kind after an equipment + // upgrade so a physical weapon never keeps a caster shot (or vice versa). + SHOT_IDS.forEach((selfId) => enabled.delete(selfId)); + enabled.add(plan.selfId); + actor.autoSoulshots = enabled; + return plan; +} + function planForRows(rows, classId) { return planFor({ classId, @@ -284,6 +301,7 @@ module.exports = { planForActorKind, kindForSelfId, isCompatibleWithActor, + enableAutoShot, planForRows, shotAmount, ensureActorStock, diff --git a/tests/test_auto_soulshots.js b/tests/test_auto_soulshots.js index abfc2882..62131eb4 100644 --- a/tests/test_auto_soulshots.js +++ b/tests/test_auto_soulshots.js @@ -8,6 +8,7 @@ const AutoSoulShot = invoke('GameServer/Network/Request/AutoSoulShot'); const ExtendedRequest = invoke('GameServer/Network/Request/ExtendedRequest'); const Attack = invoke('GameServer/Actor/Attack'); const Database = invoke('Database'); +const ShotStock = invoke('GameServer/Inventory/ShotStock'); Database.updateItemAmount = () => Promise.resolve(); Database.deleteItem = () => Promise.resolve(); @@ -127,6 +128,23 @@ ExtendedRequest(beginnerSession, beginnerPacket); assert.strictEqual(beginnerBackpack.isAutoShotEnabled(beginnerSession.actor, 'soulshot'), true, 'quest beginner Soulshot should support the C4 hotbar toggle'); assert.strictEqual(beginnerBackpack.fetchItemFromSelfId(5789).fetchAmount(), 2, 'enabling a quest beginner Soulshot should charge it immediately'); +const botPhysicalActor = { + fetchClassId: () => 4, + backpack, + autoSoulshots: new Set([2509]) +}; +assert.strictEqual(ShotStock.enableAutoShot(botPhysicalActor).selfId, 1835, 'a physical bot should enable its compatible Soulshot without a client hotbar request'); +assert.deepStrictEqual([...botPhysicalActor.autoSoulshots], [1835], 'a physical bot should clear an incompatible caster auto-shot'); + +const botCasterBackpack = new Backpack({ paperdoll: Array.from({ length: 16 }, () => ({})), items: [] }); +botCasterBackpack.items = [ + item(7, { selfId: 5, kind: 'Weapon.Blunt', equipped: true, slot: 7, spiritshot: 1 }), + item(8, { selfId: 2509, kind: 'Other.Shot', amount: 10 }) +]; +const botCasterActor = { fetchClassId: () => 10, backpack: botCasterBackpack }; +assert.strictEqual(ShotStock.enableAutoShot(botCasterActor).selfId, 2509, 'a caster bot should enable its compatible Spiritshot without a client hotbar request'); +assert.strictEqual(botCasterBackpack.fetchAutoSpiritshot(botCasterActor).selfId, 2509, 'the spell combat path should see the bot-enabled Spiritshot'); + const attack = new Attack(); let consumeCalls = 0; const combatActor = { diff --git a/tests/test_party_bot_loot.js b/tests/test_party_bot_loot.js index c5f498ac..d61cbd76 100644 --- a/tests/test_party_bot_loot.js +++ b/tests/test_party_bot_loot.js @@ -71,6 +71,8 @@ try { fetchTowards: () => false, fetchHits() { return this.hit; }, fetchCasts: () => false, + combat: false, + fetchCombats() { return this.combat; }, setTowards() {}, setHits(value) { this.hit = value; } }, @@ -113,7 +115,7 @@ try { assert.strictEqual(distantBot.storedPickup, undefined, 'only one nearest companion should receive the pickup order'); pickupCalls[0].onComplete(); - closestBot.state.hit = true; + closestBot.state.combat = true; PartyCompanionService.queueRandomGroundPickup(botSession, { fetchId: () => 500002, fetchLocX: () => 100, @@ -126,10 +128,10 @@ try { fetchLocY: () => 200, fetchLocZ: () => -310 }); - assert.strictEqual(pickupCalls.length, 1, 'a drop arriving during the killing hit should wait for that hit to finish'); - closestBot.state.hit = false; + assert.strictEqual(pickupCalls.length, 1, 'a drop arriving while the party is in combat should wait instead of interrupting the fight'); + closestBot.state.combat = false; PartyCompanionService.startQueuedGroundPickup(botSession); - assert.deepStrictEqual(pickupCalls[1] && { session: pickupCalls[1].session, actor: pickupCalls[1].actor, data: pickupCalls[1].data }, { session: botSession, actor: closestBot, data: { id: 500002 } }, 'a queued hot-bot pickup should execute server-side after the hit instead of waiting for a client position packet'); + assert.deepStrictEqual(pickupCalls[1] && { session: pickupCalls[1].session, actor: pickupCalls[1].actor, data: pickupCalls[1].data }, { session: botSession, actor: closestBot, data: { id: 500002 } }, 'a queued hot-bot pickup should execute server-side after combat instead of waiting for a client position packet'); pickupCalls[1].onComplete(); assert.deepStrictEqual(pickupCalls[2] && { session: pickupCalls[2].session, actor: pickupCalls[2].actor, data: pickupCalls[2].data }, { session: botSession, actor: closestBot, data: { id: 500003 } }, 'multiple drops assigned to the same bot should be picked up in FIFO order'); } finally { diff --git a/tests/test_party_companion_rest_follow.js b/tests/test_party_companion_rest_follow.js index 771a47e0..97c93883 100644 --- a/tests/test_party_companion_rest_follow.js +++ b/tests/test_party_companion_rest_follow.js @@ -1133,8 +1133,8 @@ try { FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { say() {}, executeCombat() {}, executePvPCombat() {} }); - assert.strictEqual(partyHudBotA.moves.length, 2, 'a stalled pull route should be replanned by the puller instead of using generic follow teleport recovery'); - assert.strictEqual(partyHudBotASession.stuckTicks, 0, 'a pull-route replan should begin with a fresh stuck sample window'); + assert.strictEqual(partyHudBotA.moves.length, 1, 'a stale generic stuck sample must not restart a healthy pull route'); + assert.strictEqual(partyHudBotASession.stuckTicks, 0, 'active pull movement should clear stale generic stuck state'); FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { say() {}, executeCombat() { throw new Error('non-puller must wait for the incoming mob'); }, executePvPCombat() {} From 399844146be9d2aa56b4b2467952af5da5448e92 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:28:37 -0400 Subject: [PATCH 06/13] Fix NPC chase sync and party pull follow --- src/GameServer/Automation.js | 13 ++++++-- src/GameServer/Bot/AI/PartyPulling.js | 15 ++++++--- .../Bot/AI/States/FollowingState.js | 15 +++++++-- src/GameServer/Npc/Npc.js | 12 +++++-- tests/test_npc_combat_range.js | 22 +++++++++++++ tests/test_party_companion_rest_follow.js | 33 ++++++++++++++++--- 6 files changed, 93 insertions(+), 17 deletions(-) diff --git a/src/GameServer/Automation.js b/src/GameServer/Automation.js index 4d36ad31..53cf7285 100644 --- a/src/GameServer/Automation.js +++ b/src/GameServer/Automation.js @@ -184,8 +184,15 @@ class Automation extends SelectedModel { src.fetchLocX(), src.fetchLocY(), src.fetchLocZ(), dst.fetchLocX(), dst.fetchLocY(), dst.fetchLocZ(), radius, src.fetchCollectiveRunSpd() ); - // Dynamically update coordinates step-by-step for bots while running to prevent teleportation/snapping on reschedule - if (session && (session.constructor.name === 'BotSession' || (session.accountId && session.accountId.startsWith('bot_')))) { + // Dynamically update coordinates step-by-step only while the bot is + // moving itself. A bot session can also drive an NPC's chase action; + // interpolating that NPC here mutates the server position without a + // matching movement packet and makes it appear to attack from afar. + const movingBot = session?.actor === src && ( + session.constructor.name === 'BotSession' + || session.accountId?.startsWith('bot_') + ); + if (movingBot) { if (session.moveTimer) { clearInterval(session.moveTimer); session.moveTimer = null; @@ -227,7 +234,7 @@ class Automation extends SelectedModel { Timer.start(this.timer.action, () => { src.state.setTowards(false); this.clearDestId(); - if (session && (session.constructor.name === 'BotSession' || (session.accountId && session.accountId.startsWith('bot_')))) { + if (movingBot) { src.setLocXYZ(stopCoords); if (session.moveTimer) { clearInterval(session.moveTimer); diff --git a/src/GameServer/Bot/AI/PartyPulling.js b/src/GameServer/Bot/AI/PartyPulling.js index 43ae54cf..89a79b5f 100644 --- a/src/GameServer/Bot/AI/PartyPulling.js +++ b/src/GameServer/Bot/AI/PartyPulling.js @@ -159,13 +159,17 @@ function attackRange(actor, target) { return role === 'archer' ? 700 : 0; } +function actorCanEngage(actor, target) { + return !!actor && !!target && distance(point(actor), point(target)) <= attackRange(actor, target); +} + function targetIsEngageable(leaderSession, target, puller) { if (!target) return false; return PartyAwareness.partyActors(leaderSession) // A leader pull has no return phase to synchronize. Release only when // a companion can actually strike the player-designated target. .filter((actor) => actor !== leaderSession.actor && actor !== puller?.actor) - .some((actor) => distance(point(actor), point(target)) <= attackRange(actor, target)); + .some((actor) => actorCanEngage(actor, target)); } function nearestFreeMonster(bot) { @@ -329,11 +333,11 @@ function current(leaderSession, settings) { if (!puller) return { enabled: false, puller: null, target: null, paused: null }; const target = clearFinishedTarget(leaderSession); const state = pullState(leaderSession); - // A bot-pulled mob must not release the party merely because it is within - // the puller's own attack range. Only tickBotPuller may promote it to the - // engage phase after returning to the group. + // A companion may meet the incoming mob while the player is moving the + // party. Release only companions that can strike from their own current + // position; the rest keep following the leader instead of chasing it. const engageable = state.source === 'bot' - ? state.phase === 'engage' + ? state.phase === 'engage' || targetIsEngageable(leaderSession, target, puller) : targetIsEngageable(leaderSession, target, puller); return { enabled: true, @@ -354,6 +358,7 @@ module.exports = { tickBotPuller, current, targetIsEngageable, + actorCanEngage, attackRange, PULL_AGGRO_TIMEOUT_MS }; diff --git a/src/GameServer/Bot/AI/States/FollowingState.js b/src/GameServer/Bot/AI/States/FollowingState.js index d8dd80e2..e2095933 100644 --- a/src/GameServer/Bot/AI/States/FollowingState.js +++ b/src/GameServer/Bot/AI/States/FollowingState.js @@ -717,14 +717,23 @@ module.exports = { } } - if (!acted && pulling.enabled && pulling.target && !pulling.engageable) { + const waitingForPullAtOwnRange = pulling.enabled && pulling.target && ( + !pulling.engageable || ( + pulling.puller?.session !== session && + !PartyPulling.actorCanEngage(bot, pulling.target) + ) + ); + if (!acted && waitingForPullAtOwnRange) { session.currentTargetId = undefined; bot.unselect(); - recordRoleDecision(session, bot, 'hold_for_pull', pulling.paused || 'mob_not_in_range', { + // The marked mob is intentionally not a chase target. Keep the + // formation on the player, then join combat once this particular + // companion can strike from its own position. + recordRoleDecision(session, bot, 'follow_leader', pulling.paused || 'hold_for_pull', { targetId: pulling.target.fetchId(), pullerId: pulling.puller?.actor?.fetchId?.() || null }); - return; + keepRoleDecision = true; } if (!acted && role === 'tank') { diff --git a/src/GameServer/Npc/Npc.js b/src/GameServer/Npc/Npc.js index c95e0c6b..3bf6f9b3 100644 --- a/src/GameServer/Npc/Npc.js +++ b/src/GameServer/Npc/Npc.js @@ -109,9 +109,17 @@ class Npc extends NpcModel { if (this.state.inMotion()) { if (coords.locX !== newDstX || coords.locY !== newDstY) { - this.setLocXYZ(new SpeckMath.Point3D(this.fetchLocX(), this.fetchLocY(), this.fetchLocZ()).midPoint(new SpeckMath.Point3D(coords.locX, coords.locY, coords.locZ), this.automation.fetchDistanceRatio() * 1.3).toCoords()); // TODO: Another hack to catch-up - + const progress = Math.min(1, Math.max(0, Number(this.automation.fetchDistanceRatio()) || 0)); + this.setLocXYZ( + new SpeckMath.Point3D(this.fetchLocX(), this.fetchLocY(), this.fetchLocZ()) + .midPoint(new SpeckMath.Point3D(coords.locX, coords.locY, coords.locZ), progress) + .toCoords() + ); this.automation.abortAll(this); + // The authoritative chase position changed before the + // scheduled move ended. Freeze the client at exactly + // that position before scheduling the next chase leg. + this.stopForCombatAction(session); } return; } diff --git a/tests/test_npc_combat_range.js b/tests/test_npc_combat_range.js index 42ebeda8..7592d8fc 100644 --- a/tests/test_npc_combat_range.js +++ b/tests/test_npc_combat_range.js @@ -89,6 +89,28 @@ function actorAt(x) { const rangedNpc = npcWithRange(500); const target = actorAt(1000); +class BotSession { + constructor(actor) { + this.actor = actor; + this.accountId = 'bot_range_test'; + this.packets = []; + this.moveTimer = null; + } + + dataSendToMeAndOthers(packet) { + this.packets.push(packet); + } +} + +const npcChaseSession = new BotSession(target); +rangedNpc.automation.scheduleAction(npcChaseSession, rangedNpc, target, 500, () => {}); +assert.strictEqual( + npcChaseSession.moveTimer, + null, + 'an NPC chase through a bot session must not use the bot-only coordinate interpolation timer' +); +rangedNpc.automation.abortAll(rangedNpc); + assert.strictEqual( rangedNpc.fetchCombatAttackRange(target), 500, diff --git a/tests/test_party_companion_rest_follow.js b/tests/test_party_companion_rest_follow.js index 97c93883..ae3e155e 100644 --- a/tests/test_party_companion_rest_follow.js +++ b/tests/test_party_companion_rest_follow.js @@ -1139,7 +1139,20 @@ try { FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { say() {}, executeCombat() { throw new Error('non-puller must wait for the incoming mob'); }, executePvPCombat() {} }); - assert.strictEqual(partyHudBotBSession.roleDecision.action, 'hold_for_pull', 'non-puller should wait while the mob is outside party attack range'); + assert.strictEqual(partyHudBotBSession.roleDecision.action, 'follow_leader', 'non-puller should keep following while the mob is outside its attack range'); + assert.strictEqual(partyHudBotBSession.roleDecision.reason, 'hold_for_pull', 'following companion must not chase the marked pull target'); + + partyHudLeader.locX = 600; + partyHudBotB.moves = []; + FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { + say() {}, executeCombat() { throw new Error('non-puller must follow the leader, not chase the distant pull target'); }, executePvPCombat() {} + }); + assert.strictEqual(partyHudBotB.moves.length, 1, 'a held pull must not stop the companion from following a moving leader'); + assert( + partyHudBotB.moves[0].to.locX > partyHudBotB.locX && partyHudBotB.moves[0].to.locX < pulledMob.locX, + 'held-pull movement should head to the leader formation target, not the distant mob' + ); + partyHudLeader.locX = 0; partyHudBotA.locX = pulledMob.locX; FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { @@ -1162,7 +1175,19 @@ try { FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { say() {}, executeCombat() { throw new Error('non-puller must not chase a mob that has only just aggroed the distant puller'); }, executePvPCombat() {} }); - assert.strictEqual(partyHudBotBSession.roleDecision.action, 'hold_for_pull', 'party should remain held until the puller has returned to the camp'); + assert.strictEqual(partyHudBotBSession.roleDecision.action, 'follow_leader', 'party should keep following until each companion can reach the marked mob'); + + pulledMob.locX = partyHudBotB.locX; + let earlyMeetAssistId = null; + FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { + say() {}, executeCombat(_session, _bot, npc) { earlyMeetAssistId = npc.fetchId(); }, executePvPCombat() {} + }); + assert.strictEqual( + earlyMeetAssistId, + pulledMob.fetchId(), + 'a companion that meets the returning mob should engage before the puller completes its return' + ); + pulledMob.locX = 1200; FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { say() {}, executeCombat() { throw new Error('confirmed aggro should make the puller return before the party engages'); }, executePvPCombat() {} @@ -1180,7 +1205,7 @@ try { FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { say() {}, executeCombat() { throw new Error('melee companion must not chase an incoming pull outside its attack range'); }, executePvPCombat() {} }); - assert.strictEqual(partyHudBotBSession.roleDecision.action, 'hold_for_pull', 'melee companions should keep holding until the mob reaches their actual attack range'); + assert.strictEqual(partyHudBotBSession.roleDecision.action, 'follow_leader', 'melee companions should keep following until the mob reaches their actual attack range'); partyHudBotA.locX = partyHudLeader.locX; pulledMob.locX = partyHudBotB.locX; @@ -1237,7 +1262,7 @@ try { FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { say() {}, executeCombat() { throw new Error('party must wait for a leader pull outside range'); }, executePvPCombat() {} }); - assert.strictEqual(partyHudBotBSession.roleDecision.action, 'hold_for_pull', 'leader pull should use the same hold gate as a bot pull'); + assert.strictEqual(partyHudBotBSession.roleDecision.action, 'follow_leader', 'leader pull should keep companions in follow formation outside their range'); pulledMob.locX = partyHudBotB.locX; assistedPulledMobId = null; FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { From 685d1dd2558c6c04236b37bf4e60e56856ee8c46 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:43:13 -0400 Subject: [PATCH 07/13] Add party resurrection recovery --- src/GameServer/Actor/Attack.js | 7 +- src/GameServer/Bot/AI/PartyRevivalService.js | 200 ++++++++++++++++++ .../Bot/AI/States/FollowingState.js | 9 + src/GameServer/Bot/BotAI.js | 25 ++- src/GameServer/Skills/C4SkillEffects.js | 13 ++ tests/test_party_revival.js | 167 +++++++++++++++ 6 files changed, 416 insertions(+), 5 deletions(-) create mode 100644 src/GameServer/Bot/AI/PartyRevivalService.js create mode 100644 tests/test_party_revival.js diff --git a/src/GameServer/Actor/Attack.js b/src/GameServer/Actor/Attack.js index 04d889db..0199925f 100644 --- a/src/GameServer/Actor/Attack.js +++ b/src/GameServer/Actor/Attack.js @@ -185,7 +185,8 @@ class Attack { remoteHit(session, creature, skill) { const actor = session.actor; - const corpseTarget = skill.fetchTargetKind?.() === 'corpse_mob'; + const corpseTarget = ['corpse_mob', 'corpse_player', 'corpse_pet', 'corpse_ally'] + .includes(skill.fetchTargetKind?.()); if (this.checkParticipants(actor, creature, { allowDeadTarget: corpseTarget })) { invoke('GameServer/Bot/AI/BotSupportPlanner').cancelPendingSupportCast(session, actor, creature, skill); @@ -381,6 +382,10 @@ class Attack { return target.fetchAttackable?.() === true && target.isDead?.() === true; } + if (['corpse_player', 'corpse_pet', 'corpse_ally'].includes(targetKind)) { + return target.state?.fetchDead?.() === true || target.isDead?.() === true; + } + if (targetKind === 'enemy') { if (this.isNpcCombatant(actor)) { return target !== actor && !target.fetchKind && target.state?.fetchDead?.() !== true && target.isDead?.() !== true; diff --git a/src/GameServer/Bot/AI/PartyRevivalService.js b/src/GameServer/Bot/AI/PartyRevivalService.js new file mode 100644 index 00000000..9c095892 --- /dev/null +++ b/src/GameServer/Bot/AI/PartyRevivalService.js @@ -0,0 +1,200 @@ +const C4SkillRules = invoke('GameServer/Skills/C4SkillRules'); +const DataCache = invoke('GameServer/DataCache'); +const SkillModel = invoke('GameServer/Model/Skill'); +const PartyAwareness = invoke('GameServer/Bot/AI/PartyAwareness'); +const World = invoke('GameServer/World/World'); + +const PARTY_REVIVE_TIMEOUT_MS = 60000; +const RESURRECTION_SCROLL_SKILL_ID = 2014; +const PLAYER_RESURRECTION_SCROLLS = new Set([737, 3936, 3959]); + +function isCompanionOf(session, leaderSession) { + return !!( + session?.actor && + session.followPlayerSession === leaderSession && + session.partyCompanion === true + ); +} + +function partySessions(leaderSession) { + if (!leaderSession?.actor) return []; + const BotManager = invoke('GameServer/Bot/BotManager'); + return [leaderSession, ...(BotManager.sessions || []).filter((session) => isCompanionOf(session, leaderSession))]; +} + +function isAlive(session) { + return !!session?.actor && session.actor.fetchIsOnline?.() === true && !session.actor.isDead?.(); +} + +function deadMembers(leaderSession) { + return partySessions(leaderSession).filter((session) => ( + session?.actor?.fetchIsOnline?.() === true && session.actor.isDead?.() + )); +} + +function partyCombatInProgress(leaderSession) { + if (PartyAwareness.findThreatTargetingParty(leaderSession)) return true; + const members = partySessions(leaderSession); + if (members + .filter(isAlive) + .some((session) => { + const state = session.actor.state; + return !!(state?.fetchCombats?.() || state?.fetchHits?.() || state?.fetchCasts?.()); + })) return true; + + // PartyAwareness intentionally ignores corpses. For resurrection that is + // too narrow: a monster can keep its combat loop on a fallen party member + // for a short time after the lethal hit, and a healer must not begin a + // long resurrection cast in front of it. + const partyIds = new Set(members.map((member) => member.actor?.fetchId?.()).filter(Boolean)); + return (World.npc?.spawns || []).some((npc) => ( + npc.fetchAttackable?.() === true && + npc.isDead?.() !== true && + npc.state?.fetchCombats?.() === true && + partyIds.has(npc.fetchDestId?.()) + )); +} + +function learnedResurrectionSkills(actor) { + return (actor?.skillset?.skills || []) + .filter((skill) => skill && !skill.fetchPassive?.()) + .filter((skill) => skill.fetchSkillType?.() === C4SkillRules.RESURRECT) + .filter((skill) => skill.fetchTargetKind?.() === 'corpse_player'); +} + +function resurrectionSkill(actor) { + return learnedResurrectionSkills(actor) + .filter((skill) => actor.canUseSkill?.(skill) !== false) + .filter((skill) => Number(actor.fetchMp?.() || 0) >= Number(skill.fetchConsumedMp?.() || 0)) + .sort((a, b) => Number(b.fetchPower?.() || 0) - Number(a.fetchPower?.() || 0))[0] || null; +} + +function resurrectionScrollSkill() { + const source = (DataCache.skills || []).find((skill) => Number(skill.selfId) === RESURRECTION_SCROLL_SKILL_ID); + if (!source) { + // Bot AI may begin a hot-session tick while the datapack cache is + // still warming. The C4 rules are keyed by selfId, so this sourced + // fallback preserves the same native scroll cast without waiting for + // a persistent inventory item. + return new SkillModel({ + selfId: RESURRECTION_SCROLL_SKILL_ID, + name: 'Scroll of resurrection', + passive: false, + spell: false, + distance: 400, + hitTime: 15000, + reuse: 0, + power: 1, + mp: 0, + hp: 0, + itemId: 0, + itemCount: 0, + level: 1 + }); + } + const level = Number(source.levels?.[0]?.level) || 1; + const levelData = source.levels?.find((entry) => Number(entry.level) === level) || {}; + return new SkillModel({ ...utils.crushOb(source), ...levelData, level }); +} + +function playerCanResurrect(leaderSession) { + const player = leaderSession?.actor; + if (!isAlive(leaderSession)) return false; + if (learnedResurrectionSkills(player).length > 0) return true; + return (player.backpack?.fetchItems?.() || []) + .some((item) => PLAYER_RESURRECTION_SCROLLS.has(Number(item.fetchSelfId?.())) && Number(item.fetchAmount?.() || 0) > 0); +} + +function clearExpiredAttempt(leaderSession, now) { + const attempt = leaderSession?.partyRevivalAttempt; + if (attempt && now - Number(attempt.startedAt || 0) > 25000) { + leaderSession.partyRevivalAttempt = null; + } +} + +function castScroll(session, actor, target, skill) { + actor.select?.({ id: target.fetchId() }); + session.currentTargetId = target.fetchId(); + actor.automation.scheduleAction(session, actor, target, skill.fetchDistance(), () => { + actor.attack.remoteHit(session, target, skill); + }); +} + +function tick(session, leaderSession, Generics) { + if (!isCompanionOf(session, leaderSession) || !isAlive(session)) return { handled: false }; + + const now = Date.now(); + clearExpiredAttempt(leaderSession, now); + const dead = deadMembers(leaderSession); + if (dead.length === 0) { + leaderSession.partyRevivalAttempt = null; + return { handled: false, dead }; + } + if (partyCombatInProgress(leaderSession)) return { handled: false, dead }; + + const attempt = leaderSession.partyRevivalAttempt; + if (attempt) return { handled: attempt.providerId === session.actor.fetchId(), waiting: true, targetId: attempt.targetId }; + + const targetSession = dead.sort((a, b) => Number(a.actor.fetchId()) - Number(b.actor.fetchId()))[0]; + const providers = partySessions(leaderSession) + .filter(isAlive) + .filter((memberSession) => memberSession !== leaderSession) + .filter((memberSession) => memberSession.actor !== session.actor || !session.actor.state?.fetchCasts?.()); + const skilled = providers + .map((providerSession) => ({ session: providerSession, skill: resurrectionSkill(providerSession.actor) })) + .filter((entry) => entry.skill) + .sort((a, b) => Number(a.session.actor.fetchId()) - Number(b.session.actor.fetchId()))[0] || null; + const provider = skilled?.session || providers.sort((a, b) => Number(a.actor.fetchId()) - Number(b.actor.fetchId()))[0] || null; + if (!provider || provider !== session) return { handled: false, dead }; + + const skill = skilled?.skill || resurrectionScrollSkill(); + if (!skill) return { handled: false, dead }; + + leaderSession.partyRevivalAttempt = { + providerId: session.actor.fetchId(), + targetId: targetSession.actor.fetchId(), + source: skilled ? 'skill' : 'scroll', + startedAt: now + }; + + if (skilled) { + session.currentTargetId = targetSession.actor.fetchId(); + session.actor.select?.({ id: targetSession.actor.fetchId() }); + Generics.skillExec(session, session.actor, { + id: targetSession.actor.fetchId(), + selfId: skill.fetchSelfId(), + ctrl: false + }); + } else { + castScroll(session, session.actor, targetSession.actor, skill); + } + + return { + handled: true, + target: targetSession.actor, + source: skilled ? 'skill' : 'scroll' + }; +} + +function shouldTownRespawn(leaderSession, deadSession, now = Date.now()) { + if (!isCompanionOf(deadSession, leaderSession) || !leaderSession?.actor?.fetchIsOnline?.()) return true; + + const members = partySessions(leaderSession); + const living = members.filter(isAlive); + if (living.length === 0) return true; + if (living.length === 1 && living[0] === leaderSession && !playerCanResurrect(leaderSession)) return true; + + return now - Number(deadSession.deathTimerStart || now) >= PARTY_REVIVE_TIMEOUT_MS; +} + +module.exports = { + PARTY_REVIVE_TIMEOUT_MS, + partySessions, + deadMembers, + partyCombatInProgress, + learnedResurrectionSkills, + resurrectionSkill, + playerCanResurrect, + tick, + shouldTownRespawn +}; diff --git a/src/GameServer/Bot/AI/States/FollowingState.js b/src/GameServer/Bot/AI/States/FollowingState.js index e2095933..323cb991 100644 --- a/src/GameServer/Bot/AI/States/FollowingState.js +++ b/src/GameServer/Bot/AI/States/FollowingState.js @@ -8,6 +8,7 @@ const BotSupportPlanner = invoke('GameServer/Bot/AI/BotSupportPlanner'); const PartyAwareness = invoke('GameServer/Bot/AI/PartyAwareness'); const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); const PartyPulling = invoke('GameServer/Bot/AI/PartyPulling'); +const PartyRevivalService = invoke('GameServer/Bot/AI/PartyRevivalService'); const EffectStore = invoke('GameServer/Effects/EffectStore'); const ShotStock = invoke('GameServer/Inventory/ShotStock'); const TradeService = invoke('GameServer/Bot/TradeService'); @@ -488,6 +489,14 @@ module.exports = { const leaderSeated = player.state?.fetchSeated?.() === true; const botRecovering = botVitals.hpRatio < 0.95 || botVitals.mpRatio < 0.95; + const revival = PartyRevivalService.tick(session, playerSession, Generics); + if (revival.handled) { + recordRoleDecision(session, bot, 'resurrect_party', revival.source || 'waiting', { + targetId: revival.target?.fetchId?.() || revival.targetId || null + }); + return; + } + if (!partyThreat && !leaderTargetId && leaderSeated) { session.currentTargetId = undefined; bot.unselect(); diff --git a/src/GameServer/Bot/BotAI.js b/src/GameServer/Bot/BotAI.js index ff69f184..9d3073bc 100644 --- a/src/GameServer/Bot/BotAI.js +++ b/src/GameServer/Bot/BotAI.js @@ -6,6 +6,7 @@ const BotCombatUtility = invoke('GameServer/Bot/AI/BotCombatUtility'); const PopulationService = invoke('GameServer/Bot/Population/PopulationService'); const BotEquipmentUpgrade = invoke('GameServer/Bot/AI/BotEquipmentUpgrade'); const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); +const PartyRevivalService = invoke('GameServer/Bot/AI/PartyRevivalService'); const TownRespawn = invoke('GameServer/World/TownRespawn'); const CHAT_PHRASES = { @@ -362,15 +363,21 @@ const BotAI = { const wasCompanion = session.partyCompanion === true && !!session.followPlayerSession; if (!session.deathTimerStart) { session.deathTimerStart = Date.now(); - this.say(session, "Oops... I died! Resurrecting shortly."); + this.say(session, wasCompanion ? "I'm down. Waiting for a resurrection." : "Oops... I died! Resurrecting shortly."); if (wasCompanion && session.followPlayerSession?.actor?.isDead?.()) { const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); BotSocialMemory.recordEvent(session.followPlayerSession, session, 'party_wiped', 'bot_and_leader_dead'); } } - // Revive after 12 seconds of death - if (Date.now() - session.deathTimerStart > 12000) { + const partyRescuePending = wasCompanion && !PartyRevivalService.shouldTownRespawn( + session.followPlayerSession, + session + ); + // Companions wait for the party's resurrection attempt. The + // normal town restart remains the escape hatch for a wipe, an + // unsupported solo leader, or an unanswered corpse. + if (!partyRescuePending && Date.now() - session.deathTimerStart > 12000) { // TeleportTo rejects actors that are still marked dead, so bot // respawns must complete before applying the new town location. Generics.revive(session, bot, { delayMs: 0, restoreFullVitals: true }); @@ -384,7 +391,17 @@ const BotAI = { session.plan = 'pk_hunting'; spawnTarget = this.getDeathRespawnTarget(session, bot); } else { - if (session.plan === 'merchant' || (bot.fetchPrivateStore && bot.fetchPrivateStore())) { + if (wasCompanion) { + PartyCompanionService.clearCompanion(session, { + plan: 'hunting', + rebuildWindow: false, + refreshPanel: false + }); + session.plan = 'hunting'; + session.currentSpot = null; + session.noTargetTicks = 0; + spawnTarget = this.getDeathRespawnTarget(session, bot, false); + } else if (session.plan === 'merchant' || (bot.fetchPrivateStore && bot.fetchPrivateStore())) { session.plan = 'merchant'; bot.state.setSeated(true); spawnTarget = { diff --git a/src/GameServer/Skills/C4SkillEffects.js b/src/GameServer/Skills/C4SkillEffects.js index d99d7d5c..749df06b 100644 --- a/src/GameServer/Skills/C4SkillEffects.js +++ b/src/GameServer/Skills/C4SkillEffects.js @@ -48,6 +48,11 @@ function execute(session, actor, target, skill, context = {}) { return result; } + if (semantic.skillType === C4SkillRules.RESURRECT) { + result.resurrected = applyResurrection(session, target); + return result; + } + if (semantic.skillType === C4SkillRules.HEAL) { result.heal = applyHeal(session, actor, target, skill, semantic, magicSkill, context.attack); return result; @@ -350,6 +355,14 @@ function applyCombatPointHeal(session, actor, target, skill, semantic, magicSkil return Math.max(0, nextCp - currentCp); } +function applyResurrection(session, target) { + if (!target?.state?.fetchDead?.()) return false; + const targetSession = target.session; + if (!targetSession) return false; + invoke(path.actor).revive(targetSession, target); + return true; +} + function applyCombatPointDamage(session, actor, target, skill, semantic, magicSkill, attack) { const percent = Math.max(0, Number(semantic.cpDamagePercent ?? skill.fetchPower?.()) || 0); const currentCp = Math.max(0, Number(target.fetchCp?.()) || 0); diff --git a/tests/test_party_revival.js b/tests/test_party_revival.js new file mode 100644 index 00000000..f9879de6 --- /dev/null +++ b/tests/test_party_revival.js @@ -0,0 +1,167 @@ +const assert = require('assert'); + +require('../src/Global'); + +const World = invoke('GameServer/World/World'); +const BotManager = invoke('GameServer/Bot/BotManager'); +const PartyRevivalService = invoke('GameServer/Bot/AI/PartyRevivalService'); +const C4SkillEffects = invoke('GameServer/Skills/C4SkillEffects'); + +function actor(id, { dead = false, skills = [], items = [] } = {}) { + const state = { + dead, + fetchDead() { return this.dead; }, + setDead(value) { this.dead = value; }, + fetchCombats: () => false, + fetchHits: () => false, + fetchCasts: () => false, + setCasts() {} + }; + return { + id, + state, + hp: 100, + mp: 100, + fetchId() { return this.id; }, + fetchName: () => `actor_${id}`, + fetchLocX: () => 0, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchHead: () => 0, + fetchIsOnline: () => true, + isDead() { return this.state.fetchDead(); }, + fetchMp() { return this.mp; }, + fetchMaxMp: () => 100, + fetchMaxHp: () => 100, + fetchHp() { return this.hp; }, + fillupVitals() { this.hp = 100; this.mp = 100; }, + canUseSkill: () => true, + select(data) { this.destId = data.id; }, + skillset: { skills }, + backpack: { fetchItems: () => items }, + automation: { + stopReplenish() {}, + replenishVitals() {}, + scheduleAction(...args) { this.scheduled = args; } + }, + attack: { + remoteHit(...args) { this.resurrectionCast = args; } + } + }; +} + +function session(actor, accountId) { + const value = { + actor, + accountId, + packets: [], + dataSendToMe() {}, + dataSendToMeAndOthers(packet) { this.packets.push(packet); } + }; + actor.session = value; + return value; +} + +const originalUsers = World.user; +const originalNpcs = World.npc; +const originalFetchNpcs = World.fetchNpcsInRadius; +const originalSessions = BotManager.sessions; + +try { + const resurrection = { + fetchPassive: () => false, + fetchSkillType: () => 'resurrect', + fetchTargetKind: () => 'corpse_player', + fetchConsumedMp: () => 20, + fetchPower: () => 20, + fetchSelfId: () => 1016 + }; + const leader = actor(2000100, { dead: true }); + const healer = actor(2000101, { skills: [resurrection] }); + const fallen = actor(2000102, { dead: true }); + const leaderSession = session(leader, 'player_party_revival'); + const healerSession = session(healer, 'bot_party_healer'); + const fallenSession = session(fallen, 'bot_party_fallen'); + [healerSession, fallenSession].forEach((member) => { + member.followPlayerSession = leaderSession; + member.partyCompanion = true; + member.plan = 'following'; + }); + World.user = { sessions: [leaderSession, healerSession, fallenSession] }; + World.npc = { spawns: [] }; + World.fetchNpcsInRadius = () => []; + BotManager.sessions = [healerSession, fallenSession]; + + World.npc.spawns = [{ + fetchAttackable: () => true, + isDead: () => false, + fetchDestId: () => leader.fetchId(), + state: { fetchCombats: () => true } + }]; + const combatHeldResult = PartyRevivalService.tick(healerSession, leaderSession, { skillExec() {} }); + assert.strictEqual(combatHeldResult.handled, false, 'a monster still fighting a fallen party member must block resurrection'); + World.npc.spawns = []; + + let skillCast = null; + const skillResult = PartyRevivalService.tick(healerSession, leaderSession, { + skillExec(...args) { skillCast = args; } + }); + assert.strictEqual(skillResult.source, 'skill', 'a learned Resurrection skill must take priority over the unlimited scroll'); + assert.strictEqual(skillCast[2].selfId, 1016, 'party resurrection should use the healer\'s learned Resurrection skill'); + assert.strictEqual(skillCast[2].id, leader.fetchId(), 'party resurrection should target the first fallen member'); + + leaderSession.partyRevivalAttempt = null; + healer.skillset.skills = []; + const scrollResult = PartyRevivalService.tick(healerSession, leaderSession, { skillExec() {} }); + assert.strictEqual(scrollResult.source, 'scroll', 'a living companion must fall back to its unlimited resurrection scroll'); + assert(healer.automation.scheduled, 'scroll resurrection should use the native move-and-cast path'); + + leader.state.setDead(false); + healer.state.setDead(true); + leader.skillset.skills = []; + leader.backpack.fetchItems = () => []; + BotManager.sessions = [healerSession]; + assert.strictEqual( + PartyRevivalService.shouldTownRespawn(leaderSession, healerSession), + true, + 'a dead companion should return to town when the leader is alone and cannot resurrect it' + ); + leader.backpack.fetchItems = () => [{ fetchSelfId: () => 737, fetchAmount: () => 1 }]; + assert.strictEqual( + PartyRevivalService.shouldTownRespawn(leaderSession, healerSession), + false, + 'a player with a resurrection scroll should retain a dead companion while choosing to revive it' + ); + leader.backpack.fetchItems = () => []; + leader.skillset.skills = [resurrection]; + leader.mp = 0; + assert.strictEqual( + PartyRevivalService.shouldTownRespawn(leaderSession, healerSession), + false, + 'a player who knows Resurrection should retain the companion while regenerating MP' + ); + leader.skillset.skills = []; + + leader.state.setDead(true); + assert.strictEqual( + PartyRevivalService.shouldTownRespawn(leaderSession, healerSession), + true, + 'a full party wipe should release dead companions for town respawn' + ); + + const nativeTarget = actor(2000103, { dead: true }); + const nativeTargetSession = session(nativeTarget, 'bot_native_resurrection_target'); + const nativeResult = C4SkillEffects.execute(healerSession, healer, nativeTarget, { + fetchSemantic: () => ({ skillType: 'resurrect' }), + fetchSpell: () => true + }); + assert.strictEqual(nativeResult.resurrected, true, 'the shared skill-effect path must apply a Resurrection cast to a corpse player'); + assert(nativeTargetSession.packets.some((packet) => packet[0] === 0x07), 'native resurrection should send the standard revive packet'); +} finally { + World.user = originalUsers; + World.npc = originalNpcs; + World.fetchNpcsInRadius = originalFetchNpcs; + BotManager.sessions = originalSessions; +} + +console.log('Party revival checks passed'); From 2960ede1727c812dd16901b224594c38c34928a8 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:13:40 -0400 Subject: [PATCH 08/13] Stabilize companion party coordination --- src/GameServer/Bot/AI/BotCombatUtility.js | 4 + src/GameServer/Bot/AI/BotLootEtiquette.js | 3 + .../Bot/AI/GearAcquisitionPlanner.js | 11 ++ src/GameServer/Bot/AI/PartyAwareness.js | 52 ++++-- .../Bot/AI/PartyCompanionService.js | 132 ++++++++++++-- src/GameServer/Bot/AI/PartyPulling.js | 24 ++- src/GameServer/Bot/AI/PartyRevivalService.js | 24 ++- .../Bot/AI/States/FollowingState.js | 161 ++++++++++++++---- src/GameServer/Bot/BotAI.js | 29 +++- src/GameServer/Bot/BotManager.js | 7 +- .../Bot/Population/GeneratedColdSeeder.js | 11 +- src/GameServer/Network/Shared.js | 11 ++ tests/test_bot_chat_commands.js | 8 +- tests/test_bot_combat_skill_selection.js | 13 ++ tests/test_bot_gear_acquisition.js | 8 + tests/test_bot_loot_etiquette.js | 41 +++++ tests/test_equipment_slots.js | 10 ++ tests/test_party_bot_loot.js | 76 +++++++++ tests/test_party_companion_rest_follow.js | 131 +++++++++++++- tests/test_party_revival.js | 7 + 20 files changed, 678 insertions(+), 85 deletions(-) create mode 100644 tests/test_bot_loot_etiquette.js diff --git a/src/GameServer/Bot/AI/BotCombatUtility.js b/src/GameServer/Bot/AI/BotCombatUtility.js index 05145c96..9a4d835a 100644 --- a/src/GameServer/Bot/AI/BotCombatUtility.js +++ b/src/GameServer/Bot/AI/BotCombatUtility.js @@ -26,6 +26,10 @@ function reserveRatio(role) { function evaluate(bot, target, skill, role) { if (!skill || skill.fetchPassive?.()) return null; + // SkillRequest rejects a skill still on reuse after the combat planner has + // already committed to it. Treat that as unavailable here so a melee bot + // falls back to its normal attack instead of idling until cooldown ends. + if (bot.canUseSkill?.(skill) === false) return null; const semantic = skill.fetchSemantic?.() || {}; if (semantic.notUsedInC4) return null; const allowedWeapons = Number(semantic.requires?.weaponsAllowed) || 0; diff --git a/src/GameServer/Bot/AI/BotLootEtiquette.js b/src/GameServer/Bot/AI/BotLootEtiquette.js index 8f883b4e..8c46ae6d 100644 --- a/src/GameServer/Bot/AI/BotLootEtiquette.js +++ b/src/GameServer/Bot/AI/BotLootEtiquette.js @@ -173,6 +173,7 @@ function shouldRecordIgnoredRequest(request) { if (!request.playerSession?.actor || !request.botSession?.actor) return false; if (request.botSession.followPlayerSession !== request.playerSession) return false; if (request.botSession.partyCompanion !== true) return false; + if (request.botSession.actor.isDead?.() || request.botSession.actor.state?.fetchDead?.()) return false; return actorDistance(request.playerSession.actor, request.botSession.actor) <= IGNORE_PENALTY_RANGE; } @@ -198,6 +199,8 @@ function expireRequest(request) { } const BotLootEtiquette = { + shouldRecordIgnoredRequest, + observeDrop(playerSession, npc, selfId, amount) { if (!playerSession?.actor || isBotSession(playerSession) || !npc || selfId === ADENA_ID) { return; diff --git a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js index b608fefa..56ed8cb1 100644 --- a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js +++ b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js @@ -305,6 +305,17 @@ function equipInventoryUpgrades(state = {}, inventory = {}) { } next[String(entry.selfId)] = { ...next[String(entry.selfId)], equipped: true, slot }; }); + const hasTwoHandedWeapon = Object.values(next).some((owned) => { + const template = (DataCache.items || []).find((item) => Number(item.selfId) === Number(owned?.selfId)); + return owned?.equipped && Number(template?.etc?.slot || 0) === 14 && + String(template?.template?.kind || '').startsWith('Weapon.'); + }); + if (hasTwoHandedWeapon) { + Object.values(next).forEach((owned) => { + const template = (DataCache.items || []).find((item) => Number(item.selfId) === Number(owned?.selfId)); + if (Number(template?.etc?.slot || 0) === 8) owned.equipped = false; + }); + } return next; } diff --git a/src/GameServer/Bot/AI/PartyAwareness.js b/src/GameServer/Bot/AI/PartyAwareness.js index d76afd86..e87487a3 100644 --- a/src/GameServer/Bot/AI/PartyAwareness.js +++ b/src/GameServer/Bot/AI/PartyAwareness.js @@ -1,7 +1,14 @@ -const World = invoke('GameServer/World/World'); +const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); const RECENT_INCOMING_THREAT_MS = 5000; +// World loads bot controls as part of its own initialization. Resolving it at +// module scope here can therefore retain Node's empty circular-dependency +// export forever. Read the completed singleton when a decision is made. +function world() { + return invoke('GameServer/World/World'); +} + function isOnlineActor(actor) { return !!actor && actor.fetchIsOnline && actor.fetchIsOnline() && !actor.state?.fetchDead?.(); } @@ -17,7 +24,7 @@ function isPartySession(session, leaderSession) { function partySessions(leaderSession) { if (!leaderSession) return []; - return World.user.sessions.filter((session) => ( + return (world().user?.sessions || []).filter((session) => ( session && isPartySession(session, leaderSession) && isOnlineActor(session.actor) @@ -54,7 +61,7 @@ function uniqueNpcsAround(actors, radius) { const npcs = []; actors.forEach((actor) => { - World.fetchNpcsInRadius(actor.fetchLocX(), actor.fetchLocY(), radius).forEach((npc) => { + world().fetchNpcsInRadius(actor.fetchLocX(), actor.fetchLocY(), radius).forEach((npc) => { const id = actorId(npc); if (seen.has(id)) return; seen.add(id); @@ -70,7 +77,7 @@ function recentIncomingNpc(session, npcRadius = 2500) { const threatAt = Number(session?.incomingThreatAt || 0); if (!threatId || Date.now() - threatAt > RECENT_INCOMING_THREAT_MS || !session?.actor) return null; - const npc = (World.npc?.spawns || []).find((spawn) => actorId(spawn) === threatId); + const npc = (world().npc?.spawns || []).find((spawn) => actorId(spawn) === threatId); if (!npc || !npc.fetchAttackable?.() || npc.isDead?.()) return null; if (distance2d(actorLoc(npc), actorLoc(session.actor)) > npcRadius) return null; @@ -93,6 +100,20 @@ function recentIncomingNpcThreat(leaderSession, memberSessions, npcRadius) { return null; } +function npcThreatPriority(leaderSession, memberSessions, npc) { + const targetId = Number(npc.fetchDestId?.() || 0); + const targetSession = memberSessions.find((session) => Number(actorId(session.actor)) === targetId); + if (!targetSession) return Number.MAX_SAFE_INTEGER; + + const pullerId = Number(leaderSession?.partyPullState?.pullerId || 0); + if (targetId === pullerId) return 0; + const role = BotRoles.inferRole(targetSession.actor); + if (role === 'healer') return 1; + if (role === 'buffer') return 2; + if (targetSession === leaderSession) return 3; + return 4; +} + function findThreatTargetingParty(leaderSession, options = {}) { const memberSessions = partySessions(leaderSession); const members = memberSessions.map((session) => session.actor); @@ -105,12 +126,17 @@ function findThreatTargetingParty(leaderSession, options = {}) { const recentThreat = recentIncomingNpcThreat(leaderSession, memberSessions, npcRadius); if (recentThreat) return recentThreat; - const npcThreat = uniqueNpcsAround(members, npcRadius).find((npc) => ( - npc.fetchAttackable && - npc.fetchAttackable() && - !npc.isDead() && - memberIds.has(npc.fetchDestId && npc.fetchDestId()) - )); + const npcThreat = uniqueNpcsAround(members, npcRadius) + .filter((npc) => ( + npc.fetchAttackable && + npc.fetchAttackable() && + !npc.isDead() && + memberIds.has(npc.fetchDestId && npc.fetchDestId()) + )) + .sort((a, b) => ( + npcThreatPriority(leaderSession, memberSessions, a) - npcThreatPriority(leaderSession, memberSessions, b) || + actorId(a) - actorId(b) + ))[0]; if (npcThreat) { return { type: 'npc', @@ -119,7 +145,7 @@ function findThreatTargetingParty(leaderSession, options = {}) { }; } - const playerThreatSession = World.user.sessions.find((session) => { + const playerThreatSession = (world().user?.sessions || []).find((session) => { const actor = session?.actor; const id = actorId(actor); if (!isOnlineActor(actor) || memberIds.has(id)) return false; @@ -151,12 +177,12 @@ function leaderCombatTargetId(leaderSession) { if (!targetId) return null; if (partyActorIds(leaderSession).has(targetId)) return null; - const npc = (World.npc?.spawns || []).find((spawn) => actorId(spawn) === targetId); + const npc = (world().npc?.spawns || []).find((spawn) => actorId(spawn) === targetId); if (npc) { return npc.fetchAttackable?.() && !npc.isDead?.() ? targetId : null; } - const targetSession = (World.user?.sessions || []).find((session) => actorId(session?.actor) === targetId); + const targetSession = (world().user?.sessions || []).find((session) => actorId(session?.actor) === targetId); const target = targetSession?.actor; if (target) { if (!isOnlineActor(target)) return null; diff --git a/src/GameServer/Bot/AI/PartyCompanionService.js b/src/GameServer/Bot/AI/PartyCompanionService.js index 5a02c48a..352c7f5b 100644 --- a/src/GameServer/Bot/AI/PartyCompanionService.js +++ b/src/GameServer/Bot/AI/PartyCompanionService.js @@ -1,4 +1,5 @@ const ServerResponse = invoke('GameServer/Network/Response'); +const PartyAwareness = invoke('GameServer/Bot/AI/PartyAwareness'); const DEFAULT_PARTY_DISTRIBUTION = 1; const DEFAULT_PARTY_SETTINGS = { @@ -10,6 +11,7 @@ const DEFAULT_PARTY_SETTINGS = { itemLastLootIndex: -1 }; const PARTY_LOOT_RADIUS = 2500; +const GROUND_LOOT_SCAN_INTERVAL_MS = 500; const RANDOM_LOOT_DISTRIBUTIONS = new Set([1, 2]); const FORMATION_OFFSETS = [ { locX: -90, locY: -70 }, @@ -22,6 +24,10 @@ const FORMATION_OFFSETS = [ { locX: -330, locY: 0 } ]; +function world() { + return invoke('GameServer/World/World'); +} + function hasOwn(object, key) { return Object.prototype.hasOwnProperty.call(object || {}, key); } @@ -140,15 +146,29 @@ function nextTurnMember(leaderSession, members) { function canPickGroundLoot(session, leaderSession, item) { const actor = session?.actor; if (!isActiveCompanion(session, leaderSession) || !isAliveOnline(session)) return false; - if (['resting', 'getting_buffed', 'shopping', 'merchant'].includes(session.plan)) return false; - if (actor?.state?.fetchSeated?.()) return false; + // A finished fight often leaves the whole party seated. Ground loot is + // still available then: the chosen companion stands, picks it up and the + // normal following/resting logic puts it back into formation afterwards. + if (['getting_buffed', 'shopping', 'merchant'].includes(session.plan)) return false; + const pullState = leaderSession?.partyPullState || {}; + if ( + ['approach', 'aggro', 'return'].includes(pullState.phase) && + Number(actor?.fetchId?.()) === Number(pullState.pullerId || 0) + ) return false; if (actor?.storedPickup) return false; return distance2d(actor, item) <= PARTY_LOOT_RADIUS; } function partyCombatInProgress(leaderSession) { + const pullState = leaderSession?.partyPullState || {}; + const pullerId = Number(pullState.pullerId || 0); + // While a puller is approaching, gaining aggro or returning, the mob has + // not reached the camp yet. That travel must not make old nearby drops + // wait forever; a real fight by any other party member still blocks loot. + const pullTravel = ['approach', 'aggro', 'return'].includes(pullState.phase); return [leaderSession, ...membersForLeader(leaderSession)] .some((memberSession) => { + if (pullTravel && Number(memberSession?.actor?.fetchId?.()) === pullerId) return false; const state = memberSession?.actor?.state; return !!( state?.fetchCombats?.() || @@ -158,6 +178,52 @@ function partyCombatInProgress(leaderSession) { }); } +function queuedGroundLootIds(leaderSession) { + return new Set(membersForLeader(leaderSession) + .flatMap((memberSession) => memberSession.partyGroundPickupQueue || []) + .map((entry) => Number(entry?.id || 0)) + .filter(Boolean)); +} + +function availableGroundLoot(leaderSession) { + const members = [leaderSession, ...membersForLeader(leaderSession)] + .filter(isAliveOnline); + const queuedIds = queuedGroundLootIds(leaderSession); + return (world().items?.spawns || []) + .filter((item) => item?.fetchId && item?.fetchLocX && item?.fetchLocY) + .filter((item) => !queuedIds.has(Number(item.fetchId()))) + .filter((item) => members.some((memberSession) => distance2d(memberSession.actor, item) <= PARTY_LOOT_RADIUS)) + .sort((a, b) => Number(a.fetchId()) - Number(b.fetchId())); +} + +function hasCampThreat(leaderSession) { + if (!world().user?.sessions) return false; + + const threat = PartyAwareness.findThreatTargetingParty(leaderSession); + if (!threat) return false; + + const pullState = leaderSession?.partyPullState || {}; + const travellingPull = ['approach', 'aggro', 'return'].includes(pullState.phase); + // The single mob a distant puller is deliberately bringing home is not + // camp combat yet. Any other incoming target must preempt ground pickup. + return !( + travellingPull && + Number(threat.actor?.fetchId?.()) === Number(pullState.targetId || 0) + ); +} + +function reconcileGroundLoot(looterSession) { + const leaderSession = partyLeaderSession(looterSession); + if (!leaderSession || partyCombatInProgress(leaderSession) || hasCampThreat(leaderSession)) return 0; + + const now = Date.now(); + if (now - Number(leaderSession.lastGroundLootScanAt || 0) < GROUND_LOOT_SCAN_INTERVAL_MS) return 0; + leaderSession.lastGroundLootScanAt = now; + + return availableGroundLoot(leaderSession) + .reduce((assigned, item) => assigned + Number(!!queueRandomGroundPickup(leaderSession, item)), 0); +} + function nearestGroundLootPicker(looterSession, item) { const leaderSession = partyLeaderSession(looterSession); if (!leaderSession || !item || !RANDOM_LOOT_DISTRIBUTIONS.has(distributionForLeader(leaderSession))) return null; @@ -175,11 +241,37 @@ function startQueuedGroundPickup(pickerSession) { const queue = pickerSession?.partyGroundPickupQueue; if (!picker || pickerSession.partyGroundPickupInProgress || !queue?.length) return false; const leaderSession = partyLeaderSession(pickerSession); - if (partyCombatInProgress(leaderSession)) return false; + // A queued drop is lower priority than a resurrection. This also + // protects queues that were assigned before a companion died, rather + // than letting the only living support bot run away from the corpse. + if ([leaderSession, ...membersForLeader(leaderSession)].some((memberSession) => memberSession?.actor?.isDead?.())) { + return false; + } + // Re-check transient plans at execution time. A queue may have been + // built while following and become stale after the player assigns this + // bot as the puller or it starts a town/support action. + const pullState = leaderSession?.partyPullState || {}; + const settings = settingsForLeader(leaderSession); + if ( + ['getting_buffed', 'shopping', 'merchant'].includes(pickerSession.plan) || + ( + settings.pullMode === 'bot' && + Number(settings.pullerId || 0) === Number(picker.fetchId?.()) + ) || + ( + ['approach', 'aggro', 'return'].includes(pullState.phase) && + Number(picker.fetchId?.()) === Number(pullState.pullerId || 0) + ) + ) return false; + if (partyCombatInProgress(leaderSession) || hasCampThreat(leaderSession)) return false; if (picker.state?.fetchPickinUp?.()) return false; const pickup = queue[0]; pickerSession.partyGroundPickupInProgress = true; + if (picker.state?.fetchSeated?.()) { + picker.state.setSeated(false); + pickerSession.dataSendToMeAndOthers?.(ServerResponse.sitAndStand(picker), picker); + } const Generics = invoke(path.actor); Generics.stopAutomation(pickerSession, picker); Generics.pickupExec(pickerSession, picker, pickup, () => { @@ -195,6 +287,22 @@ function startQueuedGroundPickup(pickerSession) { return true; } +function queueRandomGroundPickup(looterSession, item) { + const pickerSession = nearestGroundLootPicker(looterSession, item); + if (!pickerSession) return null; + + const pickup = { id: item.fetchId() }; + // Player pickup requests wait for the next client ValidatePosition. + // Hot bots update their location server-side, so leaving this in + // storedPickup makes the visible drop stay on the ground forever. + // Keep an independent FIFO because a mob can drop Adena and items in + // the same reward pass while Automation has only one pickup timer. + pickerSession.partyGroundPickupQueue ??= []; + pickerSession.partyGroundPickupQueue.push(pickup); + startQueuedGroundPickup(pickerSession); + return pickerSession; +} + function formationSlotFor(companionSession) { const leaderSession = companionSession?.followPlayerSession; const members = membersForLeader(leaderSession); @@ -378,24 +486,12 @@ const PartyCompanionService = { .filter((entry) => entry.amount > 0); }, - queueRandomGroundPickup(looterSession, item) { - const pickerSession = nearestGroundLootPicker(looterSession, item); - if (!pickerSession) return null; - - const pickup = { id: item.fetchId() }; - // Player pickup requests wait for the next client ValidatePosition. - // Hot bots update their location server-side, so leaving this in - // storedPickup makes the visible drop stay on the ground forever. - // Keep an independent FIFO because a mob can drop Adena and items in - // the same reward pass while Automation has only one pickup timer. - pickerSession.partyGroundPickupQueue ??= []; - pickerSession.partyGroundPickupQueue.push(pickup); - startQueuedGroundPickup(pickerSession); - return pickerSession; - }, + queueRandomGroundPickup, startQueuedGroundPickup, + reconcileGroundLoot, + attach(leaderSession, companionSession, options = {}) { const leader = leaderSession?.actor; const bot = companionSession?.actor; diff --git a/src/GameServer/Bot/AI/PartyPulling.js b/src/GameServer/Bot/AI/PartyPulling.js index 89a79b5f..10adac22 100644 --- a/src/GameServer/Bot/AI/PartyPulling.js +++ b/src/GameServer/Bot/AI/PartyPulling.js @@ -127,6 +127,15 @@ function supportProviders(leaderSession) { } function pauseReason(leaderSession, puller) { + const state = pullState(leaderSession); + const threat = PartyAwareness.findThreatTargetingParty(leaderSession); + const ownPullTarget = threat && + Number(threat.actor?.fetchId?.()) === Number(state.targetId || 0); + // Do not select a new target while the camp is already handling an add. + // The current shared pull target is the sole exception: it remains the + // party's intended fight from first aggro until it dies. + if (threat && !ownPullTarget) return 'party_under_attack'; + const members = PartyAwareness.partySessions(leaderSession); if (members.some((memberSession) => ( memberSession !== leaderSession && ( @@ -163,13 +172,20 @@ function actorCanEngage(actor, target) { return !!actor && !!target && distance(point(actor), point(target)) <= attackRange(actor, target); } +function canDeliverPull(actor, target) { + // Support roles use only their basic attack once the target is delivered, + // but they still need to release a player-led pull when they are the only + // companions in range. + return actorCanEngage(actor, target); +} + function targetIsEngageable(leaderSession, target, puller) { if (!target) return false; return PartyAwareness.partyActors(leaderSession) // A leader pull has no return phase to synchronize. Release only when // a companion can actually strike the player-designated target. .filter((actor) => actor !== leaderSession.actor && actor !== puller?.actor) - .some((actor) => actorCanEngage(actor, target)); + .some((actor) => canDeliverPull(actor, target)); } function nearestFreeMonster(bot) { @@ -191,6 +207,11 @@ function shouldKeepPullMove(session, bot, state, phase, target) { session.stuckTicks = 0; session.lastStuckSampleAt = Date.now(); } + // The leader can cover more than the normal target-drift threshold while + // a puller is returning. Replanning mid-route aborts the server movement + // after the client has already rendered it, which looks like a snap back. + // Finish the active return leg, then take a fresh formation target. + if (phase === 'return') return true; return distance(state.moveTarget, point(target)) <= PULL_MOVE_TARGET_DRIFT; } @@ -359,6 +380,7 @@ module.exports = { current, targetIsEngageable, actorCanEngage, + canDeliverPull, attackRange, PULL_AGGRO_TIMEOUT_MS }; diff --git a/src/GameServer/Bot/AI/PartyRevivalService.js b/src/GameServer/Bot/AI/PartyRevivalService.js index 9c095892..9781bd52 100644 --- a/src/GameServer/Bot/AI/PartyRevivalService.js +++ b/src/GameServer/Bot/AI/PartyRevivalService.js @@ -2,12 +2,15 @@ const C4SkillRules = invoke('GameServer/Skills/C4SkillRules'); const DataCache = invoke('GameServer/DataCache'); const SkillModel = invoke('GameServer/Model/Skill'); const PartyAwareness = invoke('GameServer/Bot/AI/PartyAwareness'); -const World = invoke('GameServer/World/World'); const PARTY_REVIVE_TIMEOUT_MS = 60000; const RESURRECTION_SCROLL_SKILL_ID = 2014; const PLAYER_RESURRECTION_SCROLLS = new Set([737, 3936, 3959]); +function world() { + return invoke('GameServer/World/World'); +} + function isCompanionOf(session, leaderSession) { return !!( session?.actor && @@ -34,20 +37,23 @@ function deadMembers(leaderSession) { function partyCombatInProgress(leaderSession) { if (PartyAwareness.findThreatTargetingParty(leaderSession)) return true; - const members = partySessions(leaderSession); - if (members + if (PartyAwareness.leaderCombatTargetId(leaderSession)) return true; + + // A pending hit/cast is an actual native action, unlike a lingering + // combat marker after an already finished fight. Do not begin a long + // resurrection while a living party member is still executing one. + if (partySessions(leaderSession) .filter(isAlive) - .some((session) => { - const state = session.actor.state; - return !!(state?.fetchCombats?.() || state?.fetchHits?.() || state?.fetchCasts?.()); - })) return true; + .some((session) => ( + session.actor.state?.fetchHits?.() || session.actor.state?.fetchCasts?.() + ))) return true; // PartyAwareness intentionally ignores corpses. For resurrection that is // too narrow: a monster can keep its combat loop on a fallen party member // for a short time after the lethal hit, and a healer must not begin a // long resurrection cast in front of it. - const partyIds = new Set(members.map((member) => member.actor?.fetchId?.()).filter(Boolean)); - return (World.npc?.spawns || []).some((npc) => ( + const partyIds = new Set(partySessions(leaderSession).map((member) => member.actor?.fetchId?.()).filter(Boolean)); + return (world().npc?.spawns || []).some((npc) => ( npc.fetchAttackable?.() === true && npc.isDead?.() !== true && npc.state?.fetchCombats?.() === true && diff --git a/src/GameServer/Bot/AI/States/FollowingState.js b/src/GameServer/Bot/AI/States/FollowingState.js index 323cb991..6bb21886 100644 --- a/src/GameServer/Bot/AI/States/FollowingState.js +++ b/src/GameServer/Bot/AI/States/FollowingState.js @@ -13,6 +13,7 @@ const EffectStore = invoke('GameServer/Effects/EffectStore'); const ShotStock = invoke('GameServer/Inventory/ShotStock'); const TradeService = invoke('GameServer/Bot/TradeService'); const MarketOpportunity = invoke('GameServer/Bot/Economy/MarketOpportunity'); +const TownPathfinder = invoke('GameServer/Bot/AI/TownPathfinder'); const FOLLOW_RUN_DISTANCE = 250; const FOLLOW_RETARGET_DISTANCE = 900; @@ -25,6 +26,8 @@ const NEWBIE_GUIDE_TOWN_RADIUS = 7500; const NEWBIE_GUIDE_RECOVERY_MAX_LEVEL = 20; const COMPANION_TOWN_ERRAND_RADIUS = 7500; const COMPANION_TOWN_ERRAND_COOLDOWN_MS = 60000; +const TOWN_CENTER_FALLBACK_RADIUS = 1500; +const STARTER_GUIDE_TOWN_RADIUS = 1500; function ratio(value, max) { if (!max) return 0; @@ -85,10 +88,36 @@ function townForCompanionErrand(player, BotAI) { const town = BotAI.getClosestTown?.(player.fetchLocX(), player.fetchLocY()); if (!town) return null; - return distance2d( + const distance = distance2d( { locX: player.fetchLocX(), locY: player.fetchLocY() }, { locX: town.x, locY: town.y } - ) <= COMPANION_TOWN_ERRAND_RADIUS ? town : null; + ); + if (distance > COMPANION_TOWN_ERRAND_RADIUS) return null; + + const playerLoc = { + locX: player.fetchLocX(), + locY: player.fetchLocY(), + locZ: player.fetchLocZ() + }; + // The town movement atlas is intentionally partial. Keep its precise + // polygons where available, with a tight center fallback for towns known + // to the respawn service. Starter villages outside that atlas are + // recognized only beside their actual Newbie Guide, never by a broad + // radius that also includes nearby farming fields. + const guide = BotAI.getClosestNewbieGuide?.(player.fetchLocX(), player.fetchLocY()); + const besideStarterGuide = guide && distance2d( + { locX: player.fetchLocX(), locY: player.fetchLocY() }, + guide + ) <= STARTER_GUIDE_TOWN_RADIUS; + const inTown = TownPathfinder.isInsideTown(playerLoc) || ( + distance <= TOWN_CENTER_FALLBACK_RADIUS + ) || besideStarterGuide; + + return { + town, + distance, + inTown + }; } function actorAdena(bot) { @@ -134,8 +163,27 @@ function plannedMarketPurchase(session, bot, town) { function companionTownErrand(session, bot, player, BotAI) { if (Date.now() - Number(session.lastCompanionTownErrandAt || 0) < COMPANION_TOWN_ERRAND_COOLDOWN_MS) return null; - const town = townForCompanionErrand(player, BotAI); - if (!town) return null; + const townContext = townForCompanionErrand(player, BotAI); + if (!townContext) return null; + const { town, inTown } = townContext; + + // In town, every companion may settle its own short task. In the field, + // leaving the leader is reserved for an immediately useful resupply; + // shopping and selling can wait until the party actually reaches town. + if (!inTown) { + if (!ShotStock.needsActorRestock(bot, 0)) return null; + return { + kind: 'restock_shots', + target: { + actorId: null, + name: `${town.name} general shop`, + locX: town.x, + locY: town.y, + locZ: town.z, + town: town.name + } + }; + } const purchase = plannedMarketPurchase(session, bot, town); if (purchase) return purchase; @@ -313,6 +361,24 @@ function partySupportMembers(leaderSession, puller) { return PartyPulling.supportMembers(leaderSession, puller); } +function partyHasBuffer(leaderSession, exceptActor = null) { + return PartyAwareness.partySessions(leaderSession) + .some((memberSession) => ( + memberSession.actor !== exceptActor && + BotRoles.inferRole(memberSession.actor) === 'buffer' + )); +} + +function returnToPartyAfterSupport(session, bot, player, target) { + // A remote heal/buff may have made the support bot walk far away from the + // formation. The native cast owns that movement until the hit lands; the + // next idle tick must head back to the leader instead of beginning combat + // around the assisted target. + if (point(target).distance(point(player)) > FOLLOW_RUN_DISTANCE) { + session.returnToPartyAfterSupport = true; + } +} + function activeBotPullTravel(session, pulling) { return pulling?.enabled === true && pulling.puller?.session === session && @@ -469,14 +535,6 @@ module.exports = { return; } - // A drop can be assigned while this companion or another party member - // is still finishing combat. Retry the FIFO only after the whole party - // is clear, so it does not lie on the ground forever or interrupt an - // active fight between attack swings. - if (PartyCompanionService.startQueuedGroundPickup(session)) { - return; - } - const botVitals = { hpRatio: ratio(bot.fetchHp(), bot.fetchMaxHp()), mpRatio: ratio(bot.fetchMp(), bot.fetchMaxMp()) @@ -497,6 +555,22 @@ module.exports = { return; } + if (session.returnToPartyAfterSupport && !isBusy(bot)) { + session.returnToPartyAfterSupport = false; + session.currentTargetId = undefined; + bot.unselect(); + if (distance > FOLLOW_RUN_DISTANCE) { + const followTarget = followTargetFor(session, player); + session.lastFollowMoveTarget = followTarget; + bot.moveTo({ + from: { locX: bot.fetchLocX(), locY: bot.fetchLocY(), locZ: bot.fetchLocZ() }, + to: followTarget + }); + recordRoleDecision(session, bot, 'follow_leader', 'return_after_support'); + return; + } + } + if (!partyThreat && !leaderTargetId && leaderSeated) { session.currentTargetId = undefined; bot.unselect(); @@ -554,13 +628,21 @@ module.exports = { const buffsNeedRefresh = BotBuffs.needsNewbieRefresh(bot); if (buffsNeedRefresh) { const unsafeToRefresh = unsafeSupportMoment(bot, partyAggroCount(playerSession)); + const inTown = TownPathfinder.isInsideTown({ + locX: player.fetchLocX(), + locY: player.fetchLocY(), + locZ: player.fetchLocZ() + }); + const expired = BotBuffs.needsNewbieRefresh(bot, 0); + const nearbyGuide = isAtNewbieGuideTown(player, BotAI); + const canMakeFieldBuffTrip = !inTown && expired && nearbyGuide && !partyHasBuffer(playerSession, bot); if (unsafeToRefresh) { recordRoleDecision(session, bot, 'refresh_buffs', 'wait_for_safe_moment', { missingBuffs: BotBuffs.missingNewbieBuffs(bot, BotBuffs.REFRESH_THRESHOLD_MS) }); keepRoleDecision = true; - } else if (!isAtNewbieGuideTown(player, BotAI)) { + } else if (!nearbyGuide || (!inTown && !canMakeFieldBuffTrip)) { recordRoleDecision(session, bot, 'refresh_buffs', 'wait_for_newbie_guide_town', { missingBuffs: BotBuffs.missingNewbieBuffs(bot, BotBuffs.REFRESH_THRESHOLD_MS) }); @@ -592,6 +674,21 @@ module.exports = { partySupportMembers(playerSession, pulling.puller), PartyPulling.supportProviders(playerSession) ); + const healerSkill = role === 'healer' ? BotSkillCapabilities.healSkill(bot) : null; + const healerCanCast = !!healerSkill && + bot.fetchMp() >= healerSkill.fetchConsumedMp() && + !isBusy(bot) && + !impairments.silenced; + const woundedPartyMember = role === 'healer' + ? weakestPartyMember(playerSession, bot, pulling.puller?.actor) + : null; + // Healing is the healer's first obligation. Do not queue a regular + // party buff and then overwrite it with a heal in this same AI tick. + const healerNeedsAction = role === 'healer' && healerCanCast && ( + woundedPartyMember?.hpRatio < 0.45 || + (woundedPartyMember?.hpRatio < 0.70 && botVitals.mpRatio >= 0.35) || + (botVitals.hpRatio < 0.55 && botVitals.mpRatio >= 0.25) + ); const rebuff = !partyThreat && !leaderTargetId && !isBusy(bot) ? BotSupportPlanner.rebuffRequest(bot, PartyPulling.supportProviders(playerSession)) : null; @@ -599,7 +696,7 @@ module.exports = { session.lastRebuffRequestAt = Date.now(); BotAI.say(session, `${rebuff.provider.fetchName()}, could you refresh ${rebuff.skill.fetchName()}?`); } - if (!acted && supportBuffTarget) { + if (!acted && supportBuffTarget && !healerNeedsAction) { const activeMobs = partyAggroCount(playerSession); if (unsafeSupportMoment(bot, activeMobs)) { recordRoleDecision(session, bot, 'buff_party', 'wait_for_safe_moment', { @@ -623,6 +720,7 @@ module.exports = { // Attack.remoteHit once the native cast has actually started. BotSupportPlanner.queueSupportCast(session, supportBuffTarget); castSkillOn(session, bot, Generics, supportBuffTarget.target, supportBuffTarget.skill.fetchSelfId(), false); + returnToPartyAfterSupport(session, bot, player, supportBuffTarget.target); recordRoleDecision(session, bot, 'buff_party', supportBuffTarget.effect, { buff: supportBuffTarget.effect, skillId: supportBuffTarget.skill.fetchSelfId(), @@ -672,32 +770,30 @@ module.exports = { BotAI.say(session, text); } - if (role === 'healer') { - const skill = BotSkillCapabilities.healSkill(bot); - const canCast = !!skill && bot.fetchMp() >= skill.fetchConsumedMp() && !isBusy(bot) && !impairments.silenced; - const woundedPartyMember = weakestPartyMember(playerSession, bot, pulling.puller?.actor); - - if (woundedPartyMember?.hpRatio < 0.45 && canCast) { + if (!acted && role === 'healer') { + if (woundedPartyMember?.hpRatio < 0.45 && healerCanCast) { acted = true; recordRoleDecision(session, bot, 'heal_party', 'emergency_heal', { targetId: woundedPartyMember.actor.fetchId() }); - castSkillOn(session, bot, Generics, woundedPartyMember.actor, skill.fetchSelfId(), false); + castSkillOn(session, bot, Generics, woundedPartyMember.actor, healerSkill.fetchSelfId(), false); + returnToPartyAfterSupport(session, bot, player, woundedPartyMember.actor); if (Math.random() < 0.15) { BotAI.say(session, "Emergency heal on " + woundedPartyMember.actor.fetchName() + "!"); } - } else if (woundedPartyMember?.hpRatio < 0.70 && botVitals.mpRatio >= 0.35 && canCast) { + } else if (woundedPartyMember?.hpRatio < 0.70 && botVitals.mpRatio >= 0.35 && healerCanCast) { acted = true; recordRoleDecision(session, bot, 'heal_party', 'top_off', { targetId: woundedPartyMember.actor.fetchId() }); - castSkillOn(session, bot, Generics, woundedPartyMember.actor, skill.fetchSelfId(), false); + castSkillOn(session, bot, Generics, woundedPartyMember.actor, healerSkill.fetchSelfId(), false); + returnToPartyAfterSupport(session, bot, player, woundedPartyMember.actor); if (Math.random() < 0.15) { BotAI.say(session, "Healing " + woundedPartyMember.actor.fetchName() + "!"); } } else if (woundedPartyMember?.hpRatio < 0.70 && botVitals.mpRatio < 0.35) { recordRoleDecision(session, bot, 'save_mp', woundedPartyMember.hpRatio < 0.45 ? 'low_mp_emergency' : 'party_not_critical'); keepRoleDecision = true; - } else if (botVitals.hpRatio < 0.55 && botVitals.mpRatio >= 0.25 && canCast) { + } else if (botVitals.hpRatio < 0.55 && botVitals.mpRatio >= 0.25 && healerCanCast) { acted = true; recordRoleDecision(session, bot, 'heal_self', 'self_preservation', { targetId: bot.fetchId() }); - castSkillOn(session, bot, Generics, bot, skill.fetchSelfId(), false); + castSkillOn(session, bot, Generics, bot, healerSkill.fetchSelfId(), false); if (Math.random() < 0.15) { BotAI.say(session, "Healing myself!"); } @@ -707,7 +803,7 @@ module.exports = { } else if (botVitals.mpRatio < 0.25) { recordRoleDecision(session, bot, 'save_mp', 'low_mp'); keepRoleDecision = true; - } else if (!skill && woundedPartyMember?.hpRatio < 0.70) { + } else if (!healerSkill && woundedPartyMember?.hpRatio < 0.70) { recordRoleDecision(session, bot, 'cannot_heal', 'no_learned_heal'); keepRoleDecision = true; } @@ -830,10 +926,11 @@ module.exports = { } if (!isBusy(bot)) { + const basicAttackOnly = role === 'healer' || role === 'buffer'; if (partyThreat.type === 'player') { - BotAI.executePvPCombat(session, bot, target, Generics); + BotAI.executePvPCombat(session, bot, target, Generics, { basicAttackOnly }); } else { - BotAI.executeCombat(session, bot, target, Generics); + BotAI.executeCombat(session, bot, target, Generics, { basicAttackOnly }); } } acted = true; @@ -874,7 +971,9 @@ module.exports = { if (isBusy(bot)) { return; } - BotAI.executePvPCombat(session, bot, user, Generics); + BotAI.executePvPCombat(session, bot, user, Generics, { + basicAttackOnly: role === 'healer' || role === 'buffer' + }); } else { if (session.currentTargetId === playerTargetId) { session.currentTargetId = undefined; @@ -906,7 +1005,9 @@ module.exports = { if (isBusy(bot)) { return; } - BotAI.executeCombat(session, bot, npc, Generics); + BotAI.executeCombat(session, bot, npc, Generics, { + basicAttackOnly: role === 'healer' || role === 'buffer' + }); } else { if (session.currentTargetId === playerTargetId) { session.currentTargetId = undefined; diff --git a/src/GameServer/Bot/BotAI.js b/src/GameServer/Bot/BotAI.js index 9d3073bc..2011fb81 100644 --- a/src/GameServer/Bot/BotAI.js +++ b/src/GameServer/Bot/BotAI.js @@ -397,6 +397,12 @@ const BotAI = { rebuildWindow: false, refreshPanel: false }); + // A corpse that timed out of party resurrection has + // just been sent to town. Keep the now-solo bot hot + // long enough to complete that visible transition; + // otherwise population policy can remove it in the + // same scheduler pass before the client sees town. + session.populationHotAt = Date.now(); session.plan = 'hunting'; session.currentSpot = null; session.noTargetTicks = 0; @@ -447,6 +453,18 @@ const BotAI = { BotEquipmentUpgrade.applyBestUpgrades(session); + // Ground drops belong to the party, not to a particular movement + // plan. Reconcile them before routing follow/hold/rest/pull states so + // idle companions can collect available loot in every party stance. + // PartyCompanionService itself blocks real combat and incoming adds. + if (isCompanion) { + PartyCompanionService.reconcileGroundLoot(session); + if (PartyCompanionService.startQueuedGroundPickup(session)) { + session.botStatus = BotStatus.getStatus(session); + return; + } + } + // 3. Dynamic State Machine Routing const state = States[session.plan]; if (state) { @@ -461,14 +479,17 @@ const BotAI = { } }, - executePvPCombat(session, bot, victim, Generics) { - this.executeCombat(session, bot, victim, Generics); + executePvPCombat(session, bot, victim, Generics, options = {}) { + this.executeCombat(session, bot, victim, Generics, options); }, - executeCombat(session, bot, npc, Generics) { + executeCombat(session, bot, npc, Generics, options = {}) { const role = BotRoles.inferRole(bot); const ARCHER_ATTACK_RANGE = 700; - const decision = BotCombatUtility.select(bot, npc, role); + // Healers and buffers may assist the party with their weapon, but + // their role controller must be able to keep their MP for support. + // Do not make that policy depend on the generic combat selector. + const decision = options.basicAttackOnly ? null : BotCombatUtility.select(bot, npc, role); if (decision) { session.lastCombatDecision = { action: 'cast_skill', diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js index 54faa856..72d43b34 100644 --- a/src/GameServer/Bot/BotManager.js +++ b/src/GameServer/Bot/BotManager.js @@ -655,10 +655,15 @@ const BotManager = { awardBaseGear(id, classId) { const items = DataCache.newbieItems.find(ob => ob.classId === classId)?.items ?? []; + const hasTwoHandedWeapon = items.some((item) => { + const template = DataCache.items.find((entry) => entry.selfId === item.selfId); + return Number(template?.etc?.slot || 0) === 14 && + String(template?.template?.kind || '').startsWith('Weapon.'); + }); items.forEach((item) => { item.slot = DataCache.items.find(ob => ob.selfId === item.selfId)?.etc?.slot ?? 0; // Equip weapons/armors automatically for bots - item.equipped = true; + item.equipped = !(hasTwoHandedWeapon && Number(item.slot) === 8); Database.setItem(id, item); }); }, diff --git a/src/GameServer/Bot/Population/GeneratedColdSeeder.js b/src/GameServer/Bot/Population/GeneratedColdSeeder.js index d8cd84d2..f87627d3 100644 --- a/src/GameServer/Bot/Population/GeneratedColdSeeder.js +++ b/src/GameServer/Bot/Population/GeneratedColdSeeder.js @@ -171,6 +171,11 @@ function migratePopulationNames(states = []) { function awardBaseGear(characterId, classId) { const items = DataCache.newbieItems.find((row) => row.classId === classId)?.items || []; + const starterTemplates = items.map((item) => DataCache.items.find((row) => row.selfId === item.selfId)); + const hasTwoHandedWeapon = starterTemplates.some((template) => ( + Number(template?.etc?.slot || 0) === 14 && + String(template?.template?.kind || '').startsWith('Weapon.') + )); return Database.fetchItems(characterId).then((existing) => { const existingIds = new Set((existing || []).map((item) => Number(item.selfId))); return Promise.all(items @@ -180,7 +185,11 @@ function awardBaseGear(characterId, classId) { return Database.setItem(characterId, { ...item, slot: template?.etc?.slot || 0, - equipped: true + // Bows and other slot-14 weapons occupy both hands. + // Newbie templates may still grant a Buckler, but it + // must remain in inventory until the two-handed weapon + // is replaced. + equipped: !(hasTwoHandedWeapon && Number(template?.etc?.slot || 0) === 8) }); })); }); diff --git a/src/GameServer/Network/Shared.js b/src/GameServer/Network/Shared.js index 089b1061..29363bda 100644 --- a/src/GameServer/Network/Shared.js +++ b/src/GameServer/Network/Shared.js @@ -43,6 +43,17 @@ const Shared = { } }); + // Slot 14 is a two-handed weapon in the C4 + // paperdoll. Older starter-bot rows could retain a + // shield in slot 8 as well, which grants impossible + // shield stats and renders an invalid character. + if (equippedBySlot.has(14) && equippedBySlot.has(8)) { + const shield = equippedBySlot.get(8); + shield.equipped = 0; + repaired.push(shield); + equippedBySlot.delete(8); + } + Promise.all(repaired.map((item) => Database.updateItemEquipState(character.id, item.id, false, item.slot))) .catch((error) => utils.infoWarn('Character', 'failed to repair equipment state for %s: %s', character.name, error.message)); diff --git a/tests/test_bot_chat_commands.js b/tests/test_bot_chat_commands.js index cf401f74..79403bf7 100644 --- a/tests/test_bot_chat_commands.js +++ b/tests/test_bot_chat_commands.js @@ -164,7 +164,7 @@ try { assert.deepStrictEqual(tankReplies, [], 'a bot without friendly support skills must ignore a direct buff request'); BotManager.botTell = originalBotTell; - const buffer = fakeActor(2000008, 'MageWithBuff', { classId: 25, mp: 30, locX: 100 }); + const buffer = fakeActor(2000008, 'FighterWithBuff', { classId: 0, mp: 40, locX: 100 }); buffer.skillset.skills.push(new SkillModel({ selfId: 1068, name: 'Might', @@ -187,7 +187,7 @@ try { id: player.fetchId(), selfId: 1068, ctrl: false - }, 'any bot with a learned friendly buff should cast it, regardless of role'); + }, 'a bot with a learned friendly buff should cast it for an eligible role'); supportReplies.length = 0; EffectStore.apply(player, { @@ -202,8 +202,8 @@ try { EffectStore.remove(player, 'might'); player.supportReservations = {}; - const lowerMpBuffer = fakeActor(2000009, 'LowerMpBuffer', { classId: 25, mp: 20, locX: 100 }); - const higherMpBuffer = fakeActor(2000010, 'HigherMpBuffer', { classId: 25, mp: 50, locX: 100 }); + const lowerMpBuffer = fakeActor(2000009, 'LowerMpBuffer', { classId: 0, mp: 40, locX: 100 }); + const higherMpBuffer = fakeActor(2000010, 'HigherMpBuffer', { classId: 0, mp: 50, locX: 100 }); [lowerMpBuffer, higherMpBuffer].forEach((caster) => caster.skillset.skills.push(new SkillModel({ selfId: 1068, name: 'Might', level: 1, passive: false, spell: true, hp: 0, mp: 10, hitTime: 1000, reuse: 1000, power: 0, distance: 600 }))); diff --git a/tests/test_bot_combat_skill_selection.js b/tests/test_bot_combat_skill_selection.js index 1c8060a8..abf1e2e8 100644 --- a/tests/test_bot_combat_skill_selection.js +++ b/tests/test_bot_combat_skill_selection.js @@ -88,6 +88,13 @@ try { BotAI.executeCombat({}, swordFighter, npc(1111), swordFighterGenerics); assert.strictEqual(swordFighterGenerics.skills[0].selfId, 3, 'a sword fighter must not prepare Power Shot and should use its valid melee skill'); + const cooldownFighter = bot(0, [skill(3, { name: 'Power Strike', mp: 5, range: 40, power: 30 })], 100, 'Weapon.Sword'); + cooldownFighter.canUseSkill = () => false; + const cooldownFighterGenerics = generics(); + BotAI.executeCombat({}, cooldownFighter, npc(1113), cooldownFighterGenerics); + assert.strictEqual(cooldownFighterGenerics.skills.length, 0, 'a melee skill on reuse must not be selected again'); + assert.strictEqual(cooldownFighterGenerics.attacks.length, 1, 'a melee bot must use its normal attack while its offensive skill is on reuse'); + const fighter = bot(0, [], 20); const fighterGenerics = generics(); BotAI.executeCombat({}, fighter, npc(1103), fighterGenerics); @@ -149,6 +156,12 @@ try { assert.strictEqual(reserveGenerics.skills.length, 0, 'healer should preserve support MP instead of casting an expensive nuke'); assert.strictEqual(reserveGenerics.attacks.length, 1, 'healer with no affordable utility should use a basic attack'); + const supportingHealer = bot(15, [skill(1301, { mp: 5, power: 20, spell: true })], 100); + const supportingGenerics = generics(); + BotAI.executeCombat({}, supportingHealer, npc(1112), supportingGenerics, { basicAttackOnly: true }); + assert.strictEqual(supportingGenerics.skills.length, 0, 'a hot-party healer ordered to conserve MP must not use an offensive spell'); + assert.strictEqual(supportingGenerics.attacks.length, 1, 'a hot-party healer may still contribute a normal weapon attack'); + const dagger = bot(7, [ skill(1400, { mp: 5, power: 30, range: 40 }), skill(1401, { mp: 5, power: 25, range: 40, type: C4SkillRules.BLOW }) diff --git a/tests/test_bot_gear_acquisition.js b/tests/test_bot_gear_acquisition.js index 5897cb5f..6308753c 100644 --- a/tests/test_bot_gear_acquisition.js +++ b/tests/test_bot_gear_acquisition.js @@ -101,6 +101,14 @@ const equippedUpgrade = GearAcquisitionPlanner.equipInventoryUpgrades({ level: 2 }); assert.strictEqual(equippedUpgrade[entryDSword.selfId].equipped, true, 'a useful D drop must equip immediately in the cold inventory'); assert.strictEqual(equippedUpgrade[noGradeSword.selfId].equipped, false, 'the replaced no-grade weapon must be unequipped'); +const starterBow = DataCache.items.find((item) => Number(item.selfId) === 274); +const starterShield = DataCache.items.find((item) => Number(item.selfId) === 20); +const bowAndShieldInventory = GearAcquisitionPlanner.equipInventoryUpgrades({ level: 24, stats: { role: 'archer' } }, { + [starterBow.selfId]: { selfId: starterBow.selfId, amount: 1, equipped: true, slot: 14 }, + [starterShield.selfId]: { selfId: starterShield.selfId, amount: 1, equipped: true, slot: 8 } +}); +assert.strictEqual(bowAndShieldInventory[starterBow.selfId].equipped, true, 'the cold inventory should retain its two-handed bow'); +assert.strictEqual(bowAndShieldInventory[starterShield.selfId].equipped, false, 'the cold inventory must unequip a shield when a two-handed bow is equipped'); const entryDTarget = GearAcquisitionPlanner.preferredTarget({ level: 20, stats: { classId: 0, role: 'dps' }, inventory: {} }); assert(entryDTarget, 'a new D-grade bot must receive an attainable equipment target'); assert(Number(entryDTarget.item.template.price) < Number(atubaMace.template.price), 'a fresh D-grade bot must not begin by chasing the top D weapon'); diff --git a/tests/test_bot_loot_etiquette.js b/tests/test_bot_loot_etiquette.js new file mode 100644 index 00000000..b4e6f557 --- /dev/null +++ b/tests/test_bot_loot_etiquette.js @@ -0,0 +1,41 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotLootEtiquette = invoke('GameServer/Bot/AI/BotLootEtiquette'); + +function actor(id, dead = false) { + return { + fetchId: () => id, + fetchLocX: () => 0, + fetchLocY: () => 0, + fetchLocZ: () => 0, + isDead: () => dead, + state: { fetchDead: () => dead } + }; +} + +const playerSession = { actor: actor(1) }; +const liveBotSession = { + actor: actor(2), + followPlayerSession: playerSession, + partyCompanion: true +}; +const deadBotSession = { + actor: actor(3, true), + followPlayerSession: playerSession, + partyCompanion: true +}; + +assert.strictEqual( + BotLootEtiquette.shouldRecordIgnoredRequest({ playerSession, botSession: liveBotSession }), + true, + 'an ignored request from a live companion should still affect social trust' +); +assert.strictEqual( + BotLootEtiquette.shouldRecordIgnoredRequest({ playerSession, botSession: deadBotSession }), + false, + 'a request that expires after the companion dies must not penalize the player' +); + +console.log('Bot loot etiquette checks passed'); diff --git a/tests/test_equipment_slots.js b/tests/test_equipment_slots.js index 187e5063..d4902174 100644 --- a/tests/test_equipment_slots.js +++ b/tests/test_equipment_slots.js @@ -88,6 +88,16 @@ try { assert.strictEqual(duplicateWeapons.fetchItemRaw(8).fetchEquipped(), false, 'unequipping a conflicted weapon slot must clear the visible weapon'); assert.strictEqual(duplicateWeapons.fetchPaperdollId(7), undefined, 'unequipping a conflicted weapon slot must clear paperdoll state'); + const bowAndShield = backpack([ + item(9, 274, 'Weapon.Bow', 14), + item(10, 20, 'Armor.Shield', 8) + ]); + const bowSession = sessionFor(bowAndShield); + bowAndShield.equipGear(bowSession, bowAndShield.fetchItemRaw(10)); + bowAndShield.equipGear(bowSession, bowAndShield.fetchItemRaw(9)); + assert.strictEqual(bowAndShield.fetchItemRaw(9).fetchEquipped(), true, 'the two-handed bow should be equipped'); + assert.strictEqual(bowAndShield.fetchItemRaw(10).fetchEquipped(), false, 'equipping a two-handed bow must remove the shield'); + console.log('Equipment slot checks passed'); } finally { ActorGenerics.calculateStats = originalCalculateStats; diff --git a/tests/test_party_bot_loot.js b/tests/test_party_bot_loot.js index d61cbd76..06afd263 100644 --- a/tests/test_party_bot_loot.js +++ b/tests/test_party_bot_loot.js @@ -3,6 +3,7 @@ const assert = require('assert'); require('../src/Global'); const NpcRewards = invoke('GameServer/World/Generics/NpcRewards'); +const World = invoke('GameServer/World/World'); const DataCache = invoke('GameServer/DataCache'); const ProgressionRates = invoke('GameServer/ProgressionRates'); const BotManager = invoke('GameServer/Bot/BotManager'); @@ -17,6 +18,9 @@ const originalScaleAmount = ProgressionRates.scaleAmount; const originalRandom = Math.random; const originalBotSessions = BotManager.sessions; const originalPickupExec = ActorGenerics.pickupExec; +const originalWorldItems = World.items; +const originalWorldUsers = World.user; +const originalFetchNpcsInRadius = World.fetchNpcsInRadius; try { DataCache.fetchNpcRewardsFromSelfId = (_id, callback) => callback({ @@ -134,6 +138,75 @@ try { assert.deepStrictEqual(pickupCalls[1] && { session: pickupCalls[1].session, actor: pickupCalls[1].actor, data: pickupCalls[1].data }, { session: botSession, actor: closestBot, data: { id: 500002 } }, 'a queued hot-bot pickup should execute server-side after combat instead of waiting for a client position packet'); pickupCalls[1].onComplete(); assert.deepStrictEqual(pickupCalls[2] && { session: pickupCalls[2].session, actor: pickupCalls[2].actor, data: pickupCalls[2].data }, { session: botSession, actor: closestBot, data: { id: 500003 } }, 'multiple drops assigned to the same bot should be picked up in FIFO order'); + pickupCalls[2].onComplete(); + + botSession.partyGroundPickupQueue = [{ id: 500007 }]; + leaderSession.actor.isDead = () => true; + assert.strictEqual(PartyCompanionService.startQueuedGroundPickup(botSession), false, 'queued loot must wait for a dead party member to be resurrected'); + assert.strictEqual(pickupCalls.length, 3, 'a pending resurrection must preempt queued loot'); + leaderSession.actor.isDead = () => false; + leaderSession.partyCompanionSettings = { distribution: 1, pullMode: 'bot', pullerId: closestBot.fetchId() }; + assert.strictEqual(PartyCompanionService.startQueuedGroundPickup(botSession), false, 'a bot newly assigned as puller must not execute stale queued loot before its first pull tick'); + botSession.partyGroundPickupQueue = []; + leaderSession.partyCompanionSettings = { distribution: 1 }; + leaderSession.partyPullState = {}; + + // Loot reconciliation must not depend on the death that produced the + // item. A pre-existing drop is still party loot once the group is idle. + World.items = { + spawns: [{ + fetchId: () => 500004, + fetchLocX: () => 130, + fetchLocY: () => 200, + fetchLocZ: () => -310 + }] + }; + PartyCompanionService.reconcileGroundLoot(botSession); + assert.deepStrictEqual(pickupCalls[3] && { session: pickupCalls[3].session, actor: pickupCalls[3].actor, data: pickupCalls[3].data }, { session: botSession, actor: closestBot, data: { id: 500004 } }, 'an idle hot party should collect reachable loot that was already lying on the ground'); + pickupCalls[3].onComplete(); + + // A returning puller is travelling, not fighting at camp. It must not + // block recovery of an older drop that another companion can collect. + closestBot.state.combat = true; + leaderSession.partyPullState = { phase: 'return', pullerId: closestBot.fetchId() }; + World.items = { + spawns: [{ + fetchId: () => 500005, + fetchLocX: () => 130, + fetchLocY: () => 200, + fetchLocZ: () => -310 + }] + }; + leaderSession.lastGroundLootScanAt = 0; + PartyCompanionService.reconcileGroundLoot(botSession); + assert.deepStrictEqual(pickupCalls[4] && { session: pickupCalls[4].session, actor: pickupCalls[4].actor, data: pickupCalls[4].data }, { session: distantBotSession, actor: distantBot, data: { id: 500005 } }, 'a distant return pull should let another companion collect old loot without interrupting the puller'); + pickupCalls[4].onComplete(); + + // An NPC already targeting the party is combat even before a companion + // has started its own hit/cast animation; loot must not delay defense. + closestBot.state.combat = false; + leaderSession.partyPullState = {}; + const incomingThreat = { + fetchId: () => 800001, + fetchAttackable: () => true, + isDead: () => false, + fetchDestId: () => leaderSession.actor.fetchId(), + fetchLocX: () => 130, + fetchLocY: () => 200 + }; + World.user = { sessions: [leaderSession, botSession, distantBotSession] }; + World.fetchNpcsInRadius = () => [incomingThreat]; + World.items = { + spawns: [{ + fetchId: () => 500006, + fetchLocX: () => 130, + fetchLocY: () => 200, + fetchLocZ: () => -310 + }] + }; + leaderSession.lastGroundLootScanAt = 0; + PartyCompanionService.reconcileGroundLoot(botSession); + assert.strictEqual(pickupCalls.length, 5, 'an incoming NPC threat must block ground pickup before party members start their own combat action'); } finally { DataCache.fetchNpcRewardsFromSelfId = originalRewards; ProgressionRates.rollGroup = originalRollGroup; @@ -142,6 +215,9 @@ try { Math.random = originalRandom; BotManager.sessions = originalBotSessions; ActorGenerics.pickupExec = originalPickupExec; + World.items = originalWorldItems; + World.user = originalWorldUsers; + World.fetchNpcsInRadius = originalFetchNpcsInRadius; } console.log('Party bot loot checks passed'); diff --git a/tests/test_party_companion_rest_follow.js b/tests/test_party_companion_rest_follow.js index ae3e155e..30f8929b 100644 --- a/tests/test_party_companion_rest_follow.js +++ b/tests/test_party_companion_rest_follow.js @@ -15,6 +15,7 @@ const BotStatus = invoke('GameServer/Bot/AI/BotStatus'); const BotBrainContext = invoke('GameServer/Bot/AI/BotBrainContext'); const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); const PartyPulling = invoke('GameServer/Bot/AI/PartyPulling'); +const PartyAwareness = invoke('GameServer/Bot/AI/PartyAwareness'); const MarketOpportunity = invoke('GameServer/Bot/Economy/MarketOpportunity'); const CompanionControl = invoke('GameServer/World/Generics/NpcBypasses/CompanionControl'); const EffectStore = invoke('GameServer/Effects/EffectStore'); @@ -435,6 +436,13 @@ try { fetchLocY: () => 0 }]; + assert.strictEqual(PartyAwareness.partySessions(leaderSession).length, 2, 'party awareness should include the leader and resting companion'); + assert.strictEqual( + PartyAwareness.findThreatTargetingParty(leaderSession)?.actor?.fetchId?.(), + 1001, + 'party awareness should expose an NPC that targets the party leader' + ); + RestingState.tick(restingSession, restingBot, {}, { say() {} }); assert.strictEqual(restingSession.plan, 'following', 'resting companion should wake when party is attacked'); @@ -674,6 +682,7 @@ try { const healerLeaderSession = fakeSession('player_healer_party', healerLeader); const healerBot = fakeActor(2000025, { locX: 80, locY: 0, classId: 15 }); learnSkill(healerBot, { selfId: 1011, name: 'Heal', spell: true, mp: 15 }); + learnSkill(healerBot, { selfId: 1040, name: 'Shield', spell: true, mp: 10 }); const healerSession = fakeSession('bot_healer_party', healerBot); healerSession.followPlayerSession = healerLeaderSession; healerSession.partyCompanion = true; @@ -685,15 +694,43 @@ try { woundedCompanionSession.plan = 'following'; World.user = { sessions: [healerLeaderSession, healerSession, woundedCompanionSession] }; World.fetchNpcsInRadius = () => []; - let healedTargetId = null; + const healerCasts = []; FollowingState.tick(healerSession, healerBot, { - skillExec(session, bot, data) { healedTargetId = data.id; } + skillExec(session, bot, data) { healerCasts.push(data); } }, { say() {}, executeCombat() {}, executePvPCombat() {} }); - assert.strictEqual(healedTargetId, woundedCompanion.fetchId(), 'healer should heal the wounded companion, not only the leader'); + assert.deepStrictEqual(healerCasts, [{ id: woundedCompanion.fetchId(), selfId: 1011, ctrl: false }], 'an emergency heal must preempt a normal party buff instead of issuing two casts in one tick'); assert.strictEqual(healerSession.roleDecision.action, 'heal_party', 'healer role decision should be party-wide'); + const healerAssistBot = fakeActor(2000027, { locX: 120, locY: 0, classId: 15 }); + const healerAssistSession = fakeSession('bot_healer_basic_assist', healerAssistBot); + healerAssistSession.followPlayerSession = healerLeaderSession; + healerAssistSession.partyCompanion = true; + healerAssistSession.plan = 'following'; + const healerAssistThreat = { + fetchId: () => 1012, + fetchAttackable: () => true, + isDead: () => false, + fetchDestId: () => healerLeader.fetchId(), + fetchLocX: () => 100, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchName: () => 'healer assist threat' + }; + let healerAssistOptions = null; + World.user = { sessions: [healerLeaderSession, healerAssistSession] }; + World.npc = { spawns: [healerAssistThreat] }; + World.fetchNpcsInRadius = () => [healerAssistThreat]; + FollowingState.tick(healerAssistSession, healerAssistBot, {}, { + say() {}, + executeCombat(_session, _bot, _npc, _generics, options) { healerAssistOptions = options; }, + executePvPCombat() {} + }); + assert.strictEqual(healerAssistOptions?.basicAttackOnly, true, 'a healer assisting the party must be restricted to a no-MP basic attack'); + World.npc = { spawns: [] }; + World.fetchNpcsInRadius = () => []; + const unskilledHealer = fakeActor(2000033, { locX: 90, locY: 0, classId: 15 }); const unskilledHealerSession = fakeSession('bot_unskilled_healer', unskilledHealer); unskilledHealerSession.followPlayerSession = healerLeaderSession; @@ -873,6 +910,50 @@ try { assert.strictEqual(marketSession.shoppingTarget?.actorId, marketSeller.fetchId(), 'companion market errand should walk to the live seller'); MarketOpportunity.findOffers = originalFindOffers; + const starterTownLeader = fakeActor(2000046, { locX: 45475, locY: 48359, locZ: -3060 }); + const starterTownLeaderSession = fakeSession('player_elven_town_errand_party', starterTownLeader); + const starterTownSeller = fakeActor(2000047, { locX: 45520, locY: 48359, locZ: -3060 }); + const starterTownBot = fakeActor(2000048, { locX: 45500, locY: 48359, locZ: -3060 }); + const starterTownSession = fakeSession('bot_elven_town_market_errand', starterTownBot); + starterTownSession.followPlayerSession = starterTownLeaderSession; + starterTownSession.partyCompanion = true; + starterTownSession.plan = 'following'; + starterTownSession.coldLifeState = { stats: { equipmentPlan: { strategy: 'market', target: { selfId: 1 } } } }; + MarketOpportunity.findOffers = () => ([{ + sourceType: 'private_store', sourceId: starterTownSeller.fetchId(), itemName: 'Sword of Reflection', price: 0, + town: 'Elven Village', session: { accountId: 'bot_elven_market_seller', actor: starterTownSeller } + }]); + World.user = { sessions: [starterTownLeaderSession, starterTownSession, { accountId: 'seller', actor: starterTownSeller }] }; + World.fetchNpcsInRadius = () => []; + FollowingState.tick(starterTownSession, starterTownBot, {}, { + getClosestNewbieGuide: () => ({ locX: 45475, locY: 48359, locZ: -3060 }), + getClosestTown: () => ({ name: 'Elven Village', x: 46926, y: 51511, z: -2976 }), + say() {}, executeCombat() {}, executePvPCombat() {} + }); + assert.strictEqual(starterTownSession.companionShopping?.kind, 'market_purchase', 'a starter village outside the movement atlas must still allow normal in-town errands'); + MarketOpportunity.findOffers = originalFindOffers; + + const fieldNearStarterLeader = fakeActor(2000051, { locX: 49475, locY: 48359, locZ: -3060 }); + const fieldNearStarterLeaderSession = fakeSession('player_near_elven_field_party', fieldNearStarterLeader); + const fieldNearStarterBot = fakeActor(2000052, { locX: 49500, locY: 48359, locZ: -3060 }); + const fieldNearStarterSession = fakeSession('bot_near_elven_field_market', fieldNearStarterBot); + fieldNearStarterSession.followPlayerSession = fieldNearStarterLeaderSession; + fieldNearStarterSession.partyCompanion = true; + fieldNearStarterSession.plan = 'following'; + fieldNearStarterSession.coldLifeState = { stats: { equipmentPlan: { strategy: 'market', target: { selfId: 1 } } } }; + MarketOpportunity.findOffers = () => ([{ + sourceType: 'private_store', sourceId: starterTownSeller.fetchId(), itemName: 'Sword of Reflection', price: 0, + town: 'Elven Village', session: { accountId: 'bot_elven_market_seller', actor: starterTownSeller } + }]); + World.user = { sessions: [fieldNearStarterLeaderSession, fieldNearStarterSession, { accountId: 'seller', actor: starterTownSeller }] }; + FollowingState.tick(fieldNearStarterSession, fieldNearStarterBot, {}, { + getClosestNewbieGuide: () => ({ locX: 45475, locY: 48359, locZ: -3060 }), + getClosestTown: () => ({ name: 'Elven Village', x: 46926, y: 51511, z: -2976 }), + say() {}, executeCombat() {}, executePvPCombat() {} + }); + assert.notStrictEqual(fieldNearStarterSession.companionShopping?.kind, 'market_purchase', 'a nearby farming field must not be treated as a starter village market'); + MarketOpportunity.findOffers = originalFindOffers; + World.user = { sessions: [bufferLeaderSession, bufferSession, unbuffedCompanionSession] }; const compactPartyStatus = BotBrainContext.compactStatus( @@ -936,6 +1017,13 @@ try { [partyHudBotA, partyHudBotB], 'the human leader must be a buff recipient, not an autonomous support provider' ); + const supportPullTarget = fakeActor(2000049, { locX: 0, locY: 0 }); + const supportPuller = fakeActor(2000050, { locX: 0, locY: 0, classId: 15 }); + assert.strictEqual( + PartyPulling.canDeliverPull(supportPuller, supportPullTarget), + true, + 'a healer in weapon range must release a player-led pull when it is the only companion able to engage' + ); partyHudBotASession.lastTargetEvaluation = { targetId: 9001, targetName: 'Keltir', @@ -1195,11 +1283,13 @@ try { assert.strictEqual(partyHudBotASession.roleDecision.reason, 'return', 'puller should return to the leader after aggro is confirmed'); const returnMoves = partyHudBotA.moves.length; partyHudBotA.state.setTowards('move'); + partyHudLeader.locX = 500; FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { say() {}, executeCombat() {}, executePvPCombat() {} }); - assert.strictEqual(partyHudBotA.moves.length, returnMoves, 'an active pull return must keep its current route instead of restarting pathfinding every AI tick'); + assert.strictEqual(partyHudBotA.moves.length, returnMoves, 'an active pull return must finish its route even when the leader moves, instead of snapping back to a replanned path'); partyHudBotA.state.setTowards(false); + partyHudLeader.locX = 0; pulledMob.locX = 700; FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { @@ -1300,6 +1390,39 @@ try { assert(!companionHtml.includes('bgcolor=222222'), 'party control panel should avoid the flat grey panel background'); assert(!companionHtml.includes('bgcolor=333333'), 'companion cards should avoid the flat grey card background'); + const activeAdd = { + fetchId: () => 3012, + fetchAttackable: () => true, + isDead: () => false, + fetchLevel: () => 26, + fetchDestId: () => partyHudLeader.fetchId(), + fetchLocX: () => 300, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchName: () => 'active add' + }; + const freePullTarget = { + ...pulledMob, + id: 3013, + destId: undefined, + fetchId() { return this.id; }, + fetchDestId() { return this.destId; }, + fetchName: () => 'next pull target' + }; + World.npc = { spawns: [activeAdd, freePullTarget] }; + World.fetchNpcsInRadius = () => [activeAdd, freePullTarget]; + partyHudBotA.moves = []; + partyHudLeaderSession.partyPullState = {}; + CompanionControl(partyHudLeaderSession, ['companion-control', 'member-pull', 'on', partyHudBotA.fetchName()]); + FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { + say() {}, executeCombat() { throw new Error('puller must not start a new pull while the party is fighting an add'); }, executePvPCombat() {} + }); + assert.strictEqual(partyHudBotASession.roleDecision.reason, 'party_under_attack', 'an unrelated incoming threat must pause bot pulling'); + assert.strictEqual(partyHudLeaderSession.partyPullState.targetId, undefined, 'a live party threat must not be replaced with a new pull target'); + assert.strictEqual(partyHudBotA.moves.length, 0, 'a paused puller must stay with the party during an active fight'); + World.npc = { spawns: [pulledMob] }; + World.fetchNpcsInRadius = () => [pulledMob]; + CompanionControl(partyHudLeaderSession, ['companion-control', 'member-pull', 'on', partyHudBotA.fetchName()]); assert.strictEqual(PartyCompanionService.getSettings(partyHudLeaderSession).pullMode, 'bot', 'selected companion should remain the explicit puller until its status changes'); let cancelledPullCast = 0; diff --git a/tests/test_party_revival.js b/tests/test_party_revival.js index f9879de6..72645500 100644 --- a/tests/test_party_revival.js +++ b/tests/test_party_revival.js @@ -101,6 +101,12 @@ try { const combatHeldResult = PartyRevivalService.tick(healerSession, leaderSession, { skillExec() {} }); assert.strictEqual(combatHeldResult.handled, false, 'a monster still fighting a fallen party member must block resurrection'); World.npc.spawns = []; + healer.state.fetchCombats = () => true; + healer.state.fetchHits = () => true; + + const activeActionHeldResult = PartyRevivalService.tick(healerSession, leaderSession, { skillExec() {} }); + assert.strictEqual(activeActionHeldResult.handled, false, 'a living companion still executing a hit must block resurrection even before the NPC target state is visible'); + healer.state.fetchHits = () => false; let skillCast = null; const skillResult = PartyRevivalService.tick(healerSession, leaderSession, { @@ -109,6 +115,7 @@ try { assert.strictEqual(skillResult.source, 'skill', 'a learned Resurrection skill must take priority over the unlimited scroll'); assert.strictEqual(skillCast[2].selfId, 1016, 'party resurrection should use the healer\'s learned Resurrection skill'); assert.strictEqual(skillCast[2].id, leader.fetchId(), 'party resurrection should target the first fallen member'); + healer.state.fetchCombats = () => false; leaderSession.partyRevivalAttempt = null; healer.skillset.skills = []; From d1e31fa442e971a376f4263a8ff32f2b4eb863fb Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:40:37 -0400 Subject: [PATCH 09/13] Improve party pull opening attacks --- src/GameServer/Bot/AI/BotBuffs.js | 4 +++- src/GameServer/Bot/AI/PartyPulling.js | 13 ++++------- tests/test_party_companion_rest_follow.js | 28 +++++++++++++++++++++-- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/GameServer/Bot/AI/BotBuffs.js b/src/GameServer/Bot/AI/BotBuffs.js index 80f23c5b..d0d1e950 100644 --- a/src/GameServer/Bot/AI/BotBuffs.js +++ b/src/GameServer/Bot/AI/BotBuffs.js @@ -6,6 +6,7 @@ const C4SkillRules = invoke('GameServer/Skills/C4SkillRules'); const BUFF_DURATION_MS = 20 * 60 * 1000; const REFRESH_THRESHOLD_MS = 2 * 60 * 1000; +const NEWBIE_GUIDE_MAX_LEVEL = 20; const ALL_BUFFS = BuffCatalog.ALL_BUFFS; const NEWBIE_BUFF_TYPES = ['windwalk', 'shield', 'haste']; @@ -41,7 +42,7 @@ function effectData(buff) { } function isNewbieEligible(actor) { - return actor && actor.fetchLevel() <= 25 && actor.fetchKarma() === 0; + return actor && actor.fetchLevel() <= NEWBIE_GUIDE_MAX_LEVEL && actor.fetchKarma() === 0; } function remainingMs(actor, key) { @@ -173,6 +174,7 @@ function snapshot(actor) { module.exports = { BUFF_DURATION_MS, REFRESH_THRESHOLD_MS, + NEWBIE_GUIDE_MAX_LEVEL, ALL_BUFFS, NEWBIE_BUFFS, SUPPORT_BUFFS, diff --git a/src/GameServer/Bot/AI/PartyPulling.js b/src/GameServer/Bot/AI/PartyPulling.js index 10adac22..8d5e564d 100644 --- a/src/GameServer/Bot/AI/PartyPulling.js +++ b/src/GameServer/Bot/AI/PartyPulling.js @@ -1,7 +1,6 @@ const World = invoke('GameServer/World/World'); const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); const BotCombatUtility = invoke('GameServer/Bot/AI/BotCombatUtility'); -const BotSkillCapabilities = invoke('GameServer/Bot/AI/BotSkillCapabilities'); const BotSupportPlanner = invoke('GameServer/Bot/AI/BotSupportPlanner'); const PartyAwareness = invoke('GameServer/Bot/AI/PartyAwareness'); @@ -291,13 +290,11 @@ function tickBotPuller(session, bot, leaderSession, settings, Generics, BotAI) { bot.automation?.abortAll?.(bot); clearPullMove(state); bot.select({ id: target.fetchId() }); - const aggression = BotRoles.inferRole(bot) === 'tank' ? BotSkillCapabilities.aggressionSkill(bot) : null; - if (aggression && bot.fetchMp() >= aggression.fetchConsumedMp()) { - Generics.skillExec(session, bot, { id: target.fetchId(), selfId: aggression.fetchSelfId(), ctrl: true }); - } else { - BotAI.executeCombat(session, bot, target, Generics); - } - // AttackExec/SkillExec schedules the native hit asynchronously. Do + // The opening pull hit favors a reliable instant attack over damage or + // taunt. A failed/cooldown skill must not delay the mob starting to + // chase the puller. + BotAI.executeCombat(session, bot, target, Generics, { basicAttackOnly: true }); + // AttackExec schedules the native hit asynchronously. Do // not issue moveTo yet: MoveTo aborts automation and would cancel the // very hit that is supposed to put the mob into combat. state.phase = 'aggro'; diff --git a/tests/test_party_companion_rest_follow.js b/tests/test_party_companion_rest_follow.js index 30f8929b..e6036985 100644 --- a/tests/test_party_companion_rest_follow.js +++ b/tests/test_party_companion_rest_follow.js @@ -825,6 +825,20 @@ try { assert.notStrictEqual(fieldRefreshSession.plan, 'getting_buffed', 'companion should keep following until the player reaches a Newbie Guide town'); assert.strictEqual(fieldRefreshSession.roleDecision.reason, 'wait_for_newbie_guide_town', 'field companion should explain why it did not leave for a distant Newbie Guide'); + const overleveledRefreshBot = fakeActor(2000046, { locX: 80, locY: 0, level: 21 }); + Object.keys(overleveledRefreshBot.activeBuffs).forEach((key) => { overleveledRefreshBot.activeBuffs[key] = 0; }); + const overleveledRefreshSession = fakeSession('bot_overleveled_refresh_party', overleveledRefreshBot); + overleveledRefreshSession.followPlayerSession = fieldRefreshLeaderSession; + overleveledRefreshSession.partyCompanion = true; + overleveledRefreshSession.plan = 'following'; + World.user = { sessions: [fieldRefreshLeaderSession, overleveledRefreshSession] }; + FollowingState.tick(overleveledRefreshSession, overleveledRefreshBot, {}, { + getClosestNewbieGuide: () => ({ locX: -84081, locY: 243227, locZ: -3723 }), + say() {}, executeCombat() {}, executePvPCombat() {} + }); + assert.notStrictEqual(overleveledRefreshSession.roleDecision?.reason, 'wait_for_newbie_guide_town', 'companions above level 20 must not wait for Newbie Guide buffs'); + assert.notStrictEqual(overleveledRefreshSession.plan, 'getting_buffed', 'companions above level 20 must not start a Newbie Guide trip'); + const refreshLeader = fakeActor(2000034, { locX: -84081, locY: 243227, locZ: -3723, level: 10 }); const refreshLeaderSession = fakeSession('player_refresh_party', refreshLeader); const refreshBot = fakeActor(2000035, { locX: -84001, locY: 243227, locZ: -3723, level: 10 }); @@ -1195,6 +1209,7 @@ try { partyHudBotA.moves = []; partyHudBotB.moves = []; let pulledTargetId = null; + let openingPullCombatOptions = null; const pullChat = []; CompanionControl(partyHudLeaderSession, ['companion-control', 'member-pull', 'on', partyHudBotA.fetchName()]); @@ -1242,13 +1257,22 @@ try { ); partyHudLeader.locX = 0; + learnSkill(partyHudBotA, { selfId: 28, name: 'Aggression', mp: 5, distance: 400 }); partyHudBotA.locX = pulledMob.locX; FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { say(_session, text) { pullChat.push(text); }, - executeCombat(_session, _bot, npc) { pulledTargetId = npc.fetchId(); }, + executeCombat(_session, _bot, npc, _generics, options) { + pulledTargetId = npc.fetchId(); + openingPullCombatOptions = options; + }, executePvPCombat() {} }); - assert.strictEqual(pulledTargetId, pulledMob.fetchId(), 'tank should aggro with a normal attack when Aggression is not learned'); + assert.strictEqual(pulledTargetId, pulledMob.fetchId(), 'tank should aggro the pull target'); + assert.strictEqual( + openingPullCombatOptions?.basicAttackOnly, + true, + 'opening pull aggro must use a basic attack even when the tank knows Aggression' + ); assert(pullChat.some((text) => text.includes('pull target')), 'puller should announce the specific mob in party chat'); partyHudLeaderSession.partyPullState.aggroRequestedAt = Date.now() - 3000; From e389f9866f24efa54016b59f8a11649cb91ac922 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:40:46 -0400 Subject: [PATCH 10/13] Revive companions on leader town restart --- .../Network/Request/RestartPoint.js | 42 ++++++++++++++ tests/test_restart_point_revive.js | 58 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/src/GameServer/Network/Request/RestartPoint.js b/src/GameServer/Network/Request/RestartPoint.js index 83dfe303..be42e342 100644 --- a/src/GameServer/Network/Request/RestartPoint.js +++ b/src/GameServer/Network/Request/RestartPoint.js @@ -1,6 +1,46 @@ const ReceivePacket = invoke('Packet/Receive'); const ServerResponse = invoke('GameServer/Network/Response'); +const COMPANION_RESPAWN_OFFSETS = [ + { locX: 80, locY: 0 }, + { locX: -80, locY: 0 }, + { locX: 0, locY: 80 }, + { locX: 0, locY: -80 }, + { locX: 60, locY: 60 }, + { locX: -60, locY: -60 } +]; + +function reviveDeadCompanions(leaderSession, townRespawn, Generics, botManager = invoke('GameServer/Bot/BotManager')) { + const companions = (botManager.sessions || []).filter((companionSession) => ( + companionSession?.partyCompanion === true && + companionSession.followPlayerSession === leaderSession && + companionSession.actor?.isDead?.() === true + )); + + companions.forEach((companionSession, index) => { + const companion = companionSession.actor; + const offset = COMPANION_RESPAWN_OFFSETS[index % COMPANION_RESPAWN_OFFSETS.length]; + + // A leader's explicit town restart ends the field rescue attempt. Keep + // the party intact, but revive every fallen companion before moving it + // to the same town so none remains a corpse on the old hunting spot. + Generics.revive(companionSession, companion, { delayMs: 0, restoreFullVitals: true }); + companionSession.deathTimerStart = undefined; + companionSession.currentTargetId = undefined; + companionSession.incomingThreatId = undefined; + companionSession.incomingThreatAt = undefined; + companionSession.plan = 'following'; + companion.unselect?.(); + Generics.teleportTo(companionSession, companion, { + locX: townRespawn.locX + offset.locX, + locY: townRespawn.locY + offset.locY, + locZ: townRespawn.locZ + }); + }); + + return companions.length; +} + function restartPoint(session, buffer) { const packet = new ReceivePacket(buffer); @@ -30,7 +70,9 @@ function consume(session, data) { session.dataSendToMe(ServerResponse.userInfo(actor)); Generics.teleportTo(session, actor, townRespawn); + reviveDeadCompanions(session, townRespawn, Generics); } module.exports = restartPoint; module.exports.consume = consume; +module.exports.reviveDeadCompanions = reviveDeadCompanions; diff --git a/tests/test_restart_point_revive.js b/tests/test_restart_point_revive.js index 2a010453..0986f69b 100644 --- a/tests/test_restart_point_revive.js +++ b/tests/test_restart_point_revive.js @@ -7,6 +7,7 @@ const revive = invoke('GameServer/Actor/Generics/Revive'); const StateModel = invoke('GameServer/Model/State'); const EffectStore = invoke('GameServer/Effects/EffectStore'); const calculateStats = invoke('GameServer/Actor/Generics/CalculateStats'); +const RestartPoint = invoke('GameServer/Network/Request/RestartPoint'); const dyingActor = { state: new StateModel(), @@ -123,4 +124,61 @@ assert.strictEqual(packets.length, 2, 'town restart should send revive and stand assert.strictEqual(packets[0][0], 0x07, 'first packet should be Revive'); assert.strictEqual(packets[1][0], 0x2d, 'second packet should be SocialAction stand-up'); +function deadCompanion(id) { + return { + id, + unselected: false, + fetchId: () => id, + isDead: () => true, + unselect() { this.unselected = true; } + }; +} + +const leaderSession = { actor: { fetchId: () => 43 } }; +const fallenA = deadCompanion(44); +const fallenB = deadCompanion(45); +const aliveCompanion = { fetchId: () => 46, isDead: () => false }; +const fallenSessionA = { + actor: fallenA, + partyCompanion: true, + followPlayerSession: leaderSession, + plan: 'resting', + deathTimerStart: Date.now(), + currentTargetId: 999 +}; +const fallenSessionB = { + actor: fallenB, + partyCompanion: true, + followPlayerSession: leaderSession, + plan: 'resting', + deathTimerStart: Date.now(), + incomingThreatId: 1000 +}; +const aliveSession = { actor: aliveCompanion, partyCompanion: true, followPlayerSession: leaderSession }; +const unrelatedSession = { actor: deadCompanion(47), partyCompanion: true, followPlayerSession: { actor: {} } }; +const companionCalls = []; +const revivedCompanions = RestartPoint.reviveDeadCompanions(leaderSession, { + locX: 1000, + locY: 2000, + locZ: -3000 +}, { + revive(session, actor, options) { companionCalls.push({ type: 'revive', session, actor, options }); }, + teleportTo(session, actor, coords) { companionCalls.push({ type: 'teleport', session, actor, coords }); } +}, { + sessions: [fallenSessionA, fallenSessionB, aliveSession, unrelatedSession] +}); + +assert.strictEqual(revivedCompanions, 2, 'leader town restart should revive every dead companion in the same party'); +assert.deepStrictEqual(companionCalls.filter((call) => call.type === 'revive').map((call) => call.actor.fetchId()), [44, 45], 'only fallen companions should be revived'); +assert.deepStrictEqual(companionCalls.filter((call) => call.type === 'teleport').map((call) => call.coords), [ + { locX: 1080, locY: 2000, locZ: -3000 }, + { locX: 920, locY: 2000, locZ: -3000 } +], 'fallen companions should arrive beside the player town restart point'); +assert.strictEqual(fallenSessionA.plan, 'following', 'town-revived companion must remain in the party follow state'); +assert.strictEqual(fallenSessionA.deathTimerStart, undefined, 'town restart must clear the stale death timeout'); +assert.strictEqual(fallenSessionA.currentTargetId, undefined, 'town restart must clear stale combat targets'); +assert.strictEqual(fallenSessionB.incomingThreatId, undefined, 'town restart must clear stale incoming threats'); +assert.strictEqual(fallenA.unselected, true, 'town-revived companion must clear its old target'); +assert.strictEqual(aliveSession.plan, undefined, 'living companions must not be reset by a leader town restart'); + console.log('Restart point revive checks passed'); From 1f044245cd05a6615a634ea6615d367579e1ef5b Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:08:57 -0400 Subject: [PATCH 11/13] Revive companions on leader town restart --- src/GameServer/Actor/Generics/TeleportTo.js | 72 +++++++++++++++++++ src/GameServer/Bot/BotAI.js | 2 - .../Network/Request/RestartPoint.js | 42 ----------- tests/test_bot_death_respawn.js | 46 ++++++++++++ tests/test_restart_point_revive.js | 59 ++++++++++----- 5 files changed, 161 insertions(+), 60 deletions(-) diff --git a/src/GameServer/Actor/Generics/TeleportTo.js b/src/GameServer/Actor/Generics/TeleportTo.js index 0cf6b4bb..4903949a 100644 --- a/src/GameServer/Actor/Generics/TeleportTo.js +++ b/src/GameServer/Actor/Generics/TeleportTo.js @@ -1,6 +1,72 @@ const ServerResponse = invoke('GameServer/Network/Response'); const GeodataEngine = invoke('GameServer/Geodata/GeodataEngine'); +const COMPANION_TELEPORT_OFFSETS = [ + { locX: 80, locY: 0 }, + { locX: -80, locY: 0 }, + { locX: 0, locY: 80 }, + { locX: 0, locY: -80 }, + { locX: 60, locY: 60 }, + { locX: -60, locY: -60 }, + { locX: 60, locY: -60 }, + { locX: -60, locY: 60 } +]; + +function isBotSession(session) { + return session?.constructor?.name === 'BotSession' + || String(session?.accountId || '').startsWith('bot_'); +} + +function syncPartyCompanions(leaderSession, destination, Generics, companions = null) { + if (isBotSession(leaderSession)) { + return 0; + } + + const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); + const activeCompanions = companions || PartyCompanionService.membersForLeader(leaderSession); + let moved = 0; + + activeCompanions.forEach((companionSession) => { + if (!companionSession?.actor) { + return; + } + + const companion = companionSession.actor; + const offset = COMPANION_TELEPORT_OFFSETS[moved % COMPANION_TELEPORT_OFFSETS.length]; + const companionDestination = { + locX: destination.locX + offset.locX, + locY: destination.locY + offset.locY, + locZ: destination.locZ + }; + moved += 1; + + // A leader's teleport ends a field recovery attempt. Revive first because + // TeleportTo deliberately rejects dead actors, then place every active + // companion beside the leader instead of leaving it on the former spot. + if (companion.isDead?.()) { + Generics.revive(companionSession, companion, { delayMs: 0, restoreFullVitals: true }); + } + + companionSession.deathTimerStart = undefined; + companionSession.currentTargetId = undefined; + companionSession.incomingThreatId = undefined; + companionSession.incomingThreatAt = undefined; + companionSession.returnToPartyAfterSupport = false; + companionSession.resumeAfterBuff = undefined; + companionSession.companionShopping = undefined; + companionSession.shoppingTarget = undefined; + companionSession.preShopLocation = undefined; + companionSession.plan = 'following'; + // Keep an explicit Hold order, but move its anchor to the leader's new + // location. A teleport must not silently turn a player command off. + companionSession.stayLocation = companionSession.botStay ? { ...companionDestination } : null; + companion.unselect?.(); + Generics.teleportTo(companionSession, companion, companionDestination); + }); + + return moved; +} + function teleportTo(session, actor, coords) { const Generics = invoke(path.actor); @@ -22,6 +88,11 @@ function teleportTo(session, actor, coords) { setTimeout(() => { Generics.updatePosition(session, actor, coords, { immediateNpcInfo: true, forceRefresh: true }); + // The leader must be at the destination before companions receive an + // AI wakeup. Otherwise their follow tick still reads the old leader + // position and immediately schedules a catch-up teleport backwards. + syncPartyCompanions(session, coords, Generics); + // Wake up bot AI after teleportation is complete and position updated if (session.aiActive) { const BotAI = invoke('GameServer/Bot/BotAI'); @@ -31,3 +102,4 @@ function teleportTo(session, actor, coords) { } module.exports = teleportTo; +module.exports.syncPartyCompanions = syncPartyCompanions; diff --git a/src/GameServer/Bot/BotAI.js b/src/GameServer/Bot/BotAI.js index 2011fb81..f63b81d1 100644 --- a/src/GameServer/Bot/BotAI.js +++ b/src/GameServer/Bot/BotAI.js @@ -394,7 +394,6 @@ const BotAI = { if (wasCompanion) { PartyCompanionService.clearCompanion(session, { plan: 'hunting', - rebuildWindow: false, refreshPanel: false }); // A corpse that timed out of party resurrection has @@ -423,7 +422,6 @@ const BotAI = { if (wasCompanion) { PartyCompanionService.clearCompanion(session, { plan: 'hunting', - rebuildWindow: false, refreshPanel: false }); } diff --git a/src/GameServer/Network/Request/RestartPoint.js b/src/GameServer/Network/Request/RestartPoint.js index be42e342..83dfe303 100644 --- a/src/GameServer/Network/Request/RestartPoint.js +++ b/src/GameServer/Network/Request/RestartPoint.js @@ -1,46 +1,6 @@ const ReceivePacket = invoke('Packet/Receive'); const ServerResponse = invoke('GameServer/Network/Response'); -const COMPANION_RESPAWN_OFFSETS = [ - { locX: 80, locY: 0 }, - { locX: -80, locY: 0 }, - { locX: 0, locY: 80 }, - { locX: 0, locY: -80 }, - { locX: 60, locY: 60 }, - { locX: -60, locY: -60 } -]; - -function reviveDeadCompanions(leaderSession, townRespawn, Generics, botManager = invoke('GameServer/Bot/BotManager')) { - const companions = (botManager.sessions || []).filter((companionSession) => ( - companionSession?.partyCompanion === true && - companionSession.followPlayerSession === leaderSession && - companionSession.actor?.isDead?.() === true - )); - - companions.forEach((companionSession, index) => { - const companion = companionSession.actor; - const offset = COMPANION_RESPAWN_OFFSETS[index % COMPANION_RESPAWN_OFFSETS.length]; - - // A leader's explicit town restart ends the field rescue attempt. Keep - // the party intact, but revive every fallen companion before moving it - // to the same town so none remains a corpse on the old hunting spot. - Generics.revive(companionSession, companion, { delayMs: 0, restoreFullVitals: true }); - companionSession.deathTimerStart = undefined; - companionSession.currentTargetId = undefined; - companionSession.incomingThreatId = undefined; - companionSession.incomingThreatAt = undefined; - companionSession.plan = 'following'; - companion.unselect?.(); - Generics.teleportTo(companionSession, companion, { - locX: townRespawn.locX + offset.locX, - locY: townRespawn.locY + offset.locY, - locZ: townRespawn.locZ - }); - }); - - return companions.length; -} - function restartPoint(session, buffer) { const packet = new ReceivePacket(buffer); @@ -70,9 +30,7 @@ function consume(session, data) { session.dataSendToMe(ServerResponse.userInfo(actor)); Generics.teleportTo(session, actor, townRespawn); - reviveDeadCompanions(session, townRespawn, Generics); } module.exports = restartPoint; module.exports.consume = consume; -module.exports.reviveDeadCompanions = reviveDeadCompanions; diff --git a/tests/test_bot_death_respawn.js b/tests/test_bot_death_respawn.js index 63da5397..7c75a9a4 100644 --- a/tests/test_bot_death_respawn.js +++ b/tests/test_bot_death_respawn.js @@ -4,6 +4,8 @@ require('../src/Global'); const BotAI = invoke('GameServer/Bot/BotAI'); const revive = invoke('GameServer/Actor/Generics/Revive'); +const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); +const BotManager = invoke('GameServer/Bot/BotManager'); function botAt(loc) { return { @@ -93,4 +95,48 @@ assert.strictEqual(respawningBot.state.dead, false, 'bot must be alive before it assert.strictEqual(respawningBot.hp, 100, 'bot town respawn should restore HP before teleport validation'); assert.strictEqual(respawnPackets.length, 2, 'bot town respawn should send revive and stand-up packets synchronously'); +const partyPackets = []; +const partyLeader = { + actor: { + fetchId: () => 201, + fetchName: () => 'Party leader', + fetchCp: () => 0, + fetchMaxCp: () => 0, + fetchHp: () => 100, + fetchMaxHp: () => 100, + fetchMp: () => 100, + fetchMaxMp: () => 100, + fetchLevel: () => 20, + fetchClassId: () => 0 + }, + dataSendToMe(packet) { partyPackets.push(packet); } +}; +const timedOutCompanion = { + actor: { + fetchId: () => 202, + fetchName: () => 'Timed out companion', + attack: { abortCast() {}, clearTimers() {} }, + automation: { abortAll() {} }, + state: { setHits() {}, setCasts() {} }, + unselect() {} + }, + partyCompanion: true, + followPlayerSession: partyLeader, + plan: 'following' +}; +const originalSessions = BotManager.sessions; +BotManager.sessions = [timedOutCompanion]; +try { + assert.strictEqual( + PartyCompanionService.clearCompanion(timedOutCompanion, { plan: 'hunting', refreshPanel: false }), + true, + 'a timed-out companion should detach from its leader' + ); +} finally { + BotManager.sessions = originalSessions; +} +assert.strictEqual(timedOutCompanion.partyCompanion, false, 'timed-out companion should clear party membership'); +assert.strictEqual(timedOutCompanion.followPlayerSession, null, 'timed-out companion should clear its leader reference'); +assert.strictEqual(partyPackets.some((packet) => packet[0] === 0x50), true, 'companion timeout must clear the player party window immediately'); + console.log('Bot death respawn checks passed'); diff --git a/tests/test_restart_point_revive.js b/tests/test_restart_point_revive.js index 0986f69b..200b27fe 100644 --- a/tests/test_restart_point_revive.js +++ b/tests/test_restart_point_revive.js @@ -7,7 +7,7 @@ const revive = invoke('GameServer/Actor/Generics/Revive'); const StateModel = invoke('GameServer/Model/State'); const EffectStore = invoke('GameServer/Effects/EffectStore'); const calculateStats = invoke('GameServer/Actor/Generics/CalculateStats'); -const RestartPoint = invoke('GameServer/Network/Request/RestartPoint'); +const TeleportTo = invoke('GameServer/Actor/Generics/TeleportTo'); const dyingActor = { state: new StateModel(), @@ -154,31 +154,58 @@ const fallenSessionB = { deathTimerStart: Date.now(), incomingThreatId: 1000 }; -const aliveSession = { actor: aliveCompanion, partyCompanion: true, followPlayerSession: leaderSession }; -const unrelatedSession = { actor: deadCompanion(47), partyCompanion: true, followPlayerSession: { actor: {} } }; +const aliveSession = { + actor: aliveCompanion, + partyCompanion: true, + followPlayerSession: leaderSession, + botStay: true, + stayLocation: { locX: 12, locY: 34, locZ: 56 } +}; const companionCalls = []; -const revivedCompanions = RestartPoint.reviveDeadCompanions(leaderSession, { +const movedCompanions = TeleportTo.syncPartyCompanions(leaderSession, { locX: 1000, locY: 2000, locZ: -3000 }, { revive(session, actor, options) { companionCalls.push({ type: 'revive', session, actor, options }); }, teleportTo(session, actor, coords) { companionCalls.push({ type: 'teleport', session, actor, coords }); } -}, { - sessions: [fallenSessionA, fallenSessionB, aliveSession, unrelatedSession] -}); +}, [fallenSessionA, fallenSessionB, aliveSession]); -assert.strictEqual(revivedCompanions, 2, 'leader town restart should revive every dead companion in the same party'); +assert.strictEqual(movedCompanions, 3, 'a leader teleport should move every active companion in the same party'); assert.deepStrictEqual(companionCalls.filter((call) => call.type === 'revive').map((call) => call.actor.fetchId()), [44, 45], 'only fallen companions should be revived'); assert.deepStrictEqual(companionCalls.filter((call) => call.type === 'teleport').map((call) => call.coords), [ { locX: 1080, locY: 2000, locZ: -3000 }, - { locX: 920, locY: 2000, locZ: -3000 } -], 'fallen companions should arrive beside the player town restart point'); -assert.strictEqual(fallenSessionA.plan, 'following', 'town-revived companion must remain in the party follow state'); -assert.strictEqual(fallenSessionA.deathTimerStart, undefined, 'town restart must clear the stale death timeout'); -assert.strictEqual(fallenSessionA.currentTargetId, undefined, 'town restart must clear stale combat targets'); -assert.strictEqual(fallenSessionB.incomingThreatId, undefined, 'town restart must clear stale incoming threats'); -assert.strictEqual(fallenA.unselected, true, 'town-revived companion must clear its old target'); -assert.strictEqual(aliveSession.plan, undefined, 'living companions must not be reset by a leader town restart'); + { locX: 920, locY: 2000, locZ: -3000 }, + { locX: 1000, locY: 2080, locZ: -3000 } +], 'all companions should arrive beside the player teleport point'); +assert.strictEqual(fallenSessionA.plan, 'following', 'teleported companion must remain in the party follow state'); +assert.strictEqual(fallenSessionA.deathTimerStart, undefined, 'leader teleport must clear the stale death timeout'); +assert.strictEqual(fallenSessionA.currentTargetId, undefined, 'leader teleport must clear stale combat targets'); +assert.strictEqual(fallenSessionB.incomingThreatId, undefined, 'leader teleport must clear stale incoming threats'); +assert.strictEqual(fallenA.unselected, true, 'teleported companion must clear its old target'); +assert.strictEqual(aliveSession.plan, 'following', 'living companions must return to follow when the leader teleports'); +assert.strictEqual(aliveSession.botStay, true, 'leader teleport must preserve an explicit Hold order'); +assert.deepStrictEqual(aliveSession.stayLocation, { locX: 1000, locY: 2080, locZ: -3000 }, 'a held companion must use its new teleport location as the hold anchor'); + +const formationCalls = []; +const fullParty = Array.from({ length: 8 }, (_value, index) => ({ + actor: { fetchId: () => 60 + index, isDead: () => false, unselect() {} }, + partyCompanion: true, + followPlayerSession: leaderSession +})); +TeleportTo.syncPartyCompanions(leaderSession, { locX: 3000, locY: 4000, locZ: -2000 }, { + revive() { throw new Error('living companions must not be revived'); }, + teleportTo(_session, _actor, coords) { formationCalls.push(coords); } +}, fullParty); +assert.strictEqual(new Set(formationCalls.map((coords) => `${coords.locX}:${coords.locY}:${coords.locZ}`)).size, 8, 'a full party must receive distinct teleport landing positions'); + +const botSourceCalls = []; +assert.strictEqual(TeleportTo.syncPartyCompanions({ + constructor: { name: 'BotSession' } +}, { locX: 1, locY: 2, locZ: 3 }, { + revive() { botSourceCalls.push('revive'); }, + teleportTo() { botSourceCalls.push('teleport'); } +}, [fallenSessionA]), 0, 'a bot teleport must not recursively move its party'); +assert.deepStrictEqual(botSourceCalls, [], 'a bot teleport must leave other companions untouched'); console.log('Restart point revive checks passed'); From 2820a01b1e46890f52e64f5355559de8a57606f9 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:09:05 -0400 Subject: [PATCH 12/13] Improve hot party coordination and rewards --- src/GameServer/Actor/Generics/MoveTo.js | 4 + src/GameServer/Actor/Generics/NpcDied.js | 70 ++++++++- .../Actor/Generics/UpdatePosition.js | 1 + src/GameServer/Bot/AI/BotSkillCapabilities.js | 14 ++ src/GameServer/Bot/AI/BotStatus.js | 23 ++- src/GameServer/Bot/AI/PartyCombatState.js | 130 +++++++++++++++++ .../Bot/AI/PartyCompanionService.js | 134 +++++++++++++++--- src/GameServer/Bot/AI/PartyPulling.js | 13 +- src/GameServer/Bot/AI/PartyRevivalService.js | 29 +--- .../Bot/AI/States/FollowingState.js | 43 ++++++ .../Network/Response/PartyMemberPosition.js | 21 +++ src/GameServer/Network/Response/index.js | 1 + .../Generics/NpcBypasses/CompanionControl.js | 16 +++ src/GameServer/World/World.js | 7 +- tests/test_bot_skill_capabilities.js | 42 ++++++ tests/test_c4_protocol_packets.js | 8 ++ tests/test_party_capacity.js | 53 +++++++ tests/test_party_companion_rest_follow.js | 8 +- tests/test_party_revival.js | 18 ++- tests/test_party_rewards.js | 32 +++++ 20 files changed, 603 insertions(+), 64 deletions(-) create mode 100644 src/GameServer/Bot/AI/PartyCombatState.js create mode 100644 src/GameServer/Network/Response/PartyMemberPosition.js create mode 100644 tests/test_bot_skill_capabilities.js create mode 100644 tests/test_party_capacity.js create mode 100644 tests/test_party_rewards.js diff --git a/src/GameServer/Actor/Generics/MoveTo.js b/src/GameServer/Actor/Generics/MoveTo.js index 611c7c2b..f2d3fea5 100644 --- a/src/GameServer/Actor/Generics/MoveTo.js +++ b/src/GameServer/Actor/Generics/MoveTo.js @@ -66,6 +66,7 @@ function moveTo(session, actor, coords) { const snappedTo = { ...requestedTo }; snappedTo.locZ = GeodataEngine.getHeight(snappedTo.locX, snappedTo.locY, snappedTo.locZ); actor.setLocXYZ(snappedTo); + invoke('GameServer/Bot/AI/PartyCompanionService').updatePosition(session, actor); session.lastPathfinding = { requestedTo, routedTo: { ...snappedTo }, @@ -132,6 +133,7 @@ function moveTo(session, actor, coords) { if (distance === 0) { actor.setLocXYZ(nextLoc); + invoke('GameServer/Bot/AI/PartyCompanionService').updatePosition(session, actor); moveAlongPath(index + 1); return; } @@ -157,6 +159,7 @@ function moveTo(session, actor, coords) { if (step >= steps) { clearInterval(session.moveTimer); actor.setLocXYZ(nextLoc); + invoke('GameServer/Bot/AI/PartyCompanionService').updatePosition(session, actor); moveAlongPath(index + 1); } else { const ratio = step / steps; @@ -169,6 +172,7 @@ function moveTo(session, actor, coords) { locY: nextY, locZ: snappedZ }); + invoke('GameServer/Bot/AI/PartyCompanionService').updatePosition(session, actor); } }, tickRate); }; diff --git a/src/GameServer/Actor/Generics/NpcDied.js b/src/GameServer/Actor/Generics/NpcDied.js index 26bc57ed..c1befeaf 100644 --- a/src/GameServer/Actor/Generics/NpcDied.js +++ b/src/GameServer/Actor/Generics/NpcDied.js @@ -1,6 +1,9 @@ const World = invoke('GameServer/World/World'); const PARTY_REWARD_RADIUS = 2500; +// C4/L2J party reward curve. The total reward grows with the eligible party, +// then is split by squared level rather than being divided equally. +const PARTY_EXP_SP_BONUS = [1, 1.30, 1.39, 1.50, 1.54, 1.58, 1.63, 1.67, 1.71]; function distance2d(a, b) { const dx = a.fetchLocX() - b.fetchLocX(); @@ -39,9 +42,9 @@ function ownerSessionForSummon(actor) { function rewardParticipants(killerSession, killer, npc) { const leaderSession = partyLeaderSession(killerSession); const leader = leaderSession?.actor; - if (!leader || !isAliveOnline(leaderSession)) return [killerSession]; + if (!leader) return killer && !killer.isDead() ? [killerSession] : []; - const members = [leaderSession]; + const members = [leaderSession, killerSession]; World.user.sessions.forEach((candidate) => { if ( candidate !== leaderSession && @@ -56,11 +59,62 @@ function rewardParticipants(killerSession, killer, npc) { .filter(isAliveOnline) .filter((memberSession) => distance2d(memberSession.actor, npc) <= PARTY_REWARD_RADIUS); - if (nearbyMembers.includes(killerSession)) return nearbyMembers; + if (nearbyMembers.length > 0) return nearbyMembers; if (killer && !killer.isDead()) return [killerSession]; return []; } +function levelOf(session) { + return Math.max(1, Number(session?.actor?.fetchLevel?.() || 1)); +} + +function partyBonus(memberCount) { + const index = Math.max(0, Math.min(PARTY_EXP_SP_BONUS.length - 1, Number(memberCount || 1) - 1)); + return PARTY_EXP_SP_BONUS[index]; +} + +function validPartyMembers(participants) { + if (participants.length < 2) return participants; + + // L2J's automatic cutoff excludes members whose level is so far below + // the group that they would otherwise be power-levelled for free. + const squaredLevelSum = participants.reduce((sum, memberSession) => { + const level = levelOf(memberSession); + return sum + (level * level); + }, 0); + const previousBonus = partyBonus(participants.length - 1); + const currentBonus = partyBonus(participants.length); + const cutoff = squaredLevelSum * (1 - (1 / (1 + currentBonus - previousBonus))); + + return participants.filter((memberSession) => { + const level = levelOf(memberSession); + return (level * level) >= cutoff; + }); +} + +function partyRewardShares(participants, exp, sp) { + const validMembers = validPartyMembers(participants); + if (validMembers.length === 0) return []; + + const totalWeight = validMembers.reduce((sum, memberSession) => { + const level = levelOf(memberSession); + return sum + (level * level); + }, 0); + const bonus = partyBonus(validMembers.length); + const totalExp = Math.max(0, Number(exp || 0)) * bonus; + const totalSp = Math.max(0, Number(sp || 0)) * bonus; + + return validMembers.map((memberSession) => { + const level = levelOf(memberSession); + const weight = (level * level) / totalWeight; + return { + session: memberSession, + exp: Math.max(0, Math.round(totalExp * weight)), + sp: Math.max(0, Math.round(totalSp * weight)) + }; + }); +} + function npcDied(session, actor, npc) { const Generics = invoke(path.actor); @@ -83,8 +137,7 @@ function npcDied(session, actor, npc) { const rewardActor = ownerSession?.actor || actor; const participants = rewardParticipants(session, rewardActor, npc); - const rewardExp = Math.max(0, Math.floor(npc.fetchAcquiredExp() / Math.max(1, participants.length))); - const rewardSp = Math.max(0, Math.floor(npc.fetchRewardSp() / Math.max(1, participants.length))); + const rewards = partyRewardShares(participants, npc.fetchAcquiredExp(), npc.fetchRewardSp()); // C4's ordinary quest callback is attributed to the actual killer, not to // every party member that receives shared EXP. @@ -92,9 +145,12 @@ function npcDied(session, actor, npc) { utils.infoWarn('Quest', 'kill callback failed: %s', error.message); }); - participants.forEach((memberSession) => { - Generics.experienceReward(memberSession, memberSession.actor, rewardExp, rewardSp); + rewards.forEach(({ session: memberSession, exp, sp }) => { + Generics.experienceReward(memberSession, memberSession.actor, exp, sp); }); } module.exports = npcDied; +module.exports.PARTY_EXP_SP_BONUS = PARTY_EXP_SP_BONUS; +module.exports.partyRewardShares = partyRewardShares; +module.exports.validPartyMembers = validPartyMembers; diff --git a/src/GameServer/Actor/Generics/UpdatePosition.js b/src/GameServer/Actor/Generics/UpdatePosition.js index 3f7e3ba3..2d85259f 100644 --- a/src/GameServer/Actor/Generics/UpdatePosition.js +++ b/src/GameServer/Actor/Generics/UpdatePosition.js @@ -17,6 +17,7 @@ function updatePosition(session, actor, coords, environmentOptions) { // Update Online users, NPCs, underwater locations Generics.updateEnvironment(session, actor, environmentOptions); Generics.underwaterCheck (session, actor); + invoke('GameServer/Bot/AI/PartyCompanionService').updatePosition(session, actor); // Reschedule actions based on updated position if (actor.storedAttack) { diff --git a/src/GameServer/Bot/AI/BotSkillCapabilities.js b/src/GameServer/Bot/AI/BotSkillCapabilities.js index 321a3f75..a3490a9b 100644 --- a/src/GameServer/Bot/AI/BotSkillCapabilities.js +++ b/src/GameServer/Bot/AI/BotSkillCapabilities.js @@ -8,6 +8,10 @@ const FRIENDLY_HEAL_TYPES = new Set([ C4SkillRules.HEAL_HOT, C4SkillRules.HEAL_CLEANSE ]); +const FRIENDLY_MANA_TYPES = new Set([ + C4SkillRules.MANA_RECHARGE, + C4SkillRules.MANA_HEAL +]); function activeSkills(actor) { return (actor?.skillset?.skills || []).filter((skill) => skill && !skill.fetchPassive?.()); @@ -33,9 +37,19 @@ function buffSkill(actor, buffType) { return buff ? learnedSkill(actor, buff.id) : null; } +function manaRechargeSkill(actor) { + return activeSkills(actor) + .filter((skill) => FRIENDLY_MANA_TYPES.has(skill.fetchSkillType?.())) + .filter((skill) => ['friendly', 'party'].includes(skill.fetchTargetKind?.())) + .filter((skill) => actor.canUseSkill?.(skill) !== false) + .filter((skill) => Number(actor.fetchMp?.() || 0) >= Number(skill.fetchConsumedMp?.() || 0)) + .sort((a, b) => Number(b.fetchPower?.() || 0) - Number(a.fetchPower?.() || 0))[0] || null; +} + module.exports = { aggressionSkill: (actor) => learnedSkill(actor, 28), buffSkill, healSkill, + manaRechargeSkill, learnedSkill }; diff --git a/src/GameServer/Bot/AI/BotStatus.js b/src/GameServer/Bot/AI/BotStatus.js index ee496878..9e80f747 100644 --- a/src/GameServer/Bot/AI/BotStatus.js +++ b/src/GameServer/Bot/AI/BotStatus.js @@ -4,6 +4,7 @@ const TownPathfinder = invoke('GameServer/Bot/AI/TownPathfinder'); const PartyAwareness = invoke('GameServer/Bot/AI/PartyAwareness'); const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); const PartyPulling = invoke('GameServer/Bot/AI/PartyPulling'); +const PartyCombatState = invoke('GameServer/Bot/AI/PartyCombatState'); const EffectStore = invoke('GameServer/Effects/EffectStore'); const GearSkillHints = invoke('GameServer/Bot/AI/GearSkillHints'); @@ -231,6 +232,7 @@ const BotStatus = { const leaderSession = session.followPlayerSession && session.partyCompanion === true ? session.followPlayerSession : null; const partySettings = leaderSession ? PartyCompanionService.getSettings(leaderSession) : null; const pull = leaderSession ? PartyPulling.current(leaderSession, partySettings) : null; + const combat = leaderSession ? PartyCombatState.combatState(leaderSession) : null; const isAssignedPuller = partySettings?.pullMode === 'bot' && Number(partySettings.pullerId || 0) === Number(bot.fetchId()); const party = leaderSession ? { @@ -251,7 +253,26 @@ const BotStatus = { members: PartyAwareness.partySessions(leaderSession) .map((memberSession) => partyMemberSummary(memberSession, leaderSession, bot)) .filter(Boolean), - threat: partyThreatSummary(leaderSession, bot) + threat: partyThreatSummary(leaderSession, bot), + combat: combat?.active ? { + reason: combat.reason, + targetId: combat.target?.fetchId?.() || null, + targetName: combat.target?.fetchName?.() || null + } : null, + recovery: leaderSession.partyRecoveryCast && Number(leaderSession.partyRecoveryCast.expiresAt || 0) > Date.now() + ? { + kind: 'recharge', + providerId: leaderSession.partyRecoveryCast.providerId, + targetId: leaderSession.partyRecoveryCast.targetId, + expiresAt: leaderSession.partyRecoveryCast.expiresAt + } + : null, + revival: leaderSession.partyRevivalAttempt ? { + providerId: leaderSession.partyRevivalAttempt.providerId, + targetId: leaderSession.partyRevivalAttempt.targetId, + source: leaderSession.partyRevivalAttempt.source, + startedAt: leaderSession.partyRevivalAttempt.startedAt + } : null } : null; const status = { diff --git a/src/GameServer/Bot/AI/PartyCombatState.js b/src/GameServer/Bot/AI/PartyCombatState.js new file mode 100644 index 00000000..11994b70 --- /dev/null +++ b/src/GameServer/Bot/AI/PartyCombatState.js @@ -0,0 +1,130 @@ +const PartyAwareness = invoke('GameServer/Bot/AI/PartyAwareness'); + +function world() { + return invoke('GameServer/World/World'); +} + +function actorId(actor) { + return Number(actor?.fetchId?.() || 0) || null; +} + +function isAlive(session) { + return !!session?.actor && session.actor.fetchIsOnline?.() === true && !session.actor.isDead?.(); +} + +function partySessions(leaderSession, { includeDead = false } = {}) { + if (!leaderSession) return []; + + // A companion is owned by BotManager before every visibility/update path + // has necessarily populated World.user. Read both registries so combat, + // loot and revival see the same party during that short lifecycle gap. + const BotManager = invoke('GameServer/Bot/BotManager'); + const candidates = [ + leaderSession, + ...(world().user?.sessions || []), + ...(BotManager.sessions || []) + ]; + const unique = new Set(); + return candidates.filter((session) => { + if (!session || unique.has(session)) return false; + unique.add(session); + return PartyAwareness.isPartySession(session, leaderSession) && + (includeDead || isAlive(session)); + }); +} + +function npcById(id) { + const numericId = Number(id || 0); + if (!numericId) return null; + return (world().npc?.spawns || []).find((npc) => Number(npc?.fetchId?.()) === numericId) || null; +} + +function isHostileNpc(npc) { + return !!npc && npc.fetchAttackable?.() === true && npc.isDead?.() !== true; +} + +function travellingPull(leaderSession) { + const pull = leaderSession?.partyPullState || {}; + return { + active: ['approach', 'aggro', 'return'].includes(pull.phase), + pullerId: Number(pull.pullerId || 0) || null, + targetId: Number(pull.targetId || 0) || null + }; +} + +function ignoredPullAction(session, pull, options) { + if (!options.ignoreTravellingPuller || !pull.active) return false; + return actorId(session?.actor) === pull.pullerId; +} + +function activeActionTarget(session) { + const actor = session?.actor; + const state = actor?.state; + if (!state?.fetchHits?.() && !state?.fetchCasts?.()) return null; + + // StateModel keeps these flags as bare booleans. They can survive an + // aborted queue, so only treat them as combat when their current target + // is still a living, attackable NPC. + const target = npcById(actor?.fetchDestId?.()); + return isHostileNpc(target) ? target : null; +} + +function combatState(leaderSession, options = {}) { + const members = partySessions(leaderSession, { includeDead: true }); + const living = members.filter(isAlive); + const pull = travellingPull(leaderSession); + const ignoredTargetIds = new Set((options.ignoreTargetIds || []) + .map((id) => Number(id || 0)) + .filter(Boolean)); + + const threat = PartyAwareness.findThreatTargetingParty(leaderSession); + if (threat?.actor && !ignoredTargetIds.has(actorId(threat.actor))) { + const isTravellingPullTarget = pull.active && + options.ignoreTravellingPuller === true && + actorId(threat.actor) === pull.targetId && + Number(threat.targetId || 0) === pull.pullerId; + if (!isTravellingPullTarget) { + return { active: true, reason: 'threat_targeting_party', target: threat.actor }; + } + } + + const leaderTargetId = PartyAwareness.leaderCombatTargetId(leaderSession); + if (leaderTargetId && !ignoredTargetIds.has(Number(leaderTargetId))) { + return { active: true, reason: 'leader_targeting_hostile', target: npcById(leaderTargetId) }; + } + + for (const memberSession of living) { + if (ignoredPullAction(memberSession, pull, options)) continue; + if (memberSession.actor?.state?.fetchCombats?.() === true) { + return { active: true, reason: 'member_combat_state', memberSession }; + } + const target = activeActionTarget(memberSession); + if (target && !ignoredTargetIds.has(actorId(target))) { + return { active: true, reason: 'member_action_against_hostile', target, memberSession }; + } + } + + // A mob may keep its combat state on a corpse immediately after the + // lethal hit. Include dead members here so a resurrection cannot begin + // in front of that mob, while still ignoring stale action flags. + const partyIds = new Set(members.map((member) => actorId(member.actor)).filter(Boolean)); + const attackingCorpse = (world().npc?.spawns || []).find((npc) => ( + isHostileNpc(npc) && + npc.state?.fetchCombats?.() === true && + partyIds.has(Number(npc.fetchDestId?.() || 0)) && + !ignoredTargetIds.has(actorId(npc)) + )); + if (attackingCorpse) { + return { active: true, reason: 'hostile_combat_record', target: attackingCorpse }; + } + + return { active: false, reason: null, target: null }; +} + +module.exports = { + partySessions, + combatState, + isActive(leaderSession, options) { + return combatState(leaderSession, options).active; + } +}; diff --git a/src/GameServer/Bot/AI/PartyCompanionService.js b/src/GameServer/Bot/AI/PartyCompanionService.js index 352c7f5b..319bd952 100644 --- a/src/GameServer/Bot/AI/PartyCompanionService.js +++ b/src/GameServer/Bot/AI/PartyCompanionService.js @@ -1,5 +1,7 @@ const ServerResponse = invoke('GameServer/Network/Response'); const PartyAwareness = invoke('GameServer/Bot/AI/PartyAwareness'); +const PartyCombatState = invoke('GameServer/Bot/AI/PartyCombatState'); +const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); const DEFAULT_PARTY_DISTRIBUTION = 1; const DEFAULT_PARTY_SETTINGS = { @@ -13,6 +15,9 @@ const DEFAULT_PARTY_SETTINGS = { const PARTY_LOOT_RADIUS = 2500; const GROUND_LOOT_SCAN_INTERVAL_MS = 500; const RANDOM_LOOT_DISTRIBUTIONS = new Set([1, 2]); +const MAX_PARTY_MEMBERS = 9; +const MAX_COMPANIONS = MAX_PARTY_MEMBERS - 1; +const PARTY_POSITION_UPDATE_DISTANCE = 150; const FORMATION_OFFSETS = [ { locX: -90, locY: -70 }, { locX: -90, locY: 70 }, @@ -23,6 +28,40 @@ const FORMATION_OFFSETS = [ { locX: -250, locY: 170 }, { locX: -330, locY: 0 } ]; +const ROLE_FORMATION_OFFSETS = { + // Local +X is in front of the leader. Tanks screen the group while the + // support line remains behind it; pull travel still overrides this slot. + tank: [ + { locX: 90, locY: 0 }, + { locX: 45, locY: -90 }, + { locX: 45, locY: 90 } + ], + dagger: [ + { locX: 15, locY: -125 }, + { locX: 15, locY: 125 } + ], + dps: FORMATION_OFFSETS, + archer: [ + { locX: -160, locY: -135 }, + { locX: -160, locY: 135 } + ], + mage: [ + { locX: -205, locY: -95 }, + { locX: -205, locY: 95 } + ], + healer: [ + { locX: -285, locY: -70 }, + { locX: -285, locY: 70 } + ], + buffer: [ + { locX: -330, locY: 0 }, + { locX: -275, locY: 145 } + ], + crafter: [ + { locX: -250, locY: 145 }, + { locX: -250, locY: -145 } + ] +}; function world() { return invoke('GameServer/World/World'); @@ -71,7 +110,12 @@ function distributionForLeader(leaderSession) { function setDistribution(leaderSession, distribution) { const settings = settingsForLeader(leaderSession); - settings.distribution = normalizeDistribution(distribution); + const next = normalizeDistribution(distribution); + if (settings.distribution !== next) { + settings.distribution = next; + // Turn order is meaningful only for the currently selected rule. + settings.itemLastLootIndex = -1; + } return settings.distribution; } @@ -112,6 +156,11 @@ function membersForLeader(leaderSession) { return botSessions().filter((session) => isActiveCompanion(session, leaderSession)); } +function hasCapacity(leaderSession, companionSession = null) { + if (isActiveCompanion(companionSession, leaderSession)) return true; + return membersForLeader(leaderSession).length < MAX_COMPANIONS; +} + function lootMembersForLeader(leaderSession, target) { const members = [leaderSession, ...membersForLeader(leaderSession)] .filter(isAliveOnline); @@ -160,22 +209,9 @@ function canPickGroundLoot(session, leaderSession, item) { } function partyCombatInProgress(leaderSession) { - const pullState = leaderSession?.partyPullState || {}; - const pullerId = Number(pullState.pullerId || 0); - // While a puller is approaching, gaining aggro or returning, the mob has - // not reached the camp yet. That travel must not make old nearby drops - // wait forever; a real fight by any other party member still blocks loot. - const pullTravel = ['approach', 'aggro', 'return'].includes(pullState.phase); - return [leaderSession, ...membersForLeader(leaderSession)] - .some((memberSession) => { - if (pullTravel && Number(memberSession?.actor?.fetchId?.()) === pullerId) return false; - const state = memberSession?.actor?.state; - return !!( - state?.fetchCombats?.() || - state?.fetchHits?.() || - state?.fetchCasts?.() - ); - }); + // While the designated puller is travelling, its own movement/aggro must + // not keep old drops locked. Every other real hostile action still does. + return PartyCombatState.isActive(leaderSession, { ignoreTravellingPuller: true }); } function queuedGroundLootIds(leaderSession) { @@ -307,9 +343,16 @@ function formationSlotFor(companionSession) { const leaderSession = companionSession?.followPlayerSession; const members = membersForLeader(leaderSession); const index = Math.max(0, members.indexOf(companionSession)); + const role = BotRoles.inferRole(companionSession?.actor); + const sameRoleIndex = members + .filter((memberSession) => BotRoles.inferRole(memberSession.actor) === role) + .sort((a, b) => Number(a.actor?.fetchId?.()) - Number(b.actor?.fetchId?.())) + .indexOf(companionSession); + const offsets = ROLE_FORMATION_OFFSETS[role] || FORMATION_OFFSETS; return { index, - offset: FORMATION_OFFSETS[index % FORMATION_OFFSETS.length] + role, + offset: offsets[Math.max(0, sameRoleIndex) % offsets.length] || FORMATION_OFFSETS[index % FORMATION_OFFSETS.length] }; } @@ -318,14 +361,48 @@ function formationTargetFor(companionSession) { if (!leader) return null; const slot = formationSlotFor(companionSession); + // C4 heading is a 16-bit turn where zero faces +X. Formation offsets are + // authored in leader-local space, so the group stays behind/beside the + // leader as they change direction instead of forming against world north. + const radians = (Number(leader.fetchHead?.() || 0) / 65536) * Math.PI * 2; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + const locX = (slot.offset.locX * cos) - (slot.offset.locY * sin); + const locY = (slot.offset.locX * sin) + (slot.offset.locY * cos); return { - locX: leader.fetchLocX() + slot.offset.locX, - locY: leader.fetchLocY() + slot.offset.locY, + locX: Math.round(leader.fetchLocX() + locX), + locY: Math.round(leader.fetchLocY() + locY), locZ: leader.fetchLocZ(), slot: slot.index }; } +function partyActorsForLeader(leaderSession) { + return [leaderSession?.actor, ...membersForLeader(leaderSession).map((memberSession) => memberSession.actor)] + .filter((actor) => actor?.fetchIsOnline?.() !== false); +} + +function positionChanged(session, actor) { + const previous = session?.lastPartyPosition; + const next = { locX: actor.fetchLocX(), locY: actor.fetchLocY(), locZ: actor.fetchLocZ() }; + session.lastPartyPosition = next; + if (!previous) return true; + + const dx = next.locX - previous.locX; + const dy = next.locY - previous.locY; + const dz = next.locZ - previous.locZ; + return (dx * dx) + (dy * dy) + (dz * dz) >= PARTY_POSITION_UPDATE_DISTANCE * PARTY_POSITION_UPDATE_DISTANCE; +} + +function sendPartyPositions(leaderSession, sourceSession = null, force = false) { + const leader = leaderSession?.actor; + if (!leader || !leaderSession?.dataSendToMe || membersForLeader(leaderSession).length === 0) return false; + if (!force && sourceSession?.actor && !positionChanged(sourceSession, sourceSession.actor)) return false; + + leaderSession.dataSendToMe(ServerResponse.partyMemberPosition(partyActorsForLeader(leaderSession))); + return true; +} + function sendPartyWindow(leaderSession, distribution = 0) { const leader = leaderSession?.actor; if (!leader || !leaderSession.dataSendToMe) return; @@ -338,6 +415,7 @@ function sendPartyWindow(leaderSession, distribution = 0) { leaderSession.dataSendToMe(ServerResponse.partySmallWindowDeleteAll()); if (members.length > 0) { leaderSession.dataSendToMe(ServerResponse.partySmallWindowAll(leader.fetchId(), distribution, members)); + sendPartyPositions(leaderSession, null, true); leaderSession.dataSendToMe(ServerResponse.partySpelled.fromActor(leader)); memberSessions.forEach((memberSession) => { if (memberSession.actor) { @@ -404,8 +482,13 @@ function clearPullerIfDetached(leaderSession, companionSession) { } const PartyCompanionService = { + MAX_PARTY_MEMBERS, + MAX_COMPANIONS, + membersForLeader, + hasCapacity, + activeActorsForLeader(leaderSession) { return membersForLeader(leaderSession).map((session) => session.actor).filter(Boolean); }, @@ -414,6 +497,15 @@ const PartyCompanionService = { formationTargetFor, + sendPartyPositions, + + updatePosition(session, actor) { + const leaderSession = partyLeaderSession(session); + if (!leaderSession || !actor) return false; + if (session !== leaderSession && !isActiveCompanion(session, leaderSession)) return false; + return sendPartyPositions(leaderSession, session, false); + }, + lootMembersForLeader, distributionForLeader, @@ -496,6 +588,7 @@ const PartyCompanionService = { const leader = leaderSession?.actor; const bot = companionSession?.actor; if (!leader || !bot) return false; + if (!hasCapacity(leaderSession, companionSession)) return false; const previousLeader = companionSession.followPlayerSession; const wasResting = companionSession.plan === 'resting'; @@ -581,6 +674,7 @@ const PartyCompanionService = { if (!leaderSession.actor?.fetchIsOnline?.()) return false; leaderSession.dataSendToMe(ServerResponse.partySmallWindowUpdate(companionSession.actor)); leaderSession.dataSendToMe(ServerResponse.partySpelled.fromActor(companionSession.actor)); + sendPartyPositions(leaderSession, companionSession, true); return true; } }; diff --git a/src/GameServer/Bot/AI/PartyPulling.js b/src/GameServer/Bot/AI/PartyPulling.js index 8d5e564d..0e5647f5 100644 --- a/src/GameServer/Bot/AI/PartyPulling.js +++ b/src/GameServer/Bot/AI/PartyPulling.js @@ -3,6 +3,7 @@ const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); const BotCombatUtility = invoke('GameServer/Bot/AI/BotCombatUtility'); const BotSupportPlanner = invoke('GameServer/Bot/AI/BotSupportPlanner'); const PartyAwareness = invoke('GameServer/Bot/AI/PartyAwareness'); +const PartyCombatState = invoke('GameServer/Bot/AI/PartyCombatState'); const PULL_SEARCH_RADIUS = 2200; const PULL_CONTACT_DISTANCE = 260; @@ -127,13 +128,17 @@ function supportProviders(leaderSession) { function pauseReason(leaderSession, puller) { const state = pullState(leaderSession); - const threat = PartyAwareness.findThreatTargetingParty(leaderSession); - const ownPullTarget = threat && - Number(threat.actor?.fetchId?.()) === Number(state.targetId || 0); + const recovery = leaderSession?.partyRecoveryCast; + if (Number(recovery?.expiresAt || 0) > Date.now()) return 'party_recharging'; + if (recovery) delete leaderSession.partyRecoveryCast; // Do not select a new target while the camp is already handling an add. // The current shared pull target is the sole exception: it remains the // party's intended fight from first aggro until it dies. - if (threat && !ownPullTarget) return 'party_under_attack'; + const combat = PartyCombatState.combatState(leaderSession, { + ignoreTravellingPuller: true, + ignoreTargetIds: state.targetId ? [state.targetId] : [] + }); + if (combat.active) return 'party_under_attack'; const members = PartyAwareness.partySessions(leaderSession); if (members.some((memberSession) => ( diff --git a/src/GameServer/Bot/AI/PartyRevivalService.js b/src/GameServer/Bot/AI/PartyRevivalService.js index 9781bd52..5b8c0246 100644 --- a/src/GameServer/Bot/AI/PartyRevivalService.js +++ b/src/GameServer/Bot/AI/PartyRevivalService.js @@ -1,7 +1,7 @@ const C4SkillRules = invoke('GameServer/Skills/C4SkillRules'); const DataCache = invoke('GameServer/DataCache'); const SkillModel = invoke('GameServer/Model/Skill'); -const PartyAwareness = invoke('GameServer/Bot/AI/PartyAwareness'); +const PartyCombatState = invoke('GameServer/Bot/AI/PartyCombatState'); const PARTY_REVIVE_TIMEOUT_MS = 60000; const RESURRECTION_SCROLL_SKILL_ID = 2014; @@ -36,29 +36,7 @@ function deadMembers(leaderSession) { } function partyCombatInProgress(leaderSession) { - if (PartyAwareness.findThreatTargetingParty(leaderSession)) return true; - if (PartyAwareness.leaderCombatTargetId(leaderSession)) return true; - - // A pending hit/cast is an actual native action, unlike a lingering - // combat marker after an already finished fight. Do not begin a long - // resurrection while a living party member is still executing one. - if (partySessions(leaderSession) - .filter(isAlive) - .some((session) => ( - session.actor.state?.fetchHits?.() || session.actor.state?.fetchCasts?.() - ))) return true; - - // PartyAwareness intentionally ignores corpses. For resurrection that is - // too narrow: a monster can keep its combat loop on a fallen party member - // for a short time after the lethal hit, and a healer must not begin a - // long resurrection cast in front of it. - const partyIds = new Set(partySessions(leaderSession).map((member) => member.actor?.fetchId?.()).filter(Boolean)); - return (world().npc?.spawns || []).some((npc) => ( - npc.fetchAttackable?.() === true && - npc.isDead?.() !== true && - npc.state?.fetchCombats?.() === true && - partyIds.has(npc.fetchDestId?.()) - )); + return PartyCombatState.isActive(leaderSession); } function learnedResurrectionSkills(actor) { @@ -136,7 +114,8 @@ function tick(session, leaderSession, Generics) { leaderSession.partyRevivalAttempt = null; return { handled: false, dead }; } - if (partyCombatInProgress(leaderSession)) return { handled: false, dead }; + const combat = PartyCombatState.combatState(leaderSession); + if (combat.active) return { handled: false, dead, blockedBy: combat.reason, threat: combat.target }; const attempt = leaderSession.partyRevivalAttempt; if (attempt) return { handled: attempt.providerId === session.actor.fetchId(), waiting: true, targetId: attempt.targetId }; diff --git a/src/GameServer/Bot/AI/States/FollowingState.js b/src/GameServer/Bot/AI/States/FollowingState.js index 6bb21886..0f49db07 100644 --- a/src/GameServer/Bot/AI/States/FollowingState.js +++ b/src/GameServer/Bot/AI/States/FollowingState.js @@ -361,6 +361,36 @@ function partySupportMembers(leaderSession, puller) { return PartyPulling.supportMembers(leaderSession, puller); } +function manaPriority(entry, pullerActor) { + const role = BotRoles.inferRole(entry.actor); + if (entry.actor === pullerActor) return 0; + if (role === 'buffer') return 1; + if (role === 'healer') return 2; + if (role === 'mage') return 3; + if (role === 'tank') return 4; + return 5; +} + +function lowestManaPartyMember(leaderSession, bot, pullerActor = null, maxDistance = 900) { + return partyMembersInSupportRange(leaderSession, bot, maxDistance) + .filter((entry) => entry.actor !== bot) + .filter((entry) => entry.mpRatio < 0.55) + .sort((a, b) => ( + manaPriority(a, pullerActor) - manaPriority(b, pullerActor) || + a.mpRatio - b.mpRatio || + Number(a.actor.fetchId()) - Number(b.actor.fetchId()) + ))[0] || null; +} + +function markPartyRecharge(leaderSession, bot, target, skill) { + const castMs = Number(skill?.fetchCalculatedHitTime?.() || skill?.fetchHitTime?.() || 0); + leaderSession.partyRecoveryCast = { + providerId: bot.fetchId(), + targetId: target.fetchId(), + expiresAt: Date.now() + Math.max(1000, castMs + 1000) + }; +} + function partyHasBuffer(leaderSession, exceptActor = null) { return PartyAwareness.partySessions(leaderSession) .some((memberSession) => ( @@ -675,6 +705,7 @@ module.exports = { PartyPulling.supportProviders(playerSession) ); const healerSkill = role === 'healer' ? BotSkillCapabilities.healSkill(bot) : null; + const rechargeSkill = role === 'healer' ? BotSkillCapabilities.manaRechargeSkill(bot) : null; const healerCanCast = !!healerSkill && bot.fetchMp() >= healerSkill.fetchConsumedMp() && !isBusy(bot) && @@ -682,6 +713,9 @@ module.exports = { const woundedPartyMember = role === 'healer' ? weakestPartyMember(playerSession, bot, pulling.puller?.actor) : null; + const manaPartyMember = role === 'healer' && rechargeSkill && !partyThreat && !leaderTargetId + ? lowestManaPartyMember(playerSession, bot, pulling.puller?.actor) + : null; // Healing is the healer's first obligation. Do not queue a regular // party buff and then overwrite it with a heal in this same AI tick. const healerNeedsAction = role === 'healer' && healerCanCast && ( @@ -803,6 +837,15 @@ module.exports = { } else if (botVitals.mpRatio < 0.25) { recordRoleDecision(session, bot, 'save_mp', 'low_mp'); keepRoleDecision = true; + } else if (manaPartyMember && rechargeSkill && !isBusy(bot) && !healerNeedsAction) { + acted = true; + markPartyRecharge(playerSession, bot, manaPartyMember.actor, rechargeSkill); + recordRoleDecision(session, bot, 'recharge_party', 'restore_mp', { + targetId: manaPartyMember.actor.fetchId(), + skillId: rechargeSkill.fetchSelfId() + }); + castSkillOn(session, bot, Generics, manaPartyMember.actor, rechargeSkill.fetchSelfId(), false); + returnToPartyAfterSupport(session, bot, player, manaPartyMember.actor); } else if (!healerSkill && woundedPartyMember?.hpRatio < 0.70) { recordRoleDecision(session, bot, 'cannot_heal', 'no_learned_heal'); keepRoleDecision = true; diff --git a/src/GameServer/Network/Response/PartyMemberPosition.js b/src/GameServer/Network/Response/PartyMemberPosition.js new file mode 100644 index 00000000..00a0546d --- /dev/null +++ b/src/GameServer/Network/Response/PartyMemberPosition.js @@ -0,0 +1,21 @@ +const SendPacket = invoke('Packet/Send'); + +// C4 opcode 0xa7. The client uses this snapshot for party member markers on +// the minimap; it is deliberately a complete party snapshot, not a delta. +function partyMemberPosition(members = []) { + const partyMembers = members.filter((member) => member?.fetchId && member?.fetchLocX && member?.fetchLocY && member?.fetchLocZ); + const packet = new SendPacket(0xa7); + packet.writeD(partyMembers.length); + + partyMembers.forEach((member) => { + packet + .writeD(member.fetchId()) + .writeD(Math.round(member.fetchLocX())) + .writeD(Math.round(member.fetchLocY())) + .writeD(Math.round(member.fetchLocZ())); + }); + + return packet.fetchBuffer(); +} + +module.exports = partyMemberPosition; diff --git a/src/GameServer/Network/Response/index.js b/src/GameServer/Network/Response/index.js index bd31f4f8..a2f6cb24 100644 --- a/src/GameServer/Network/Response/index.js +++ b/src/GameServer/Network/Response/index.js @@ -83,6 +83,7 @@ module.exports = { partySmallWindowDelete: require('./PartySmallWindowDelete'), partySmallWindowDeleteAll: require('./PartySmallWindowDeleteAll'), partySmallWindowUpdate: require('./PartySmallWindowUpdate'), + partyMemberPosition: require('./PartyMemberPosition'), partySpelled: require('./PartySpelled'), abnormalStatusUpdate: require('./AbnormalStatusUpdate'), joinParty: require('./JoinParty'), diff --git a/src/GameServer/World/Generics/NpcBypasses/CompanionControl.js b/src/GameServer/World/Generics/NpcBypasses/CompanionControl.js index b8fa20f5..7d47c69b 100644 --- a/src/GameServer/World/Generics/NpcBypasses/CompanionControl.js +++ b/src/GameServer/World/Generics/NpcBypasses/CompanionControl.js @@ -120,6 +120,13 @@ function setPullMode(session, mode) { }); } +function setLootDistribution(session, distribution) { + const value = Number(distribution); + if (![0, 1, 2, 3, 4].includes(value)) return false; + PartyCompanionService.rebuildWindow(session, value); + return true; +} + function setMemberPuller(session, targetSession) { const role = BotRoles.inferRole(targetSession?.actor); if (!['tank', 'dagger', 'dps'].includes(role)) return false; @@ -243,6 +250,13 @@ function renderModePanel(settings, count) { { label: 'Off', active: settings.pullMode === 'off', command: 'companion-control pull off' } ], { columns: 3 }), Html.font(`Loot: ${lootLabel(settings.distribution)}`, Html.COLOR.muted), + actionRow([ + { label: 'Finders', active: settings.distribution === 0, command: 'companion-control loot 0' }, + { label: 'Random', active: settings.distribution === 1, command: 'companion-control loot 1' }, + { label: 'Random+Spoil', active: settings.distribution === 2, command: 'companion-control loot 2' }, + { label: 'By Turn', active: settings.distribution === 3, command: 'companion-control loot 3' }, + { label: 'Turn+Spoil', active: settings.distribution === 4, command: 'companion-control loot 4' } + ], { columns: 5 }), '' ].join(''); } @@ -324,6 +338,8 @@ function companionControl(session, parts) { setCombatMode(session, value); } else if (subCommand === 'pull') { setPullMode(session, value); + } else if (subCommand === 'loot') { + setLootDistribution(session, value); } else if (subCommand === 'member-pull') { const targetSession = findCompanion(session, parts[3]); if (value === 'on' && targetSession) { diff --git a/src/GameServer/World/World.js b/src/GameServer/World/World.js index 9fa7491b..4f91316f 100644 --- a/src/GameServer/World/World.js +++ b/src/GameServer/World/World.js @@ -163,7 +163,12 @@ const World = { } const wasResting = targetSession.plan === 'resting'; - PartyCompanionService.attach(session, targetSession, attachOptions); + if (!PartyCompanionService.attach(session, targetSession, attachOptions)) { + BotSocialMemory.recordEvent(session, targetSession, 'party_refused', 'party_full'); + session.dataSendToMe(ServerResponse.actionFailed()); + BotManager.botTell(targetSession, session, "Your party is full. Ask me again after making room."); + return false; + } BotSocialMemory.recordEvent(session, targetSession, 'party_formed', source); setTimeout(() => { diff --git a/tests/test_bot_skill_capabilities.js b/tests/test_bot_skill_capabilities.js new file mode 100644 index 00000000..843453fc --- /dev/null +++ b/tests/test_bot_skill_capabilities.js @@ -0,0 +1,42 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotSkillCapabilities = invoke('GameServer/Bot/AI/BotSkillCapabilities'); + +function skill(type, { power = 10, target = 'friendly', mp = 0 } = {}) { + return { + fetchPassive: () => false, + fetchSkillType: () => type, + fetchTargetKind: () => target, + fetchPower: () => power, + fetchConsumedMp: () => mp + }; +} + +const rechargeLow = skill('manaRecharge', { power: 20, mp: 10 }); +const rechargeHigh = skill('manaRecharge', { power: 60, mp: 20 }); +const damage = skill('damage', { power: 999 }); +const actor = { + fetchMp: () => 30, + canUseSkill: () => true, + skillset: { skills: [damage, rechargeLow, rechargeHigh] } +}; + +assert.strictEqual( + BotSkillCapabilities.manaRechargeSkill(actor), + rechargeHigh, + 'party support should select the strongest affordable learned Recharge skill' +); +assert.strictEqual( + BotSkillCapabilities.manaRechargeSkill({ ...actor, fetchMp: () => 15 }), + rechargeLow, + 'party support should not select a Recharge spell it cannot afford' +); +assert.strictEqual( + BotSkillCapabilities.manaRechargeSkill({ ...actor, skillset: { skills: [damage] } }), + null, + 'ordinary damage skills must never be treated as mana support' +); + +console.log('Bot skill capability checks passed'); diff --git a/tests/test_c4_protocol_packets.js b/tests/test_c4_protocol_packets.js index 65ef202e..e283d1fc 100644 --- a/tests/test_c4_protocol_packets.js +++ b/tests/test_c4_protocol_packets.js @@ -358,6 +358,14 @@ assert.strictEqual(partyUpdate.readInt32LE(updateVitalsOffset + 8), actor.fetchH assert.strictEqual(ServerResponse.partySmallWindowDeleteAll()[0], 0x50, 'C4 party delete all opcode should be 0x50'); assert.strictEqual(ServerResponse.partySmallWindowDelete(actor.fetchId(), actor.fetchName())[0], 0x51, 'C4 party delete opcode should be 0x51'); +const partyPositions = ServerResponse.partyMemberPosition([actor]); +assert.strictEqual(partyPositions[0], 0xa7, 'C4 PartyMemberPosition should use opcode 0xa7'); +assert.strictEqual(partyPositions.readInt32LE(1), 1, 'PartyMemberPosition should include the full member count'); +assert.strictEqual(partyPositions.readInt32LE(5), actor.fetchId(), 'PartyMemberPosition should include the member object id'); +assert.strictEqual(partyPositions.readInt32LE(9), actor.fetchLocX(), 'PartyMemberPosition should include X'); +assert.strictEqual(partyPositions.readInt32LE(13), actor.fetchLocY(), 'PartyMemberPosition should include Y'); +assert.strictEqual(partyPositions.readInt32LE(17), actor.fetchLocZ(), 'PartyMemberPosition should include Z'); + const partySpelled = ServerResponse.partySpelled(actor.fetchId(), [{ id: 1040, level: 2, duration: 120 }]); assert.strictEqual(partySpelled[0], 0xee, 'C4 PartySpelled opcode should be 0xee'); assert.strictEqual(partySpelled.readInt32LE(1), 0, 'PartySpelled should mark normal party member effects'); diff --git a/tests/test_party_capacity.js b/tests/test_party_capacity.js new file mode 100644 index 00000000..fc154998 --- /dev/null +++ b/tests/test_party_capacity.js @@ -0,0 +1,53 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotManager = invoke('GameServer/Bot/BotManager'); +const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); + +const originalSessions = BotManager.sessions; +try { + const leader = { actor: { fetchId: () => 1 } }; + const companions = Array.from({ length: PartyCompanionService.MAX_COMPANIONS }, (_, index) => ({ + actor: { fetchId: () => index + 2 }, + partyCompanion: true, + followPlayerSession: leader + })); + BotManager.sessions = companions; + + assert.strictEqual(PartyCompanionService.MAX_PARTY_MEMBERS, 9, 'hot party capacity should match the C4 nine-member party limit'); + assert.strictEqual(PartyCompanionService.hasCapacity(leader), false, 'a leader plus eight companions must be a full party'); + assert.strictEqual(PartyCompanionService.hasCapacity(leader, companions[0]), true, 'rebuilding an existing companion must not be rejected as a new ninth companion'); + + const directionalLeader = { + actor: { + fetchLocX: () => 1000, + fetchLocY: () => 2000, + fetchLocZ: () => -3000, + fetchHead: () => 16384 + } + }; + const directionalCompanion = { actor: { fetchId: () => 99 }, followPlayerSession: directionalLeader, partyCompanion: true }; + BotManager.sessions = [directionalCompanion]; + assert.deepStrictEqual( + PartyCompanionService.formationTargetFor(directionalCompanion), + { locX: 1070, locY: 1910, locZ: -3000, slot: 0 }, + 'formation offsets should rotate with the leader heading instead of staying fixed in world coordinates' + ); + + const tankCompanion = { + actor: { fetchId: () => 100, fetchClassId: () => 4 }, + followPlayerSession: directionalLeader, + partyCompanion: true + }; + BotManager.sessions = [tankCompanion]; + assert.deepStrictEqual( + PartyCompanionService.formationTargetFor(tankCompanion), + { locX: 1000, locY: 2090, locZ: -3000, slot: 0 }, + 'tanks should occupy the forward formation slot rather than the support line' + ); +} finally { + BotManager.sessions = originalSessions; +} + +console.log('Party capacity checks passed'); diff --git a/tests/test_party_companion_rest_follow.js b/tests/test_party_companion_rest_follow.js index e6036985..90d26c61 100644 --- a/tests/test_party_companion_rest_follow.js +++ b/tests/test_party_companion_rest_follow.js @@ -998,10 +998,10 @@ try { fetchRewardSp: () => 20 }); - assert.strictEqual(rewardLeader.fetchExp(), 50, 'leader should receive split exp when companion kills nearby mob'); - assert.strictEqual(rewardLeader.fetchSp(), 10, 'leader should receive split sp when companion kills nearby mob'); - assert.strictEqual(rewardBot.fetchExp(), 50, 'companion should keep its split exp from the kill'); - assert.strictEqual(rewardBot.fetchSp(), 10, 'companion should keep its split sp from the kill'); + assert.strictEqual(rewardLeader.fetchExp(), 65, 'two eligible party members should receive the C4 1.30 party EXP bonus'); + assert.strictEqual(rewardLeader.fetchSp(), 13, 'two eligible party members should receive the C4 1.30 party SP bonus'); + assert.strictEqual(rewardBot.fetchExp(), 65, 'companion should receive its squared-level share of the party EXP bonus'); + assert.strictEqual(rewardBot.fetchSp(), 13, 'companion should receive its squared-level share of the party SP bonus'); const partyHudLeader = fakeActor(2000030, { locX: 0, locY: 0 }); const partyHudLeaderSession = fakeSession('player_party_hud', partyHudLeader); diff --git a/tests/test_party_revival.js b/tests/test_party_revival.js index 72645500..5dd7fa04 100644 --- a/tests/test_party_revival.js +++ b/tests/test_party_revival.js @@ -34,6 +34,7 @@ function actor(id, { dead = false, skills = [], items = [] } = {}) { fetchMaxMp: () => 100, fetchMaxHp: () => 100, fetchHp() { return this.hp; }, + fetchDestId() { return this.destId; }, fillupVitals() { this.hp = 100; this.mp = 100; }, canUseSkill: () => true, select(data) { this.destId = data.id; }, @@ -101,12 +102,25 @@ try { const combatHeldResult = PartyRevivalService.tick(healerSession, leaderSession, { skillExec() {} }); assert.strictEqual(combatHeldResult.handled, false, 'a monster still fighting a fallen party member must block resurrection'); World.npc.spawns = []; - healer.state.fetchCombats = () => true; healer.state.fetchHits = () => true; + const staleActionResult = PartyRevivalService.tick(healerSession, leaderSession, { skillExec() {} }); + assert.strictEqual(staleActionResult.handled, true, 'a stale hit flag without a living hostile target must not strand a dead companion'); + assert.strictEqual(staleActionResult.source, 'skill', 'a stale hit flag must still allow the preferred resurrection skill'); + leaderSession.partyRevivalAttempt = null; + healer.destId = 3000100; + World.npc.spawns = [{ + fetchId: () => 3000100, + fetchAttackable: () => true, + isDead: () => false, + fetchDestId: () => null, + state: { fetchCombats: () => false } + }]; const activeActionHeldResult = PartyRevivalService.tick(healerSession, leaderSession, { skillExec() {} }); - assert.strictEqual(activeActionHeldResult.handled, false, 'a living companion still executing a hit must block resurrection even before the NPC target state is visible'); + assert.strictEqual(activeActionHeldResult.handled, false, 'a living companion still striking a living hostile target must block resurrection'); healer.state.fetchHits = () => false; + healer.destId = undefined; + World.npc.spawns = []; let skillCast = null; const skillResult = PartyRevivalService.tick(healerSession, leaderSession, { diff --git a/tests/test_party_rewards.js b/tests/test_party_rewards.js new file mode 100644 index 00000000..ea5f52c8 --- /dev/null +++ b/tests/test_party_rewards.js @@ -0,0 +1,32 @@ +const assert = require('assert'); + +require('../src/Global'); + +const NpcDied = invoke('GameServer/Actor/Generics/NpcDied'); + +function member(level) { + return { actor: { fetchLevel: () => level } }; +} + +const equalLevelShares = NpcDied.partyRewardShares([member(20), member(20)], 100, 20); +assert.deepStrictEqual( + equalLevelShares.map(({ exp, sp }) => ({ exp, sp })), + [{ exp: 65, sp: 13 }, { exp: 65, sp: 13 }], + 'two same-level members should split the C4 1.30 party reward bonus equally' +); + +const weightedShares = NpcDied.partyRewardShares([member(20), member(30)], 130, 26); +assert.deepStrictEqual( + weightedShares.map(({ exp, sp }) => ({ exp, sp })), + [{ exp: 52, sp: 10 }, { exp: 117, sp: 23 }], + 'eligible members should receive party rewards proportionally to squared level' +); + +const cutoffShares = NpcDied.partyRewardShares([member(20), member(1)], 100, 20); +assert.deepStrictEqual( + cutoffShares.map(({ session, exp, sp }) => ({ level: session.actor.fetchLevel(), exp, sp })), + [{ level: 20, exp: 100, sp: 20 }], + 'an extreme low-level passenger should be excluded by the automatic party cutoff' +); + +console.log('Party reward checks passed'); From a64e88ee2862216e3a9698bbfc2ca929f0cc1751 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:30:23 -0400 Subject: [PATCH 13/13] Stabilize hot party coordination --- scripts/run-tests.js | 4 + src/GameServer/Bot/AI/BotSupportPlanner.js | 16 ++- src/GameServer/Bot/AI/PartyCombatState.js | 20 +++- .../Bot/AI/PartyCompanionService.js | 62 +++++++--- src/GameServer/Bot/AI/PartyPulling.js | 69 +++++++++--- src/GameServer/Bot/AI/PartyRevivalService.js | 7 +- .../Bot/AI/States/FollowingState.js | 95 +++++++++++----- src/GameServer/Network/Response/NpcHtml.js | 20 +++- src/GameServer/Npc/Npc.js | 13 ++- src/GameServer/Session.js | 9 +- .../Generics/NpcBypasses/CompanionControl.js | 84 ++++++++------ src/GameServer/World/World.js | 3 +- tests/test_bot_status_bypass.js | 30 +++++ tests/test_bot_support_planner.js | 15 +++ tests/test_c4_protocol_packets.js | 4 + tests/test_party_bot_loot.js | 56 +++++++-- tests/test_party_companion_rest_follow.js | 41 ++++--- tests/test_party_hud_throttle.js | 101 +++++++++++++++++ tests/test_party_pull_pause.js | 106 ++++++++++++++++++ tests/test_party_revival.js | 28 +++++ 20 files changed, 650 insertions(+), 133 deletions(-) create mode 100644 tests/test_bot_status_bypass.js create mode 100644 tests/test_party_hud_throttle.js create mode 100644 tests/test_party_pull_pause.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 0697ac78..657f83b1 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -103,6 +103,10 @@ const tests = [ 'tests/test_party_companion_rest_follow.js', 'tests/test_party_buff_targets.js', 'tests/test_party_bot_loot.js', + 'tests/test_party_hud_throttle.js', + 'tests/test_party_pull_pause.js', + 'tests/test_party_revival.js', + 'tests/test_bot_status_bypass.js', 'tests/test_path_obstacle.js', 'tests/test_pathfinder_astar.js', 'tests/test_player_ranged_combat.js', diff --git a/src/GameServer/Bot/AI/BotSupportPlanner.js b/src/GameServer/Bot/AI/BotSupportPlanner.js index fd01ceb5..72391aa6 100644 --- a/src/GameServer/Bot/AI/BotSupportPlanner.js +++ b/src/GameServer/Bot/AI/BotSupportPlanner.js @@ -5,6 +5,10 @@ const REFRESH_THRESHOLD_MS = 2 * 60 * 1000; const CAST_RESERVATION_MS = 5000; const PENDING_SUPPORT_CAST_TIMEOUT_MS = 30000; const MIN_SUPPORT_MP_RATIO = 0.35; +// These effects are situational utility, not part of the ordinary field +// package. Keeping them out of the party planner prevents a buffer/healer +// from spending MP and pausing pulls for a buff the group cannot use. +const EXCLUDED_PARTY_BUFF_EFFECTS = new Set(['kiss_of_eva']); const PHYSICAL_ROLES = new Set(['tank', 'dagger', 'archer', 'dps']); const CASTER_ROLES = new Set(['mage', 'healer', 'buffer']); @@ -47,7 +51,10 @@ function supportSkills(actor) { // the support planner request it continuously and pauses pulling. const skillType = skill.fetchSkillType?.(); const periodicHeal = skillType === 'hot' || skillType === 'healHot' || skillType === 'manaHot'; - return !periodicHeal && semantic?.effectType === 'buff' && ['friendly', 'ally', 'party'].includes(semantic.target); + return !periodicHeal && + semantic?.effectType === 'buff' && + !EXCLUDED_PARTY_BUFF_EFFECTS.has(semantic.effect) && + ['friendly', 'ally', 'party'].includes(semantic.target); }); } @@ -82,9 +89,14 @@ function overlaps(effect, keys) { function needsSkill(target, skill) { const keys = statKeys(skill); const level = Number(skill.fetchLevel?.() || 1); + const skillId = Number(skill.fetchSelfId?.() || 0); const semantic = skill.fetchSemantic?.() || {}; const current = EffectStore.list(target, { includeDebuffs: false }) - .filter((effect) => overlaps(effect, keys)); + // The effect id is the authoritative identity for a completed cast. + // Keep the stat/effect-key fallback for old saved effects, but do not + // re-request a buff merely because an older payload lacked its modern + // semantic stat keys. + .filter((effect) => Number(effect.id || 0) === skillId || overlaps(effect, keys)); // `activeBuffs` is retained for packet/UI compatibility only. It can outlive // an effect after death, dispel, or an interrupted cast, so support decisions diff --git a/src/GameServer/Bot/AI/PartyCombatState.js b/src/GameServer/Bot/AI/PartyCombatState.js index 11994b70..87149e39 100644 --- a/src/GameServer/Bot/AI/PartyCombatState.js +++ b/src/GameServer/Bot/AI/PartyCombatState.js @@ -43,6 +43,17 @@ function isHostileNpc(npc) { return !!npc && npc.fetchAttackable?.() === true && npc.isDead?.() !== true; } +function distance2d(a, b) { + const dx = Number(a?.fetchLocX?.() || 0) - Number(b?.fetchLocX?.() || 0); + const dy = Number(a?.fetchLocY?.() || 0) - Number(b?.fetchLocY?.() || 0); + return Math.sqrt((dx * dx) + (dy * dy)); +} + +// A monster still standing over a corpse is a real resurrection danger. Its +// lingering combat flag after the party has moved on is not. Keep this close +// to the normal threat radius so a stale target cannot strand a party forever. +const CORPSE_COMBAT_DANGER_DISTANCE = 1400; + function travellingPull(leaderSession) { const pull = leaderSession?.partyPullState || {}; return { @@ -95,9 +106,6 @@ function combatState(leaderSession, options = {}) { for (const memberSession of living) { if (ignoredPullAction(memberSession, pull, options)) continue; - if (memberSession.actor?.state?.fetchCombats?.() === true) { - return { active: true, reason: 'member_combat_state', memberSession }; - } const target = activeActionTarget(memberSession); if (target && !ignoredTargetIds.has(actorId(target))) { return { active: true, reason: 'member_action_against_hostile', target, memberSession }; @@ -107,11 +115,13 @@ function combatState(leaderSession, options = {}) { // A mob may keep its combat state on a corpse immediately after the // lethal hit. Include dead members here so a resurrection cannot begin // in front of that mob, while still ignoring stale action flags. - const partyIds = new Set(members.map((member) => actorId(member.actor)).filter(Boolean)); + const fallenMembers = members.filter((member) => !isAlive(member)); + const fallenIds = new Set(fallenMembers.map((member) => actorId(member.actor)).filter(Boolean)); const attackingCorpse = (world().npc?.spawns || []).find((npc) => ( isHostileNpc(npc) && npc.state?.fetchCombats?.() === true && - partyIds.has(Number(npc.fetchDestId?.() || 0)) && + fallenIds.has(Number(npc.fetchDestId?.() || 0)) && + fallenMembers.some((member) => distance2d(npc, member.actor) <= CORPSE_COMBAT_DANGER_DISTANCE) && !ignoredTargetIds.has(actorId(npc)) )); if (attackingCorpse) { diff --git a/src/GameServer/Bot/AI/PartyCompanionService.js b/src/GameServer/Bot/AI/PartyCompanionService.js index 319bd952..393cc880 100644 --- a/src/GameServer/Bot/AI/PartyCompanionService.js +++ b/src/GameServer/Bot/AI/PartyCompanionService.js @@ -18,6 +18,7 @@ const RANDOM_LOOT_DISTRIBUTIONS = new Set([1, 2]); const MAX_PARTY_MEMBERS = 9; const MAX_COMPANIONS = MAX_PARTY_MEMBERS - 1; const PARTY_POSITION_UPDATE_DISTANCE = 150; +const PARTY_MEMBER_UPDATE_INTERVAL_MS = 1000; const FORMATION_OFFSETS = [ { locX: -90, locY: -70 }, { locX: -90, locY: 70 }, @@ -204,7 +205,13 @@ function canPickGroundLoot(session, leaderSession, item) { ['approach', 'aggro', 'return'].includes(pullState.phase) && Number(actor?.fetchId?.()) === Number(pullState.pullerId || 0) ) return false; - if (actor?.storedPickup) return false; + // Companion pickup uses the server-side queue. A stale storedPickup is + // from the player ValidatePosition path and will never complete for a bot; + // leaving it in place permanently excludes that companion from all later + // ground drops. + if (actor?.storedPickup) { + delete actor.storedPickup; + } return distance2d(actor, item) <= PARTY_LOOT_RADIUS; } @@ -254,9 +261,14 @@ function reconcileGroundLoot(looterSession) { const now = Date.now(); if (now - Number(leaderSession.lastGroundLootScanAt || 0) < GROUND_LOOT_SCAN_INTERVAL_MS) return 0; + const items = availableGroundLoot(leaderSession); + // This shared timestamp protects the hot party from every companion + // walking the entire world-item list on every AI tick. Fresh NPC drops do + // not wait for this scan: NpcRewards queues them directly at spawn. leaderSession.lastGroundLootScanAt = now; + if (items.length === 0) return 0; - return availableGroundLoot(leaderSession) + return items .reduce((assigned, item) => assigned + Number(!!queueRandomGroundPickup(leaderSession, item)), 0); } @@ -284,16 +296,12 @@ function startQueuedGroundPickup(pickerSession) { return false; } // Re-check transient plans at execution time. A queue may have been - // built while following and become stale after the player assigns this - // bot as the puller or it starts a town/support action. + // built while following and become stale after it starts a town/support + // action. Merely assigning a bot as puller is not combat: when no pull + // is in progress it may collect ground loot like every other companion. const pullState = leaderSession?.partyPullState || {}; - const settings = settingsForLeader(leaderSession); if ( ['getting_buffed', 'shopping', 'merchant'].includes(pickerSession.plan) || - ( - settings.pullMode === 'bot' && - Number(settings.pullerId || 0) === Number(picker.fetchId?.()) - ) || ( ['approach', 'aggro', 'return'].includes(pullState.phase) && Number(picker.fetchId?.()) === Number(pullState.pullerId || 0) @@ -319,6 +327,10 @@ function startQueuedGroundPickup(pickerSession) { } pickerSession.partyGroundPickupInProgress = false; startQueuedGroundPickup(pickerSession); + // A completed queue entry can make another idle ground drop eligible + // immediately. Do not wait for a later AI cadence just because the + // original drop was assigned while the party was still fighting. + reconcileGroundLoot(pickerSession); }); return true; } @@ -425,6 +437,23 @@ function sendPartyWindow(leaderSession, distribution = 0) { } } +function restoreJoiningCompanion(session, bot) { + bot.automation?.stopReplenish?.(); + // Joining the player party is a recovery convenience, not a revive or + // level-up: restore exactly the requested HP and MP, leaving CP intact. + if (typeof bot.setHp === 'function' && typeof bot.fetchMaxHp === 'function') { + bot.setHp(bot.fetchMaxHp()); + } + if (typeof bot.setMp === 'function' && typeof bot.fetchMaxMp === 'function') { + bot.setMp(bot.fetchMaxMp()); + } + + if (bot.state?.fetchSeated?.() === true) { + bot.state.setSeated(false); + session?.dataSendToMeAndOthers?.(ServerResponse.sitAndStand(bot), bot); + } +} + function renderPanel(leaderSession) { if (!leaderSession?.actor) return; try { @@ -591,7 +620,6 @@ const PartyCompanionService = { if (!hasCapacity(leaderSession, companionSession)) return false; const previousLeader = companionSession.followPlayerSession; - const wasResting = companionSession.plan === 'resting'; const distribution = hasOwn(options, 'distribution') ? setDistribution(leaderSession, options.distribution) : distributionForLeader(leaderSession); @@ -600,7 +628,8 @@ const PartyCompanionService = { leaderSession.dataSendToMe(ServerResponse.joinParty(distribution)); } - companionSession.plan = wasResting ? 'resting' : 'following'; + restoreJoiningCompanion(companionSession, bot); + companionSession.plan = 'following'; companionSession.followPlayerSession = leaderSession; companionSession.partyCompanion = true; companionSession.botStay = false; @@ -672,9 +701,14 @@ const PartyCompanionService = { const leaderSession = companionSession.followPlayerSession; if (!leaderSession.actor?.fetchIsOnline?.()) return false; - leaderSession.dataSendToMe(ServerResponse.partySmallWindowUpdate(companionSession.actor)); - leaderSession.dataSendToMe(ServerResponse.partySpelled.fromActor(companionSession.actor)); - sendPartyPositions(leaderSession, companionSession, true); + const now = Date.now(); + if (now - Number(companionSession.lastPartyMemberUpdateAt || 0) >= PARTY_MEMBER_UPDATE_INTERVAL_MS) { + leaderSession.dataSendToMe(ServerResponse.partySmallWindowUpdate(companionSession.actor)); + companionSession.lastPartyMemberUpdateAt = now; + } + // PartySpelled is sent by updateActorEffects when an effect actually + // changes. Re-emitting it every bot AI tick flooded the C4 client. + sendPartyPositions(leaderSession, companionSession, false); return true; } }; diff --git a/src/GameServer/Bot/AI/PartyPulling.js b/src/GameServer/Bot/AI/PartyPulling.js index 0e5647f5..d2624eed 100644 --- a/src/GameServer/Bot/AI/PartyPulling.js +++ b/src/GameServer/Bot/AI/PartyPulling.js @@ -10,6 +10,12 @@ const PULL_CONTACT_DISTANCE = 260; const PULL_RETURN_DISTANCE = 180; const PULL_AGGRO_TIMEOUT_MS = 8000; const PULL_MOVE_TARGET_DRIFT = 200; +const PULL_ABANDON_DISTANCE = 5000; +// This is a delivery radius, not the exact native hit range. Once an +// incoming mob is this close to a melee companion, releasing the shared +// target lets the normal combat action close the final few steps instead of +// holding the entire party for coordinate-perfect overlap. +const PULL_DELIVERY_MELEE_DISTANCE = 250; function point(actor) { return { @@ -26,6 +32,12 @@ function distance(a, b) { return Math.sqrt((dx * dx) + (dy * dy) + (dz * dz)); } +function distance2d(a, b) { + const dx = a.locX - b.locX; + const dy = a.locY - b.locY; + return Math.sqrt((dx * dx) + (dy * dy)); +} + function enabled(settings) { return settings?.pullMode === 'bot' || settings?.pullMode === 'leader'; } @@ -78,7 +90,10 @@ function clearFinishedTarget(leaderSession) { const state = pullState(leaderSession); const npc = npcById(state.targetId); if (!state.targetId) return null; - if (!npc || npc.isDead?.()) { + // The leader can relocate (teleport, town respawn, zone transfer) while a + // pull target remains alive in the old region. Do not let that orphaned + // id keep the party in combat or make a bot attack a despawned entity. + if (!npc || npc.isDead?.() || distance(point(leaderSession.actor), point(npc)) > PULL_ABANDON_DISTANCE) { leaderSession.partyPullState = {}; return null; } @@ -128,6 +143,7 @@ function supportProviders(leaderSession) { function pauseReason(leaderSession, puller) { const state = pullState(leaderSession); + if (leaderSession?.actor?.isDead?.()) return 'party_revival'; const recovery = leaderSession?.partyRecoveryCast; if (Number(recovery?.expiresAt || 0) > Date.now()) return 'party_recharging'; if (recovery) delete leaderSession.partyRecoveryCast; @@ -141,13 +157,15 @@ function pauseReason(leaderSession, puller) { if (combat.active) return 'party_under_attack'; const members = PartyAwareness.partySessions(leaderSession); - if (members.some((memberSession) => ( - memberSession !== leaderSession && ( - memberSession.actor?.state?.fetchSeated?.() || - memberSession.plan === 'resting' || - memberSession.plan === 'getting_buffed' - ) - ))) { + const seatedMembers = members.filter((memberSession) => ( + memberSession.actor?.state?.fetchSeated?.() === true + )); + const pullerIsSeated = puller?.actor?.state?.fetchSeated?.() === true; + // A single support companion may sit briefly without halting the camp. + // Stop only when the designated puller is regenerating or a material + // fraction of the whole party is seated at the same time. Plans and low + // HP/MP are intentionally not signals here: both can outlive the action. + if (pullerIsSeated || (members.length > 0 && seatedMembers.length / members.length > 0.4)) { return 'party_recovering'; } @@ -161,19 +179,31 @@ function pauseReason(leaderSession, puller) { return null; } +function cancelForRevival(leaderSession) { + if (!leaderSession?.partyPullState || Object.keys(leaderSession.partyPullState).length === 0) return false; + leaderSession.partyPullState = {}; + return true; +} + function attackRange(actor, target) { const role = BotRoles.inferRole(actor); const combat = BotCombatUtility.select(actor, target, role); - if (Number.isFinite(Number(combat?.range))) return Number(combat.range); - - // This is the same fallback used by BotAI.executeCombat: archers pass a - // ranged basic attack, while every other role uses the native melee - // attack, whose scheduled range is zero. - return role === 'archer' ? 700 : 0; + const skillRange = Number(combat?.range); + // This predicate decides whether normal combat may begin, rather than + // whether its first selected skill can land in place. A melee skill may + // have a native 40-50 range, but once the mob reaches the delivery radius + // BotAI.executeCombat will close that final distance itself. Otherwise a + // ready Power Strike can make the party hold a successfully delivered mob + // forever. Preserve longer spell/archer ranges for actual ranged combat. + const baselineRange = role === 'archer' ? 700 : PULL_DELIVERY_MELEE_DISTANCE; + return Number.isFinite(skillRange) ? Math.max(baselineRange, skillRange) : baselineRange; } function actorCanEngage(actor, target) { - return !!actor && !!target && distance(point(actor), point(target)) <= attackRange(actor, target); + // Combat ranges are horizontal. World/map Z may temporarily differ while + // a mob is traversing a slope, and treating that as distance made a mob at + // the camp remain permanently "not delivered". + return !!actor && !!target && distance2d(point(actor), point(target)) <= attackRange(actor, target); } function canDeliverPull(actor, target) { @@ -183,12 +213,12 @@ function canDeliverPull(actor, target) { return actorCanEngage(actor, target); } -function targetIsEngageable(leaderSession, target, puller) { +function targetIsEngageable(leaderSession, target, puller, { includePuller = false } = {}) { if (!target) return false; return PartyAwareness.partyActors(leaderSession) // A leader pull has no return phase to synchronize. Release only when // a companion can actually strike the player-designated target. - .filter((actor) => actor !== leaderSession.actor && actor !== puller?.actor) + .filter((actor) => actor !== leaderSession.actor && (includePuller || actor !== puller?.actor)) .some((actor) => canDeliverPull(actor, target)); } @@ -360,7 +390,9 @@ function current(leaderSession, settings) { // party. Release only companions that can strike from their own current // position; the rest keep following the leader instead of chasing it. const engageable = state.source === 'bot' - ? state.phase === 'engage' || targetIsEngageable(leaderSession, target, puller) + ? state.phase === 'engage' + ? targetIsEngageable(leaderSession, target, puller, { includePuller: true }) + : targetIsEngageable(leaderSession, target, puller) : targetIsEngageable(leaderSession, target, puller); return { enabled: true, @@ -384,5 +416,6 @@ module.exports = { actorCanEngage, canDeliverPull, attackRange, + cancelForRevival, PULL_AGGRO_TIMEOUT_MS }; diff --git a/src/GameServer/Bot/AI/PartyRevivalService.js b/src/GameServer/Bot/AI/PartyRevivalService.js index 5b8c0246..2a84c783 100644 --- a/src/GameServer/Bot/AI/PartyRevivalService.js +++ b/src/GameServer/Bot/AI/PartyRevivalService.js @@ -120,7 +120,12 @@ function tick(session, leaderSession, Generics) { const attempt = leaderSession.partyRevivalAttempt; if (attempt) return { handled: attempt.providerId === session.actor.fetchId(), waiting: true, targetId: attempt.targetId }; - const targetSession = dead.sort((a, b) => Number(a.actor.fetchId()) - Number(b.actor.fetchId()))[0]; + // The leader is the party's anchor. Restore them first even if another + // companion happens to have a lower character id. + const targetSession = dead.sort((a, b) => ( + Number(b === leaderSession) - Number(a === leaderSession) || + Number(a.actor.fetchId()) - Number(b.actor.fetchId()) + ))[0]; const providers = partySessions(leaderSession) .filter(isAlive) .filter((memberSession) => memberSession !== leaderSession) diff --git a/src/GameServer/Bot/AI/States/FollowingState.js b/src/GameServer/Bot/AI/States/FollowingState.js index 0f49db07..66ada030 100644 --- a/src/GameServer/Bot/AI/States/FollowingState.js +++ b/src/GameServer/Bot/AI/States/FollowingState.js @@ -19,6 +19,7 @@ const FOLLOW_RUN_DISTANCE = 250; const FOLLOW_RETARGET_DISTANCE = 900; const FOLLOW_TARGET_DRIFT = 650; const FOLLOW_TELEPORT_DISTANCE = 4500; +const FOLLOW_FORMATION_TOLERANCE = 45; const STUCK_SAMPLE_INTERVAL_MS = 750; // Newbie Guides only exist in the starter villages. A companion should not // abandon a player in the field just because its starter buffs have expired. @@ -361,6 +362,23 @@ function partySupportMembers(leaderSession, puller) { return PartyPulling.supportMembers(leaderSession, puller); } +function moveToFollowTarget(session, bot, player) { + const followTarget = followTargetFor(session, player); + // Formation positions are deliberately offset from the leader. Comparing + // only against the leader made a bot repeatedly path to its current + // position once it had reached that offset. + if (distance2d(loc(bot), followTarget) <= FOLLOW_FORMATION_TOLERANCE) { + session.lastFollowMoveTarget = null; + return false; + } + session.lastFollowMoveTarget = followTarget; + bot.moveTo({ + from: { locX: bot.fetchLocX(), locY: bot.fetchLocY(), locZ: bot.fetchLocZ() }, + to: followTarget + }); + return true; +} + function manaPriority(entry, pullerActor) { const role = BotRoles.inferRole(entry.actor); if (entry.actor === pullerActor) return 0; @@ -469,6 +487,43 @@ module.exports = { } const player = playerSession.actor; + if (player.isDead?.()) { + // Do this before the provider is selected: a solo healer can be + // the first and only companion tick after the leader dies. + PartyPulling.cancelForRevival(playerSession); + } + // Resurrection is autonomous. It must win over follow/catch-up and + // over a stale pull target; chat is only conversation, never a switch + // required to make companions revive their leader. + const revival = PartyRevivalService.tick(session, playerSession, Generics); + if (revival.handled) { + recordRoleDecision(session, bot, 'resurrect_party', revival.source || 'waiting', { + targetId: revival.target?.fetchId?.() || revival.targetId || null + }); + return; + } + if (player.isDead?.()) { + if (!revival.blockedBy) { + session.currentTargetId = undefined; + bot.unselect(); + bot.attack?.abortCast?.(session, bot); + bot.attack?.clearTimers?.(); + bot.state?.setHits?.(false); + bot.state?.setCasts?.(false); + bot.automation?.abortAll?.(bot); + } + recordRoleDecision( + session, + bot, + 'wait_for_resurrection', + revival.blockedBy ? `blocked_${revival.blockedBy}` : 'awaiting_resurrection', + { targetId: player.fetchId() } + ); + // During an actual fight, leave the normal combat branch active + // so the living party can finish it. Once it clears, the next tick + // immediately schedules the prioritized leader resurrection. + if (!revival.blockedBy) return; + } const role = BotRoles.inferRole(bot); const distance = point(bot).distance(point(player)); const partySettings = PartyCompanionService.getSettings(playerSession); @@ -577,25 +632,12 @@ module.exports = { const leaderSeated = player.state?.fetchSeated?.() === true; const botRecovering = botVitals.hpRatio < 0.95 || botVitals.mpRatio < 0.95; - const revival = PartyRevivalService.tick(session, playerSession, Generics); - if (revival.handled) { - recordRoleDecision(session, bot, 'resurrect_party', revival.source || 'waiting', { - targetId: revival.target?.fetchId?.() || revival.targetId || null - }); - return; - } - if (session.returnToPartyAfterSupport && !isBusy(bot)) { session.returnToPartyAfterSupport = false; session.currentTargetId = undefined; bot.unselect(); if (distance > FOLLOW_RUN_DISTANCE) { - const followTarget = followTargetFor(session, player); - session.lastFollowMoveTarget = followTarget; - bot.moveTo({ - from: { locX: bot.fetchLocX(), locY: bot.fetchLocY(), locZ: bot.fetchLocZ() }, - to: followTarget - }); + moveToFollowTarget(session, bot, player); recordRoleDecision(session, bot, 'follow_leader', 'return_after_support'); return; } @@ -609,12 +651,7 @@ module.exports = { standUp(session, bot); recordRoleDecision(session, bot, 'rest_with_leader', 'move_near_sitting_leader'); if (!shouldKeepCurrentFollowMove(session, bot, player, distance)) { - const followTarget = followTargetFor(session, player); - session.lastFollowMoveTarget = followTarget; - bot.moveTo({ - from: { locX: bot.fetchLocX(), locY: bot.fetchLocY(), locZ: bot.fetchLocZ() }, - to: followTarget - }); + moveToFollowTarget(session, bot, player); } return; } @@ -1095,16 +1132,16 @@ module.exports = { if (!keepRoleDecision) { recordRoleDecision(session, bot, 'follow_leader', 'keep_range'); } - if (shouldKeepCurrentFollowMove(session, bot, player, distance)) { - session.lastFollowMoveHeldAt = Date.now(); - return; - } const followTarget = followTargetFor(session, player); - session.lastFollowMoveTarget = followTarget; - bot.moveTo({ - from: { locX: bot.fetchLocX(), locY: bot.fetchLocY(), locZ: bot.fetchLocZ() }, - to: followTarget - }); + if (distance2d(loc(bot), followTarget) <= FOLLOW_FORMATION_TOLERANCE) { + session.lastFollowMoveTarget = null; + } else { + if (shouldKeepCurrentFollowMove(session, bot, player, distance)) { + session.lastFollowMoveHeldAt = Date.now(); + return; + } + moveToFollowTarget(session, bot, player); + } } else { session.lastFollowMoveTarget = null; if (!keepRoleDecision) { diff --git a/src/GameServer/Network/Response/NpcHtml.js b/src/GameServer/Network/Response/NpcHtml.js index 87f7ebf1..c7a60df3 100644 --- a/src/GameServer/Network/Response/NpcHtml.js +++ b/src/GameServer/Network/Response/NpcHtml.js @@ -1,14 +1,30 @@ const SendPacket = invoke('Packet/Send'); +// C4 NpcHtmlMessage is limited to 8192 characters. The original server +// explicitly rejects a longer body because the client can crash while parsing +// it; UI callers should paginate before they reach this guard. +const MAX_NPC_HTML_LENGTH = 8192; + function npcHtml(id, html) { const packet = new SendPacket(0x0f); + const source = String(html || ''); + const safeHtml = source.length > MAX_NPC_HTML_LENGTH + ? 'Page is too large. Please reopen this window.' + : source; + + if (source.length > MAX_NPC_HTML_LENGTH) { + utils.infoWarn('NpcHtml', 'blocked oversized C4 HTML id=%d chars=%d limit=%d', id, source.length, MAX_NPC_HTML_LENGTH); + } packet .writeD(id) - .writeS(html) + .writeS(safeHtml) .writeD(0); - return packet.fetchBuffer(); + const buffer = packet.fetchBuffer(); + buffer.__packetTrace = `id=${id}:chars=${safeHtml.length}${safeHtml === source ? '' : ':truncated'}`; + return buffer; } module.exports = npcHtml; +module.exports.MAX_NPC_HTML_LENGTH = MAX_NPC_HTML_LENGTH; diff --git a/src/GameServer/Npc/Npc.js b/src/GameServer/Npc/Npc.js index 3bf6f9b3..2e773009 100644 --- a/src/GameServer/Npc/Npc.js +++ b/src/GameServer/Npc/Npc.js @@ -12,6 +12,11 @@ const EffectStats = invoke('GameServer/Effects/EffectStats'); const EffectRestrictions = invoke('GameServer/Effects/EffectRestrictions'); const AttackHelper = new Attack(); +// MoveToPawn already tells the client to follow a moving target. Rebuilding +// that request on every 100ms combat tick yields visible stop/start jitter. +const CHASE_REPATH_DISTANCE = 120; +const CHASE_REPATH_INTERVAL_MS = 300; + class Npc extends NpcModel { constructor(id, data) { // Parent inheritance @@ -92,6 +97,7 @@ class Npc extends NpcModel { locY: 0, locZ: 0, }; + let lastChaseRepathAt = 0; this.timer.combat = setInterval(() => { if (new SpeckMath.Point(this.fetchLocX(), this.fetchLocY()).distance(new SpeckMath.Point(actor.fetchLocX(), actor.fetchLocY())) >= 1500) { @@ -108,7 +114,10 @@ class Npc extends NpcModel { const newDstZ = actor.fetchLocZ(); if (this.state.inMotion()) { - if (coords.locX !== newDstX || coords.locY !== newDstY) { + const targetDrift = new SpeckMath.Point(coords.locX, coords.locY) + .distance(new SpeckMath.Point(newDstX, newDstY)); + const canRepath = Date.now() - lastChaseRepathAt >= CHASE_REPATH_INTERVAL_MS; + if (targetDrift >= CHASE_REPATH_DISTANCE && canRepath) { const progress = Math.min(1, Math.max(0, Number(this.automation.fetchDistanceRatio()) || 0)); this.setLocXYZ( new SpeckMath.Point3D(this.fetchLocX(), this.fetchLocY(), this.fetchLocZ()) @@ -120,6 +129,7 @@ class Npc extends NpcModel { // scheduled move ended. Freeze the client at exactly // that position before scheduling the next chase leg. this.stopForCombatAction(session); + lastChaseRepathAt = Date.now(); } return; } @@ -127,6 +137,7 @@ class Npc extends NpcModel { coords.locX = newDstX; coords.locY = newDstY; coords.locZ = newDstZ; + lastChaseRepathAt = Date.now(); const combatSkill = this.selectCombatSkill(actor); const actionRange = combatSkill ? this.fetchSkillCastRange(combatSkill, actor) : this.fetchCombatAttackRange(actor); diff --git a/src/GameServer/Session.js b/src/GameServer/Session.js index f4a1bde8..8ff639ea 100644 --- a/src/GameServer/Session.js +++ b/src/GameServer/Session.js @@ -282,11 +282,18 @@ class Session { utils.infoWarn('GameServer', 'connection closed'); } if (this.actor) { + // Companion social events are persisted asynchronously. Preserve + // the identity before the actor is destroyed so a normal network + // disconnect cannot overwrite the remembered player name. + this.characterId = this.characterId || this.actor.fetchId?.(); + this.characterName = this.characterName || this.actor.fetchName?.(); this.persistCharacterStatus({ currentOnly: true }); invoke('GameServer/Effects/EffectTicker').clearAll(this.actor); const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); PartyCompanionService.detachAll(this, { - event: 'party_dismissed', + // A connection close is not the player dismissing companions. + // It must not lower trust or trigger the abandonment cooldown. + event: 'party_disconnected', source: 'leader_disconnect', message: 'My companion disconnected. Returning to hunt.', rebuildWindow: false, diff --git a/src/GameServer/World/Generics/NpcBypasses/CompanionControl.js b/src/GameServer/World/Generics/NpcBypasses/CompanionControl.js index 7d47c69b..a64664bf 100644 --- a/src/GameServer/World/Generics/NpcBypasses/CompanionControl.js +++ b/src/GameServer/World/Generics/NpcBypasses/CompanionControl.js @@ -6,10 +6,27 @@ const BotStatus = invoke('GameServer/Bot/AI/BotStatus'); const Html = invoke('GameServer/World/Generics/HtmlKit'); const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); +// C4 limits NpcHtmlMessage to 8192 characters. Five compact party cards fit +// safely; a full eight-member party therefore uses a second page. +const COMPANIONS_PER_PAGE = 5; +const PARTY_ROLE_PRIORITY = { + tank: 0, + buffer: 1, + healer: 2 +}; + function companionSessions(session) { return PartyCompanionService.membersForLeader(session); } +function orderedCompanionSessions(session) { + return [...companionSessions(session)].sort((left, right) => { + const leftRole = BotRoles.inferRole(left.actor); + const rightRole = BotRoles.inferRole(right.actor); + return (PARTY_ROLE_PRIORITY[leftRole] ?? 3) - (PARTY_ROLE_PRIORITY[rightRole] ?? 3); + }); +} + function findCompanion(session, botName) { const lookup = String(botName || '').trim().toLowerCase(); if (!lookup) return null; @@ -120,13 +137,6 @@ function setPullMode(session, mode) { }); } -function setLootDistribution(session, distribution) { - const value = Number(distribution); - if (![0, 1, 2, 3, 4].includes(value)) return false; - PartyCompanionService.rebuildWindow(session, value); - return true; -} - function setMemberPuller(session, targetSession) { const role = BotRoles.inferRole(targetSession?.actor); if (!['tank', 'dagger', 'dps'].includes(role)) return false; @@ -184,16 +194,6 @@ function compactText(value, fallback = 'idle') { .slice(0, 32); } -function lootLabel(distribution) { - return ({ - 0: 'Finders', - 1: 'Random', - 2: 'Random+Spoil', - 3: 'By Turn', - 4: 'Turn+Spoil' - })[distribution] || `Native #${distribution}`; -} - function actionRow(items, options = {}) { const columns = options.columns || items.length; const width = Math.floor(Html.WIDTH / columns); @@ -211,6 +211,22 @@ function actionRow(items, options = {}) { return Html.table([Html.row(cells)]); } +function pageNavigation(command, page, totalPages, label) { + if (totalPages <= 1) return ''; + + return Html.columns([ + Html.cell( + page > 0 ? Html.link('Prev', `${command} ${page - 1}`, { color: Html.COLOR.link }) : '', + { width: 80, align: 'left' } + ), + Html.cell(Html.font(`${label} ${page + 1}/${totalPages}`, Html.COLOR.muted), { width: 110, align: 'center' }), + Html.cell( + page + 1 < totalPages ? Html.link('Next', `${command} ${page + 1}`, { color: Html.COLOR.link }) : '', + { width: 80, align: 'right' } + ) + ]) + ''; +} + function renderModePanel(settings, count) { const title = `${Html.font('Party Control', Html.COLOR.title)} ${Html.font(`${count} active`, Html.COLOR.ok)}`; const summary = [ @@ -249,14 +265,6 @@ function renderModePanel(settings, count) { { label: 'Player', active: settings.pullMode === 'leader', command: 'companion-control pull leader' }, { label: 'Off', active: settings.pullMode === 'off', command: 'companion-control pull off' } ], { columns: 3 }), - Html.font(`Loot: ${lootLabel(settings.distribution)}`, Html.COLOR.muted), - actionRow([ - { label: 'Finders', active: settings.distribution === 0, command: 'companion-control loot 0' }, - { label: 'Random', active: settings.distribution === 1, command: 'companion-control loot 1' }, - { label: 'Random+Spoil', active: settings.distribution === 2, command: 'companion-control loot 2' }, - { label: 'By Turn', active: settings.distribution === 3, command: 'companion-control loot 3' }, - { label: 'Turn+Spoil', active: settings.distribution === 4, command: 'companion-control loot 4' } - ], { columns: 5 }), '' ].join(''); } @@ -297,7 +305,7 @@ function renderCompanionCard(companionSession, settings) { const summary = Html.table([ Html.row([ - Html.cell(`${Html.font(bot.fetchName(), Html.COLOR.ok)} ${Html.font(`Lv ${bot.fetchLevel()} ${role}`, Html.COLOR.link)}`, { width: 186 }), + Html.cell(`${Html.link(bot.fetchName(), `bot-status ${bot.fetchName()}`, { color: Html.COLOR.ok })} ${Html.font(`Lv ${bot.fetchLevel()} ${role}`, Html.COLOR.link)}`, { width: 186 }), Html.cell(Html.font(stance, stance === 'follow' ? Html.COLOR.ok : Html.COLOR.warn), { width: 84, align: 'right' }) ]), Html.row([ @@ -317,10 +325,8 @@ function renderCompanionCard(companionSession, settings) { ? { label: 'Stop Pull', command: `companion-control member-pull off ${bot.fetchName()}`, color: Html.COLOR.warn } : { label: 'Pull', command: `companion-control member-pull on ${bot.fetchName()}`, color: Html.COLOR.ok }) : null, - { label: 'Call', command: `companion-control summon ${bot.fetchName()}` }, - { label: 'Info', command: `bot-status ${bot.fetchName()}` }, - { label: 'Dismiss', command: `companion-control dismiss ${bot.fetchName()}`, color: Html.COLOR.warn } - ], { columns: 5 }); + { label: 'Call', command: `companion-control summon ${bot.fetchName()}` } + ].filter(Boolean)); return `${Html.line(Html.TEXTURE.line, Html.WIDTH, 1)}${summary}${actions}`; } @@ -331,6 +337,7 @@ function companionControl(session, parts) { const subCommand = parts[1]; const value = parts[2]; + const requestedPage = subCommand === 'page' ? Number(value) : 0; if (subCommand === 'movement') { setMovementMode(session, value === 'hold' ? 'hold' : 'follow'); @@ -338,8 +345,6 @@ function companionControl(session, parts) { setCombatMode(session, value); } else if (subCommand === 'pull') { setPullMode(session, value); - } else if (subCommand === 'loot') { - setLootDistribution(session, value); } else if (subCommand === 'member-pull') { const targetSession = findCompanion(session, parts[3]); if (value === 'on' && targetSession) { @@ -354,14 +359,14 @@ function companionControl(session, parts) { handleMemberCommand(session, subCommand, value); } - renderCompanionPanel(session); + renderCompanionPanel(session, requestedPage); } -function renderCompanionPanel(session) { +function renderCompanionPanel(session, requestedPage = 0) { const actor = session.actor; if (!actor) return; - const myCompanions = PartyCompanionService.membersForLeader(session); + const myCompanions = orderedCompanionSessions(session); const settings = PartyCompanionService.getSettings(session); if (myCompanions.length === 0) { @@ -376,14 +381,21 @@ function renderCompanionPanel(session) { return; } + const totalPages = Math.max(1, Math.ceil(myCompanions.length / COMPANIONS_PER_PAGE)); + const page = Math.min(totalPages - 1, Math.max(0, Number.isFinite(Number(requestedPage)) ? Math.floor(Number(requestedPage)) : 0)); + const first = page * COMPANIONS_PER_PAGE; + const visibleCompanions = myCompanions.slice(first, first + COMPANIONS_PER_PAGE); + let body = renderModePanel(settings, myCompanions.length); body += Html.spacer(5); - myCompanions.forEach((companionSession) => { + visibleCompanions.forEach((companionSession) => { body += renderCompanionCard(companionSession, settings); body += Html.spacer(4); }); + body += pageNavigation('companion-control page', page, totalPages, 'Members'); + body += Html.actionFooter([ { label: 'Close Panel', command: 'html 7000', color: Html.COLOR.muted } ]); diff --git a/src/GameServer/World/World.js b/src/GameServer/World/World.js index 4f91316f..0002aa45 100644 --- a/src/GameServer/World/World.js +++ b/src/GameServer/World/World.js @@ -162,7 +162,6 @@ const World = { attachOptions.distribution = distribution; } - const wasResting = targetSession.plan === 'resting'; if (!PartyCompanionService.attach(session, targetSession, attachOptions)) { BotSocialMemory.recordEvent(session, targetSession, 'party_refused', 'party_full'); session.dataSendToMe(ServerResponse.actionFailed()); @@ -175,7 +174,7 @@ const World = { BotManager.botTell( targetSession, session, - wasResting ? `I'll join you, just need a moment to recover.` : `I'm with you. Lead the way.` + `I'm with you. Lead the way.` ); }, 1000); return true; diff --git a/tests/test_bot_status_bypass.js b/tests/test_bot_status_bypass.js new file mode 100644 index 00000000..a567fffc --- /dev/null +++ b/tests/test_bot_status_bypass.js @@ -0,0 +1,30 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotManager = invoke('GameServer/Bot/BotManager'); +const NpcTalkResponse = invoke('GameServer/World/Generics/NpcTalkResponse'); + +const originalFindSessionByName = BotManager.findSessionByName; +const originalRenderBotStatusPanel = BotManager.renderBotStatusPanel; + +try { + const playerSession = { actor: { fetchId: () => 1 } }; + const botSession = { actor: { fetchName: () => 'PartyHealer' } }; + let rendered = null; + + BotManager.findSessionByName = (name) => name === 'PartyHealer' ? botSession : null; + BotManager.renderBotStatusPanel = (session, target) => { rendered = { session, target }; }; + + NpcTalkResponse(playerSession, { link: 'bot-status PartyHealer' }); + assert.deepStrictEqual( + rendered, + { session: playerSession, target: botSession }, + 'bot-status bypass should route the requested companion to its status panel' + ); +} finally { + BotManager.findSessionByName = originalFindSessionByName; + BotManager.renderBotStatusPanel = originalRenderBotStatusPanel; +} + +console.log('Bot status bypass checks passed'); diff --git a/tests/test_bot_support_planner.js b/tests/test_bot_support_planner.js index 3326c141..499907ea 100644 --- a/tests/test_bot_support_planner.js +++ b/tests/test_bot_support_planner.js @@ -39,6 +39,7 @@ function actor(name, classId, skills = [], mp = 100, maxMp = 100, busy = false) const shieldOne = skill(1040, 'Shield', 1, 'shield', { pDefMul: 1.08 }); const chantOfLife = skill(1229, 'Chant of Life', 1, 'chant_of_life', {}, 'friendly', 'hot'); +const kissOfEva = skill(1073, 'Kiss of Eva', 2, 'kiss_of_eva', { breath: 7 }); const soulShieldTwo = skill(1010, 'Soul Shield', 2, 'soul_shield', { pDefMul: 1.12 }); const shaman = actor('Noren', 49, [soulShieldTwo]); const mage = actor('Saren', 25, [shieldOne]); @@ -49,6 +50,11 @@ assert.deepStrictEqual( [], 'a short heal-over-time effect must not enter the persistent party-buff planner' ); +assert.deepStrictEqual( + BotSupportPlanner.supportSkills(actor('EvaBuffer', 15, [kissOfEva])), + [], + 'Kiss of Eva must not enter the ordinary party-buff planner' +); target.activeBuffs = { shield: Date.now() + (10 * 60 * 1000) }; assert.strictEqual( @@ -65,6 +71,15 @@ assert.strictEqual( ); EffectStore.remove(target, 'shield'); +EffectStore.apply(target, { key: 'legacy_mental_shield', id: 1035, level: 1, type: 'buff', durationMs: 10 * 60 * 1000 }); +const mentalShield = skill(1035, 'Mental Shield', 1, 'mental_shield', { rootResist: 20, sleepResist: 20, mentalResist: 20 }); +assert.strictEqual( + BotSupportPlanner.needsSkill(target, mentalShield), + false, + 'an active buff with the same native skill id must block duplicate support casts even when its legacy key differs' +); +EffectStore.remove(target, 'legacy_mental_shield'); + EffectStore.apply(target, { key: 'shield', id: 1040, level: 1, type: 'buff', stats: { pDefMul: 1.08 }, durationMs: 10 * 60 * 1000 }); assert.strictEqual(BotSupportPlanner.needsSkill(target, shieldOne), false, 'do not overwrite an equal-level active buff'); assert.strictEqual(BotSupportPlanner.needsSkill(target, soulShieldTwo), true, 'upgrade an active defensive buff when the party has a higher level'); diff --git a/tests/test_c4_protocol_packets.js b/tests/test_c4_protocol_packets.js index e283d1fc..2c93f6d0 100644 --- a/tests/test_c4_protocol_packets.js +++ b/tests/test_c4_protocol_packets.js @@ -375,6 +375,10 @@ assert.strictEqual(partySpelled.readInt32LE(13), 1040, 'PartySpelled should incl assert.strictEqual(partySpelled.readInt16LE(17), 2, 'PartySpelled should include effect level'); assert.strictEqual(partySpelled.readInt32LE(19), 120, 'PartySpelled should include remaining duration'); +const oversizedNpcHtml = ServerResponse.npcHtml(actor.fetchId(), 'x'.repeat(8193)); +assert.strictEqual(oversizedNpcHtml[0], 0x0f, 'C4 NpcHtmlMessage should retain its opcode when oversized input is rejected'); +assert(oversizedNpcHtml.length < 300, 'oversized C4 HTML must be replaced with a short safe page instead of reaching the client'); + const magicUse = ServerResponse.skillStarted(actor, actor.fetchId(), { fetchSelfId: () => 2047, fetchCalculatedHitTime: () => 0, diff --git a/tests/test_party_bot_loot.js b/tests/test_party_bot_loot.js index 06afd263..f0449666 100644 --- a/tests/test_party_bot_loot.js +++ b/tests/test_party_bot_loot.js @@ -103,6 +103,8 @@ try { dataSendToMeAndOthers() {} }; BotManager.sessions = [botSession, distantBotSession]; + World.user = { sessions: [leaderSession, botSession, distantBotSession] }; + World.fetchNpcsInRadius = () => []; const npc = { fetchSelfId: () => 999, fetchLocX: () => 100, @@ -119,7 +121,16 @@ try { assert.strictEqual(distantBot.storedPickup, undefined, 'only one nearest companion should receive the pickup order'); pickupCalls[0].onComplete(); - closestBot.state.combat = true; + const activeThreat = { + fetchId: () => 900001, + fetchAttackable: () => true, + isDead: () => false, + fetchDestId: () => closestBot.fetchId(), + fetchLocX: () => 100, + fetchLocY: () => 200 + }; + World.npc = { spawns: [activeThreat] }; + World.fetchNpcsInRadius = () => [activeThreat]; PartyCompanionService.queueRandomGroundPickup(botSession, { fetchId: () => 500002, fetchLocX: () => 100, @@ -133,7 +144,8 @@ try { fetchLocZ: () => -310 }); assert.strictEqual(pickupCalls.length, 1, 'a drop arriving while the party is in combat should wait instead of interrupting the fight'); - closestBot.state.combat = false; + World.npc = { spawns: [] }; + World.fetchNpcsInRadius = () => []; PartyCompanionService.startQueuedGroundPickup(botSession); assert.deepStrictEqual(pickupCalls[1] && { session: pickupCalls[1].session, actor: pickupCalls[1].actor, data: pickupCalls[1].data }, { session: botSession, actor: closestBot, data: { id: 500002 } }, 'a queued hot-bot pickup should execute server-side after combat instead of waiting for a client position packet'); pickupCalls[1].onComplete(); @@ -146,7 +158,9 @@ try { assert.strictEqual(pickupCalls.length, 3, 'a pending resurrection must preempt queued loot'); leaderSession.actor.isDead = () => false; leaderSession.partyCompanionSettings = { distribution: 1, pullMode: 'bot', pullerId: closestBot.fetchId() }; - assert.strictEqual(PartyCompanionService.startQueuedGroundPickup(botSession), false, 'a bot newly assigned as puller must not execute stale queued loot before its first pull tick'); + assert.strictEqual(PartyCompanionService.startQueuedGroundPickup(botSession), true, 'an assigned puller should collect queued ground loot while no pull is active'); + assert.deepStrictEqual(pickupCalls[3] && { session: pickupCalls[3].session, actor: pickupCalls[3].actor, data: pickupCalls[3].data }, { session: botSession, actor: closestBot, data: { id: 500007 } }, 'idle puller loot should use the normal server-side pickup path'); + pickupCalls[3].onComplete(); botSession.partyGroundPickupQueue = []; leaderSession.partyCompanionSettings = { distribution: 1 }; leaderSession.partyPullState = {}; @@ -161,9 +175,35 @@ try { fetchLocZ: () => -310 }] }; + leaderSession.lastGroundLootScanAt = 0; PartyCompanionService.reconcileGroundLoot(botSession); - assert.deepStrictEqual(pickupCalls[3] && { session: pickupCalls[3].session, actor: pickupCalls[3].actor, data: pickupCalls[3].data }, { session: botSession, actor: closestBot, data: { id: 500004 } }, 'an idle hot party should collect reachable loot that was already lying on the ground'); - pickupCalls[3].onComplete(); + assert.deepStrictEqual(pickupCalls[4] && { session: pickupCalls[4].session, actor: pickupCalls[4].actor, data: pickupCalls[4].data }, { session: botSession, actor: closestBot, data: { id: 500004 } }, 'an idle hot party should collect reachable loot that was already lying on the ground'); + pickupCalls[4].onComplete(); + + closestBot.storedPickup = { id: 499999 }; + World.items = { + spawns: [{ + fetchId: () => 500008, + fetchLocX: () => 130, + fetchLocY: () => 200, + fetchLocZ: () => -310 + }] + }; + leaderSession.lastGroundLootScanAt = 0; + PartyCompanionService.reconcileGroundLoot(botSession); + assert.deepStrictEqual(pickupCalls[5] && { session: pickupCalls[5].session, actor: pickupCalls[5].actor, data: pickupCalls[5].data }, { session: botSession, actor: closestBot, data: { id: 500008 } }, 'a stale client pickup must not block a companion from collecting later ground loot'); + assert.strictEqual(closestBot.storedPickup, undefined, 'companion loot reconciliation should clear stale client pickup state'); + pickupCalls[5].onComplete(); + + leaderSession.lastGroundLootScanAt = 0; + World.items = { spawns: [] }; + const emptyScanStartedAt = Date.now(); + PartyCompanionService.reconcileGroundLoot(botSession); + assert.strictEqual( + leaderSession.lastGroundLootScanAt >= emptyScanStartedAt, + true, + 'an idle ground-loot scan should retain its shared throttle when no items are available' + ); // A returning puller is travelling, not fighting at camp. It must not // block recovery of an older drop that another companion can collect. @@ -179,8 +219,8 @@ try { }; leaderSession.lastGroundLootScanAt = 0; PartyCompanionService.reconcileGroundLoot(botSession); - assert.deepStrictEqual(pickupCalls[4] && { session: pickupCalls[4].session, actor: pickupCalls[4].actor, data: pickupCalls[4].data }, { session: distantBotSession, actor: distantBot, data: { id: 500005 } }, 'a distant return pull should let another companion collect old loot without interrupting the puller'); - pickupCalls[4].onComplete(); + assert.deepStrictEqual(pickupCalls[6] && { session: pickupCalls[6].session, actor: pickupCalls[6].actor, data: pickupCalls[6].data }, { session: distantBotSession, actor: distantBot, data: { id: 500005 } }, 'a distant return pull should let another companion collect old loot without interrupting the puller'); + pickupCalls[6].onComplete(); // An NPC already targeting the party is combat even before a companion // has started its own hit/cast animation; loot must not delay defense. @@ -206,7 +246,7 @@ try { }; leaderSession.lastGroundLootScanAt = 0; PartyCompanionService.reconcileGroundLoot(botSession); - assert.strictEqual(pickupCalls.length, 5, 'an incoming NPC threat must block ground pickup before party members start their own combat action'); + assert.strictEqual(pickupCalls.length, 7, 'an incoming NPC threat must block ground pickup before party members start their own combat action'); } finally { DataCache.fetchNpcRewardsFromSelfId = originalRewards; ProgressionRates.rollGroup = originalRollGroup; diff --git a/tests/test_party_companion_rest_follow.js b/tests/test_party_companion_rest_follow.js index 90d26c61..2b0afb88 100644 --- a/tests/test_party_companion_rest_follow.js +++ b/tests/test_party_companion_rest_follow.js @@ -299,8 +299,8 @@ try { BotSocialMemory.getSnapshot = originalSocialSnapshot; BotSocialMemory.recordEvent = originalSocialRecordEvent; } - assert.strictEqual(inviteTell, `I'll join you, just need a moment to recover.`, 'resting invite acknowledgement should survive PartyCompanionService.attach'); - assert.strictEqual(inviteBotSession.plan, 'resting', 'attaching a resting bot should preserve resting state'); + assert.strictEqual(inviteTell, `I'm with you. Lead the way.`, 'a recovered invite acknowledgement should not promise another rest'); + assert.strictEqual(inviteBotSession.plan, 'following', 'attaching a resting bot should resume party follow after instant recovery'); inviteBot.level = 17; inviteBot.hp = 40; inviteBot.mp = 20; @@ -1087,8 +1087,8 @@ try { ); assert.deepStrictEqual( PartyCompanionService.formationTargetFor(partyHudBotASession), - { locX: partyHudLeader.fetchLocX() - 90, locY: partyHudLeader.fetchLocY() - 70, locZ: partyHudLeader.fetchLocZ(), slot: 0 }, - 'first companion should use the first formation slot' + { locX: partyHudLeader.fetchLocX() + 90, locY: partyHudLeader.fetchLocY(), locZ: partyHudLeader.fetchLocZ(), slot: 0 }, + 'a tank should use the forward screen formation slot' ); const casterBot = fakeActor(2000033, { locX: 20, locY: 0, classId: 17 }); @@ -1289,7 +1289,10 @@ try { }); assert.strictEqual(partyHudBotBSession.roleDecision.action, 'follow_leader', 'party should keep following until each companion can reach the marked mob'); - pulledMob.locX = partyHudBotB.locX; + // Delivery uses a practical melee handoff radius, rather than requiring + // two actor origins to be exactly equal. The actual combat action closes + // the final step. + pulledMob.locX = partyHudBotB.locX + 160; let earlyMeetAssistId = null; FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { say() {}, executeCombat(_session, _bot, npc) { earlyMeetAssistId = npc.fetchId(); }, executePvPCombat() {} @@ -1322,10 +1325,19 @@ try { assert.strictEqual(partyHudBotBSession.roleDecision.action, 'follow_leader', 'melee companions should keep following until the mob reaches their actual attack range'); partyHudBotA.locX = partyHudLeader.locX; - pulledMob.locX = partyHudBotB.locX; + pulledMob.locX = partyHudBotB.locX + 160; + partyHudBotA.skillset.skills = []; + learnSkill(partyHudBotA, { selfId: 3, name: 'Power Strike', distance: 50, mp: 5 }); + assert.strictEqual( + PartyPulling.attackRange(partyHudBotA, pulledMob), + 250, + 'a short-range melee skill must not prevent a delivered pull from entering normal combat' + ); + partyHudBotA.state.setTowards('move'); FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { say() {}, executeCombat() {}, executePvPCombat() {} }); + partyHudBotA.state.setTowards(false); pulledTargetId = null; FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { say() {}, @@ -1364,11 +1376,11 @@ try { aggroRequestedAt: Date.now() }; FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { say() {}, executeCombat() {}, executePvPCombat() {} }); - assert.strictEqual(partyHudBotASession.roleDecision.reason, 'party_recovering', 'pulling should pause while any companion is regenerating'); - assert.strictEqual(partyHudBotA.moves.length, 0, 'puller should not leave the group during party recovery'); - assert.strictEqual(abortedAggro, 1, 'party recovery should cancel an aggro cast that has not landed'); - assert.strictEqual(clearedAggroTimers, 1, 'party recovery should cancel the scheduled aggro hit before it can land'); - assert.strictEqual(partyHudLeaderSession.partyPullState.phase, 'approach', 'an interrupted aggro request should retry only after the party resumes'); + assert.strictEqual(partyHudBotASession.roleDecision.reason, 'wait_for_aggro', 'one seated companion below the forty-percent threshold must not pause pulling'); + assert.strictEqual(partyHudBotA.moves.length, 0, 'an aggro request already in flight should not schedule another approach'); + assert.strictEqual(abortedAggro, 0, 'a single resting companion must not cancel an aggro cast'); + assert.strictEqual(clearedAggroTimers, 0, 'a single resting companion must not cancel the scheduled aggro hit'); + assert.strictEqual(partyHudLeaderSession.partyPullState.phase, 'aggro', 'the active aggro request should remain intact below the recovery threshold'); partyHudBotB.state.setSeated(false); partyHudLeader.destId = pulledMob.fetchId(); @@ -1396,7 +1408,8 @@ try { assert.strictEqual(partyHudBotASession.partyPuller, false, 'leaving party pull mode should clear the companion pulling stance'); const companionHtml = lastNpcHtml(partyHudLeaderSession); assert(companionHtml.includes('2 active'), 'party control panel should show active companion count'); - assert(companionHtml.includes('Loot: Random+Spoil'), 'party control panel should show readable loot mode'); + assert(!companionHtml.includes('Loot:'), 'party control panel should leave loot distribution to the native client setting'); + assert(!companionHtml.includes('companion-control loot'), 'party control panel should not offer a separate loot-distribution bypass'); assert(companionHtml.includes(' id, + fetchName: () => name, + fetchCp: () => 0, + fetchMaxCp: () => 0, + fetchLevel: () => 20, + fetchClassId: () => 0, + fetchLocX() { return this.locX; }, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchIsOnline: () => true, + hp: 100, + mp: 100, + fetchHp() { return this.hp; }, + fetchMaxHp: () => 100, + fetchMp() { return this.mp; }, + fetchMaxMp: () => 100, + setHp(value) { this.hp = value; }, + setMp(value) { this.mp = value; }, + state: { + seated: false, + fetchSeated() { return this.seated; }, + setSeated(value) { this.seated = value; }, + fetchDead: () => false, + fetchTowards: () => false, + fetchHits: () => false, + fetchCasts: () => false, + fetchPickinUp: () => false + }, + automation: { stopReplenish() {} }, + unselect() {} + }; +} + +const originalBotSessions = BotManager.sessions; +const originalNow = Date.now; +const originalRender = CompanionControl.render; +try { + let now = 10000; + Date.now = () => now; + CompanionControl.render = () => {}; + const packets = []; + const leaderSession = { + actor: actor(1, 'leader'), + dataSendToMe(packet) { packets.push(packet); } + }; + const companion = actor(2, 'companion'); + const companionSession = { + actor: companion, + partyCompanion: true, + followPlayerSession: leaderSession + }; + BotManager.sessions = [companionSession]; + + PartyCompanionService.updateMember(companionSession); + assert.strictEqual(packets.filter((packet) => packet[0] === 0x52).length, 1, 'the HUD should receive an initial vitals update'); + assert.strictEqual(packets.filter((packet) => packet[0] === 0xee).length, 0, 'an ordinary AI tick must not resend PartySpelled'); + + PartyCompanionService.updateMember(companionSession); + assert.strictEqual(packets.filter((packet) => packet[0] === 0x52).length, 1, 'repeated AI ticks must not flood PartySmallWindowUpdate'); + + companion.locX = 200; + PartyCompanionService.updateMember(companionSession); + assert.strictEqual(packets.filter((packet) => packet[0] === 0xa7).length, 2, 'a material movement should still refresh party positions'); + + now += 1000; + PartyCompanionService.updateMember(companionSession); + assert.strictEqual(packets.filter((packet) => packet[0] === 0x52).length, 2, 'HUD vitals should refresh after the bounded interval'); + + const joiningBot = actor(3, 'joining'); + joiningBot.hp = 15; + joiningBot.mp = 20; + joiningBot.state.seated = true; + const joiningSession = { + actor: joiningBot, + plan: 'resting', + dataSendToMeAndOthers() {} + }; + BotManager.sessions = [companionSession, joiningSession]; + assert.strictEqual(PartyCompanionService.attach(leaderSession, joiningSession), true, 'the invited bot should join the party'); + assert.strictEqual(joiningBot.fetchHp(), joiningBot.fetchMaxHp(), 'joining companion should restore HP immediately'); + assert.strictEqual(joiningBot.fetchMp(), joiningBot.fetchMaxMp(), 'joining companion should restore MP immediately'); + assert.strictEqual(joiningBot.state.fetchSeated(), false, 'joining companion should stand after the instant recovery'); + assert.strictEqual(joiningSession.plan, 'following', 'joining companion should return to party follow after recovery'); + + console.info('party HUD throttling tests passed'); +} finally { + BotManager.sessions = originalBotSessions; + Date.now = originalNow; + CompanionControl.render = originalRender; +} diff --git a/tests/test_party_pull_pause.js b/tests/test_party_pull_pause.js new file mode 100644 index 00000000..25c0a8e9 --- /dev/null +++ b/tests/test_party_pull_pause.js @@ -0,0 +1,106 @@ +const assert = require('assert'); + +require('../src/Global'); + +const World = invoke('GameServer/World/World'); +const PartyPulling = invoke('GameServer/Bot/AI/PartyPulling'); + +function actor(id, classId = 0) { + return { + fetchId: () => id, + fetchName: () => `actor_${id}`, + fetchClassId: () => classId, + fetchLocX: () => 0, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchDestId: () => undefined, + fetchIsOnline: () => true, + isDead: () => false, + state: { + seated: false, + combat: false, + fetchSeated() { return this.seated; }, + fetchDead: () => false, + fetchCombats() { return this.combat; }, + fetchHits: () => false, + fetchCasts: () => false + }, + skillset: { fetchSkills: () => [] } + }; +} + +const leaderSession = { actor: actor(1) }; +const pullerSession = { + actor: actor(2, 7), + partyCompanion: true, + followPlayerSession: leaderSession +}; +const recoveringPlanSession = { + actor: actor(3, 15), + partyCompanion: true, + followPlayerSession: leaderSession, + plan: 'getting_buffed' +}; + +World.user = { sessions: [leaderSession, pullerSession, recoveringPlanSession] }; +World.npc = { spawns: [] }; +World.fetchNpcsInRadius = () => []; + +const settings = { pullMode: 'bot', pullerId: pullerSession.actor.fetchId() }; + +assert.notStrictEqual( + PartyPulling.current(leaderSession, settings).paused, + 'party_recovering', + 'a standing companion must not pause pull merely because an old support plan remains' +); + +recoveringPlanSession.actor.state.seated = true; +assert.notStrictEqual( + PartyPulling.current(leaderSession, settings).paused, + 'party_recovering', + 'one seated companion in a three-member party must not pause pull' +); + +pullerSession.actor.state.seated = true; +assert.strictEqual( + PartyPulling.current(leaderSession, settings).paused, + 'party_recovering', + 'the puller sitting down must pause pull regardless of party size' +); +pullerSession.actor.state.seated = false; + +leaderSession.actor.state.seated = true; +assert.strictEqual( + PartyPulling.current(leaderSession, settings).paused, + 'party_recovering', + 'more than forty percent of the whole party sitting must pause pull' +); +leaderSession.actor.state.seated = false; +recoveringPlanSession.actor.state.seated = false; + +pullerSession.actor.state.combat = true; +assert.notStrictEqual( + PartyPulling.current(leaderSession, settings).paused, + 'party_under_attack', + 'an old inCombat flag without a living hostile target must not freeze a new pull' +); + +World.npc.spawns = [{ + fetchId: () => 3000100, + fetchAttackable: () => true, + isDead: () => false, + fetchLocX: () => 10000, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchDestId: () => undefined, + state: { fetchCombats: () => false } +}]; +leaderSession.partyPullState = { targetId: 3000100, pullerId: 2, phase: 'return' }; +assert.strictEqual( + PartyPulling.current(leaderSession, settings).target, + null, + 'a target left in another region after the leader relocates must not keep the party pulling' +); +assert.deepStrictEqual(leaderSession.partyPullState, {}, 'clearing an abandoned pull must remove its stale target id'); + +console.info('party pull pause tests passed'); diff --git a/tests/test_party_revival.js b/tests/test_party_revival.js index 5dd7fa04..dc7afefc 100644 --- a/tests/test_party_revival.js +++ b/tests/test_party_revival.js @@ -5,6 +5,7 @@ require('../src/Global'); const World = invoke('GameServer/World/World'); const BotManager = invoke('GameServer/Bot/BotManager'); const PartyRevivalService = invoke('GameServer/Bot/AI/PartyRevivalService'); +const FollowingState = invoke('GameServer/Bot/AI/States/FollowingState'); const C4SkillEffects = invoke('GameServer/Skills/C4SkillEffects'); function actor(id, { dead = false, skills = [], items = [] } = {}) { @@ -24,6 +25,7 @@ function actor(id, { dead = false, skills = [], items = [] } = {}) { mp: 100, fetchId() { return this.id; }, fetchName: () => `actor_${id}`, + fetchClassId: () => 0, fetchLocX: () => 0, fetchLocY: () => 0, fetchLocZ: () => 0, @@ -96,11 +98,24 @@ try { World.npc.spawns = [{ fetchAttackable: () => true, isDead: () => false, + fetchLocX: () => 100, + fetchLocY: () => 0, fetchDestId: () => leader.fetchId(), state: { fetchCombats: () => true } }]; const combatHeldResult = PartyRevivalService.tick(healerSession, leaderSession, { skillExec() {} }); assert.strictEqual(combatHeldResult.handled, false, 'a monster still fighting a fallen party member must block resurrection'); + World.npc.spawns = [{ + fetchAttackable: () => true, + isDead: () => false, + fetchLocX: () => 5000, + fetchLocY: () => 0, + fetchDestId: () => leader.fetchId(), + state: { fetchCombats: () => true } + }]; + const staleCorpseCombatResult = PartyRevivalService.tick(healerSession, leaderSession, { skillExec() {} }); + assert.strictEqual(staleCorpseCombatResult.handled, true, 'a stale combat record far from a corpse must not block resurrection'); + leaderSession.partyRevivalAttempt = null; World.npc.spawns = []; healer.state.fetchHits = () => true; @@ -137,6 +152,19 @@ try { assert.strictEqual(scrollResult.source, 'scroll', 'a living companion must fall back to its unlimited resurrection scroll'); assert(healer.automation.scheduled, 'scroll resurrection should use the native move-and-cast path'); + // Companion following must schedule resurrection itself; the player does + // not need to send a chat request after dying. + leaderSession.partyRevivalAttempt = null; + healer.skillset.skills = [resurrection]; + leaderSession.partyPullState = { targetId: 3000100, phase: 'return' }; + let autonomousResurrection = null; + FollowingState.tick(healerSession, healer, { + skillExec(...args) { autonomousResurrection = args; } + }, { say() {}, executeCombat() {}, executePvPCombat() {} }); + assert.strictEqual(healerSession.roleDecision.action, 'resurrect_party', 'a dead leader must immediately preempt ordinary following with resurrection'); + assert.strictEqual(autonomousResurrection[2].id, leader.fetchId(), 'automatic resurrection must target the party leader first'); + assert.deepStrictEqual(leaderSession.partyPullState, {}, 'a leader death must cancel the stale pull before resurrection begins'); + leader.state.setDead(false); healer.state.setDead(true); leader.skillset.skills = [];