diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 657f83b1..285e57fd 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -9,6 +9,7 @@ const tests = [ 'tests/test_bot_availability.js', 'tests/test_bot_chat_commands.js', 'tests/test_bot_chat_text.js', + 'tests/test_bot_party_chat.js', 'tests/test_bot_combat_skill_selection.js', 'tests/test_bot_conversation.js', 'tests/test_bot_death_respawn.js', @@ -98,6 +99,7 @@ const tests = [ 'tests/test_npc_sell_shop.js', 'tests/test_personal_warehouse.js', 'tests/test_npc_social_aggro.js', + 'tests/test_npc_hot_bot_aggro.js', 'tests/test_npc_known_object_lifecycle.js', 'tests/test_npc_respawn.js', 'tests/test_party_companion_rest_follow.js', @@ -128,6 +130,7 @@ const tests = [ 'tests/test_town_pathfinder.js', 'tests/test_town_guard_pk.js', 'tests/test_town_respawn.js', + 'tests/test_trade_equipment_upgrade.js', 'tests/test_tcp_packet_framing.js', 'tests/test_world_observer_pk.js', 'tests/test_toggle_skills.js', diff --git a/src/GameServer/Actor/Attack.js b/src/GameServer/Actor/Attack.js index 0199925f..8a0617df 100644 --- a/src/GameServer/Actor/Attack.js +++ b/src/GameServer/Actor/Attack.js @@ -190,18 +190,21 @@ class Attack { if (this.checkParticipants(actor, creature, { allowDeadTarget: corpseTarget })) { invoke('GameServer/Bot/AI/BotSupportPlanner').cancelPendingSupportCast(session, actor, creature, skill); + invoke('GameServer/Bot/AI/BotPartyChat').cancelExpectedSkillResult(session, actor, creature, skill); return; } if (actor.canUseSkill?.(skill) === false) { session.dataSendToMe?.(ServerResponse.actionFailed()); invoke('GameServer/Bot/AI/BotSupportPlanner').cancelPendingSupportCast(session, actor, creature, skill); + invoke('GameServer/Bot/AI/BotPartyChat').cancelExpectedSkillResult(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); + invoke('GameServer/Bot/AI/BotPartyChat').cancelExpectedSkillResult(session, actor, creature, skill); return; } @@ -209,6 +212,7 @@ class Attack { if (conditionFailure) { this.rejectSkillUseCondition(session, actor, conditionFailure); invoke('GameServer/Bot/AI/BotSupportPlanner').cancelPendingSupportCast(session, actor, creature, skill); + invoke('GameServer/Bot/AI/BotPartyChat').cancelExpectedSkillResult(session, actor, creature, skill); return; } @@ -230,6 +234,7 @@ class Attack { this.queueTimer(() => { if (this.checkParticipants(actor, creature, { allowDeadTarget: corpseTarget })) { invoke('GameServer/Bot/AI/BotSupportPlanner').cancelSupportCast(session, actor); + invoke('GameServer/Bot/AI/BotPartyChat').cancelExpectedSkillResult(session, actor, creature, skill); return; } @@ -238,6 +243,7 @@ class Attack { if (targets.length === 0) { actor.state.setCasts(false); invoke('GameServer/Bot/AI/BotSupportPlanner').cancelSupportCast(session, actor); + invoke('GameServer/Bot/AI/BotPartyChat').cancelExpectedSkillResult(session, actor, creature, skill); return; } @@ -258,6 +264,10 @@ class Attack { attack: this, magicSkill }); + // Chat confirmations are emitted only after the authoritative + // skill result exists. A queued, interrupted, resisted, or + // stack-rejected cast must never claim success to the party. + invoke('GameServer/Bot/AI/BotPartyChat').confirmSkillResult(session, actor, target, skill, outcome); if (outcome.damage > 0) { this.hit(session, actor, target, outcome.damage); diff --git a/src/GameServer/Actor/Backpack.js b/src/GameServer/Actor/Backpack.js index 5c0088bb..4f5fc884 100644 --- a/src/GameServer/Actor/Backpack.js +++ b/src/GameServer/Actor/Backpack.js @@ -1478,6 +1478,12 @@ class Backpack extends BackpackModel { this.equipPaperdoll(newSlot, item.fetchId(), item.fetchSelfId()); item.setEquipped(true); + // Persist every successful equip, including an empty paperdoll slot. + // Previously only unequipping an existing item scheduled persistence, + // so a newly equipped weapon could be reloaded as unequipped and then + // treated as warehouse stock on a later shopping pass. + this.updateDatabaseTimer(session.actor.fetchId(), [item]); + ConsoleText.transmit(session, ConsoleText.caption.equipped, [ { kind: ConsoleText.kind.item, value: item.fetchSelfId() } ]); @@ -1501,9 +1507,6 @@ class Backpack extends BackpackModel { return; } - // Start a database timer to update equipped state - this.updateDatabaseTimer(session.actor.fetchId()); - // Unequip from actor this.unequipPaperdoll(slot); equippedItems.forEach((item) => { @@ -1513,6 +1516,7 @@ class Backpack extends BackpackModel { { kind: ConsoleText.kind.item, value: item.fetchSelfId() } ]); }); + this.updateDatabaseTimer(session.actor.fetchId(), equippedItems); // Move removed gear to the beginning of inventory (legacy behavior). const removedIds = new Set(equippedItems.map((item) => item.fetchId())); @@ -1525,14 +1529,15 @@ class Backpack extends BackpackModel { invoke(path.actor).calculateStats(session, session.actor); } - updateDatabaseTimer(characterId) { + updateDatabaseTimer(characterId, changedItems = this.items.filter((ob) => ob.isWearable())) { clearTimeout(this.dbTimer); - this.dbTimer = setTimeout(() => { - const wearables = this.items.filter((ob) => ob.isWearable()) ?? []; - wearables.forEach((item) => { - Database.updateItemEquipState(characterId, item.fetchId(), item.fetchEquipped(), item.fetchSlot()); - }); - }, 3000); + // Equipment must reach the write queue before this actor can cool or + // visit a warehouse. A delayed timer leaves a window where the DB + // still says that a freshly equipped item is unequipped. + return Promise.all(changedItems.map((item) => ( + Database.updateItemEquipState(characterId, item.fetchId(), item.fetchEquipped(), item.fetchSlot()) + .catch((error) => utils.infoWarn('Backpack', 'failed to persist equipment for %s: %s', characterId, error.message)) + ))); } } diff --git a/src/GameServer/Actor/Generics/MoveTo.js b/src/GameServer/Actor/Generics/MoveTo.js index f2d3fea5..918b3280 100644 --- a/src/GameServer/Actor/Generics/MoveTo.js +++ b/src/GameServer/Actor/Generics/MoveTo.js @@ -2,6 +2,38 @@ const ServerResponse = invoke('GameServer/Network/Response'); const GeodataEngine = invoke('GameServer/Geodata/GeodataEngine'); const EffectRestrictions = invoke('GameServer/Effects/EffectRestrictions'); +// World.fetchVisibleUsers broadcasts movement to observers inside this same +// radius. Low-detail simulation must never silently relocate a bot that is +// already visible to a player (or whose requested destination is visible). +const CLIENT_VISIBILITY_RADIUS = 6000; + +function distanceToClosestPlayer(players, coords) { + if (!players.length) return Infinity; + + return players.reduce((closest, player) => { + const dx = player.fetchLocX() - coords.locX; + const dy = player.fetchLocY() - coords.locY; + return Math.min(closest, Math.sqrt(dx * dx + dy * dy)); + }, Infinity); +} + +function distance2d(first, second) { + const dx = Number(first?.fetchLocX?.() ?? first?.locX ?? 0) - Number(second?.fetchLocX?.() ?? second?.locX ?? 0); + const dy = Number(first?.fetchLocY?.() ?? first?.locY ?? 0) - Number(second?.fetchLocY?.() ?? second?.locY ?? 0); + return Math.sqrt((dx * dx) + (dy * dy)); +} + +function shouldUseLowLodWarp({ startDistance, destinationDistance, isCompanion, plan }) { + return !isCompanion && + plan !== 'pk_hunting' && + startDistance > CLIENT_VISIBILITY_RADIUS && + destinationDistance > CLIENT_VISIBILITY_RADIUS; +} + +function shouldPreannounceVisibleMove(startDistance, destinationDistance) { + return startDistance > CLIENT_VISIBILITY_RADIUS && destinationDistance <= CLIENT_VISIBILITY_RADIUS; +} + function moveTo(session, actor, coords) { if (actor.isDead()) { return; @@ -33,35 +65,44 @@ function moveTo(session, actor, coords) { const startY = coords.from.locY; const startZ = coords.from.locZ; - // Helper to fetch distance to closest real player - const getDistanceToClosestPlayer = () => { - const World = invoke('GameServer/World/World'); - const onlinePlayers = World.user.sessions.filter(s => - s.actor && - s.actor.fetchIsOnline() && - s.accountId && + // Keep low-detail simulation outside the client-visible area. The + // old 1500-unit threshold was much smaller than the 6000-unit world + // visibility radius, so a bot could be visibly running and then have + // its server position silently overwritten. + const World = invoke('GameServer/World/World'); + const onlinePlayerSessions = World.user.sessions + .filter(s => + s.actor && + s.actor.fetchIsOnline() && + s.accountId && !s.accountId.startsWith('bot_') ); + const onlinePlayers = onlinePlayerSessions.map((playerSession) => playerSession.actor); + const distanceToPlayer = distanceToClosestPlayer(onlinePlayers, { + locX: startX, + locY: startY + }); + const destinationDistanceToPlayer = distanceToClosestPlayer(onlinePlayers, requestedTo); + // Movement packets are normally broadcast from the bot's current + // coordinates. A player who is just outside that radius would miss + // the first packet and only discover the bot through a later refresh, + // which looks like a teleport. Prime that observer before the route + // crosses into their visible area. + const approachingObservers = onlinePlayerSessions + .filter((playerSession) => shouldPreannounceVisibleMove( + distance2d(playerSession.actor, { locX: startX, locY: startY }), + distance2d(playerSession.actor, requestedTo) + )) + .map((playerSession) => ({ session: playerSession, announced: false })); - if (onlinePlayers.length === 0) return Infinity; - - let minDist = Infinity; - onlinePlayers.forEach(pSession => { - const player = pSession.actor; - const pdx = player.fetchLocX() - startX; - const pdy = player.fetchLocY() - startY; - const pdist = Math.sqrt(pdx * pdx + pdy * pdy); - if (pdist < minDist) { - minDist = pdist; - } - }); - return minDist; - }; - - const distanceToPlayer = getDistanceToClosestPlayer(); const isCompanion = !!session.followPlayerSession && session.partyCompanion === true; - if (distanceToPlayer > 1500 && !isCompanion && session.plan !== 'pk_hunting') { + if (shouldUseLowLodWarp({ + startDistance: distanceToPlayer, + destinationDistance: destinationDistanceToPlayer, + isCompanion, + plan: session.plan + })) { // Low LOD: instant warp (we do not calculate movements at all) const snappedTo = { ...requestedTo }; snappedTo.locZ = GeodataEngine.getHeight(snappedTo.locX, snappedTo.locY, snappedTo.locZ); @@ -74,6 +115,7 @@ function moveTo(session, actor, coords) { pathLength: 0, lowLodWarp: true, distanceToPlayer, + destinationDistanceToPlayer, strategy: 'low_lod_direct', at: Date.now() }; @@ -112,6 +154,7 @@ function moveTo(session, actor, coords) { pathLength: path.length, lowLodWarp: false, distanceToPlayer, + destinationDistanceToPlayer, strategy: pathStrategy, at: Date.now() }; @@ -142,7 +185,20 @@ function moveTo(session, actor, coords) { from: currentLoc, to: nextLoc }; - session.dataSendToMeAndOthers(ServerResponse.moveToLocation(actor.fetchId(), segmentCoords), actor); + const movePacket = ServerResponse.moveToLocation(actor.fetchId(), segmentCoords); + session.dataSendToMeAndOthers(movePacket, actor); + approachingObservers.forEach((observer) => { + const observerSession = observer.session; + if (!observerSession?.actor?.fetchIsOnline?.() || !observerSession.dataSendToMe) return; + // Once the bot is in the standard broadcast radius, the + // normal dataSendToMeAndOthers call above owns delivery. + if (distance2d(observerSession.actor, currentLoc) <= CLIENT_VISIBILITY_RADIUS) return; + if (!observer.announced) { + observerSession.dataSendToMe(ServerResponse.charInfo(actor)); + observer.announced = true; + } + observerSession.dataSendToMe(movePacket); + }); const speed = actor.fetchCollectiveRunSpd() || 120; const duration = (distance / speed) * 1000; @@ -183,3 +239,6 @@ function moveTo(session, actor, coords) { } module.exports = moveTo; +module.exports.shouldUseLowLodWarp = shouldUseLowLodWarp; +module.exports.shouldPreannounceVisibleMove = shouldPreannounceVisibleMove; +module.exports.CLIENT_VISIBILITY_RADIUS = CLIENT_VISIBILITY_RADIUS; diff --git a/src/GameServer/Actor/Generics/StopAutomation.js b/src/GameServer/Actor/Generics/StopAutomation.js index fcf4977d..189b8611 100644 --- a/src/GameServer/Actor/Generics/StopAutomation.js +++ b/src/GameServer/Actor/Generics/StopAutomation.js @@ -1,7 +1,9 @@ const ServerResponse = invoke('GameServer/Network/Response'); function stopAutomation(session, creature) { - creature.automation.abortAll(creature); + // This generic emits the canonical StopMove packet below, so suppress the + // automatic notification from Automation.abortAll to avoid a duplicate. + creature.automation.abortAll(creature, { notifyClient: false }); session.dataSendToMeAndOthers( ServerResponse.stopMove(creature.fetchId(), { diff --git a/src/GameServer/Actor/Generics/UpdateEnvironment.js b/src/GameServer/Actor/Generics/UpdateEnvironment.js index 2d862ca3..6655d96c 100644 --- a/src/GameServer/Actor/Generics/UpdateEnvironment.js +++ b/src/GameServer/Actor/Generics/UpdateEnvironment.js @@ -3,6 +3,7 @@ const World = invoke('GameServer/World/World'); const SpeckMath = invoke('GameServer/SpeckMath'); const BotAI = invoke('GameServer/Bot/BotAI'); const TownGuard = invoke('GameServer/Npc/TownGuard'); +const NpcAggro = invoke('GameServer/Npc/NpcAggro'); function updateEnvironment(session, actor, { immediateNpcInfo = false, forceRefresh = false } = {}) { const actorArea = new SpeckMath.Circle(actor.fetchLocX(), actor.fetchLocY(), 6000); @@ -52,12 +53,9 @@ function updateEnvironment(session, actor, { immediateNpcInfo = false, forceRefr actor.previousXY = actorArea.toCoords(); } - // Detect hostile NPCs - const hostile = npcs.filter((ob) => ob.fetchHostile() && actorArea.distance(new SpeckMath.Point(ob.fetchLocX(), ob.fetchLocY())) <= 500) ?? []; - hostile.forEach((npc) => { - npc.setLocZ(actor.fetchLocZ()); // TODO: Remove, uber hack... - npc.enterCombatState(session, actor); - }); + // Detect hostile NPCs. This same gate is used by hot-bot movement and + // respawn processing, so the actor type cannot change auto-aggro rules. + NpcAggro.engageNearby(session, actor, { npcs }); // C4 guards are not ordinary hostile mobs: they seek only red names and // use line-of-sight before entering combat. diff --git a/src/GameServer/Automation.js b/src/GameServer/Automation.js index 53cf7285..27fd94b5 100644 --- a/src/GameServer/Automation.js +++ b/src/GameServer/Automation.js @@ -322,9 +322,10 @@ class Automation extends SelectedModel { }, ticks); } - abortAll(creature) { + abortAll(creature, { notifyClient = true } = {}) { + const wasMoving = !!creature?.state?.inMotion?.(); this.clearDestId(); - creature.state.setTowards(false); + creature.state?.setTowards?.(false); Timer.clear(this.timer.action); Timer.clear(this.timer.pickup); @@ -333,6 +334,25 @@ class Automation extends SelectedModel { clearInterval(session.moveTimer); session.moveTimer = null; } + const botSession = session && ( + session.constructor?.name === 'BotSession' || + session.accountId?.startsWith?.('bot_') + ); + + // The server owns bot movement timers. If one is cancelled without + // a StopMove, C4 keeps animating the old route until a later combat + // packet or CharInfo forces an obvious position correction. + if (wasMoving && notifyClient && botSession && session.dataSendToMeAndOthers && creature?.fetchId) { + session.dataSendToMeAndOthers( + ServerResponse.stopMove(creature.fetchId(), { + locX: creature.fetchLocX?.() || 0, + locY: creature.fetchLocY?.() || 0, + locZ: creature.fetchLocZ?.() || 0, + head: creature.fetchHead?.() || 0 + }), + creature + ); + } } } diff --git a/src/GameServer/Bot/AI/BotCombatUtility.js b/src/GameServer/Bot/AI/BotCombatUtility.js index 9a4d835a..b2cbae3c 100644 --- a/src/GameServer/Bot/AI/BotCombatUtility.js +++ b/src/GameServer/Bot/AI/BotCombatUtility.js @@ -7,9 +7,10 @@ const OFFENSIVE_TYPES = new Set([ C4SkillRules.DEATH_LINK, C4SkillRules.DRAIN, C4SkillRules.BLOW, - C4SkillRules.EFFECT, - C4SkillRules.AGGRO_DAMAGE + C4SkillRules.EFFECT ]); +const BOW_WEAPON_MASK = 32; +const MIN_BOW_SKILL_RANGE = 400; function distance2d(a, b) { if (!a?.fetchLocX || !b?.fetchLocX) return 0; @@ -43,6 +44,11 @@ function evaluate(bot, target, skill, role) { const range = Number(skill.fetchDistance?.()); if (!Number.isFinite(range) || range < 0) return null; + // Some generic fighter skills (for example Power Strike) have no weapon + // restriction in the source data. A bow user must never pick one of + // those short-range attacks and run into melee just because its score is + // higher than a shot currently on reuse. + if ((Attack.weaponMaskFor(bot) & BOW_WEAPON_MASK) !== 0 && range < MIN_BOW_SKILL_RANGE) return null; const mp = Number(bot.fetchMp?.() || 0); const maxMp = Math.max(1, Number(bot.fetchMaxMp?.() || mp || 1)); @@ -78,7 +84,7 @@ function evaluate(bot, target, skill, role) { score += 220; reasons.push('dagger_blow'); } - if (role === 'tank' && [C4SkillRules.AGGRO_DAMAGE, C4SkillRules.EFFECT].includes(type)) { + if (role === 'tank' && type === C4SkillRules.EFFECT) { score += 90; reasons.push('tank_control'); } diff --git a/src/GameServer/Bot/AI/BotPartyChat.js b/src/GameServer/Bot/AI/BotPartyChat.js new file mode 100644 index 00000000..15d38931 --- /dev/null +++ b/src/GameServer/Bot/AI/BotPartyChat.js @@ -0,0 +1,223 @@ +const PRIORITIES = Object.freeze({ + critical: { dedupeMs: 3000, partyCooldownMs: 0 }, + coordination: { dedupeMs: 12000, partyCooldownMs: 7000 }, + informational: { dedupeMs: 30000, partyCooldownMs: 15000 }, + social: { dedupeMs: 180000, partyCooldownMs: 180000 }, + direct: { dedupeMs: 3000, partyCooldownMs: 0 } +}); + +const RESULT_TIMEOUT_MS = 35000; +const EVENT_HISTORY_MS = Math.max(...Object.values(PRIORITIES).map((priority) => priority.dedupeMs)); + +function leaderFor(session) { + return session?.partyCompanion === true ? session.followPlayerSession || null : null; +} + +function stateFor(session, allowStandalone = false) { + const owner = leaderFor(session) || (allowStandalone ? session : null); + if (!owner) return null; + if (!owner.botPartyChat) { + owner.botPartyChat = { + events: {}, + lastPartyMessageAt: 0, + sequence: 0 + }; + } + return owner.botPartyChat; +} + +function clean(text) { + return String(text || '').replace(/\s+/g, ' ').trim().slice(0, 120); +} + +function chooseText(entry, state) { + if (entry.text) return clean(entry.text); + const templates = Array.isArray(entry.templates) ? entry.templates : []; + if (templates.length === 0) return ''; + const index = state.sequence % templates.length; + state.sequence += 1; + return clean(templates[index]); +} + +function canSend(state, entry, now) { + Object.entries(state.events).forEach(([key, at]) => { + if (now - Number(at || 0) > EVENT_HISTORY_MS) delete state.events[key]; + }); + const priority = PRIORITIES[entry.priority] || PRIORITIES.coordination; + const dedupeMs = Number.isFinite(Number(entry.dedupeMs)) + ? Math.max(0, Number(entry.dedupeMs)) + : priority.dedupeMs; + const previous = Number(state.events[entry.key] || 0); + if (previous && now - previous < dedupeMs) return false; + if (priority.partyCooldownMs > 0 && now - Number(state.lastPartyMessageAt || 0) < priority.partyCooldownMs) { + return false; + } + return true; +} + +function announceNpcAdd(session, npc, protectedActor) { + const npcId = Number(npc?.fetchId?.() || 0); + const protectedId = Number(protectedActor?.fetchId?.() || 0); + if (!npcId || !protectedId) return false; + + const npcName = npc.fetchName?.() || 'A mob'; + const protectedName = protectedActor.fetchName?.() || 'a party member'; + return announce(session, { + priority: 'critical', + dedupeMs: 45000, + key: `add:${npcId}:${protectedId}`, + templates: [ + `Add on ${protectedName}: ${npcName}.`, + `${npcName} is on ${protectedName} — assist.` + ] + }); +} + +function announceHealManaShortage(session, target) { + const targetId = Number(target?.fetchId?.() || 0); + if (!targetId) return false; + + const targetName = target.fetchName?.() || 'the party'; + return announce(session, { + priority: 'critical', + dedupeMs: 30000, + key: `heal-low-mp:${targetId}`, + templates: [ + `No MP for a heal on ${targetName}.`, + `I need MP before I can heal ${targetName}.` + ] + }); +} + +function announce(session, entry = {}) { + if (!session?.actor || !entry.key) return false; + // A factual reply to a direct player request also matters outside a + // companion party. Ambient and coordination events remain party-only. + const state = stateFor(session, !!entry.targetSession); + if (!state) return false; + + const now = Number(entry.now || Date.now()); + if (!canSend(state, entry, now)) return false; + const text = chooseText(entry, state); + if (!text) return false; + + const BotManager = invoke('GameServer/Bot/BotManager'); + const sent = entry.targetSession + ? BotManager.botTell(session, entry.targetSession, text) + : BotManager.botPartySay(session, text); + if (sent === false) return false; + + state.events[entry.key] = now; + state.lastPartyMessageAt = now; + return true; +} + +function expectSkillResult(session, request = {}) { + if (!session?.actor || !request.target || !request.skill) return false; + session.pendingPartyChatResult = { + targetId: Number(request.target.fetchId?.() || 0), + skillId: Number(request.skill.fetchSelfId?.() || 0), + kind: request.kind || 'support', + targetSession: request.targetSession || null, + expiresAt: Date.now() + RESULT_TIMEOUT_MS + }; + return true; +} + +function didLand(outcome) { + return !!( + outcome?.effect || + outcome?.resurrected || + Number(outcome?.heal || 0) > 0 || + Number(outcome?.mpRestore || 0) > 0 || + Number(outcome?.cpRestore || 0) > 0 + ); +} + +function resultEntry(request, target, skill) { + const targetName = target.fetchName?.() || 'the party'; + const skillName = skill.fetchName?.() || 'Support'; + if (request.kind === 'emergency_heal') { + return { + priority: 'critical', + key: `emergency-heal:${target.fetchId?.() || targetName}`, + templates: [ + `Emergency heal landed on ${targetName}.`, + `${targetName} is stabilized.` + ] + }; + } + if (request.kind === 'resurrection') { + return { + priority: 'critical', + key: `resurrection:${target.fetchId?.() || targetName}`, + templates: [ + `${targetName} is back up.`, + `Resurrection landed on ${targetName}.` + ] + }; + } + if (request.kind === 'heal') { + return { + priority: 'direct', + key: `direct-heal:${target.fetchId?.() || targetName}:${skill.fetchSelfId?.() || skillName}`, + targetSession: request.targetSession, + templates: [ + `${targetName}, ${skillName} landed.`, + `${targetName}, you're healed.` + ] + }; + } + return { + priority: 'direct', + key: `direct-support:${target.fetchId?.() || targetName}:${skill.fetchSelfId?.() || skillName}`, + targetSession: request.targetSession, + templates: [ + `${skillName} is up on ${targetName}.`, + `${targetName} has ${skillName} now.` + ] + }; +} + +function confirmSkillResult(session, actor, target, skill, outcome) { + const request = session?.pendingPartyChatResult; + if (!request || Number(request.expiresAt || 0) <= Date.now()) { + if (session) session.pendingPartyChatResult = undefined; + return false; + } + const doesNotMatchRequest = ( + Number(request.targetId) !== Number(target?.fetchId?.() || 0) || + Number(request.skillId) !== Number(skill?.fetchSelfId?.() || 0) + ); + if (doesNotMatchRequest) return false; + + session.pendingPartyChatResult = undefined; + if (!didLand(outcome)) return false; + return announce(session, resultEntry(request, target, skill)); +} + +function cancelExpectedSkillResult(session, actor, target, skill) { + const request = session?.pendingPartyChatResult; + if (!request) return false; + if ( + Number(request.targetId) !== Number(target?.fetchId?.() || 0) || + Number(request.skillId) !== Number(skill?.fetchSelfId?.() || 0) + ) { + return false; + } + session.pendingPartyChatResult = undefined; + return true; +} + +module.exports = { + PRIORITIES, + RESULT_TIMEOUT_MS, + EVENT_HISTORY_MS, + announce, + announceNpcAdd, + announceHealManaShortage, + expectSkillResult, + confirmSkillResult, + cancelExpectedSkillResult, + didLand +}; diff --git a/src/GameServer/Bot/AI/BotRoles.js b/src/GameServer/Bot/AI/BotRoles.js index d267b0fa..2c33b81f 100644 --- a/src/GameServer/Bot/AI/BotRoles.js +++ b/src/GameServer/Bot/AI/BotRoles.js @@ -59,6 +59,19 @@ function isRanged(roleOrActor) { return role === 'archer' || role === 'mage'; } +function hasMeleeWeapon(actor) { + const weapon = actor?.backpack?.fetchEquippedWeapon?.(); + if (!weapon) return false; + + const kind = String(weapon.fetchKind?.() || ''); + const name = String(weapon.fetchName?.() || ''); + // C4 stores staves, wands and rods under Weapon.Blunt together with real + // clubs and maces. Their names are the authoritative distinction in the + // datapack, so do not let a support class treat every blunt as melee. + const casterWeapon = /\b(staff|wand|rod|spellbook|voodoo|scroll)\b/i.test(name); + return kind.startsWith('Weapon.') && kind !== 'Weapon.Bow' && !casterWeapon; +} + function partyRoleStance(role) { if (role === 'healer') return 'support'; if (role === 'buffer') return 'buff_support'; @@ -76,5 +89,6 @@ module.exports = { isTank, canBuff, isRanged, + hasMeleeWeapon, partyRoleStance }; diff --git a/src/GameServer/Bot/AI/PartyAwareness.js b/src/GameServer/Bot/AI/PartyAwareness.js index e87487a3..77849641 100644 --- a/src/GameServer/Bot/AI/PartyAwareness.js +++ b/src/GameServer/Bot/AI/PartyAwareness.js @@ -1,6 +1,10 @@ const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); const RECENT_INCOMING_THREAT_MS = 5000; +// NPC combat remains active until the target is 1500 units away. Threat +// discovery must cover that full envelope: a ranged/social add at 1401-1499 +// can still be hitting the puller and therefore must wake the camp. +const NPC_THREAT_RADIUS = 1500; // World loads bot controls as part of its own initialization. Resolving it at // module scope here can therefore retain Node's empty circular-dependency @@ -120,7 +124,7 @@ function findThreatTargetingParty(leaderSession, options = {}) { if (members.length === 0) return null; const memberIds = new Set(members.map(actorId).filter((id) => id !== null)); - const npcRadius = options.npcRadius || 1400; + const npcRadius = options.npcRadius || NPC_THREAT_RADIUS; const playerRadius = options.playerRadius || 1800; const recentThreat = recentIncomingNpcThreat(leaderSession, memberSessions, npcRadius); diff --git a/src/GameServer/Bot/AI/PartyCombatState.js b/src/GameServer/Bot/AI/PartyCombatState.js index 87149e39..324e1f4f 100644 --- a/src/GameServer/Bot/AI/PartyCombatState.js +++ b/src/GameServer/Bot/AI/PartyCombatState.js @@ -120,6 +120,7 @@ function combatState(leaderSession, options = {}) { const attackingCorpse = (world().npc?.spawns || []).find((npc) => ( isHostileNpc(npc) && npc.state?.fetchCombats?.() === true && + npc.fetchStateAttack?.() === true && fallenIds.has(Number(npc.fetchDestId?.() || 0)) && fallenMembers.some((member) => distance2d(npc, member.actor) <= CORPSE_COMBAT_DANGER_DISTANCE) && !ignoredTargetIds.has(actorId(npc)) diff --git a/src/GameServer/Bot/AI/PartyCompanionService.js b/src/GameServer/Bot/AI/PartyCompanionService.js index 393cc880..1f22cef2 100644 --- a/src/GameServer/Bot/AI/PartyCompanionService.js +++ b/src/GameServer/Bot/AI/PartyCompanionService.js @@ -14,6 +14,8 @@ const DEFAULT_PARTY_SETTINGS = { }; const PARTY_LOOT_RADIUS = 2500; const GROUND_LOOT_SCAN_INTERVAL_MS = 500; +const GROUND_PICKUP_FALLBACK_TIMEOUT_MS = 8000; +const GROUND_PICKUP_TIMEOUT_GRACE_MS = 5000; const RANDOM_LOOT_DISTRIBUTIONS = new Set([1, 2]); const MAX_PARTY_MEMBERS = 9; const MAX_COMPANIONS = MAX_PARTY_MEMBERS - 1; @@ -284,10 +286,39 @@ function nearestGroundLootPicker(looterSession, item) { ))[0] || null; } +function groundPickupTimeoutMs(picker, pickup) { + const item = (world().items?.spawns || []) + .find((candidate) => Number(candidate?.fetchId?.()) === Number(pickup?.id)); + if (!item?.fetchLocX || !item?.fetchLocY || !item?.fetchLocZ) return GROUND_PICKUP_FALLBACK_TIMEOUT_MS; + const automation = picker?.automation; + const runSpeed = Number(picker?.fetchCollectiveRunSpd?.()); + if (!Number.isFinite(runSpeed) || runSpeed <= 0) return GROUND_PICKUP_FALLBACK_TIMEOUT_MS; + const travelMs = Number(automation?.ticksToMove?.( + picker.fetchLocX(), picker.fetchLocY(), picker.fetchLocZ(), + item.fetchLocX(), item.fetchLocY(), item.fetchLocZ(), + 0, + runSpeed + )); + if (!Number.isFinite(travelMs) || travelMs <= 0) return GROUND_PICKUP_FALLBACK_TIMEOUT_MS; + return Math.max(GROUND_PICKUP_FALLBACK_TIMEOUT_MS, Math.ceil(travelMs) + GROUND_PICKUP_TIMEOUT_GRACE_MS); +} + function startQueuedGroundPickup(pickerSession) { const picker = pickerSession?.actor; const queue = pickerSession?.partyGroundPickupQueue; - if (!picker || pickerSession.partyGroundPickupInProgress || !queue?.length) return false; + if (!picker || !queue?.length) return false; + const now = Date.now(); + if (pickerSession.partyGroundPickupInProgress) { + const deadlineAt = Number(pickerSession.partyGroundPickupDeadlineAt || 0); + if (!deadlineAt || now < deadlineAt) return false; + // A competing movement order can cancel Automation's pickup timer + // without invoking PickupExec's completion callback. Do not leave the + // whole FIFO permanently locked behind that stale action. + picker.automation?.abortAll?.(picker); + picker.state?.setPickinUp?.(false); + pickerSession.partyGroundPickupInProgress = false; + pickerSession.partyGroundPickupDeadlineAt = 0; + } 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 @@ -312,6 +343,9 @@ function startQueuedGroundPickup(pickerSession) { const pickup = queue[0]; pickerSession.partyGroundPickupInProgress = true; + pickerSession.partyGroundPickupDeadlineAt = now + groundPickupTimeoutMs(picker, pickup); + const attempt = Number(pickerSession.partyGroundPickupAttempt || 0) + 1; + pickerSession.partyGroundPickupAttempt = attempt; if (picker.state?.fetchSeated?.()) { picker.state.setSeated(false); pickerSession.dataSendToMeAndOthers?.(ServerResponse.sitAndStand(picker), picker); @@ -319,6 +353,7 @@ function startQueuedGroundPickup(pickerSession) { const Generics = invoke(path.actor); Generics.stopAutomation(pickerSession, picker); Generics.pickupExec(pickerSession, picker, pickup, () => { + if (Number(pickerSession.partyGroundPickupAttempt) !== attempt) return; if (queue[0]?.id === pickup.id) { queue.shift(); } else { @@ -326,6 +361,7 @@ function startQueuedGroundPickup(pickerSession) { if (index >= 0) queue.splice(index, 1); } pickerSession.partyGroundPickupInProgress = false; + pickerSession.partyGroundPickupDeadlineAt = 0; 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 diff --git a/src/GameServer/Bot/AI/PartyPulling.js b/src/GameServer/Bot/AI/PartyPulling.js index d2624eed..16aa7fd4 100644 --- a/src/GameServer/Bot/AI/PartyPulling.js +++ b/src/GameServer/Bot/AI/PartyPulling.js @@ -4,6 +4,7 @@ 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 BotPartyChat = invoke('GameServer/Bot/AI/BotPartyChat'); const PULL_SEARCH_RADIUS = 2200; const PULL_CONTACT_DISTANCE = 260; @@ -16,6 +17,7 @@ const PULL_ABANDON_DISTANCE = 5000; // 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; +const PULL_DELIVERY_CAMP_DISTANCE = 350; function point(actor) { return { @@ -222,6 +224,18 @@ function targetIsEngageable(leaderSession, target, puller, { includePuller = fal .some((actor) => canDeliverPull(actor, target)); } +function targetDeliveredToCamp(leaderSession, target) { + return !!leaderSession?.actor && !!target && + distance2d(point(leaderSession.actor), point(target)) <= PULL_DELIVERY_CAMP_DISTANCE; +} + +function hasDeadPartyMember(leaderSession) { + return (World.user?.sessions || []).some((memberSession) => ( + PartyAwareness.isPartySession(memberSession, leaderSession) && + memberSession.actor?.isDead?.() === true + )); +} + function nearestFreeMonster(bot) { return World.fetchNpcsInRadius(bot.fetchLocX(), bot.fetchLocY(), PULL_SEARCH_RADIUS) .filter((npc) => npc.fetchAttackable?.() && !npc.isDead?.()) @@ -307,6 +321,13 @@ function tickBotPuller(session, bot, leaderSession, settings, Generics, BotAI) { let target = clearFinishedTarget(leaderSession); if (!target) { + // Do not let the puller win the scheduler race immediately after a + // resurrection. The next revival target must be selected before the + // party starts another encounter, while an already active encounter is + // still allowed to finish normally. + if (hasDeadPartyMember(leaderSession)) { + return { handled: true, puller, action: 'party_revival' }; + } target = nearestFreeMonster(bot); if (!target) return { handled: true, puller, idle: true }; beginTarget(leaderSession, puller, target, 'bot'); @@ -336,7 +357,14 @@ function tickBotPuller(session, bot, leaderSession, settings, Generics, BotAI) { state.aggroRequestedAt = Date.now(); if (!state.announced) { state.announced = true; - BotAI.say(session, `Pulling ${target.fetchName()} to the party!`); + BotPartyChat.announce(session, { + priority: 'coordination', + key: `pull:${target.fetchId()}`, + templates: [ + `Pulling ${target.fetchName()} to camp.`, + `Bringing ${target.fetchName()} back — hold camp.` + ] + }); } return { handled: true, puller, action: 'aggro', target }; } @@ -386,13 +414,12 @@ function current(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. + // Keep the formation around the leader until an aggroed mob itself reaches + // the camp. Once it is there, the party must attack immediately rather + // than wait for the puller to complete a separate return tick: otherwise + // the delivered mob can hit the leader before anyone reacts. const engageable = state.source === 'bot' - ? state.phase === 'engage' - ? targetIsEngageable(leaderSession, target, puller, { includePuller: true }) - : targetIsEngageable(leaderSession, target, puller) + ? ['return', 'engage'].includes(state.phase) && targetDeliveredToCamp(leaderSession, target) : targetIsEngageable(leaderSession, target, puller); return { enabled: true, @@ -413,8 +440,10 @@ module.exports = { tickBotPuller, current, targetIsEngageable, + targetDeliveredToCamp, actorCanEngage, canDeliverPull, + hasDeadPartyMember, attackRange, cancelForRevival, PULL_AGGRO_TIMEOUT_MS diff --git a/src/GameServer/Bot/AI/PartyRevivalService.js b/src/GameServer/Bot/AI/PartyRevivalService.js index 2a84c783..c735b603 100644 --- a/src/GameServer/Bot/AI/PartyRevivalService.js +++ b/src/GameServer/Bot/AI/PartyRevivalService.js @@ -89,9 +89,12 @@ function playerCanResurrect(leaderSession) { .some((item) => PLAYER_RESURRECTION_SCROLLS.has(Number(item.fetchSelfId?.())) && Number(item.fetchAmount?.() || 0) > 0); } -function clearExpiredAttempt(leaderSession, now) { +function clearExpiredAttempt(leaderSession, dead, now) { const attempt = leaderSession?.partyRevivalAttempt; - if (attempt && now - Number(attempt.startedAt || 0) > 25000) { + const targetStillDead = dead.some((memberSession) => ( + Number(memberSession.actor?.fetchId?.()) === Number(attempt?.targetId) + )); + if (attempt && (!targetStillDead || now - Number(attempt.startedAt || 0) > 25000)) { leaderSession.partyRevivalAttempt = null; } } @@ -99,6 +102,11 @@ function clearExpiredAttempt(leaderSession, now) { function castScroll(session, actor, target, skill) { actor.select?.({ id: target.fetchId() }); session.currentTargetId = target.fetchId(); + invoke('GameServer/Bot/AI/BotPartyChat').expectSkillResult(session, { + target, + skill, + kind: 'resurrection' + }); actor.automation.scheduleAction(session, actor, target, skill.fetchDistance(), () => { actor.attack.remoteHit(session, target, skill); }); @@ -108,8 +116,11 @@ 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); + // A successful cast can revive its target while another party member is + // still dead. Do not hold the old attempt until its timeout: the next + // provider tick must immediately pick the next corpse. + clearExpiredAttempt(leaderSession, dead, now); if (dead.length === 0) { leaderSession.partyRevivalAttempt = null; return { handled: false, dead }; @@ -150,6 +161,11 @@ function tick(session, leaderSession, Generics) { if (skilled) { session.currentTargetId = targetSession.actor.fetchId(); session.actor.select?.({ id: targetSession.actor.fetchId() }); + invoke('GameServer/Bot/AI/BotPartyChat').expectSkillResult(session, { + target: targetSession.actor, + skill, + kind: 'resurrection' + }); Generics.skillExec(session, session.actor, { id: targetSession.actor.fetchId(), selfId: skill.fetchSelfId(), diff --git a/src/GameServer/Bot/AI/States/FollowingState.js b/src/GameServer/Bot/AI/States/FollowingState.js index 66ada030..be6845fc 100644 --- a/src/GameServer/Bot/AI/States/FollowingState.js +++ b/src/GameServer/Bot/AI/States/FollowingState.js @@ -9,6 +9,7 @@ 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 BotPartyChat = invoke('GameServer/Bot/AI/BotPartyChat'); const EffectStore = invoke('GameServer/Effects/EffectStore'); const ShotStock = invoke('GameServer/Inventory/ShotStock'); const TradeService = invoke('GameServer/Bot/TradeService'); @@ -21,6 +22,9 @@ const FOLLOW_TARGET_DRIFT = 650; const FOLLOW_TELEPORT_DISTANCE = 4500; const FOLLOW_FORMATION_TOLERANCE = 45; const STUCK_SAMPLE_INTERVAL_MS = 750; +// Aggression transfers threat; it must not consume every combat tick when +// the native transfer has not yet changed the monster's target. +const AGGRESSION_RETRY_MS = 5000; // 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; @@ -241,7 +245,14 @@ function beginCompanionTownErrand(session, bot, playerSession, errand, BotAI) { : 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.`); + BotPartyChat.announce(session, { + priority: 'informational', + key: `town-errand:${bot.fetchId()}:${errand.kind}`, + templates: [ + `Quick ${detail}; then I'm back to camp.`, + `Taking a moment to ${detail}, then returning.` + ] + }); } function shouldKeepCurrentFollowMove(session, bot, player, leaderDistance) { @@ -293,10 +304,35 @@ function recordRoleDecision(session, bot, action, reason, extra = {}) { } } -function castSkillOn(session, bot, Generics, target, skillId, ctrl) { +function castSkillOn(session, bot, Generics, target, skill, ctrl, announcement = null) { session.currentTargetId = target.fetchId(); bot.select({ id: target.fetchId() }); - Generics.skillExec(session, bot, { id: target.fetchId(), selfId: skillId, ctrl }); + if (announcement) { + BotPartyChat.expectSkillResult(session, { + target, + skill, + ...announcement + }); + } + Generics.skillExec(session, bot, { id: target.fetchId(), selfId: skill.fetchSelfId(), ctrl }); +} + +function canAttemptAggression(session, target) { + const previous = session.lastAggressionAttempt; + const targetId = Number(target?.fetchId?.() || 0); + const protectedId = Number(target?.fetchDestId?.() || 0); + return !previous || + previous.targetId !== targetId || + previous.protectedId !== protectedId || + Date.now() - previous.at >= AGGRESSION_RETRY_MS; +} + +function rememberAggressionAttempt(session, target) { + session.lastAggressionAttempt = { + targetId: Number(target.fetchId()), + protectedId: Number(target.fetchDestId?.() || 0), + at: Date.now() + }; } function partyActorIds(leaderSession) { @@ -311,7 +347,9 @@ function partyAggroCount(leaderSession) { const seen = new Set(); return PartyAwareness.partyActors(leaderSession).flatMap((actor) => ( - World.fetchNpcsInRadius(actor.fetchLocX(), actor.fetchLocY(), 900) + // Match PartyAwareness' full NPC combat envelope. A ranged mob can + // continue attacking from beyond the old 900-unit support check. + World.fetchNpcsInRadius(actor.fetchLocX(), actor.fetchLocY(), 1500) )) .filter((npc) => { const id = npc.fetchId?.() || npc; @@ -362,6 +400,27 @@ function partySupportMembers(leaderSession, puller) { return PartyPulling.supportMembers(leaderSession, puller); } +function announceUnexpectedNpcAdd(session, bot, leaderSession, partyThreat, leaderTargetId) { + if ( + partyThreat?.type !== 'npc' || + partyThreat.source === 'party_pull' || + Number(partyThreat.actor?.fetchId?.() || 0) === Number(leaderTargetId || 0) + ) { + return false; + } + + const protectedSession = PartyAwareness.partySessions(leaderSession).find((memberSession) => ( + Number(memberSession.actor?.fetchId?.() || 0) === Number(partyThreat.targetId || 0) + )); + const protectedActor = protectedSession?.actor; + if (!protectedActor || protectedActor === bot) return false; + + const protectedRole = protectedSession === leaderSession ? 'leader' : BotRoles.inferRole(protectedActor); + if (protectedRole !== 'leader' && protectedRole !== 'healer' && protectedRole !== 'buffer') return false; + + return BotPartyChat.announceNpcAdd(session, partyThreat.actor, protectedActor); +} + function moveToFollowTarget(session, bot, player) { const followTarget = followTargetFor(session, player); // Formation positions are deliberately offset from the leader. Comparing @@ -440,7 +499,6 @@ function pullBlockReason(session, botVitals, partyVitals, activeMobs) { if (session.currentTargetId) return 'already_assisting'; if (partyVitals?.hpRatio < 0.65) return 'party_low_hp'; if (botVitals.hpRatio < 0.55) return 'tank_low_hp'; - if (botVitals.mpRatio < 0.25) return 'save_mp'; if (activeMobs >= 2) return 'active_mobs'; return null; } @@ -452,6 +510,10 @@ function assistActionForRole(role) { return 'assist_leader'; } +function supportCanMeleeAssist(bot, role) { + return !['healer', 'buffer'].includes(role) || BotRoles.hasMeleeWeapon(bot); +} + function assistReasonForRole(role) { if (role === 'dagger') return 'close_assist'; return 'leader_target'; @@ -465,10 +527,6 @@ function followTargetFor(session, player) { }; } -function supportBuffPhrase(skill, playerName) { - return `${skill.fetchName()} on ${playerName}.`; -} - module.exports = { tick(session, bot, Generics, BotAI) { const playerSession = session.followPlayerSession; @@ -541,7 +599,7 @@ module.exports = { const holdingPulledTarget = pulling.target && !pulling.engageable; const rawThreatIsHeldPull = holdingPulledTarget && Number(rawPartyThreat?.actor?.fetchId?.()) === Number(pulling.target.fetchId()); - const partyThreat = pulling.engageable && pulling.target + let partyThreat = pulling.engageable && pulling.target ? { type: 'npc', actor: pulling.target, @@ -552,6 +610,7 @@ module.exports = { ? null : rawPartyThreat); const leaderTargetId = pulling.enabled ? undefined : configuredLeaderTargetId; + announceUnexpectedNpcAdd(session, bot, playerSession, partyThreat, leaderTargetId); const impairments = EffectStore.impairments(bot); if (impairments.disabled) { @@ -613,9 +672,11 @@ module.exports = { ...followTargetFor(session, player) }; TeleportTo(session, bot, targetLoc); - if (Math.random() < 0.20) { - BotAI.say(session, "Whew, caught up with you!"); - } + BotPartyChat.announce(session, { + priority: 'informational', + key: `catch-up:${bot.fetchId()}`, + templates: ['Caught up.'] + }); } return; } @@ -668,7 +729,15 @@ module.exports = { return; } - if (!partyThreat && !leaderTargetId && (botVitals.hpRatio < 0.30 || botVitals.mpRatio < 0.15)) { + const isActiveBotPuller = pulling.enabled && + pulling.puller?.kind === 'bot' && + pulling.puller?.session === session && + !!pulling.target; + // The puller is the one companion who must not sit while a living + // pull target is still assigned. A held incoming target is hidden + // from the camp until delivery, so without this guard low HP could + // make the puller sit in front of the mob it is returning with. + if (!partyThreat && !leaderTargetId && !isActiveBotPuller && (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 @@ -676,7 +745,14 @@ module.exports = { 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."); + BotPartyChat.announce(session, { + priority: 'coordination', + key: `recover-guide:${bot.fetchId()}`, + templates: [ + 'HP/MP is low. Recovering at the Newbie Guide, then returning.', + 'Short Newbie Guide stop for HP/MP; I will return after.' + ] + }); return; } @@ -685,7 +761,11 @@ module.exports = { bot.unselect(); sitDown(session, bot); recordRoleDecision(session, bot, botVitals.hpRatio < 0.30 ? 'recover_hp' : 'save_mp', 'resting'); - BotAI.say(session, "Phew! My HP/MP is low. Sitting down to recover."); + BotPartyChat.announce(session, { + priority: 'coordination', + key: `recover-sit:${bot.fetchId()}`, + templates: ['HP/MP is low. Sitting to recover.', 'Need a short sit for HP/MP.'] + }); return; } @@ -719,7 +799,14 @@ module.exports = { recordRoleDecision(session, bot, 'refresh_buffs', 'newbie_blessing', { missingBuffs: BotBuffs.missingNewbieBuffs(bot, BotBuffs.REFRESH_THRESHOLD_MS) }); - BotAI.say(session, "My newbie buffs are fading. Refreshing quickly, then I'll return."); + BotPartyChat.announce(session, { + priority: 'coordination', + key: `newbie-rebuff:${bot.fetchId()}`, + templates: [ + 'Newbie buffs are fading. Refreshing, then returning.', + 'Quick Newbie Guide rebuff; I will be right back.' + ] + }); return; } } @@ -765,9 +852,19 @@ module.exports = { : 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()}?`); + BotPartyChat.announce(session, { + priority: 'coordination', + key: `rebuff:${rebuff.provider.fetchId()}:${rebuff.skill.fetchSelfId()}`, + templates: [ + `${rebuff.provider.fetchName()}, refresh ${rebuff.skill.fetchName()} when safe?`, + `${rebuff.provider.fetchName()}, ${rebuff.skill.fetchName()} is fading.` + ] + }); } - if (!acted && supportBuffTarget && !healerNeedsAction) { + // A routine buff must never take the action slot from a live party + // threat. The target may be a social ranged add that is still outside + // melee range, so let the normal defence branch react immediately. + if (!acted && !partyThreat && !leaderTargetId && supportBuffTarget && !healerNeedsAction) { const activeMobs = partyAggroCount(playerSession); if (unsafeSupportMoment(bot, activeMobs)) { recordRoleDecision(session, bot, 'buff_party', 'wait_for_safe_moment', { @@ -790,84 +887,37 @@ module.exports = { // 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); + castSkillOn(session, bot, Generics, supportBuffTarget.target, supportBuffTarget.skill, false); returnToPartyAfterSupport(session, bot, player, supportBuffTarget.target); recordRoleDecision(session, bot, 'buff_party', supportBuffTarget.effect, { buff: supportBuffTarget.effect, skillId: supportBuffTarget.skill.fetchSelfId(), targetId: supportBuffTarget.target.fetchId() }); - if (Math.random() < 0.30) { - BotAI.say(session, supportBuffPhrase(supportBuffTarget.skill, supportBuffTarget.target.fetchName())); - } } } - if (Math.random() < 0.015) { - const chatterPhrases = [ - "Nice combat, leader!", - "Following you! Let's get some good exp.", - "My mana is looking good, keep pulling!", - "Are we going to Dion or Gludio next?", - "Lineage 2 is so nostalgic, love this party.", - "Anyone got any healing potions?", - "I've got your back, don't worry!", - "Let's clean up this spawn!" - ]; - const classPhrases = { - healer: [ - "Healing is ready. Watch your HP!", - "Don't worry about HP, I'm casting heals.", - "Mana is okay, but don't pull the whole room!" - ], - tank: [ - "I will take the aggro, stay behind me!", - "Aggression is ready! Pulling them off you.", - "I'm tanking this beast!" - ], - buffer: [ - "I'll keep the party buffed.", - "Buffs are ready when we have a safe moment.", - "Save a little mana before the next pull." - ], - dagger: [ - "I'll stay close and hit their weak side.", - "Mark a target and I'll get in close.", - "No bow tricks from me, just blades." - ] - }; - const pool = chatterPhrases.concat(classPhrases[role] || []); - const text = pool[Math.floor(Math.random() * pool.length)]; - BotAI.say(session, text); - } - 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, healerSkill.fetchSelfId(), false); + castSkillOn(session, bot, Generics, woundedPartyMember.actor, healerSkill, false, { kind: 'emergency_heal' }); 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 && healerCanCast) { acted = true; recordRoleDecision(session, bot, 'heal_party', 'top_off', { targetId: woundedPartyMember.actor.fetchId() }); - castSkillOn(session, bot, Generics, woundedPartyMember.actor, healerSkill.fetchSelfId(), false); + castSkillOn(session, bot, Generics, woundedPartyMember.actor, healerSkill, 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'); + if (woundedPartyMember.hpRatio < 0.45 && healerSkill && bot.fetchMp() < healerSkill.fetchConsumedMp()) { + BotPartyChat.announceHealManaShortage(session, woundedPartyMember.actor); + } keepRoleDecision = true; } 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, healerSkill.fetchSelfId(), false); - if (Math.random() < 0.15) { - BotAI.say(session, "Healing myself!"); - } + castSkillOn(session, bot, Generics, bot, healerSkill, false); } else if (impairments.silenced) { recordRoleDecision(session, bot, 'save_mp', 'silenced'); keepRoleDecision = true; @@ -881,7 +931,7 @@ module.exports = { targetId: manaPartyMember.actor.fetchId(), skillId: rechargeSkill.fetchSelfId() }); - castSkillOn(session, bot, Generics, manaPartyMember.actor, rechargeSkill.fetchSelfId(), false); + castSkillOn(session, bot, Generics, manaPartyMember.actor, rechargeSkill, false); returnToPartyAfterSupport(session, bot, player, manaPartyMember.actor); } else if (!healerSkill && woundedPartyMember?.hpRatio < 0.70) { recordRoleDecision(session, bot, 'cannot_heal', 'no_learned_heal'); @@ -902,10 +952,29 @@ module.exports = { } } + // Once the puller has returned to the formation, it can keep the + // incoming mob occupied as soon as that mob reaches the puller's own + // attack range. Do not wait for the stricter leader-centred camp + // radius: it creates a visible idle window and can leave the tank + // taking hits without responding. + const pullerCanFightHeldTarget = !partyThreat && + pulling.enabled && + pulling.target && + pulling.puller?.kind === 'bot' && + pulling.puller?.session === session && + PartyPulling.actorCanEngage(bot, pulling.target); + if (pullerCanFightHeldTarget) { + partyThreat = { + type: 'npc', + actor: pulling.target, + targetId: bot.fetchId(), + source: 'party_pull_puller_range' + }; + } + const waitingForPullAtOwnRange = pulling.enabled && pulling.target && ( - !pulling.engageable || ( - pulling.puller?.session !== session && - !PartyPulling.actorCanEngage(bot, pulling.target) + pulling.puller?.session !== session && ( + !pulling.engageable || !PartyPulling.actorCanEngage(bot, pulling.target) ) ); if (!acted && waitingForPullAtOwnRange) { @@ -927,15 +996,17 @@ module.exports = { ? partyThreat.actor : nearbyNpcs.find((npc) => npc.fetchAttackable() && !npc.isDead() && partyActorIds(playerSession).has(npc.fetchDestId())); - if (monsterToAggro) { + // Aggression is a transfer tool: use it only to take a mob away + // from another party member. Once it is already attacking this + // tank (including after a normal pull hit), continue normal combat + // below instead of repeatedly taunting the same target. + if (monsterToAggro && Number(monsterToAggro.fetchDestId?.()) !== Number(bot.fetchId())) { const skill = BotSkillCapabilities.aggressionSkill(bot); - if (skill && bot.fetchMp() >= skill.fetchConsumedMp() && !isBusy(bot)) { + if (skill && bot.fetchMp() >= skill.fetchConsumedMp() && !isBusy(bot) && canAttemptAggression(session, monsterToAggro)) { acted = true; + rememberAggressionAttempt(session, monsterToAggro); recordRoleDecision(session, bot, 'protect_leader', 'leader_targeted', { targetId: monsterToAggro.fetchId() }); - castSkillOn(session, bot, Generics, monsterToAggro, skill.fetchSelfId(), true); - if (Math.random() < 0.20) { - BotAI.say(session, "Hey, " + monsterToAggro.fetchName() + "! Attack me instead!"); - } + castSkillOn(session, bot, Generics, monsterToAggro, skill, true); } else if (!skill) { recordRoleDecision(session, bot, 'cannot_taunt', 'no_learned_aggression'); keepRoleDecision = true; @@ -948,7 +1019,9 @@ module.exports = { if (!acted && role === 'tank' && !PartyPulling.enabled(partySettings)) { const activeMobs = partyAggroCount(playerSession); - const blockReason = pullBlockReason(session, botVitals, partyVitals, activeMobs); + const blockReason = PartyPulling.hasDeadPartyMember(playerSession) + ? 'party_revival' + : pullBlockReason(session, botVitals, partyVitals, activeMobs); if (blockReason) { recordRoleDecision(session, bot, 'avoid_overpull', blockReason, { activeMobs }); @@ -969,20 +1042,13 @@ module.exports = { } if (targetMonster) { - const skill = BotSkillCapabilities.aggressionSkill(bot); - if (skill && bot.fetchMp() >= skill.fetchConsumedMp() && !isBusy(bot)) { + if (!isBusy(bot)) { acted = true; recordRoleDecision(session, bot, 'pull_target', 'safe_pull', { targetId: targetMonster.fetchId(), activeMobs }); - castSkillOn(session, bot, Generics, targetMonster, skill.fetchSelfId(), true); - if (Math.random() < 0.30) { - BotAI.say(session, "Pulling " + targetMonster.fetchName() + " to the group!"); - } - } else if (!skill) { - recordRoleDecision(session, bot, 'avoid_pull', 'no_learned_aggression'); - keepRoleDecision = true; + BotAI.executeCombat(session, bot, targetMonster, Generics, { basicAttackOnly: true }); } } } @@ -991,8 +1057,18 @@ module.exports = { if (!acted && partyThreat?.actor) { const target = partyThreat.actor; const targetId = target.fetchId(); + const holdSupportLine = !supportCanMeleeAssist(bot, role); - if (session.currentTargetId !== targetId) { + if (holdSupportLine) { + session.currentTargetId = undefined; + bot.unselect(); + recordRoleDecision(session, bot, BotRoles.partyRoleStance(role), 'hold_support_line', { + targetId, + targetType: partyThreat.type, + protectedId: partyThreat.targetId + }); + keepRoleDecision = true; + } else if (session.currentTargetId !== targetId) { session.currentTargetId = targetId; bot.select({ id: targetId }); recordRoleDecision(session, bot, assistActionForRole(role), 'party_under_attack', { @@ -1000,25 +1076,27 @@ module.exports = { targetType: partyThreat.type, protectedId: partyThreat.targetId }); - if (Math.random() < 0.20) { - BotAI.say(session, "I'm helping the party!"); - } } - if (!isBusy(bot)) { + if (!holdSupportLine && !isBusy(bot)) { const basicAttackOnly = role === 'healer' || role === 'buffer'; if (partyThreat.type === 'player') { BotAI.executePvPCombat(session, bot, target, Generics, { basicAttackOnly }); } else { BotAI.executeCombat(session, bot, target, Generics, { basicAttackOnly }); } + acted = true; } - acted = true; } if (!acted) { const playerTargetId = leaderTargetId; - if (playerTargetId && playerTargetId !== bot.fetchId() && playerTargetId !== player.fetchId()) { + if (playerTargetId && playerTargetId !== bot.fetchId() && playerTargetId !== player.fetchId() && !supportCanMeleeAssist(bot, role)) { + session.currentTargetId = undefined; + bot.unselect(); + recordRoleDecision(session, bot, BotRoles.partyRoleStance(role), 'hold_support_line', { targetId: playerTargetId }); + keepRoleDecision = true; + } else if (playerTargetId && playerTargetId !== bot.fetchId() && playerTargetId !== player.fetchId()) { acted = true; World.fetchUser(playerTargetId).then((user) => { if (PartyAwareness.leaderCombatTargetId(playerSession) !== playerTargetId) return; @@ -1044,11 +1122,8 @@ module.exports = { session.currentTargetId = playerTargetId; bot.select({ id: playerTargetId }); recordRoleDecision(session, bot, assistActionForRole(role), 'pvp_target', { targetId: playerTargetId }); - if (Math.random() < 0.20) { - BotAI.say(session, "Assisting you in PvP! Attacking " + user.fetchName() + "!"); - } } - if (isBusy(bot)) { + if (isBusy(bot) || !supportCanMeleeAssist(bot, role)) { return; } BotAI.executePvPCombat(session, bot, user, Generics, { @@ -1078,11 +1153,8 @@ module.exports = { session.currentTargetId = playerTargetId; bot.select({ id: playerTargetId }); recordRoleDecision(session, bot, assistActionForRole(role), assistReasonForRole(role), { targetId: playerTargetId }); - if (Math.random() < 0.20) { - BotAI.say(session, "Assisting you! Smashing that " + npc.fetchName() + "!"); - } } - if (isBusy(bot)) { + if (isBusy(bot) || !supportCanMeleeAssist(bot, role)) { return; } BotAI.executeCombat(session, bot, npc, Generics, { diff --git a/src/GameServer/Bot/BotAI.js b/src/GameServer/Bot/BotAI.js index f63b81d1..8441120c 100644 --- a/src/GameServer/Bot/BotAI.js +++ b/src/GameServer/Bot/BotAI.js @@ -363,7 +363,18 @@ const BotAI = { const wasCompanion = session.partyCompanion === true && !!session.followPlayerSession; if (!session.deathTimerStart) { session.deathTimerStart = Date.now(); - this.say(session, wasCompanion ? "I'm down. Waiting for a resurrection." : "Oops... I died! Resurrecting shortly."); + if (wasCompanion) { + invoke('GameServer/Bot/AI/BotPartyChat').announce(session, { + priority: 'critical', + key: `party-death:${bot.fetchId()}`, + templates: [ + `${bot.fetchName()} is down — waiting for resurrection.`, + `Down at the camp. Waiting for a resurrection.` + ] + }); + } else { + this.say(session, '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'); @@ -483,7 +494,8 @@ const BotAI = { executeCombat(session, bot, npc, Generics, options = {}) { const role = BotRoles.inferRole(bot); - const ARCHER_ATTACK_RANGE = 700; + const BOW_ATTACK_RANGE = 700; + const hasBow = bot?.backpack?.fetchTotalWeaponKind?.() === 'Weapon.Bow'; // 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. @@ -515,7 +527,7 @@ const BotAI = { Generics.attackExec(session, bot, { id: npc.fetchId(), ctrl: true, - ...(role === 'archer' ? { range: ARCHER_ATTACK_RANGE } : {}) + ...(hasBow ? { range: BOW_ATTACK_RANGE } : {}) }); }, diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js index 72d43b34..a61526cb 100644 --- a/src/GameServer/Bot/BotManager.js +++ b/src/GameServer/Bot/BotManager.js @@ -833,12 +833,19 @@ const BotManager = { return false; } + // Do not claim a heal before the native cast lands. Attack.remoteHit + // will confirm only an actual positive result through BotPartyChat. + invoke('GameServer/Bot/AI/BotPartyChat').expectSkillResult(botSession, { + target: player, + targetSession: playerSession, + skill, + kind: 'heal' + }); invoke(path.actor).skillExec(botSession, bot, { id: player.fetchId(), selfId: skill.fetchSelfId(), ctrl: false }); - this.botTell(botSession, playerSession, `Casting ${skill.fetchName()} on you.`); return true; } @@ -888,12 +895,19 @@ const BotManager = { } BotSupportPlanner.reserve(supportAction); + // The result is deliberately announced by Attack.remoteHit, after + // the effect made it through the real C4 effect stack rules. + invoke('GameServer/Bot/AI/BotPartyChat').expectSkillResult(botSession, { + target: player, + targetSession: playerSession, + skill, + kind: 'support' + }); invoke(path.actor).skillExec(botSession, bot, { id: player.fetchId(), selfId: skill.fetchSelfId(), ctrl: false }); - this.botTell(botSession, playerSession, `Casting ${skill.fetchName()} on you.`); return true; } diff --git a/src/GameServer/Effects/EffectRestrictions.js b/src/GameServer/Effects/EffectRestrictions.js index 63970fff..b87b861d 100644 --- a/src/GameServer/Effects/EffectRestrictions.js +++ b/src/GameServer/Effects/EffectRestrictions.js @@ -52,7 +52,8 @@ function interruptOnApply(session, actor, effect, source = session?.actor) { const confused = EffectStore.hasDebuff(actor, 'confusion'); if (!(impairments.disabled || impairments.rooted || confused)) return; - actor.automation?.abortAll?.(actor); + // stopMovement below sends the authoritative StopMove packet. + actor.automation?.abortAll?.(actor, { notifyClient: false }); actor.attack?.clearTimers?.(); actor.attack?.resetQueuedEvent?.(); actor.state?.setHits?.(false); diff --git a/src/GameServer/Network/Response/CharInfo.js b/src/GameServer/Network/Response/CharInfo.js index f7706a10..f7e9fb49 100644 --- a/src/GameServer/Network/Response/CharInfo.js +++ b/src/GameServer/Network/Response/CharInfo.js @@ -2,6 +2,11 @@ const SendPacket = invoke('Packet/Send'); const Pledge = invoke('GameServer/Network/Response/PledgeHelpers'); const EffectStore = invoke('GameServer/Effects/EffectStore'); +function boatObjectId(actor) { + const boat = actor?.fetchBoat?.() || actor?.boat; + return Number(actor?.fetchBoatId?.() ?? boat?.fetchId?.() ?? boat?.id ?? 0) || 0; +} + function charInfo(actor) { const packet = new SendPacket(0x03); const weaponDisplayId = actor.backpack.fetchPaperdollSelfId(7) || actor.backpack.fetchPaperdollSelfId(14) || 0; @@ -14,13 +19,22 @@ function charInfo(actor) { const swimWalkSpeed = swimSpeed || walkSpeed; const privateStoreType = actor.fetchPrivateStoreType(); const standingState = actor.state.fetchSeated() ? 0x00 : 0x01; + const runningState = actor.state.fetchWalkin?.() ? 0x00 : 0x01; + // The C4 client renders this flag as a persistent red combat aura around + // the nameplate. Combat is already represented by AutoAttackStart/Stop, + // so do not expose this cosmetic state in CharInfo for players or bots. + const combatState = 0x00; + const deadState = actor.state.fetchDead?.() ? 0x01 : 0x00; const title = actor.fetchTitle(); packet .writeD(actor.fetchLocX()) .writeD(actor.fetchLocY()) .writeD(actor.fetchLocZ()) - .writeD(actor.fetchHead()) + // C4 reserves this field for the boat object id, not the character + // heading. A non-zero heading made every ordinary character appear + // attached to a non-existent vehicle on the client. + .writeD(boatObjectId(actor)) .writeD(actor.fetchId()) .writeS(actor.fetchName()) .writeD(actor.fetchRace()) @@ -65,9 +79,9 @@ function charInfo(actor) { .writeD(Pledge.allyCrestId(actor)) // Ally Crest Id .writeD(0x00) // ? .writeC(standingState) // Sitting = 0, Standing = 1 - .writeC(0x01) // Running = 1 - .writeC(0x00) // Combat = 1 - .writeC(0x00) // Dead = 1 + .writeC(runningState) // Running = 1 + .writeC(combatState) // Combat = 1 + .writeC(deadState) // Dead = 1 .writeC(0x00) // Invisible = 1 .writeC(actor.fetchMounted?.() || actor.mounted ? 1 : 0) // Mount .writeC(privateStoreType) // Private store type @@ -97,7 +111,7 @@ function charInfo(actor) { .writeD(0xffffff); // Name color const buffer = packet.fetchBuffer(); - buffer.__packetTrace = `char=${actor.fetchId()}:${actor.fetchName()}:store=${actor.fetchPrivateStoreType()}:stand=${standingState}:titleLen=${title.length}`; + buffer.__packetTrace = `char=${actor.fetchId()}:${actor.fetchName()}:store=${actor.fetchPrivateStoreType()}:stand=${standingState}:run=${runningState}:combat=${combatState}:dead=${deadState}:titleLen=${title.length}`; return buffer; } diff --git a/src/GameServer/Network/Response/NpcInfo.js b/src/GameServer/Network/Response/NpcInfo.js index 48ac6c05..04fc4efa 100644 --- a/src/GameServer/Network/Response/NpcInfo.js +++ b/src/GameServer/Network/Response/NpcInfo.js @@ -3,6 +3,7 @@ const EffectStore = invoke('GameServer/Effects/EffectStore'); function npcInfo(npc) { const packet = new SendPacket(0x16); + const deadState = npc.state?.fetchDead?.() ? 0x01 : (npc.fetchStateDead() ? 0x01 : 0x00); packet .writeD(npc.fetchId()) @@ -33,7 +34,7 @@ function npcInfo(npc) { .writeC(0x01) // Name above character .writeC(npc.fetchStateRun()) .writeC(npc.fetchStateAttack()) - .writeC(npc.fetchStateDead()) + .writeC(deadState) .writeC(npc.fetchStateInvisible()) .writeS(npc.fetchName()) .writeS(npc.fetchTitle()) diff --git a/src/GameServer/Network/Response/UserInfo.js b/src/GameServer/Network/Response/UserInfo.js index 022229a6..c0db5ba2 100644 --- a/src/GameServer/Network/Response/UserInfo.js +++ b/src/GameServer/Network/Response/UserInfo.js @@ -3,6 +3,11 @@ const Pledge = invoke('GameServer/Network/Response/PledgeHelpers'); const ClanService = invoke('GameServer/Clan/ClanService'); const EffectStore = invoke('GameServer/Effects/EffectStore'); +function boatObjectId(actor) { + const boat = actor?.fetchBoat?.() || actor?.boat; + return Number(actor?.fetchBoatId?.() ?? boat?.fetchId?.() ?? boat?.id ?? 0) || 0; +} + function userInfo(actor) { const packet = new SendPacket(0x04); const clan = Pledge.clan(actor); @@ -16,7 +21,7 @@ function userInfo(actor) { .writeD(actor.fetchLocX()) .writeD(actor.fetchLocY()) .writeD(actor.fetchLocZ()) - .writeD(actor.fetchHead()) + .writeD(boatObjectId(actor)) .writeD(actor.fetchId()) .writeS(actor.fetchName()) .writeD(actor.fetchRace()) diff --git a/src/GameServer/Npc/Npc.js b/src/GameServer/Npc/Npc.js index 2e773009..c626840b 100644 --- a/src/GameServer/Npc/Npc.js +++ b/src/GameServer/Npc/Npc.js @@ -74,7 +74,7 @@ class Npc extends NpcModel { } enterCombatState(session, actor) { - if (this.state.fetchCombats()) { + if (this.state.fetchCombats() || actor?.isDead?.() || actor?.state?.fetchDead?.()) { return; } @@ -88,7 +88,8 @@ class Npc extends NpcModel { this.timer.combatStart = setTimeout(() => { this.timer.combatStart = undefined; - if (!this.state.fetchCombats()) { + if (!this.state.fetchCombats() || actor?.isDead?.() || actor?.state?.fetchDead?.()) { + if (this.state.fetchCombats()) this.abortCombatState(session); return; } @@ -100,6 +101,14 @@ class Npc extends NpcModel { let lastChaseRepathAt = 0; this.timer.combat = setInterval(() => { + // A dead target cannot be chased or hit. Leaving the NPC in + // combat here pins its target to the corpse indefinitely and + // makes party resurrection believe the fight never ended. + if (actor?.isDead?.() || actor?.state?.fetchDead?.()) { + this.abortCombatState(session); + return; + } + if (new SpeckMath.Point(this.fetchLocX(), this.fetchLocY()).distance(new SpeckMath.Point(actor.fetchLocX(), actor.fetchLocY())) >= 1500) { this.abortCombatState(session); // Actor is out of reach return; diff --git a/src/GameServer/Npc/NpcAggro.js b/src/GameServer/Npc/NpcAggro.js new file mode 100644 index 00000000..d54a351f --- /dev/null +++ b/src/GameServer/Npc/NpcAggro.js @@ -0,0 +1,90 @@ +// Lisvus L2AttackableAI starts every spawned attackable with global aggro -10 +// and decrements it once per second. A mob therefore cannot auto-attack for +// its first ten seconds in the world. +const SPAWN_AGGRO_DELAY_MS = 10000; +const AGGRO_RADIUS = 500; + +function isHotBotSession(session) { + return !!( + session?.actor && + (session.constructor?.name === 'BotSession' || String(session.accountId || '').startsWith('bot_')) + ); +} + +function isAlive(actor) { + return !!actor && actor.isDead?.() !== true && actor.state?.fetchDead?.() !== true; +} + +function isLiveSession(session) { + return !!( + session?.actor && + session.actor.fetchIsOnline?.() !== false && + isAlive(session.actor) + ); +} + +function isEligible(npc, now = Date.now()) { + return !!( + npc?.fetchHostile?.() && + npc.state?.fetchDead?.() !== true && + npc.state?.fetchCombats?.() !== true && + Number(npc.aggroEligibleAt || 0) <= now + ); +} + +function distanceSquared(first, second) { + const dx = first.fetchLocX() - second.fetchLocX(); + const dy = first.fetchLocY() - second.fetchLocY(); + return (dx * dx) + (dy * dy); +} + +function engageNearby(session, actor, { world = invoke('GameServer/World/World'), now = Date.now(), npcs = null } = {}) { + if (!isAlive(actor)) return []; + + const nearby = npcs || world.fetchNpcsInRadius(actor.fetchLocX(), actor.fetchLocY(), AGGRO_RADIUS); + return nearby + .filter((npc) => isEligible(npc, now) && distanceSquared(npc, actor) <= AGGRO_RADIUS * AGGRO_RADIUS) + .map((npc) => { + npc.enterCombatState(session, actor); + return npc; + }); +} + +function armSpawnGrace(npc, now = Date.now()) { + if (!npc) return 0; + npc.aggroEligibleAt = now + SPAWN_AGGRO_DELAY_MS; + return npc.aggroEligibleAt; +} + +// Lisvus runs L2AttackableAI once per second. Sweep live actors at the same +// cadence: this catches both a bot entering range and a stationary actor +// whose nearby NPC has just completed its spawn grace, without doing a world +// grid scan for every movement interpolation frame. +function tickLiveActors(world = invoke('GameServer/World/World'), now = Date.now()) { + return (world?.user?.sessions || []) + .filter(isLiveSession) + .flatMap((session) => engageNearby(session, session.actor, { world, now })); +} + +function startAggroTicker(world = invoke('GameServer/World/World'), { + setTicker = setInterval +} = {}) { + if (!world || world.npcAggroTicker) return world?.npcAggroTicker; + + const ticker = setTicker(() => tickLiveActors(world), 1000); + ticker?.unref?.(); + world.npcAggroTicker = ticker; + return ticker; +} + +module.exports = { + AGGRO_RADIUS, + SPAWN_AGGRO_DELAY_MS, + isHotBotSession, + isLiveSession, + isEligible, + engageNearby, + armSpawnGrace, + tickLiveActors, + startAggroTicker +}; diff --git a/src/GameServer/World/Generics/NpcBypasses/CompanionControl.js b/src/GameServer/World/Generics/NpcBypasses/CompanionControl.js index a64664bf..66c6cbe5 100644 --- a/src/GameServer/World/Generics/NpcBypasses/CompanionControl.js +++ b/src/GameServer/World/Generics/NpcBypasses/CompanionControl.js @@ -73,15 +73,16 @@ function assignedPullerSession(session) { return companionSessions(session).find((memberSession) => isAssignedPuller(memberSession, settings)) || null; } -function clearAssignedPuller(session) { +function clearAssignedPuller(session, { nextPullMode = 'auto' } = {}) { const settings = PartyCompanionService.getSettings(session); if (settings.pullMode !== 'bot') return false; stopPullAction(assignedPullerSession(session)); - PartyCompanionService.updateSettings(session, { pullMode: 'auto', pullerId: null }); + PartyCompanionService.updateSettings(session, { pullMode: nextPullMode, pullerId: null }); session.partyPullState = {}; companionSessions(session).forEach((memberSession) => { memberSession.partyPuller = false; + memberSession.autoTaunt = nextPullMode !== 'off'; }); return true; } @@ -350,7 +351,9 @@ function companionControl(session, parts) { if (value === 'on' && targetSession) { setMemberPuller(session, targetSession); } else if (value === 'off' && targetSession && isAssignedPuller(targetSession)) { - clearAssignedPuller(session); + // "Stop Pull" must disable autonomous pulling rather than + // silently falling back to the automatic tank puller. + clearAssignedPuller(session, { nextPullMode: 'off' }); BotManager.botSay(targetSession, 'Stopping pull duty and staying with the party.'); } } else if (subCommand === 'regroup') { diff --git a/src/GameServer/World/Generics/SpawnNpcs.js b/src/GameServer/World/Generics/SpawnNpcs.js index 71f0e74f..2736d5d2 100644 --- a/src/GameServer/World/Generics/SpawnNpcs.js +++ b/src/GameServer/World/Generics/SpawnNpcs.js @@ -2,6 +2,7 @@ const Npc = invoke('GameServer/Npc/Npc'); const DataCache = invoke('GameServer/DataCache'); const ServerResponse = invoke('GameServer/Network/Response'); const NpcVisibility = invoke('GameServer/World/NpcVisibility'); +const NpcAggro = invoke('GameServer/Npc/NpcAggro'); const VISIBILITY_RADIUS = 6000; @@ -31,6 +32,7 @@ function notifyNearby(world, npc, response = ServerResponse) { function createNpc(world, npc, coords, spawnDefinition = null) { const instance = new Npc(world.npc.nextId++, { ...utils.crushOb(npc), ...coords }); instance.spawnDefinition = spawnDefinition; + NpcAggro.armSpawnGrace(instance); world.npc.spawns.push(instance); return instance; } diff --git a/src/GameServer/World/World.js b/src/GameServer/World/World.js index 0002aa45..f211c4ea 100644 --- a/src/GameServer/World/World.js +++ b/src/GameServer/World/World.js @@ -60,6 +60,7 @@ const World = { World.spawnNpcs(); this.indexSpawnsInGrid(); + invoke('GameServer/Npc/NpcAggro').startAggroTicker(this); }, insertUser(session) { diff --git a/tests/test_bot_chat_commands.js b/tests/test_bot_chat_commands.js index 79403bf7..ed67f045 100644 --- a/tests/test_bot_chat_commands.js +++ b/tests/test_bot_chat_commands.js @@ -222,7 +222,12 @@ try { assert.deepStrictEqual(supportCast, { id: player.fetchId(), selfId: 1068, ctrl: false }, 'the highest-MP party bot should cast the shared requested buff'); - assert.deepStrictEqual(supportReplies, ['Casting Might on you.'], 'only the selected party caster should answer the direct buff request'); + assert.deepStrictEqual(supportReplies, [], 'a requested buff must not claim success before the native cast lands'); + assert.strictEqual( + higherMpSession.pendingPartyChatResult?.skillId, + 1068, + 'the selected provider should retain a pending factual confirmation for the native cast result' + ); BotManager.botTell = originalBotTell; const socialEvents = []; diff --git a/tests/test_bot_combat_skill_selection.js b/tests/test_bot_combat_skill_selection.js index abf1e2e8..daa313b7 100644 --- a/tests/test_bot_combat_skill_selection.js +++ b/tests/test_bot_combat_skill_selection.js @@ -80,6 +80,24 @@ try { assert.deepStrictEqual(archerGenerics.skills[0], { id: 1102, selfId: 56, ctrl: true }); assert.strictEqual(archerGenerics.attacks.length, 0, 'archer with learned Power Shot should cast it before ranged attack fallback'); + const bowWithMeleeSkill = bot(9, [skill(3, { name: 'Power Strike', mp: 5, range: 50, power: 500 })], 100, 'Weapon.Bow'); + const bowWithMeleeSkillGenerics = generics(); + BotAI.executeCombat({}, bowWithMeleeSkill, npc(11021), bowWithMeleeSkillGenerics); + assert.strictEqual(bowWithMeleeSkillGenerics.skills.length, 0, 'a bow user must exclude short-range offensive skills from its rotation'); + assert.deepStrictEqual( + bowWithMeleeSkillGenerics.attacks[0], + { id: 11021, ctrl: true, range: 700 }, + 'a bow user without an available ranged skill must keep its normal attack at bow range' + ); + + const bowWithMixedSkills = bot(9, [ + skill(3, { name: 'Power Strike', mp: 5, range: 50, power: 500 }), + skill(56, { name: 'Power Shot', mp: 5, range: 700, power: 24, semantic: { requires: { weaponsAllowed: 32 } } }) + ], 100, 'Weapon.Bow'); + const bowWithMixedSkillsGenerics = generics(); + BotAI.executeCombat({}, bowWithMixedSkills, npc(11022), bowWithMixedSkillsGenerics); + assert.strictEqual(bowWithMixedSkillsGenerics.skills[0].selfId, 56, 'a bow user must choose its ranged shot even when a stronger short-range skill is learned'); + const swordFighter = bot(18, [ skill(56, { name: 'Power Shot', mp: 5, range: 700, power: 90, semantic: { requires: { weaponsAllowed: 32 } } }), skill(3, { name: 'Power Strike', mp: 5, range: 40, power: 30 }) diff --git a/tests/test_bot_movement_visibility.js b/tests/test_bot_movement_visibility.js new file mode 100644 index 00000000..b05f9a55 --- /dev/null +++ b/tests/test_bot_movement_visibility.js @@ -0,0 +1,96 @@ +const assert = require('assert'); + +require('../src/Global'); + +const Automation = invoke('GameServer/Automation'); +const moveTo = invoke('GameServer/Actor/Generics/MoveTo'); + +assert.strictEqual( + moveTo.shouldUseLowLodWarp({ + startDistance: 1501, + destinationDistance: 7000, + isCompanion: false, + plan: 'hunting' + }), + false, + 'A bot inside the 6000-unit client visibility radius must use normal movement' +); +assert.strictEqual( + moveTo.shouldUseLowLodWarp({ + startDistance: 7000, + destinationDistance: 5000, + isCompanion: false, + plan: 'hunting' + }), + false, + 'An offscreen bot walking into client visibility must not silently warp' +); +assert.strictEqual( + moveTo.shouldUseLowLodWarp({ + startDistance: 7000, + destinationDistance: 7000, + isCompanion: false, + plan: 'hunting' + }), + true, + 'Low-detail movement remains available when both endpoints are offscreen' +); +assert.strictEqual( + moveTo.shouldUseLowLodWarp({ + startDistance: 7000, + destinationDistance: 7000, + isCompanion: true, + plan: 'hunting' + }), + false, + 'Party companions must always use visible movement' +); +assert.strictEqual( + moveTo.shouldPreannounceVisibleMove(6001, 5000), + true, + 'A player must receive the bot snapshot and route before it crosses into visibility' +); +assert.strictEqual( + moveTo.shouldPreannounceVisibleMove(5000, 4000), + false, + 'Normal visible movement must keep using the regular world broadcast' +); + +const packets = []; +const actor = { + state: { + towards: 'move', + inMotion() { return this.towards; }, + setTowards(value) { this.towards = value; } + }, + fetchId: () => 42, + fetchLocX: () => 100, + fetchLocY: () => 200, + fetchLocZ: () => -300, + fetchHead: () => 400, + session: { + accountId: 'bot_test', + moveTimer: setInterval(() => {}, 1000), + dataSendToMeAndOthers(packet, creature) { + packets.push({ packet, creature }); + } + } +}; + +const automation = new Automation(); +automation.abortAll(actor); +assert.strictEqual(actor.state.towards, false, 'Cancelling a route must clear the movement state'); +assert.strictEqual(actor.session.moveTimer, null, 'Cancelling a route must clear the server movement timer'); +assert.strictEqual(packets.length, 1, 'Cancelling a visible route must notify the client exactly once'); +assert.strictEqual(packets[0].packet[0], 0x47, 'Route cancellation must use the C4 StopMove packet'); + +actor.state.towards = 'move'; +automation.abortAll(actor, { notifyClient: false }); +assert.strictEqual(packets.length, 1, 'Callers that send StopMove themselves must be able to suppress duplicates'); + +actor.state.towards = 'move'; +actor.session.accountId = 'player_test'; +automation.abortAll(actor); +assert.strictEqual(packets.length, 1, 'Player automation keeps its existing explicit StopMove lifecycle'); + +console.log('Bot movement visibility checks passed'); diff --git a/tests/test_bot_party_chat.js b/tests/test_bot_party_chat.js new file mode 100644 index 00000000..e4c6ef9e --- /dev/null +++ b/tests/test_bot_party_chat.js @@ -0,0 +1,122 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotManager = invoke('GameServer/Bot/BotManager'); +const BotPartyChat = invoke('GameServer/Bot/AI/BotPartyChat'); + +function actor(id, name) { + return { + fetchId: () => id, + fetchName: () => name + }; +} + +const originalPartySay = BotManager.botPartySay; +const originalTell = BotManager.botTell; +const messages = []; + +try { + BotManager.botPartySay = (_session, text) => { + messages.push({ scope: 'party', text }); + return true; + }; + BotManager.botTell = (_session, targetSession, text) => { + messages.push({ scope: 'tell', target: targetSession.actor.fetchName(), text }); + return true; + }; + + const leaderSession = { actor: actor(1, 'Slava') }; + const companionSession = { + actor: actor(2, 'Aria'), + partyCompanion: true, + followPlayerSession: leaderSession + }; + + assert.strictEqual(BotPartyChat.announce(companionSession, { + priority: 'coordination', key: 'pull:100', text: 'Pulling Leto Lizardman.', now: 100_000 + }), true, 'the first coordination event should reach the party'); + assert.strictEqual(BotPartyChat.announce(companionSession, { + priority: 'coordination', key: 'rebuff:1', text: 'Refresh Might.', now: 100_001 + }), false, 'the shared party budget should suppress a different coordination event immediately after a pull'); + assert.strictEqual(BotPartyChat.announce(companionSession, { + priority: 'critical', key: 'add:200', text: 'Add on Slava.', now: 100_002 + }), true, 'a critical event must bypass ordinary party throttling'); + assert.strictEqual(BotPartyChat.announce(companionSession, { + priority: 'critical', key: 'add:200', text: 'Add on Slava.', now: 101_000 + }), false, 'the same critical event should still be deduplicated'); + assert.strictEqual(BotPartyChat.announce(companionSession, { + priority: 'coordination', key: 'rebuff:1', text: 'Refresh Might.', now: 107_002 + }), true, 'coordination should resume after the shared cooldown'); + + const addNpc = actor(200, 'Leto Lizardman'); + const protectedMember = actor(5, 'Belen'); + assert.strictEqual(BotPartyChat.announceNpcAdd(companionSession, addNpc, protectedMember), true, + 'an unexpected NPC add should produce a concise party warning'); + assert.match(messages.at(-1).text, /Leto Lizardman/, 'the add warning should identify the actual NPC'); + assert.strictEqual(BotPartyChat.announceNpcAdd(companionSession, addNpc, protectedMember), false, + 'the same add should remain quiet while the fight is ongoing'); + assert.strictEqual(BotPartyChat.announceHealManaShortage(companionSession, protectedMember), true, + 'a healer unable to pay for an emergency heal should tell the party'); + assert.match(messages.at(-1).text, /MP/, 'the mana warning should explain the actionable limitation'); + + const target = actor(3, 'Belen'); + const targetSession = { actor: target }; + const skill = { + fetchSelfId: () => 1068, + fetchName: () => 'Might' + }; + assert.strictEqual(BotPartyChat.expectSkillResult(companionSession, { + target, + targetSession, + skill, + kind: 'support' + }), true, 'a requested support cast should wait for a native result'); + assert.strictEqual(BotPartyChat.confirmSkillResult(companionSession, companionSession.actor, target, skill, { + effect: { key: 'might' } + }), true, 'only a landed effect may confirm the requested buff'); + assert.deepStrictEqual(messages.at(-1), { + scope: 'tell', target: 'Belen', text: 'Might is up on Belen.' + }); + + assert.strictEqual(BotPartyChat.expectSkillResult(companionSession, { + target, + targetSession, + skill, + kind: 'support' + }), true); + assert.strictEqual(BotPartyChat.confirmSkillResult(companionSession, companionSession.actor, target, skill, { + effect: null + }), false, 'a rejected effect must not produce a false success confirmation'); + assert.strictEqual(messages.length, 6, 'failed casts must remain silent'); + assert.strictEqual(companionSession.pendingPartyChatResult, undefined, 'a completed but rejected cast must not remain eligible for a later confirmation'); + assert.strictEqual(BotPartyChat.confirmSkillResult(companionSession, companionSession.actor, target, skill, { + effect: { key: 'might' } + }), false, 'a later unrelated cast must not satisfy an already-failed request'); + + const historyNow = Date.now() + BotPartyChat.EVENT_HISTORY_MS + 1; + leaderSession.botPartyChat.events['expired:pull'] = historyNow - BotPartyChat.EVENT_HISTORY_MS - 1; + assert.strictEqual(BotPartyChat.announce(companionSession, { + priority: 'coordination', key: 'pull:101', text: 'Pulling Leto Lizardman Scout.', now: historyNow + }), true, 'a new coordination event should still be sent after old history expires'); + assert.strictEqual(leaderSession.botPartyChat.events['expired:pull'], undefined, 'expired event history should be pruned instead of growing with every pull'); + + const loneSession = { actor: actor(4, 'LoneHealer') }; + assert.strictEqual(BotPartyChat.expectSkillResult(loneSession, { + target, + targetSession, + skill, + kind: 'heal' + }), true); + assert.strictEqual(BotPartyChat.confirmSkillResult(loneSession, loneSession.actor, target, skill, { + heal: 25 + }), true, 'a direct support confirmation should remain a tell outside a party'); + assert.deepStrictEqual(messages.at(-1), { + scope: 'tell', target: 'Belen', text: 'Belen, Might landed.' + }); + + console.log('Bot party chat checks passed'); +} finally { + BotManager.botPartySay = originalPartySay; + BotManager.botTell = originalTell; +} diff --git a/tests/test_c4_protocol_packets.js b/tests/test_c4_protocol_packets.js index 2c93f6d0..96d60baa 100644 --- a/tests/test_c4_protocol_packets.js +++ b/tests/test_c4_protocol_packets.js @@ -91,7 +91,10 @@ function fakeActor(paperdoll = fakePaperdoll()) { fetchCp: () => 77, fetchCharges: () => 3, state: { - fetchSeated: () => false + fetchSeated: () => false, + fetchWalkin: () => false, + fetchCombats: () => false, + fetchDead: () => false } }; @@ -247,6 +250,7 @@ assert.strictEqual(etcStatusUpdate.readInt32LE(5), 0, 'C4 EtcStatusUpdate should const userInfo = ServerResponse.userInfo(actor); assert.strictEqual(userInfo[0], 0x04); +assert.strictEqual(userInfo.readInt32LE(13), 0, 'C4 UserInfo must send boat object id, not character heading'); assert.deepStrictEqual(actor.paperdollIdSlots, [7, ...Array.from({ length: 14 }, (_, i) => i)]); assert.deepStrictEqual(actor.paperdollSelfIdSlots, [7, ...Array.from({ length: 14 }, (_, i) => i)]); assert.ok(userInfo.includes(0xff), 'C4 UserInfo should include trailing name-color bytes'); @@ -266,8 +270,13 @@ assert.ok(ServerResponse.userInfo(actor).includes(0x40), 'C4 UserInfo should exp const charInfo = ServerResponse.charInfo(actor); assert.strictEqual(charInfo[0], 0x03); +assert.strictEqual(charInfo.readInt32LE(13), 0, 'C4 CharInfo must send boat object id, not character heading'); assert.ok(charInfo.includes(0xff), 'C4 CharInfo should include trailing name-color bytes'); assert.strictEqual(charInfoEquipment(charInfo).weapon, 1007, 'C4 CharInfo should display right-hand weapons'); +const boatActor = fakeActor(); +boatActor.fetchBoatId = () => 7000001; +assert.strictEqual(ServerResponse.userInfo(boatActor).readInt32LE(13), 7000001, 'C4 UserInfo should preserve an attached boat object id'); +assert.strictEqual(ServerResponse.charInfo(boatActor).readInt32LE(13), 7000001, 'C4 CharInfo should preserve an attached boat object id'); const nameColorOffset = charInfo.lastIndexOf(Buffer.from([0xff, 0xff, 0xff, 0x00])); assert.ok(nameColorOffset > 0, 'C4 CharInfo should end its meaningful payload with name color'); const charInfoTail = charInfo.subarray(nameColorOffset + 4 - 37, nameColorOffset + 4); @@ -275,6 +284,22 @@ assert.strictEqual(charInfoTail.readInt32LE(0), 0, 'C4 CharInfo should send moun assert.strictEqual(charInfoTail.readInt32LE(4), 10, 'C4 CharInfo should send class id after mount NPC id'); assert.strictEqual(charInfoTail.readInt32LE(8), 0, 'C4 CharInfo should not send CP in the public tail'); +const visualStateActor = fakeActor(); +visualStateActor.state = { + fetchSeated: () => false, + fetchWalkin: () => true, + fetchCombats: () => true, + fetchDead: () => true +}; +const visualStateInfo = ServerResponse.charInfo(visualStateActor); +const visualNameEnd = findUtf16Terminator(visualStateInfo, 21); +const visualTitleStart = visualNameEnd + 2 + (28 * 4) + (4 * 8) + (3 * 4); +const visualTitleEnd = findUtf16Terminator(visualStateInfo, visualTitleStart); +const visualFlagsOffset = visualTitleEnd + 2 + (5 * 4); +assert.strictEqual(visualStateInfo[visualFlagsOffset + 1], 0, 'C4 CharInfo should mark a walking actor as not running'); +assert.strictEqual(visualStateInfo[visualFlagsOffset + 2], 0, 'C4 CharInfo should suppress the red combat aura around player nameplates'); +assert.strictEqual(visualStateInfo[visualFlagsOffset + 3], 1, 'C4 CharInfo must retain the authoritative dead flag after Die'); + const privateStoreSell = ServerResponse.privateStoreMsg(actor, 'Cheap C-Grade gear'); assert.strictEqual(privateStoreSell[0], 0x9c, 'C4 PrivateStoreMsgSell should use opcode 0x9c'); assert.strictEqual(privateStoreSell.readInt32LE(1), actor.fetchId(), 'C4 PrivateStoreMsgSell should include the seller object id'); @@ -431,6 +456,13 @@ const npcInfo = ServerResponse.npcInfo(fakeNpc()); assert.strictEqual(npcInfo[0], 0x16); assert.ok(npcInfo.length >= 208, 'C4 NpcInfo should include team/collision tail fields'); +const deadNpc = fakeNpc(); +deadNpc.fetchStateDead = () => 0; +deadNpc.state = { fetchDead: () => true }; +const deadNpcInfo = ServerResponse.npcInfo(deadNpc); +const npcStateOffset = 1 + (18 * 4) + (4 * 8) + (3 * 4); +assert.strictEqual(deadNpcInfo[npcStateOffset + 3], 1, 'C4 NpcInfo must use the NPC state-machine dead flag instead of stale model state'); + const deleteObject = ServerResponse.deleteOb(3000001); assert.strictEqual(deleteObject[0], 0x12, 'C4 DeleteObject opcode should be 0x12'); assert.strictEqual(deleteObject.readInt32LE(1), 3000001, 'C4 DeleteObject should include object id'); diff --git a/tests/test_equipment_slots.js b/tests/test_equipment_slots.js index d4902174..4e90b1fc 100644 --- a/tests/test_equipment_slots.js +++ b/tests/test_equipment_slots.js @@ -98,6 +98,21 @@ try { assert.strictEqual(bowAndShield.fetchItemRaw(9).fetchEquipped(), true, 'the two-handed bow should be equipped'); assert.strictEqual(bowAndShield.fetchItemRaw(10).fetchEquipped(), false, 'equipping a two-handed bow must remove the shield'); + const persistedEmptySlotWeapon = backpack([ + item(11, 5, 'Weapon.Blunt', 7) + ]); + const persistedSnapshots = []; + persistedEmptySlotWeapon.updateDatabaseTimer = () => { + persistedSnapshots.push(persistedEmptySlotWeapon.fetchItems().map((entry) => ({ + id: entry.fetchId(), + equipped: entry.fetchEquipped(), + slot: entry.fetchSlot() + }))); + }; + persistedEmptySlotWeapon.equipGear(sessionFor(persistedEmptySlotWeapon), persistedEmptySlotWeapon.fetchItemRaw(11)); + assert.deepStrictEqual(persistedSnapshots, [[{ id: 11, equipped: true, slot: 7 }]], + 'equipping into an empty weapon slot must immediately schedule durable equipped state'); + console.log('Equipment slot checks passed'); } finally { ActorGenerics.calculateStats = originalCalculateStats; diff --git a/tests/test_npc_combat_range.js b/tests/test_npc_combat_range.js index 7592d8fc..5d12018a 100644 --- a/tests/test_npc_combat_range.js +++ b/tests/test_npc_combat_range.js @@ -128,6 +128,13 @@ assert.strictEqual(rangedNpc.isTargetInAttackRange(target), true, 'ranged NPC sh assert.notStrictEqual(rangedNpc.fetchLocX(), target.fetchLocX(), 'ranged NPC must not snap onto the player'); const meleeNpc = npcWithRange(40); +const deadTarget = actorAt(100); +deadTarget.state.fetchDead = () => true; +const corpseTargetNpc = npcWithRange(40, 900001); +const combatAbortSession = { packets: [], dataSendToMeAndOthers(packet) { this.packets.push(packet); } }; +corpseTargetNpc.enterCombatState(combatAbortSession, deadTarget); +assert.strictEqual(corpseTargetNpc.state.fetchCombats(), false, 'an NPC must not enter combat against an already dead target'); + EffectStore.apply(meleeNpc, { key: 'npc_debuff_regression', id: 9999, diff --git a/tests/test_npc_hot_bot_aggro.js b/tests/test_npc_hot_bot_aggro.js new file mode 100644 index 00000000..0df7f6df --- /dev/null +++ b/tests/test_npc_hot_bot_aggro.js @@ -0,0 +1,88 @@ +const assert = require('assert'); + +require('../src/Global'); + +const NpcAggro = invoke('GameServer/Npc/NpcAggro'); + +function actorAt(id, x, y = 0) { + return { + fetchId: () => id, + fetchLocX: () => x, + fetchLocY: () => y, + fetchLocZ: () => 0, + fetchIsOnline: () => true, + isDead: () => false, + state: { fetchDead: () => false } + }; +} + +function hostileNpcAt(x, y = 0) { + const npc = { + combats: 0, + fetchHostile: () => true, + fetchLocX: () => x, + fetchLocY: () => y, + state: { + fetchDead: () => false, + fetchCombats: () => false + }, + enterCombatState(session, actor) { + this.combats++; + this.targetSession = session; + this.target = actor; + } + }; + return npc; +} + +const bot = actorAt(2000001, 100); +const hotSession = { constructor: { name: 'BotSession' }, accountId: 'bot_hot_aggro', actor: bot }; +const npc = hostileNpcAt(0); +const world = { + npc: { + spawns: [npc], + grid: { '0_0': [npc] } + }, + user: { sessions: [hotSession] }, + fetchNpcsInRadius() { return this.npc.spawns; } +}; + +const spawnedAt = 5000; +assert.strictEqual(NpcAggro.armSpawnGrace(npc, spawnedAt), spawnedAt + 10000, 'spawn grace must match the source 10-second global-aggro delay'); +assert.deepStrictEqual( + NpcAggro.engageNearby(hotSession, bot, { world, now: spawnedAt + 9999 }), + [], + 'a hot bot entering range during spawn grace must not be auto-aggroed' +); +assert.strictEqual(npc.combats, 0, 'spawn grace must suppress combat before the delay ends'); + +NpcAggro.engageNearby(hotSession, bot, { world, now: spawnedAt + 10000 }); +assert.strictEqual(npc.combats, 1, 'a moving hot bot must trigger native hostile aggro after spawn grace'); +assert.strictEqual(npc.target, bot, 'the hostile NPC must target the hot bot that entered its aggro radius'); + +const respawnedNpc = hostileNpcAt(0); +world.npc.spawns = [respawnedNpc]; +world.npc.grid = { '0_0': [respawnedNpc] }; +NpcAggro.armSpawnGrace(respawnedNpc, 9000); +NpcAggro.tickLiveActors(world, 18999); +assert.strictEqual(respawnedNpc.combats, 0, 'a respawn must not immediately aggro a stationary hot bot'); +NpcAggro.tickLiveActors(world, 19000); +assert.strictEqual(respawnedNpc.combats, 1, 'a respawned hostile NPC must aggro a stationary nearby hot bot after spawn grace'); +assert.strictEqual(respawnedNpc.targetSession, hotSession, 'respawn aggro must use the hot bot session for native combat delivery'); + +const startupNpc = hostileNpcAt(0); +world.npc.spawns = [startupNpc]; +world.npc.grid = { '0_0': [startupNpc] }; +NpcAggro.armSpawnGrace(startupNpc, 12000); +NpcAggro.tickLiveActors(world, 22000); +assert.strictEqual(startupNpc.combats, 1, 'the shared ticker must aggro a stationary hot bot after the initial-spawn grace period too'); + +const playerNpc = hostileNpcAt(0); +const player = actorAt(10001, 100); +world.npc.spawns = [playerNpc]; +world.user.sessions = [{ accountId: 'player_stationary', actor: player }]; +NpcAggro.armSpawnGrace(playerNpc, 20000); +NpcAggro.tickLiveActors(world, 30000); +assert.strictEqual(playerNpc.target, player, 'the shared ticker must also aggro a stationary player after initial-spawn grace'); + +console.log('NPC hot-bot aggro regression checks passed'); diff --git a/tests/test_party_bot_loot.js b/tests/test_party_bot_loot.js index f0449666..7eddb210 100644 --- a/tests/test_party_bot_loot.js +++ b/tests/test_party_bot_loot.js @@ -247,6 +247,47 @@ try { leaderSession.lastGroundLootScanAt = 0; PartyCompanionService.reconcileGroundLoot(botSession); assert.strictEqual(pickupCalls.length, 7, 'an incoming NPC threat must block ground pickup before party members start their own combat action'); + + // A follow/combat movement command can cancel Automation's pickup timer + // before PickupExec reaches its completion callback. The queue must retry + // rather than becoming permanently unavailable after that one dropped + // completion. + World.npc = { spawns: [] }; + World.fetchNpcsInRadius = () => []; + World.items = { spawns: [] }; + botSession.partyGroundPickupQueue = [{ id: 500009 }]; + botSession.partyGroundPickupInProgress = false; + assert.strictEqual(PartyCompanionService.startQueuedGroundPickup(botSession), true, 'a queued pickup should start normally before a simulated cancellation'); + const cancelledPickup = pickupCalls[7]; + botSession.partyGroundPickupDeadlineAt = Date.now() - 1; + assert.strictEqual(PartyCompanionService.startQueuedGroundPickup(botSession), true, 'an expired pickup action should be retried instead of locking future loot'); + const retriedPickup = pickupCalls[8]; + cancelledPickup.onComplete(); + assert.deepStrictEqual(botSession.partyGroundPickupQueue, [{ id: 500009 }], 'a stale completion must not remove the retried pickup from the queue'); + retriedPickup.onComplete(); + assert.deepStrictEqual(botSession.partyGroundPickupQueue, [], 'the current pickup completion should remove the recovered queue entry'); + + closestBot.fetchCollectiveRunSpd = () => 120; + closestBot.automation.ticksToMove = () => 21000; + World.items = { + spawns: [{ + fetchId: () => 500010, + fetchLocX: () => 2500, + fetchLocY: () => 200, + fetchLocZ: () => -310 + }] + }; + botSession.partyGroundPickupQueue = [{ id: 500010 }]; + botSession.partyGroundPickupInProgress = false; + assert.strictEqual(PartyCompanionService.startQueuedGroundPickup(botSession), true, 'a distant pickup should start normally'); + const longWalkPickup = pickupCalls[9]; + assert.strictEqual( + PartyCompanionService.startQueuedGroundPickup(botSession), + false, + 'a valid long walk should remain in progress instead of being cancelled by the short fallback timeout' + ); + longWalkPickup.onComplete(); + assert.deepStrictEqual(botSession.partyGroundPickupQueue, [], 'a completed long walk should still clear its queue entry'); } finally { DataCache.fetchNpcRewardsFromSelfId = originalRewards; ProgressionRates.rollGroup = originalRollGroup; diff --git a/tests/test_party_companion_rest_follow.js b/tests/test_party_companion_rest_follow.js index 2b0afb88..87f0ccde 100644 --- a/tests/test_party_companion_rest_follow.js +++ b/tests/test_party_companion_rest_follow.js @@ -16,6 +16,8 @@ const BotBrainContext = invoke('GameServer/Bot/AI/BotBrainContext'); const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); const PartyPulling = invoke('GameServer/Bot/AI/PartyPulling'); const PartyAwareness = invoke('GameServer/Bot/AI/PartyAwareness'); +const BotCombatUtility = invoke('GameServer/Bot/AI/BotCombatUtility'); +const C4SkillRules = invoke('GameServer/Skills/C4SkillRules'); const MarketOpportunity = invoke('GameServer/Bot/Economy/MarketOpportunity'); const CompanionControl = invoke('GameServer/World/Generics/NpcBypasses/CompanionControl'); const EffectStore = invoke('GameServer/Effects/EffectStore'); @@ -198,6 +200,7 @@ const originalUpdateCharacterExperience = Database.updateCharacterExperience; const originalExperience = DataCache.experience; const originalRandom = Math.random; const originalBotSessions = BotManager.sessions; +const originalBotPartySay = BotManager.botPartySay; const originalApplySupportBuff = BotBuffs.applySupportBuff; const originalFindOffers = MarketOpportunity.findOffers; @@ -573,6 +576,8 @@ try { threatAssistSession.partyCompanion = true; threatAssistSession.plan = 'following'; let assistedNpcId = null; + const threatChat = []; + leader.destId = undefined; World.user = { sessions: [leaderSession, threatAssistSession] }; World.fetchNpcsInRadius = () => [{ fetchId: () => 1006, @@ -584,6 +589,10 @@ try { fetchLocZ: () => 0, fetchName: () => 'angry mob' }]; + BotManager.botPartySay = (_session, text) => { + threatChat.push(text); + return true; + }; FollowingState.tick(threatAssistSession, threatAssistBot, {}, { say() {}, @@ -593,6 +602,50 @@ try { assert.strictEqual(threatAssistSession.currentTargetId, 1006, 'companion with no target should acquire mob attacking leader'); assert.strictEqual(assistedNpcId, 1006, 'companion should assist against mob attacking leader'); + assert.strictEqual(threatChat.length, 1, 'an unexpected mob on the leader should produce one party warning'); + assert.match(threatChat[0], /angry mob/, 'the party warning should identify the actual unexpected mob'); + + FollowingState.tick(threatAssistSession, threatAssistBot, {}, { + say() {}, + executeCombat() {}, + executePvPCombat() {} + }); + assert.strictEqual(threatChat.length, 1, 'the same active add must not be repeated every AI tick'); + BotManager.botPartySay = originalBotPartySay; + + // An NPC keeps its native combat state while the target is just under + // 1500 units away. This is the important social-pull case: after the + // first mob dies, a ranged add must be acquired without the player + // manually selecting it. + const distantArcher = { + fetchId: () => 1010, + fetchAttackable: () => true, + isDead: () => false, + fetchDestId: () => leader.fetchId(), + fetchLocX: () => 1490, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchName: () => 'Turek Orc Archer' + }; + const distantThreatBot = fakeActor(2000053, { locX: 120, locY: 0 }); + const distantThreatSession = fakeSession('bot_distant_threat_assist', distantThreatBot); + distantThreatSession.followPlayerSession = leaderSession; + distantThreatSession.partyCompanion = true; + distantThreatSession.plan = 'following'; + let distantThreatAssistId = null; + PartyCompanionService.updateSettings(leaderSession, { pullMode: 'auto' }); + World.user = { sessions: [leaderSession, distantThreatSession] }; + World.npc = { spawns: [distantArcher] }; + World.fetchNpcsInRadius = (_x, _y, radius) => radius >= 1490 ? [distantArcher] : []; + + FollowingState.tick(distantThreatSession, distantThreatBot, {}, { + say() {}, + executeCombat(_session, _bot, npc) { distantThreatAssistId = npc.fetchId(); }, + executePvPCombat() {} + }); + + assert.strictEqual(distantThreatSession.currentTargetId, distantArcher.fetchId(), 'party should acquire a distant archer that is still attacking a member'); + assert.strictEqual(distantThreatAssistId, distantArcher.fetchId(), 'party should attack a social ranged add without a manual leader target'); const hiddenAggroNpc = { fetchId: () => 1007, @@ -703,7 +756,22 @@ try { assert.deepStrictEqual(healerCasts, [{ id: woundedCompanion.fetchId(), selfId: 1011, ctrl: false }], 'an emergency heal must preempt a normal party buff instead of issuing two casts in one tick'); assert.strictEqual(healerSession.roleDecision.action, 'heal_party', 'healer role decision should be party-wide'); - const healerAssistBot = fakeActor(2000027, { locX: 120, locY: 0, classId: 15 }); + healerBot.mp = 20; + healerBot.skillset.skills.find((skill) => skill.fetchSelfId() === 1011).model.mp = 30; + const lowManaHealChat = []; + BotManager.botPartySay = (_session, text) => { + lowManaHealChat.push(text); + return true; + }; + FollowingState.tick(healerSession, healerBot, { + skillExec() { throw new Error('a healer without enough MP must not cast'); } + }, { say() {}, executeCombat() {}, executePvPCombat() {} }); + assert.strictEqual(healerSession.roleDecision.reason, 'low_mp_emergency', 'the healer should expose an emergency MP shortage in its role decision'); + assert.strictEqual(lowManaHealChat.length, 1, 'an emergency heal blocked by MP should be reported once to the party'); + assert.match(lowManaHealChat[0], /No MP|need MP/, 'the party should receive a concrete MP limitation instead of a fake heal confirmation'); + BotManager.botPartySay = originalBotPartySay; + + const healerAssistBot = fakeActor(2000027, { locX: 700, locY: 0, classId: 15 }); const healerAssistSession = fakeSession('bot_healer_basic_assist', healerAssistBot); healerAssistSession.followPlayerSession = healerLeaderSession; healerAssistSession.partyCompanion = true; @@ -719,6 +787,10 @@ try { fetchName: () => 'healer assist threat' }; let healerAssistOptions = null; + healerAssistBot.backpack.fetchEquippedWeapon = () => ({ + fetchKind: () => 'Weapon.Blunt', + fetchName: () => 'Willow Staff' + }); World.user = { sessions: [healerLeaderSession, healerAssistSession] }; World.npc = { spawns: [healerAssistThreat] }; World.fetchNpcsInRadius = () => [healerAssistThreat]; @@ -727,7 +799,18 @@ try { executeCombat(_session, _bot, _npc, _generics, options) { healerAssistOptions = options; }, executePvPCombat() {} }); - assert.strictEqual(healerAssistOptions?.basicAttackOnly, true, 'a healer assisting the party must be restricted to a no-MP basic attack'); + assert.strictEqual(healerAssistOptions, null, 'a healer with a staff should stay in support formation instead of attacking'); + assert.strictEqual(healerAssistBot.moves.length, 1, 'a healer with a staff should continue following the leader during combat'); + healerAssistBot.backpack.fetchEquippedWeapon = () => ({ + fetchKind: () => 'Weapon.Sword', + fetchName: () => 'Orcish Sword' + }); + FollowingState.tick(healerAssistSession, healerAssistBot, {}, { + say() {}, + executeCombat(_session, _bot, _npc, _generics, options) { healerAssistOptions = options; }, + executePvPCombat() {} + }); + assert.strictEqual(healerAssistOptions?.basicAttackOnly, true, 'a healer with a melee weapon may assist using only a basic attack'); World.npc = { spawns: [] }; World.fetchNpcsInRadius = () => []; @@ -776,6 +859,113 @@ try { assert.strictEqual(unskilledTank.skillset.skills.length, 0, 'tank AI should not inject Aggression into the actor'); assert.strictEqual(tankFallbackTarget, tankThreat.fetchId(), 'tank without Aggression should still defend with normal combat'); + const aggressionRotationSkill = { + fetchPassive: () => false, + fetchSkillType: () => C4SkillRules.AGGRO_DAMAGE, + fetchTargetKind: () => 'enemy', + fetchSemantic: () => ({}), + fetchDistance: () => 400, + fetchConsumedMp: () => 10, + fetchPower: () => 0 + }; + assert.strictEqual( + BotCombatUtility.evaluate(unskilledTank, tankThreat, aggressionRotationSkill, 'tank'), + null, + 'Aggression must not be selected as an ordinary tank damage skill' + ); + + const transferTank = fakeActor(2000054, { locX: 90, locY: 0, classId: 4 }); + const transferTankSession = fakeSession('bot_aggression_transfer_tank', transferTank); + transferTankSession.followPlayerSession = healerLeaderSession; + transferTankSession.partyCompanion = true; + transferTankSession.plan = 'following'; + learnSkill(transferTank, { selfId: 28, name: 'Aggression', mp: 10 }); + World.user = { sessions: [healerLeaderSession, transferTankSession] }; + World.npc = { spawns: [tankThreat] }; + World.fetchNpcsInRadius = () => [tankThreat]; + let aggressionCasts = 0; + let transferTankBasicAttacks = 0; + FollowingState.tick(transferTankSession, transferTank, { + skillExec() { aggressionCasts++; } + }, { + say() {}, executeCombat() { transferTankBasicAttacks++; }, executePvPCombat() {} + }); + FollowingState.tick(transferTankSession, transferTank, { + skillExec() { aggressionCasts++; } + }, { + say() {}, executeCombat() { transferTankBasicAttacks++; }, executePvPCombat() {} + }); + assert.strictEqual(aggressionCasts, 1, 'a failed threat transfer must not cast Aggression again on the next AI tick'); + assert.strictEqual(transferTankBasicAttacks, 1, 'after one transfer attempt the tank must continue with normal combat'); + + const autoPullTank = fakeActor(2000097, { locX: 90, locY: 0, mp: 20, maxMp: 100, classId: 4 }); + const autoPullTankSession = fakeSession('bot_auto_pull_tank', autoPullTank); + autoPullTankSession.followPlayerSession = healerLeaderSession; + autoPullTankSession.partyCompanion = true; + autoPullTankSession.plan = 'following'; + learnSkill(autoPullTank, { selfId: 28, name: 'Aggression', mp: 30 }); + let autoPullTargetId = undefined; + const autoPullTarget = { + fetchId: () => 1010, + fetchAttackable: () => true, + isDead: () => false, + fetchDestId: () => autoPullTargetId, + fetchLocX: () => 150, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchName: () => 'free pull target' + }; + World.user = { sessions: [healerLeaderSession, autoPullTankSession] }; + World.npc = { spawns: [autoPullTarget] }; + World.fetchNpcsInRadius = () => [autoPullTarget]; + let autoPullSkillCast = false; + let autoPullOptions = null; + FollowingState.tick(autoPullTankSession, autoPullTank, { + skillExec() { autoPullSkillCast = true; } + }, { + say() {}, + executeCombat(_session, _bot, npc, _generics, options) { + assert.strictEqual(npc, autoPullTarget, 'auto pull should attack the nearest safe target'); + autoPullOptions = options; + }, + executePvPCombat() {} + }); + assert.strictEqual(autoPullSkillCast, false, 'auto pull must not cast Aggression even when it is learned'); + assert.strictEqual(autoPullOptions?.basicAttackOnly, true, 'auto pull should start with a basic attack'); + assert.strictEqual(autoPullTankSession.roleDecision.reason, 'safe_pull', 'low MP should not disable a basic-attack pull'); + autoPullTank.mp = 100; + autoPullTargetId = autoPullTank.fetchId(); + let engagedPulledTarget = null; + FollowingState.tick(autoPullTankSession, autoPullTank, { + skillExec() { autoPullSkillCast = true; } + }, { + say() {}, + executeCombat(_session, _bot, npc) { engagedPulledTarget = npc; }, + executePvPCombat() {} + }); + assert.strictEqual(autoPullSkillCast, false, 'a mob already attacking its tank must not be taunted again'); + assert.strictEqual(engagedPulledTarget, autoPullTarget, 'the tank should fight the mob it already pulled'); + const fallenAutoPullMember = fakeActor(2000098, { locX: 60, locY: 0 }); + fallenAutoPullMember.state.dead = true; + const fallenAutoPullSession = fakeSession('bot_auto_pull_fallen_member', fallenAutoPullMember); + fallenAutoPullSession.followPlayerSession = healerLeaderSession; + fallenAutoPullSession.partyCompanion = true; + fallenAutoPullSession.plan = 'following'; + autoPullTargetId = undefined; + autoPullTankSession.currentTargetId = undefined; + autoPullTank.unselect(); + World.user = { sessions: [healerLeaderSession, autoPullTankSession, fallenAutoPullSession] }; + let blockedAutoPullCombat = false; + FollowingState.tick(autoPullTankSession, autoPullTank, { + skillExec() { blockedAutoPullCombat = true; } + }, { + say() {}, + executeCombat() { blockedAutoPullCombat = true; }, + executePvPCombat() {} + }); + assert.strictEqual(blockedAutoPullCombat, false, 'auto pull must wait for a fallen party member to be resurrected'); + assert.strictEqual(autoPullTankSession.roleDecision.reason, 'party_revival', 'auto pull pause should report the pending party revival'); + const bufferLeader = fakeActor(2000027, { locX: 0, locY: 0 }); const bufferLeaderSession = fakeSession('player_buffer_party', bufferLeader); const bufferBot = fakeActor(2000028, { locX: 80, locY: 0, classId: 17 }); @@ -891,16 +1081,21 @@ try { errandSession.plan = 'following'; const errandLines = []; BotManager.sessions = []; + BotManager.botPartySay = (_session, text) => { + errandLines.push(text); + return true; + }; World.user = { sessions: [errandLeaderSession, errandSession] }; FollowingState.tick(errandSession, errandBot, {}, { getClosestNewbieGuide: () => ({ locX: -84081, locY: 243227, locZ: -3723 }), getClosestTown: () => ({ name: 'Giran', x: 83396, y: 147904, z: -3404 }), say(_session, text) { errandLines.push(text); }, executeCombat() {}, executePvPCombat() {} }); + BotManager.botPartySay = originalBotPartySay; assert.strictEqual(errandSession.plan, 'shopping', 'companion with no shots should make a brief errand only after the party reaches town'); assert.strictEqual(errandSession.companionShopping?.kind, 'restock_shots', 'town errand should describe the actual missing supply'); assert.strictEqual(errandSession.shoppingTarget?.town, 'Giran', 'companion errand should stay in the player town'); - assert(errandLines.some((line) => line.includes("then I'll return")), 'companion should tell the player it will return before shopping'); + assert(errandLines.some((line) => line.includes('returning') || line.includes('back to camp')), 'companion should announce its return before shopping'); assert.strictEqual(errandBot.fetchPrivateStore?.(), undefined, 'companion errand must never create a private sale store'); const marketSeller = fakeActor(2000039, { locX: 83500, locY: 147904, locZ: -3404 }); @@ -1211,6 +1406,10 @@ try { let pulledTargetId = null; let openingPullCombatOptions = null; const pullChat = []; + BotManager.botPartySay = (_session, text) => { + pullChat.push(text); + return true; + }; CompanionControl(partyHudLeaderSession, ['companion-control', 'member-pull', 'on', partyHudBotA.fetchName()]); assert.strictEqual(PartyCompanionService.getSettings(partyHudLeaderSession).pullMode, 'bot', 'assigning a bot to pull should enable the dedicated bot pull mode'); @@ -1289,19 +1488,16 @@ try { }); assert.strictEqual(partyHudBotBSession.roleDecision.action, 'follow_leader', 'party should keep following until each companion can reach the marked mob'); - // Delivery uses a practical melee handoff radius, rather than requiring - // two actor origins to be exactly equal. The actual combat action closes - // the final step. + // The pull target may cross a companion's personal attack range on its + // way back. Formation still stays with the leader until the puller has + // returned and the mob reaches the camp. pulledMob.locX = partyHudBotB.locX + 160; let earlyMeetAssistId = null; FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { say() {}, executeCombat(_session, _bot, npc) { earlyMeetAssistId = npc.fetchId(); }, executePvPCombat() {} }); - assert.strictEqual( - earlyMeetAssistId, - pulledMob.fetchId(), - 'a companion that meets the returning mob should engage before the puller completes its return' - ); + assert.strictEqual(earlyMeetAssistId, null, 'a companion must not engage an incoming pull before it reaches camp'); + assert.strictEqual(partyHudBotBSession.roleDecision.reason, 'hold_for_pull', 'an incoming pull should keep every non-puller in leader formation'); pulledMob.locX = 1200; FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { @@ -1333,6 +1529,41 @@ try { 250, 'a short-range melee skill must not prevent a delivered pull from entering normal combat' ); + let campArrivalAssistId = null; + FollowingState.tick(partyHudBotBSession, partyHudBotB, {}, { + say() {}, + executeCombat(_session, _bot, npc) { campArrivalAssistId = npc.fetchId(); }, + executePvPCombat() {} + }); + assert.strictEqual(campArrivalAssistId, pulledMob.fetchId(), 'party should attack as soon as an aggroed pull reaches camp, before the puller finishes its return tick'); + + // The camp-radius check is intentionally stricter than a puller's own + // melee range. The tank must nevertheless keep hitting a held target it + // can already reach, including at low HP; it may not sit and turn the + // whole party into "party_recovering" while the target is alive. + partyHudLeaderSession.partyPullState = { + targetId: pulledMob.fetchId(), + pullerId: partyHudBotA.fetchId(), + source: 'bot', + phase: 'engage', + startedAt: Date.now() + }; + partyHudBotA.locX = 180; + partyHudBotA.hp = 20; + partyHudBotA.state.setSeated(false); + pulledMob.locX = 400; + pulledMob.destId = partyHudBotA.fetchId(); + let heldPullerAssistId = null; + FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { + say() {}, + executeCombat(_session, _bot, npc) { heldPullerAssistId = npc.fetchId(); }, + executePvPCombat() {} + }); + assert.strictEqual(heldPullerAssistId, pulledMob.fetchId(), 'puller should attack a held target in its own range before full camp delivery'); + assert.strictEqual(partyHudBotA.state.fetchSeated(), false, 'low-HP puller must not sit while its living pull target is active'); + partyHudBotA.hp = partyHudBotA.maxHp; + partyHudBotA.locX = partyHudLeader.locX; + pulledMob.locX = partyHudBotB.locX + 160; partyHudBotA.state.setTowards('move'); FollowingState.tick(partyHudBotASession, partyHudBotA, {}, { say() {}, executeCombat() {}, executePvPCombat() {} @@ -1355,6 +1586,10 @@ try { }); assert.strictEqual(assistedPulledMobId, pulledMob.fetchId(), 'party should engage the marked mob once it reaches attack range'); + CompanionControl(partyHudLeaderSession, ['companion-control', 'member-pull', 'on', partyHudBotA.fetchName()]); + CompanionControl(partyHudLeaderSession, ['companion-control', 'member-pull', 'off', partyHudBotA.fetchName()]); + assert.strictEqual(PartyCompanionService.getSettings(partyHudLeaderSession).pullMode, 'off', 'Stop Pull must disable automatic fallback pulls'); + assert.strictEqual(PartyPulling.enabled(PartyCompanionService.getSettings(partyHudLeaderSession)), false, 'Stop Pull must disable the tank auto-pull behaviour'); CompanionControl(partyHudLeaderSession, ['companion-control', 'member-pull', 'on', partyHudBotA.fetchName()]); partyHudBotB.state.setSeated(true); partyHudBotA.locX = 40; @@ -1617,6 +1852,7 @@ try { Database.updateCharacterExperience = originalUpdateCharacterExperience; DataCache.experience = originalExperience; BotManager.sessions = originalBotSessions; + BotManager.botPartySay = originalBotPartySay; BotBuffs.applySupportBuff = originalApplySupportBuff; MarketOpportunity.findOffers = originalFindOffers; } diff --git a/tests/test_party_pull_pause.js b/tests/test_party_pull_pause.js index 25c0a8e9..ad7927e9 100644 --- a/tests/test_party_pull_pause.js +++ b/tests/test_party_pull_pause.js @@ -78,6 +78,28 @@ assert.strictEqual( leaderSession.actor.state.seated = false; recoveringPlanSession.actor.state.seated = false; +World.npc.spawns = [{ + fetchId: () => 3000099, + fetchAttackable: () => true, + isDead: () => false, + fetchLocX: () => 100, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchDestId: () => undefined +}]; +recoveringPlanSession.actor.isDead = () => true; +const revivalPause = PartyPulling.tickBotPuller( + pullerSession, + pullerSession.actor, + leaderSession, + settings, + {}, + { executeCombat() { throw new Error('puller must not begin a new encounter while a party member needs resurrection'); } } +); +assert.strictEqual(revivalPause.action, 'party_revival', 'a dead party member must pause a new pull until revival is handled'); +recoveringPlanSession.actor.isDead = () => false; +World.npc.spawns = []; + pullerSession.actor.state.combat = true; assert.notStrictEqual( PartyPulling.current(leaderSession, settings).paused, diff --git a/tests/test_party_revival.js b/tests/test_party_revival.js index dc7afefc..68202f4f 100644 --- a/tests/test_party_revival.js +++ b/tests/test_party_revival.js @@ -101,6 +101,7 @@ try { fetchLocX: () => 100, fetchLocY: () => 0, fetchDestId: () => leader.fetchId(), + fetchStateAttack: () => true, state: { fetchCombats: () => true } }]; const combatHeldResult = PartyRevivalService.tick(healerSession, leaderSession, { skillExec() {} }); @@ -111,11 +112,24 @@ try { fetchLocX: () => 5000, fetchLocY: () => 0, fetchDestId: () => leader.fetchId(), + fetchStateAttack: () => true, state: { fetchCombats: () => true } }]; const staleCorpseCombatResult = PartyRevivalService.tick(healerSession, leaderSession, { skillExec() {} }); assert.strictEqual(staleCorpseCombatResult.handled, true, 'a stale combat record far from a corpse must not block resurrection'); leaderSession.partyRevivalAttempt = null; + World.npc.spawns = [{ + fetchAttackable: () => true, + isDead: () => false, + fetchLocX: () => 100, + fetchLocY: () => 0, + fetchDestId: () => leader.fetchId(), + fetchStateAttack: () => false, + state: { fetchCombats: () => true } + }]; + const staleCloseCorpseCombatResult = PartyRevivalService.tick(healerSession, leaderSession, { skillExec() {} }); + assert.strictEqual(staleCloseCorpseCombatResult.handled, true, 'a close stale combat flag without an active attack must not block resurrection forever'); + leaderSession.partyRevivalAttempt = null; World.npc.spawns = []; healer.state.fetchHits = () => true; @@ -146,7 +160,16 @@ try { assert.strictEqual(skillCast[2].id, leader.fetchId(), 'party resurrection should target the first fallen member'); healer.state.fetchCombats = () => false; + leader.state.setDead(false); + let secondResurrectionCast = null; + const secondResurrectionResult = PartyRevivalService.tick(healerSession, leaderSession, { + skillExec(...args) { secondResurrectionCast = args; } + }); + assert.strictEqual(secondResurrectionResult.source, 'skill', 'a remaining fallen companion should be revived without waiting for the first attempt timeout'); + assert.strictEqual(secondResurrectionCast[2].id, fallen.fetchId(), 'the next resurrection should immediately target the remaining corpse'); + leaderSession.partyRevivalAttempt = null; + leader.state.setDead(true); healer.skillset.skills = []; const scrollResult = PartyRevivalService.tick(healerSession, leaderSession, { skillExec() {} }); assert.strictEqual(scrollResult.source, 'scroll', 'a living companion must fall back to its unlimited resurrection scroll'); diff --git a/tests/test_trade_equipment_upgrade.js b/tests/test_trade_equipment_upgrade.js new file mode 100644 index 00000000..1eda1030 --- /dev/null +++ b/tests/test_trade_equipment_upgrade.js @@ -0,0 +1,63 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotTradeService = invoke('GameServer/Bot/BotTradeService'); +const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); +const BotLootEtiquette = invoke('GameServer/Bot/AI/BotLootEtiquette'); +const BotEquipmentUpgrade = invoke('GameServer/Bot/AI/BotEquipmentUpgrade'); +const BotManager = invoke('GameServer/Bot/BotManager'); +const tradeDone = invoke('GameServer/Network/Request/TradeDone'); + +const original = { + commit: BotTradeService.commit, + recordEvent: BotSocialMemory.recordEvent, + resolveTrade: BotLootEtiquette.resolveTrade, + applyBestUpgrades: BotEquipmentUpgrade.applyBestUpgrades, + botTell: BotManager.botTell +}; + +const playerSession = { + actor: { backpack: { fetchItems: () => [] } }, + dataSendToMe() {} +}; +const botSession = { + accountId: 'bot_trade_upgrade', + actor: { backpack: { fetchItems: () => [] } } +}; + +let reevaluated = null; + +(async () => { + try { + BotTradeService.commit = async () => ({ + ok: true, + partnerSession: botSession, + moved: [{ selfId: 5, name: 'Mace', count: 1 }] + }); + BotSocialMemory.recordEvent = () => Promise.resolve(null); + BotLootEtiquette.resolveTrade = () => null; + BotManager.botTell = () => {}; + BotEquipmentUpgrade.applyBestUpgrades = (session, options) => { + reevaluated = { session, options }; + return [{ item: 'Mace' }]; + }; + + await tradeDone(playerSession, Buffer.from([0x17, 1, 0, 0, 0])); + + assert.deepStrictEqual(reevaluated, { + session: botSession, + options: { force: true } + }, 'a bot must immediately re-evaluate suitable upgrades after receiving a player trade'); + console.log('Trade equipment upgrade checks passed'); + } finally { + BotTradeService.commit = original.commit; + BotSocialMemory.recordEvent = original.recordEvent; + BotLootEtiquette.resolveTrade = original.resolveTrade; + BotEquipmentUpgrade.applyBestUpgrades = original.applyBestUpgrades; + BotManager.botTell = original.botTell; + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +});