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
2 changes: 1 addition & 1 deletion src/GameServer/Actor/Actor.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ class Actor extends ActorModel {
}

moveTo(data) {
invoke(path.actor).moveTo(
return invoke(path.actor).moveTo(
this.session, this, data
);
}
Expand Down
42 changes: 33 additions & 9 deletions src/GameServer/Actor/Generics/MoveTo.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,12 @@ function moveTo(session, actor, coords) {
return;
}

// Abort scheduled movement, user redirected the actor
actor.automation.abortAll(actor);
const previewOnly = coords.previewOnly === true;
// A route preview must not alter the actor or emit a false movement packet.
if (!previewOnly) {
// Abort scheduled movement, user redirected the actor
actor.automation.abortAll(actor);
}

const isBot = session && (session.constructor.name === 'BotSession' || (session.accountId && session.accountId.startsWith('bot_')));
const requestedTo = { ...coords.to };
Expand Down Expand Up @@ -106,20 +110,22 @@ function moveTo(session, actor, coords) {
// Low LOD: instant warp (we do not calculate movements at all)
const snappedTo = { ...requestedTo };
snappedTo.locZ = GeodataEngine.getHeight(snappedTo.locX, snappedTo.locY, snappedTo.locZ);
actor.setLocXYZ(snappedTo);
invoke('GameServer/Bot/AI/PartyCompanionService').updatePosition(session, actor);
session.lastPathfinding = {
requestedTo,
routedTo: { ...snappedTo },
townRoute: null,
pathLength: 0,
routeUsable: true,
lowLodWarp: true,
distanceToPlayer,
destinationDistanceToPlayer,
strategy: 'low_lod_direct',
at: Date.now()
};
return;
if (previewOnly) return session.lastPathfinding;
actor.setLocXYZ(snappedTo);
invoke('GameServer/Bot/AI/PartyCompanionService').updatePosition(session, actor);
return session.lastPathfinding;
}

const isClose = isCompanion || distanceToPlayer <= 500;
Expand All @@ -130,19 +136,32 @@ function moveTo(session, actor, coords) {

if (!path || path.length <= 1) {
const TownPathfinder = invoke('GameServer/Bot/AI/TownPathfinder');
const previousTownRoutePlan = session.townRoutePlan;
const routeResult = TownPathfinder.routeWithSession(session, actor, coords.from, requestedTo);
if (previewOnly) session.townRoutePlan = previousTownRoutePlan;
pathTarget = { ...routeResult.to };
townRouteDiagnostics = routeResult.diagnostics;
coords.to.locX = pathTarget.locX;
coords.to.locY = pathTarget.locY;
coords.to.locZ = pathTarget.locZ;
if (!previewOnly) {
coords.to.locX = pathTarget.locX;
coords.to.locY = pathTarget.locY;
coords.to.locZ = pathTarget.locZ;
}
pathStrategy = townRouteDiagnostics?.changedTarget ? 'town_waypoint_fallback' : 'direct_fallback';

path = GeodataEngine.findPath(startX, startY, startZ, pathTarget.locX, pathTarget.locY, pathTarget.locZ);
} else if (session) {
} else if (session && !previewOnly) {
session.townRoutePlan = null;
}

const routeFound = Array.isArray(path) && path.length > 1;
// A* is deliberately bounded and can return null in otherwise open
// terrain. The runtime has always handled that case with a direct
// movement fallback, so distinguish a clear line from a genuinely
// blocked destination before callers decide to reject the route.
const fallbackLineOfSight = !routeFound && GeodataEngine.hasLineOfSight(
startX, startY, startZ,
pathTarget.locX, pathTarget.locY, pathTarget.locZ
);
console.log(`[PATHFIND] Bot ${actor.fetchName()}: from (${startX}, ${startY}, ${startZ}) to (${pathTarget.locX}, ${pathTarget.locY}, ${pathTarget.locZ}) strategy=${pathStrategy} -> Waypoints: ${path ? path.length : 0}`);
if (!path || path.length <= 1) {
path = [{ locX: pathTarget.locX, locY: pathTarget.locY, locZ: pathTarget.locZ }];
Expand All @@ -152,12 +171,16 @@ function moveTo(session, actor, coords) {
routedTo: { ...pathTarget },
townRoute: townRouteDiagnostics,
pathLength: path.length,
routeUsable: routeFound || fallbackLineOfSight,
lowLodWarp: false,
distanceToPlayer,
destinationDistanceToPlayer,
strategy: pathStrategy,
at: Date.now()
};
if (previewOnly) {
return session.lastPathfinding;
}

const moveAlongPath = (index) => {
if (index >= path.length) {
Expand Down Expand Up @@ -235,6 +258,7 @@ function moveTo(session, actor, coords) {

actor.state.setTowards('move');
moveAlongPath(0);
return session.lastPathfinding;
}
}

Expand Down
8 changes: 7 additions & 1 deletion src/GameServer/Actor/Generics/PickupExec.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ function pickupExec(session, actor, data, onComplete) {
}, 500);
});
}).catch((err) => {
utils.infoWarn('GameServer', 'Pickup -> ' + err);
utils.infoWarn(
'GameServer',
'Pickup failed actor=%s item=%s error=%s',
actor?.fetchName?.() || actor?.fetchId?.() || 'unknown',
data?.id || 'unknown',
err?.message || String(err)
);
onComplete?.();
});
}
Expand Down
12 changes: 10 additions & 2 deletions src/GameServer/Actor/Generics/Revive.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
const ServerResponse = invoke('GameServer/Network/Response');

function finishRevive(session, actor) {
actor.state.setDead(false);
// BotAI uses this marker to run the one-time death lifecycle. A native
// in-place resurrection must release it so a later death is counted and
// announced instead of looking like the same corpse forever.
session.deathTimerStart = undefined;
}

function revive(session, actor, { delayMs = 2500, restoreFullVitals = false } = {}) {
if (restoreFullVitals) {
actor.automation.stopReplenish();
Expand All @@ -9,7 +17,7 @@ function revive(session, actor, { delayMs = 2500, restoreFullVitals = false } =
}

if (delayMs <= 0) {
actor.state.setDead(false);
finishRevive(session, actor);
session.dataSendToMeAndOthers(ServerResponse.revive(actor.fetchId()), actor);
session.dataSendToMeAndOthers(ServerResponse.socialAction(actor.fetchId(), 9), actor);
return;
Expand All @@ -18,7 +26,7 @@ function revive(session, actor, { delayMs = 2500, restoreFullVitals = false } =
session.dataSendToMeAndOthers(ServerResponse.revive(actor.fetchId()), actor);

setTimeout(() => {
actor.state.setDead(false);
finishRevive(session, actor);
session.dataSendToMeAndOthers(ServerResponse.socialAction(actor.fetchId(), 9), actor); // SWAG stand-up
}, delayMs);
}
Expand Down
32 changes: 32 additions & 0 deletions src/GameServer/Bot/AI/BotPartyChat.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,37 @@ function didLand(outcome) {
);
}

function recordSupportCredit(session, request, target, skill) {
const leaderSession = session?.partyCompanion === true ? session.followPlayerSession : null;
if (!leaderSession?.actor) return false;
const targetId = Number(target?.fetchId?.() || 0);
const belongsToParty = targetId === Number(leaderSession.actor.fetchId?.() || 0) ||
(invoke('GameServer/Bot/BotManager').sessions || []).some(candidate => (
candidate?.partyCompanion === true &&
candidate.followPlayerSession === leaderSession &&
Number(candidate.actor?.fetchId?.() || 0) === targetId
));
if (!belongsToParty) return false;
const now = Date.now();
const key = `${request.kind}:${target?.fetchId?.() || 0}:${skill?.fetchSelfId?.() || 0}`;
session.partySupportSocialCredit ??= new Map();
const previousAt = Number(session.partySupportSocialCredit.get(key) || 0);
if (now - previousAt < 60000) return false;
session.partySupportSocialCredit.set(key, now);
if (session.partySupportSocialCredit.size > 100) {
for (const [entryKey, at] of session.partySupportSocialCredit) {
if (now - Number(at) >= 60000) session.partySupportSocialCredit.delete(entryKey);
}
}
invoke('GameServer/Bot/AI/BotSocialMemory').recordEvent(
leaderSession,
session,
'supported_party',
`${request.kind} ${skill?.fetchSelfId?.() || 0} on ${target?.fetchId?.() || 0}`
);
return true;
}

function resultEntry(request, target, skill) {
const targetName = target.fetchName?.() || 'the party';
const skillName = skill.fetchName?.() || 'Support';
Expand Down Expand Up @@ -193,6 +224,7 @@ function confirmSkillResult(session, actor, target, skill, outcome) {

session.pendingPartyChatResult = undefined;
if (!didLand(outcome)) return false;
recordSupportCredit(session, request, target, skill);
return announce(session, resultEntry(request, target, skill));
}

Expand Down
3 changes: 3 additions & 0 deletions src/GameServer/Bot/AI/BotSocialMemory.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ function applyEvent(record, eventName) {
updated.trust += 3;
updated.familiarity += 1;
updated.helpedInCombat += 1;
} else if (eventName === 'supported_party') {
updated.trust += 1;
updated.familiarity += 1;
} else if (eventName === 'trade_completed') {
updated.trust += 1;
updated.familiarity += 1;
Expand Down
64 changes: 58 additions & 6 deletions src/GameServer/Bot/AI/PartyCompanionService.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const DEFAULT_PARTY_SETTINGS = {
itemLastLootIndex: -1
};
const PARTY_LOOT_RADIUS = 2500;
const PARTY_GROUND_LOOT_LEASH_RADIUS = 1200;
const GROUND_LOOT_SCAN_INTERVAL_MS = 500;
const GROUND_PICKUP_FALLBACK_TIMEOUT_MS = 8000;
const GROUND_PICKUP_TIMEOUT_GRACE_MS = 5000;
Expand Down Expand Up @@ -214,7 +215,21 @@ function canPickGroundLoot(session, leaderSession, item) {
if (actor?.storedPickup) {
delete actor.storedPickup;
}
return distance2d(actor, item) <= PARTY_LOOT_RADIUS;
return distance2d(actor, item) <= PARTY_GROUND_LOOT_LEASH_RADIUS;
}

function partyGroundLootLeaderId(item) {
return Number(item?.model?.partyLootLeaderId ?? item?.partyLootLeaderId ?? 0);
}

function isOwnedPartyGroundLoot(leaderSession, item) {
const leaderId = Number(leaderSession?.actor?.fetchId?.() || 0);
return leaderId > 0 && partyGroundLootLeaderId(item) === leaderId;
}

function isInsidePartyGroundLootLeash(leaderSession, item) {
return !!leaderSession?.actor &&
distance2d(leaderSession.actor, item) <= PARTY_GROUND_LOOT_LEASH_RADIUS;
}

function partyCombatInProgress(leaderSession) {
Expand All @@ -237,7 +252,9 @@ function availableGroundLoot(leaderSession) {
return (world().items?.spawns || [])
.filter((item) => item?.fetchId && item?.fetchLocX && item?.fetchLocY)
.filter((item) => !queuedIds.has(Number(item.fetchId())))
.filter((item) => members.some((memberSession) => distance2d(memberSession.actor, item) <= PARTY_LOOT_RADIUS))
.filter((item) => isOwnedPartyGroundLoot(leaderSession, item))
.filter((item) => isInsidePartyGroundLootLeash(leaderSession, item))
.filter((item) => members.some((memberSession) => distance2d(memberSession.actor, item) <= PARTY_GROUND_LOOT_LEASH_RADIUS))
.sort((a, b) => Number(a.fetchId()) - Number(b.fetchId()));
}

Expand Down Expand Up @@ -277,6 +294,7 @@ function reconcileGroundLoot(looterSession) {
function nearestGroundLootPicker(looterSession, item) {
const leaderSession = partyLeaderSession(looterSession);
if (!leaderSession || !item || !AUTOMATED_LOOT_DISTRIBUTIONS.has(distributionForLeader(leaderSession))) return null;
if (!isOwnedPartyGroundLoot(leaderSession, item) || !isInsidePartyGroundLootLeash(leaderSession, item)) return null;

return membersForLeader(leaderSession)
.filter((memberSession) => canPickGroundLoot(memberSession, leaderSession, item))
Expand Down Expand Up @@ -308,18 +326,46 @@ function startQueuedGroundPickup(pickerSession) {
const queue = pickerSession?.partyGroundPickupQueue;
if (!picker || !queue?.length) return false;
const now = Date.now();
const leaderSession = partyLeaderSession(pickerSession);
const pullState = leaderSession?.partyPullState || {};
const partyNeedsAttention = (
[leaderSession, ...membersForLeader(leaderSession)].some((memberSession) => memberSession?.actor?.isDead?.()) ||
['getting_buffed', 'shopping', 'merchant'].includes(pickerSession.plan) ||
(
['approach', 'aggro', 'return'].includes(pullState.phase) &&
Number(picker.fetchId?.()) === Number(pullState.pullerId || 0)
) ||
partyCombatInProgress(leaderSession) ||
hasCampThreat(leaderSession)
);
const pickup = queue[0];
const queuedItem = (world().items?.spawns || [])
.find((item) => Number(item?.fetchId?.()) === Number(pickup?.id));
const invalidPickup = !queuedItem ||
!isOwnedPartyGroundLoot(leaderSession, queuedItem) ||
!isInsidePartyGroundLootLeash(leaderSession, queuedItem) ||
distance2d(picker, queuedItem) > PARTY_GROUND_LOOT_LEASH_RADIUS;
if (pickerSession.partyGroundPickupInProgress) {
const deadlineAt = Number(pickerSession.partyGroundPickupDeadlineAt || 0);
if (!deadlineAt || now < deadlineAt) return false;
// This is a handled AI action. Falling through into FollowingState
// would issue a formation move, cancel the pickup timer and make the
// bot visibly run out and back without collecting anything. Combat,
// revival and a broken leash still outrank loot and must reclaim the
// current tick immediately.
if ((!deadlineAt || now < deadlineAt) && !partyNeedsAttention && !invalidPickup) return true;
// 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;
// Invalidate a completion that was already queued before abortAll.
// The item stays queued across transient combat and can retry later.
pickerSession.partyGroundPickupAttempt = Number(pickerSession.partyGroundPickupAttempt || 0) + 1;
if (invalidPickup) queue.shift();
if (partyNeedsAttention || invalidPickup) return false;
}
const leaderSession = partyLeaderSession(pickerSession);
// A queued drop is lower priority than a resurrection. This also
// protects queues that were assigned before a companion died, rather
// than letting the only living support bot run away from the corpse.
Expand All @@ -330,7 +376,6 @@ function startQueuedGroundPickup(pickerSession) {
// built while following and become stale after it starts a town/support
// action. Merely assigning a bot as puller is not combat: when no pull
// is in progress it may collect ground loot like every other companion.
const pullState = leaderSession?.partyPullState || {};
if (
['getting_buffed', 'shopping', 'merchant'].includes(pickerSession.plan) ||
(
Expand All @@ -341,7 +386,14 @@ function startQueuedGroundPickup(pickerSession) {
if (partyCombatInProgress(leaderSession) || hasCampThreat(leaderSession)) return false;
if (picker.state?.fetchPickinUp?.()) return false;

const pickup = queue[0];
if (!queuedItem ||
!isOwnedPartyGroundLoot(leaderSession, queuedItem) ||
!isInsidePartyGroundLootLeash(leaderSession, queuedItem) ||
!canPickGroundLoot(pickerSession, leaderSession, queuedItem)) {
queue.shift();
pickerSession.partyGroundPickupDeadlineAt = 0;
return startQueuedGroundPickup(pickerSession);
}
pickerSession.partyGroundPickupInProgress = true;
pickerSession.partyGroundPickupDeadlineAt = now + groundPickupTimeoutMs(picker, pickup);
const attempt = Number(pickerSession.partyGroundPickupAttempt || 0) + 1;
Expand Down
Loading
Loading