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
4 changes: 4 additions & 0 deletions scripts/run-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ const tests = [
'tests/test_party_companion_rest_follow.js',
'tests/test_party_buff_targets.js',
'tests/test_party_bot_loot.js',
'tests/test_party_hud_throttle.js',
'tests/test_party_pull_pause.js',
'tests/test_party_revival.js',
'tests/test_bot_status_bypass.js',
'tests/test_path_obstacle.js',
'tests/test_pathfinder_astar.js',
'tests/test_player_ranged_combat.js',
Expand Down
40 changes: 38 additions & 2 deletions src/GameServer/Actor/Attack.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,16 @@ class Attack {
case 'move' : Generics.moveTo (session, actor, queue.data); break;
case 'attack' : Generics.attackRequest(session, actor, queue.data); break;
case 'skill' : Generics.skillRequest (session, actor, queue.data); break;
case 'pickup' : Generics.pickupRequest(session, actor, queue.data); break;
case 'pickup' : {
const isBot = session?.constructor?.name === 'BotSession' || String(session?.accountId || '').startsWith('bot_');
// Hot bots move entirely on the server and never send the
// ValidatePosition that a player's pickupRequest waits for.
// A queued pickup commonly follows the killing hit, so it
// must use the server-side execution path as well.
if (isBot) Generics.pickupExec(session, actor, queue.data);
else Generics.pickupRequest(session, actor, queue.data);
break;
}
case 'sit' : Generics.basicAction (session, actor, queue.data); break;
}
this.resetQueuedEvent();
Expand Down Expand Up @@ -90,6 +99,7 @@ class Attack {
this.resetQueuedEvent();
actor.state.setCasts(false);
actor.storedSpell = undefined;
invoke('GameServer/Bot/AI/BotSupportPlanner').cancelSupportCast(session, actor);

session?.dataSendToMeAndOthers?.(
ServerResponse.magicSkillCanceld(actor.fetchId()),
Expand Down Expand Up @@ -158,6 +168,11 @@ class Attack {
return;
}

actor.state.setHits(false);
if (invoke('GameServer/Bot/AI/PartyCompanionService').startQueuedGroundPickup(session)) {
return;
}

if (this.queue.name) {
this.dequeueEvent(session);
return;
Expand All @@ -170,25 +185,30 @@ class Attack {

remoteHit(session, creature, skill) {
const actor = session.actor;
const corpseTarget = skill.fetchTargetKind?.() === 'corpse_mob';
const corpseTarget = ['corpse_mob', 'corpse_player', 'corpse_pet', 'corpse_ally']
.includes(skill.fetchTargetKind?.());

if (this.checkParticipants(actor, creature, { allowDeadTarget: corpseTarget })) {
invoke('GameServer/Bot/AI/BotSupportPlanner').cancelPendingSupportCast(session, actor, creature, skill);
return;
}

if (actor.canUseSkill?.(skill) === false) {
session.dataSendToMe?.(ServerResponse.actionFailed());
invoke('GameServer/Bot/AI/BotSupportPlanner').cancelPendingSupportCast(session, actor, creature, skill);
return;
}

if (actor.fetchMp() < skill.fetchConsumedMp()) {
ConsoleText.transmit(session, ConsoleText.caption.depletedMp);
invoke('GameServer/Bot/AI/BotSupportPlanner').cancelPendingSupportCast(session, actor, creature, skill);
return;
}

const conditionFailure = this.skillUseConditionFailure(actor, skill);
if (conditionFailure) {
this.rejectSkillUseCondition(session, actor, conditionFailure);
invoke('GameServer/Bot/AI/BotSupportPlanner').cancelPendingSupportCast(session, actor, creature, skill);
return;
}

Expand All @@ -197,20 +217,27 @@ class Attack {

const attackRate = magicSkill ? actor.fetchCollectiveCastSpd() : actor.fetchCollectiveAtkSpd();
skill.setCalculatedHitTime(Formulas.calcRemoteAtkTime(skill.fetchHitTime(), attackRate));
// Companion support selection runs before a native cast is accepted.
// Only create its reservation at this point, after every rejection
// gate above has passed and the cast is about to begin. The calculated
// hit time is available here, so the reservation covers the full cast.
invoke('GameServer/Bot/AI/BotSupportPlanner').beginSupportCast(session, actor, creature, skill);
actor.markSkillReuse?.(skill);
session.dataSendToMeAndOthers(ServerResponse.skillStarted(actor, creature.fetchId(), skill), actor);
session.dataSendToMe(ServerResponse.skillDurationBar(skill.fetchCalculatedHitTime()));
actor.state.setCasts(true);

this.queueTimer(() => {
if (this.checkParticipants(actor, creature, { allowDeadTarget: corpseTarget })) {
invoke('GameServer/Bot/AI/BotSupportPlanner').cancelSupportCast(session, actor);
return;
}

const targets = this.resolveSkillTargets(session, actor, creature, skill);

if (targets.length === 0) {
actor.state.setCasts(false);
invoke('GameServer/Bot/AI/BotSupportPlanner').cancelSupportCast(session, actor);
return;
}

Expand Down Expand Up @@ -250,10 +277,15 @@ class Attack {
});
this.clearLoadedShot(actor, magicSkill);
actor.state.setCasts(false);
invoke('GameServer/Bot/AI/BotSupportPlanner').finishSupportCast(session, actor, skill);

// Start replenish
actor.automation.replenishVitals(actor);

if (invoke('GameServer/Bot/AI/PartyCompanionService').startQueuedGroundPickup(session)) {
return;
}

if (this.queue.name) {
this.dequeueEvent(session);
return;
Expand Down Expand Up @@ -350,6 +382,10 @@ class Attack {
return target.fetchAttackable?.() === true && target.isDead?.() === true;
}

if (['corpse_player', 'corpse_pet', 'corpse_ally'].includes(targetKind)) {
return target.state?.fetchDead?.() === true || target.isDead?.() === true;
}

if (targetKind === 'enemy') {
if (this.isNpcCombatant(actor)) {
return target !== actor && !target.fetchKind && target.state?.fetchDead?.() !== true && target.isDead?.() !== true;
Expand Down
4 changes: 4 additions & 0 deletions src/GameServer/Actor/Generics/MoveTo.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ function moveTo(session, actor, coords) {
const snappedTo = { ...requestedTo };
snappedTo.locZ = GeodataEngine.getHeight(snappedTo.locX, snappedTo.locY, snappedTo.locZ);
actor.setLocXYZ(snappedTo);
invoke('GameServer/Bot/AI/PartyCompanionService').updatePosition(session, actor);
session.lastPathfinding = {
requestedTo,
routedTo: { ...snappedTo },
Expand Down Expand Up @@ -132,6 +133,7 @@ function moveTo(session, actor, coords) {

if (distance === 0) {
actor.setLocXYZ(nextLoc);
invoke('GameServer/Bot/AI/PartyCompanionService').updatePosition(session, actor);
moveAlongPath(index + 1);
return;
}
Expand All @@ -157,6 +159,7 @@ function moveTo(session, actor, coords) {
if (step >= steps) {
clearInterval(session.moveTimer);
actor.setLocXYZ(nextLoc);
invoke('GameServer/Bot/AI/PartyCompanionService').updatePosition(session, actor);
moveAlongPath(index + 1);
} else {
const ratio = step / steps;
Expand All @@ -169,6 +172,7 @@ function moveTo(session, actor, coords) {
locY: nextY,
locZ: snappedZ
});
invoke('GameServer/Bot/AI/PartyCompanionService').updatePosition(session, actor);
}
}, tickRate);
};
Expand Down
70 changes: 63 additions & 7 deletions src/GameServer/Actor/Generics/NpcDied.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
const World = invoke('GameServer/World/World');

const PARTY_REWARD_RADIUS = 2500;
// C4/L2J party reward curve. The total reward grows with the eligible party,
// then is split by squared level rather than being divided equally.
const PARTY_EXP_SP_BONUS = [1, 1.30, 1.39, 1.50, 1.54, 1.58, 1.63, 1.67, 1.71];

function distance2d(a, b) {
const dx = a.fetchLocX() - b.fetchLocX();
Expand Down Expand Up @@ -39,9 +42,9 @@ function ownerSessionForSummon(actor) {
function rewardParticipants(killerSession, killer, npc) {
const leaderSession = partyLeaderSession(killerSession);
const leader = leaderSession?.actor;
if (!leader || !isAliveOnline(leaderSession)) return [killerSession];
if (!leader) return killer && !killer.isDead() ? [killerSession] : [];

const members = [leaderSession];
const members = [leaderSession, killerSession];
World.user.sessions.forEach((candidate) => {
if (
candidate !== leaderSession &&
Expand All @@ -56,11 +59,62 @@ function rewardParticipants(killerSession, killer, npc) {
.filter(isAliveOnline)
.filter((memberSession) => distance2d(memberSession.actor, npc) <= PARTY_REWARD_RADIUS);

if (nearbyMembers.includes(killerSession)) return nearbyMembers;
if (nearbyMembers.length > 0) return nearbyMembers;
if (killer && !killer.isDead()) return [killerSession];
return [];
}

function levelOf(session) {
return Math.max(1, Number(session?.actor?.fetchLevel?.() || 1));
}

function partyBonus(memberCount) {
const index = Math.max(0, Math.min(PARTY_EXP_SP_BONUS.length - 1, Number(memberCount || 1) - 1));
return PARTY_EXP_SP_BONUS[index];
}

function validPartyMembers(participants) {
if (participants.length < 2) return participants;

// L2J's automatic cutoff excludes members whose level is so far below
// the group that they would otherwise be power-levelled for free.
const squaredLevelSum = participants.reduce((sum, memberSession) => {
const level = levelOf(memberSession);
return sum + (level * level);
}, 0);
const previousBonus = partyBonus(participants.length - 1);
const currentBonus = partyBonus(participants.length);
const cutoff = squaredLevelSum * (1 - (1 / (1 + currentBonus - previousBonus)));

return participants.filter((memberSession) => {
const level = levelOf(memberSession);
return (level * level) >= cutoff;
});
}

function partyRewardShares(participants, exp, sp) {
const validMembers = validPartyMembers(participants);
if (validMembers.length === 0) return [];

const totalWeight = validMembers.reduce((sum, memberSession) => {
const level = levelOf(memberSession);
return sum + (level * level);
}, 0);
const bonus = partyBonus(validMembers.length);
const totalExp = Math.max(0, Number(exp || 0)) * bonus;
const totalSp = Math.max(0, Number(sp || 0)) * bonus;

return validMembers.map((memberSession) => {
const level = levelOf(memberSession);
const weight = (level * level) / totalWeight;
return {
session: memberSession,
exp: Math.max(0, Math.round(totalExp * weight)),
sp: Math.max(0, Math.round(totalSp * weight))
};
});
}

function npcDied(session, actor, npc) {
const Generics = invoke(path.actor);

Expand All @@ -83,18 +137,20 @@ function npcDied(session, actor, npc) {

const rewardActor = ownerSession?.actor || actor;
const participants = rewardParticipants(session, rewardActor, npc);
const rewardExp = Math.max(0, Math.floor(npc.fetchAcquiredExp() / Math.max(1, participants.length)));
const rewardSp = Math.max(0, Math.floor(npc.fetchRewardSp() / Math.max(1, participants.length)));
const rewards = partyRewardShares(participants, npc.fetchAcquiredExp(), npc.fetchRewardSp());

// C4's ordinary quest callback is attributed to the actual killer, not to
// every party member that receives shared EXP.
invoke('GameServer/Quest/QuestService').onKill(session, npc).catch((error) => {
utils.infoWarn('Quest', 'kill callback failed: %s', error.message);
});

participants.forEach((memberSession) => {
Generics.experienceReward(memberSession, memberSession.actor, rewardExp, rewardSp);
rewards.forEach(({ session: memberSession, exp, sp }) => {
Generics.experienceReward(memberSession, memberSession.actor, exp, sp);
});
}

module.exports = npcDied;
module.exports.PARTY_EXP_SP_BONUS = PARTY_EXP_SP_BONUS;
module.exports.partyRewardShares = partyRewardShares;
module.exports.validPartyMembers = validPartyMembers;
4 changes: 3 additions & 1 deletion src/GameServer/Actor/Generics/PickupExec.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const ServerResponse = invoke('GameServer/Network/Response');
const World = invoke('GameServer/World/World');

function pickupExec(session, actor, data) {
function pickupExec(session, actor, data, onComplete) {
World.fetchItem(data.id).then((item) => {
actor.automation.schedulePickup(session, actor, item, () => {
actor.state.setPickinUp(true);
Expand All @@ -13,10 +13,12 @@ function pickupExec(session, actor, data) {

setTimeout(() => {
actor.state.setPickinUp(false);
onComplete?.();
}, 500);
});
}).catch((err) => {
utils.infoWarn('GameServer', 'Pickup -> ' + err);
onComplete?.();
});
}

Expand Down
4 changes: 3 additions & 1 deletion src/GameServer/Actor/Generics/ReceivedHit.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ function wakeBotOnDamage(victimSession, attacker) {
if (now - Number(victimSession.lastDamageWakeAt || 0) < BOT_WAKEUP_THROTTLE_MS) return;

victimSession.lastDamageWakeAt = now;
invoke('GameServer/Bot/BotAI').wakeup(victimSession);
// Damage needs a prompt response even if a visibility refresh woke this
// bot a moment ago. Repeated damage is already rate-limited above.
invoke('GameServer/Bot/BotAI').wakeup(victimSession, { urgent: true });
}

function shouldDamageCp(session, actor) {
Expand Down
Loading
Loading