Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions scripts/run-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
10 changes: 10 additions & 0 deletions src/GameServer/Actor/Attack.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,25 +190,29 @@ 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;
}

const conditionFailure = this.skillUseConditionFailure(actor, skill);
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;
}

Expand All @@ -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;
}

Expand All @@ -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;
}

Expand All @@ -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);
Expand Down
25 changes: 15 additions & 10 deletions src/GameServer/Actor/Backpack.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
]);
Expand All @@ -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) => {
Expand All @@ -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()));
Expand All @@ -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))
)));
}
}

Expand Down
109 changes: 84 additions & 25 deletions src/GameServer/Actor/Generics/MoveTo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -74,6 +115,7 @@ function moveTo(session, actor, coords) {
pathLength: 0,
lowLodWarp: true,
distanceToPlayer,
destinationDistanceToPlayer,
strategy: 'low_lod_direct',
at: Date.now()
};
Expand Down Expand Up @@ -112,6 +154,7 @@ function moveTo(session, actor, coords) {
pathLength: path.length,
lowLodWarp: false,
distanceToPlayer,
destinationDistanceToPlayer,
strategy: pathStrategy,
at: Date.now()
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
4 changes: 3 additions & 1 deletion src/GameServer/Actor/Generics/StopAutomation.js
Original file line number Diff line number Diff line change
@@ -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(), {
Expand Down
10 changes: 4 additions & 6 deletions src/GameServer/Actor/Generics/UpdateEnvironment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
24 changes: 22 additions & 2 deletions src/GameServer/Automation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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
);
}
}
}

Expand Down
12 changes: 9 additions & 3 deletions src/GameServer/Bot/AI/BotCombatUtility.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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');
}
Expand Down
Loading
Loading