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/Actor/Attack.js b/src/GameServer/Actor/Attack.js index 63425891..0199925f 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(); @@ -90,6 +99,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()), @@ -158,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; @@ -170,25 +185,30 @@ 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); 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 +217,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 +229,7 @@ class Attack { this.queueTimer(() => { if (this.checkParticipants(actor, creature, { allowDeadTarget: corpseTarget })) { + invoke('GameServer/Bot/AI/BotSupportPlanner').cancelSupportCast(session, actor); return; } @@ -211,6 +237,7 @@ class Attack { if (targets.length === 0) { actor.state.setCasts(false); + invoke('GameServer/Bot/AI/BotSupportPlanner').cancelSupportCast(session, actor); return; } @@ -250,10 +277,15 @@ 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); + if (invoke('GameServer/Bot/AI/PartyCompanionService').startQueuedGroundPickup(session)) { + return; + } + if (this.queue.name) { this.dequeueEvent(session); return; @@ -350,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/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/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/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/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/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/BotBuffs.js b/src/GameServer/Bot/AI/BotBuffs.js index 6b08b9b3..d0d1e950 100644 --- a/src/GameServer/Bot/AI/BotBuffs.js +++ b/src/GameServer/Bot/AI/BotBuffs.js @@ -2,9 +2,11 @@ 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; +const NEWBIE_GUIDE_MAX_LEVEL = 20; const ALL_BUFFS = BuffCatalog.ALL_BUFFS; const NEWBIE_BUFF_TYPES = ['windwalk', 'shield', 'haste']; @@ -23,8 +25,24 @@ 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; + return actor && actor.fetchLevel() <= NEWBIE_GUIDE_MAX_LEVEL && actor.fetchKarma() === 0; } function remainingMs(actor, key) { @@ -91,11 +109,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 +141,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]); @@ -164,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/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/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/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/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 050fac77..9e80f747 100644 --- a/src/GameServer/Bot/AI/BotStatus.js +++ b/src/GameServer/Bot/AI/BotStatus.js @@ -3,6 +3,8 @@ 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 PartyCombatState = invoke('GameServer/Bot/AI/PartyCombatState'); const EffectStore = invoke('GameServer/Effects/EffectStore'); const GearSkillHints = invoke('GameServer/Bot/AI/GearSkillHints'); @@ -229,18 +231,48 @@ 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 combat = leaderSession ? PartyCombatState.combatState(leaderSession) : 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)) .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/BotSupportPlanner.js b/src/GameServer/Bot/AI/BotSupportPlanner.js index ecf50644..72391aa6 100644 --- a/src/GameServer/Bot/AI/BotSupportPlanner.js +++ b/src/GameServer/Bot/AI/BotSupportPlanner.js @@ -3,6 +3,12 @@ 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; +// 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']); @@ -39,7 +45,16 @@ 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' && + !EXCLUDED_PARTY_BUFF_EFFECTS.has(semantic.effect) && + ['friendly', 'ally', 'party'].includes(semantic.target); }); } @@ -55,8 +70,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) { @@ -68,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 @@ -86,6 +112,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 +146,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 +159,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 +179,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 +295,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/GearAcquisitionPlanner.js b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js index 33a36a68..56ed8cb1 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); @@ -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; } @@ -437,7 +448,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 +464,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 +485,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 +495,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/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/PartyCombatState.js b/src/GameServer/Bot/AI/PartyCombatState.js new file mode 100644 index 00000000..87149e39 --- /dev/null +++ b/src/GameServer/Bot/AI/PartyCombatState.js @@ -0,0 +1,140 @@ +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 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 { + 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; + 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 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 && + fallenIds.has(Number(npc.fetchDestId?.() || 0)) && + fallenMembers.some((member) => distance2d(npc, member.actor) <= CORPSE_COMBAT_DANGER_DISTANCE) && + !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 643baad8..393cc880 100644 --- a/src/GameServer/Bot/AI/PartyCompanionService.js +++ b/src/GameServer/Bot/AI/PartyCompanionService.js @@ -1,4 +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 = { @@ -6,9 +9,16 @@ const DEFAULT_PARTY_SETTINGS = { movementMode: 'follow', combatMode: 'assist', pullMode: 'auto', + pullerId: null, itemLastLootIndex: -1 }; 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 PARTY_MEMBER_UPDATE_INTERVAL_MS = 1000; const FORMATION_OFFSETS = [ { locX: -90, locY: -70 }, { locX: -90, locY: 70 }, @@ -19,6 +29,44 @@ 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'); +} function hasOwn(object, key) { return Object.prototype.hasOwnProperty.call(object || {}, key); @@ -50,7 +98,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]; } }); @@ -63,7 +111,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; } @@ -104,6 +157,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); @@ -135,13 +193,178 @@ function nextTurnMember(leaderSession, members) { return members[nextIndex]; } +function canPickGroundLoot(session, leaderSession, item) { + const actor = session?.actor; + if (!isActiveCompanion(session, leaderSession) || !isAliveOnline(session)) 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; + // 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; +} + +function partyCombatInProgress(leaderSession) { + // 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) { + 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; + 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 items + .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; + + 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; + const leaderSession = partyLeaderSession(pickerSession); + // 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 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 || {}; + if ( + ['getting_buffed', 'shopping', 'merchant'].includes(pickerSession.plan) || + ( + ['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, () => { + 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); + // 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; +} + +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); 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] }; } @@ -150,14 +373,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; @@ -170,6 +427,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) { @@ -179,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 { @@ -200,20 +475,49 @@ 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 = { + MAX_PARTY_MEMBERS, + MAX_COMPANIONS, + membersForLeader, + hasCapacity, + activeActorsForLeader(leaderSession) { return membersForLeader(leaderSession).map((session) => session.actor).filter(Boolean); }, @@ -222,6 +526,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, @@ -294,13 +607,19 @@ const PartyCompanionService = { .filter((entry) => entry.amount > 0); }, + queueRandomGroundPickup, + + startQueuedGroundPickup, + + reconcileGroundLoot, + attach(leaderSession, companionSession, options = {}) { 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'; const distribution = hasOwn(options, 'distribution') ? setDistribution(leaderSession, options.distribution) : distributionForLeader(leaderSession); @@ -309,12 +628,14 @@ 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; companionSession.stayLocation = null; companionSession.currentTargetId = undefined; + companionSession.partyPuller = false; companionSession.actor?.unselect?.(); companionSession.autoTaunt = settingsForLeader(leaderSession).pullMode !== 'off'; @@ -338,6 +659,7 @@ const PartyCompanionService = { BotSocialMemory.recordEvent(leaderSession, companionSession, event, source); } + clearPullerIfDetached(leaderSession, companionSession); detachState(companionSession, options.plan || 'hunting'); if (options.message) { @@ -364,6 +686,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); @@ -378,8 +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)); + 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 new file mode 100644 index 00000000..d2624eed --- /dev/null +++ b/src/GameServer/Bot/AI/PartyPulling.js @@ -0,0 +1,421 @@ +const World = invoke('GameServer/World/World'); +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; +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 { + 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 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'; +} + +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; + // 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; + } + 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 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; + // 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. + const combat = PartyCombatState.combatState(leaderSession, { + ignoreTravellingPuller: true, + ignoreTargetIds: state.targetId ? [state.targetId] : [] + }); + if (combat.active) return 'party_under_attack'; + + const members = PartyAwareness.partySessions(leaderSession); + 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'; + } + + const membersForSupport = supportMembers(leaderSession, puller); + if (BotSupportPlanner.hasPendingAction( + membersForSupport, + supportProviders(leaderSession) + )) { + return 'party_buffing'; + } + 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); + 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) { + // 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) { + // 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, { 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 && (includePuller || actor !== puller?.actor)) + .some((actor) => canDeliverPull(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 shouldKeepPullMove(session, bot, state, phase, target) { + if (!state?.moveTarget || state.movePhase !== phase) return false; + if (!(session.moveTimer || bot.state?.fetchTowards?.())) 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(); + } + // 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; +} + +function moveTo(session, bot, state, phase, target) { + if (shouldKeepPullMove(session, bot, state, phase, target)) return false; + // 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) }); + return true; +} + +function clearPullMove(state) { + delete state.movePhase; + delete state.moveTarget; +} + +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); + 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 + // 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, state, 'approach', 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); + clearPullMove(state); + bot.select({ id: target.fetchId() }); + // 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'; + 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'; + 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, state, 'return', 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. + clearPullMove(state); + 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 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' + ? targetIsEngageable(leaderSession, target, puller, { includePuller: true }) + : targetIsEngageable(leaderSession, target, puller) + : 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, + actorCanEngage, + canDeliverPull, + attackRange, + cancelForRevival, + PULL_AGGRO_TIMEOUT_MS +}; diff --git a/src/GameServer/Bot/AI/PartyRevivalService.js b/src/GameServer/Bot/AI/PartyRevivalService.js new file mode 100644 index 00000000..2a84c783 --- /dev/null +++ b/src/GameServer/Bot/AI/PartyRevivalService.js @@ -0,0 +1,190 @@ +const C4SkillRules = invoke('GameServer/Skills/C4SkillRules'); +const DataCache = invoke('GameServer/DataCache'); +const SkillModel = invoke('GameServer/Model/Skill'); +const PartyCombatState = invoke('GameServer/Bot/AI/PartyCombatState'); + +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 && + 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) { + return PartyCombatState.isActive(leaderSession); +} + +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 }; + } + 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 }; + + // 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) + .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 f44fcc04..66ada030 100644 --- a/src/GameServer/Bot/AI/States/FollowingState.js +++ b/src/GameServer/Bot/AI/States/FollowingState.js @@ -7,12 +7,28 @@ 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 PartyRevivalService = invoke('GameServer/Bot/AI/PartyRevivalService'); 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; 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. +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; @@ -37,6 +53,197 @@ 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 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; + + const distance = distance2d( + { locX: player.fetchLocX(), locY: player.fetchLocY() }, + { locX: town.x, locY: town.y } + ); + 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) { + 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 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; + + 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; @@ -116,11 +323,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) { @@ -136,10 +345,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) { @@ -147,11 +358,80 @@ 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 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; + 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) => ( + 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 && + pulling.puller.kind === 'bot' && + ['approach', 'return'].includes(pulling.phase); } function pullBlockReason(session, botVitals, partyVitals, activeMobs) { @@ -207,17 +487,71 @@ 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); 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) { @@ -237,10 +571,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)) { @@ -261,7 +599,12 @@ module.exports = { return; } - if (session.stuckTicks >= 3 || distance > FOLLOW_TELEPORT_DISTANCE) { + // 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; recordRoleDecision(session, bot, 'follow_leader', distance > FOLLOW_TELEPORT_DISTANCE ? 'catch_up' : 'unstuck'); const TeleportTo = invoke('GameServer/Actor/Generics/TeleportTo'); @@ -289,6 +632,17 @@ module.exports = { const leaderSeated = player.state?.fetchSeated?.() === true; const botRecovering = botVitals.hpRatio < 0.95 || botVitals.mpRatio < 0.95; + if (session.returnToPartyAfterSupport && !isBusy(bot)) { + session.returnToPartyAfterSupport = false; + session.currentTargetId = undefined; + bot.unselect(); + if (distance > FOLLOW_RUN_DISTANCE) { + moveToFollowTarget(session, bot, player); + recordRoleDecision(session, bot, 'follow_leader', 'return_after_support'); + return; + } + } + if (!partyThreat && !leaderTargetId && leaderSeated) { session.currentTargetId = undefined; bot.unselect(); @@ -297,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; } @@ -320,6 +669,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(); @@ -334,28 +694,28 @@ module.exports = { const buffsNeedRefresh = BotBuffs.needsNewbieRefresh(bot); if (buffsNeedRefresh) { - const unsafeToRefresh = unsafeSupportMoment(session, bot, player, partyAggroCount(playerSession)); + 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 (!nearbyGuide || (!inTown && !canMakeFieldBuffTrip)) { + 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'; - 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) }); @@ -364,21 +724,52 @@ 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), - PartyAwareness.partyActors(playerSession) + partySupportMembers(playerSession, pulling.puller), + 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) && + !impairments.silenced; + 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 && ( + 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, 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(); 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(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(), @@ -396,8 +787,11 @@ 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); + returnToPartyAfterSupport(session, bot, player, supportBuffTarget.target); recordRoleDecision(session, bot, 'buff_party', supportBuffTarget.effect, { buff: supportBuffTarget.effect, skillId: supportBuffTarget.skill.fetchSelfId(), @@ -447,32 +841,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); - - 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!"); } @@ -482,12 +874,53 @@ 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 (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; } } + 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; + } + } + + 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(); + // 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 + }); + keepRoleDecision = true; + } + if (!acted && role === 'tank') { const nearbyNpcs = World.fetchNpcsInRadius(bot.fetchLocX(), bot.fetchLocY(), 800); const monsterToAggro = partyThreat?.type === 'npc' @@ -513,7 +946,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); @@ -573,10 +1006,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; @@ -617,7 +1051,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; @@ -649,7 +1085,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; @@ -694,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/Bot/AI/States/RestingState.js b/src/GameServer/Bot/AI/States/RestingState.js index d53175b6..b463a41e 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; @@ -9,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()); @@ -61,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(); @@ -103,29 +135,54 @@ 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(); 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. 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/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/BotAI.js b/src/GameServer/Bot/BotAI.js index a21f498a..f63b81d1 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 = { @@ -34,6 +35,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 +141,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; @@ -352,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 }); @@ -374,7 +391,22 @@ 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', + 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; + spawnTarget = this.getDeathRespawnTarget(session, bot, false); + } else if (session.plan === 'merchant' || (bot.fetchPrivateStore && bot.fetchPrivateStore())) { session.plan = 'merchant'; bot.state.setSeated(true); spawnTarget = { @@ -390,7 +422,6 @@ const BotAI = { if (wasCompanion) { PartyCompanionService.clearCompanion(session, { plan: 'hunting', - rebuildWindow: false, refreshPanel: false }); } @@ -420,6 +451,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) { @@ -434,14 +477,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 8f2eb03f..72d43b34 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 @@ -651,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/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/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/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/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/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/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/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/src/GameServer/Npc/Npc.js b/src/GameServer/Npc/Npc.js index c95e0c6b..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,10 +114,22 @@ class Npc extends NpcModel { const newDstZ = actor.fetchLocZ(); 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 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()) + .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); + lastChaseRepathAt = Date.now(); } return; } @@ -119,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/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/src/GameServer/World/Generics/NpcBypasses/CompanionControl.js b/src/GameServer/World/Generics/NpcBypasses/CompanionControl.js index a72e19bb..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; @@ -34,6 +51,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 +102,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,13 +124,39 @@ 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) { companionSessions(session).forEach((memberSession, index) => { summonNear(session, memberSession, 60 + (index * 20)); @@ -89,9 +168,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') { @@ -113,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); @@ -140,12 +211,28 @@ 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' } + ) + ]) + '