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
1 change: 1 addition & 0 deletions scripts/run-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const tests = [
'tests/test_generated_cold_skills.js',
'tests/test_population_seed_planner.js',
'tests/test_spot_profile_state_priority.js',
'tests/test_bot_hunting_ground_rules.js',
'tests/test_population_starter_party_grouping.js',
'tests/test_bot_goal_state.js',
'tests/test_bot_persona.js',
Expand Down
5 changes: 4 additions & 1 deletion src/GameServer/Bot/AI/BotDecisionService.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ const BotDecisionService = {
};
}

if (status.mode === 'hunting' && status.nearby.attackableNpcs === 0) {
if (status.mode === 'hunting' && (
status.nearby.attackableNpcs === 0
|| Number(status.nearby.eligibleAttackableNpcs ?? status.nearby.attackableNpcs) === 0
)) {
if (!canMoveToSpot(session)) {
return {
action: 'search_locally',
Expand Down
93 changes: 93 additions & 0 deletions src/GameServer/Bot/AI/BotSpotTravel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
const ServerResponse = invoke('GameServer/Network/Response');
const SpotService = invoke('GameServer/Bot/AI/SpotService');
const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal');

const SOE_SKILL_ID = 2013;
const SOE_CAST_MS = 20000;
const TELEPORT_SETTLE_MS = 1200;

function hasFiniteCoordinate(value) {
return value !== null
&& value !== undefined
&& String(value).trim() !== ''
&& Number.isFinite(Number(value));
}

function active(session) {
return !!session?.spotRelocation;
}

function cancel(session, bot, reason = 'cancelled') {
if (!session?.spotRelocation) return false;
if (session.spotRelocation.arrivalPending) return false;
session.spotRelocation = undefined;
bot?.state?.setCasts?.(false);
session.lastSpotRelocation = { reason, at: Date.now() };
return true;
}

function start(session, bot, spot, targetLoc = null) {
if (!session || !bot || !spot) return false;
if (session.spotRelocation) return session.spotRelocation.spotId === spot.id;

const token = Symbol('spot-relocation');
const destination = { ...(targetLoc || spot.center) };
if (!['locX', 'locY', 'locZ'].every((key) => hasFiniteCoordinate(destination[key]))) return false;
destination.locX = Number(destination.locX);
destination.locY = Number(destination.locY);
destination.locZ = Number(destination.locZ);
session.spotRelocation = {
token,
spotId: spot.id,
destination,
startedAt: Date.now(),
completesAt: Date.now() + SOE_CAST_MS,
method: 'soe_gatekeeper'
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
bot.state.setCasts(true);
const skill = {
fetchSelfId: () => SOE_SKILL_ID,
fetchCalculatedHitTime: () => SOE_CAST_MS,
fetchReuseTime: () => 0
};
session.dataSendToMeAndOthers?.(ServerResponse.skillStarted(bot, bot.fetchId(), skill), bot);

setTimeout(() => {
const relocation = session.spotRelocation;
if (!relocation || relocation.token !== token) return;
if (bot.isDead?.() || session.currentTargetId || session.incomingThreatId) {
cancel(session, bot, 'combat_interrupt');
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

bot.state.setCasts(false);
const TeleportTo = invoke('GameServer/Actor/Generics/TeleportTo');
TeleportTo(session, bot, destination);
const arrivedSpot = SpotService.findById(spot.id) || spot;
SpotService.assignSpot(session, arrivedSpot);
session.initialSpawnCoord = { ...arrivedSpot.center };
session.townRoutePlan = null;
session.spotRelocation = { ...relocation, arrivalPending: true };
setTimeout(() => {
if (session.spotRelocation?.token !== token) return;
session.spotRelocation = undefined;
session.lastSpotRelocation = {
spotId: arrivedSpot.id,
method: 'soe_gatekeeper',
at: Date.now()
};
}, TELEPORT_SETTLE_MS);
Promise.resolve(BotEventJournal.record({
botId: bot.fetchId(),
eventType: 'travel_complete',
summary: `${bot.fetchName?.() || 'Bot'} reached ${arrivedSpot.name || 'a hunting ground'} via SoE and gatekeeper.`,
weight: 2,
dedupeKey: `spot-travel:${bot.fetchId()}:${arrivedSpot.id}`,
coalesceWindowMs: 30000,
meta: { spotId: arrivedSpot.id, method: 'soe_gatekeeper' }
})).catch(() => {});
}, SOE_CAST_MS);
return true;
}

module.exports = { SOE_CAST_MS, TELEPORT_SETTLE_MS, active, cancel, start };
8 changes: 7 additions & 1 deletion src/GameServer/Bot/AI/BotStatus.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay');
const BotAmbientDirector = invoke('GameServer/Bot/AI/BotAmbientDirector');
const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget');
const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing');
const BotTargetScorer = invoke('GameServer/Bot/AI/BotTargetScorer');

function ratio(value, max) {
if (!max) return 0;
Expand Down Expand Up @@ -161,6 +162,7 @@ function nearbySnapshot(bot) {
let friendlyBots = 0;
let hostilePlayers = 0;
let attackableNpcs = 0;
let eligibleAttackableNpcs = 0;

World.user.sessions.forEach((session) => {
const actor = session.actor;
Expand All @@ -181,10 +183,14 @@ function nearbySnapshot(bot) {
World.fetchNpcsInRadius(bot.fetchLocX(), bot.fetchLocY(), 1500).forEach((npc) => {
if (npc.fetchAttackable() && !npc.isDead()) {
attackableNpcs++;
const levelGap = Number(npc.fetchLevel?.() || bot.fetchLevel()) - Number(bot.fetchLevel() || 1);
if (levelGap >= BotTargetScorer.MIN_LEVEL_GAP && levelGap <= BotTargetScorer.MAX_LEVEL_ADVANTAGE) {
eligibleAttackableNpcs++;
}
}
});

return { realPlayers, friendlyBots, hostilePlayers, attackableNpcs };
return { realPlayers, friendlyBots, hostilePlayers, attackableNpcs, eligibleAttackableNpcs };
}

function tradeSnapshot(session, bot) {
Expand Down
5 changes: 5 additions & 0 deletions src/GameServer/Bot/AI/BotTargetScorer.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const MAX_LEVEL_ADVANTAGE = 8;
const MIN_LEVEL_GAP = -7;
const MAX_VERTICAL_GAP = 1200;

function number(value, fallback = 0) {
Expand All @@ -23,6 +24,9 @@ function score(context = {}) {
if (!context.incomingThreat && levelGap > MAX_LEVEL_ADVANTAGE) {
return { eligible: false, score: -Infinity, reason: 'level_too_high', reasons: ['level_too_high'] };
}
if (!context.incomingThreat && levelGap < MIN_LEVEL_GAP) {
return { eligible: false, score: -Infinity, reason: 'level_too_low', reasons: ['level_too_low'] };
}
if (verticalGap > MAX_VERTICAL_GAP) {
return { eligible: false, score: -Infinity, reason: 'vertical_gap', reasons: ['vertical_gap'] };
}
Expand Down Expand Up @@ -98,6 +102,7 @@ function rank(candidates) {

module.exports = {
MAX_LEVEL_ADVANTAGE,
MIN_LEVEL_GAP,
MAX_VERTICAL_GAP,
rank,
score
Expand Down
93 changes: 65 additions & 28 deletions src/GameServer/Bot/AI/GearAcquisitionPlanner.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ const ProgressionRates = invoke('GameServer/ProgressionRates');
const BotRoles = invoke('GameServer/Bot/AI/BotRoles');
const CraftShopService = invoke('GameServer/Bot/Economy/CraftShopService');
const CraftSupplementMaterials = invoke('GameServer/Bot/Economy/CraftSupplementMaterials');
let sourceIndexCache = { spots: null, rewards: null, byItemId: new Map() };
const MAX_RESOLVED_SOURCE_CACHE = 512;
let sourceIndexCache = { spots: null, rewards: null, byItemId: new Map(), resolved: new Map() };
const BotGear = invoke('GameServer/Bot/AI/BotGear');
const GearLifecycle = invoke('GameServer/Bot/AI/GearLifecycle');
const MarketOpportunity = invoke('GameServer/Bot/Economy/MarketOpportunity');
Expand Down Expand Up @@ -189,18 +190,18 @@ function candidateEffort(candidate, state, options = {}) {
// diagnostics and a pre-route preview). Do not scan every NPC reward and
// every component tree when no spot atlas is available.
if (!spots.length) return marketEffortValue;
const direct = bestSourceForState(sourceForItem(item.selfId, spots, state), state);
const direct = bestSourceForState(sourceForItem(item.selfId, spots, state, options), state);
const directEffort = direct
? (1 / Math.max(Number(direct.expectedYield || 0), 0.000001))
* (soloSafeForSource(state, direct) ? 1 : 1.35)
: Infinity;
if (!candidate.recipe) return Math.min(directEffort, marketEffortValue);

const allowedRecipeIds = stationRecipeIds();
const allowedRecipeIds = options.allowedRecipeIds || stationRecipeIds();
const materialEffort = missingMaterials(candidate.recipe, state.inventory)
.filter((material) => material.missing > 0 && !CraftSupplementMaterials.isSupplementalMaterial(material.selfId))
.reduce((sum, material) => {
const source = farmSourceForMaterial(material.selfId, state, spots, allowedRecipeIds, material.missing);
const source = farmSourceForMaterial(material.selfId, state, spots, allowedRecipeIds, material.missing, new Set(), options);
return sum + (source ? material.missing / Math.max(Number(source.expectedYield || 0), 0.000001) : 1000000);
}, 8);
return Math.min(directEffort, marketEffortValue, materialEffort);
Expand Down Expand Up @@ -363,15 +364,19 @@ function preferredTarget(state = {}, options = {}) {
// still prevents a leap to a top-tier option.
const entryWeaponFallback = !hasCurrentGradeWeapon && weaponFirst.length > 0;
if (!options.recipeId && Number.isFinite(cap) && affordable.length === 0 && !entryWeaponFallback) return null;
const effortOptions = options.allowedRecipeIds
? options
: { ...options, allowedRecipeIds: stationRecipeIds() };
const candidates = shortlistCandidates(affordable.length ? affordable : progressionCandidates, options)
.map((candidate) => ({ candidate, score: opportunityScore(candidate, state, effortOptions) }))
.sort((a, b) => {
const scoreDelta = opportunityScore(b, state, options) - opportunityScore(a, state, options);
const scoreDelta = b.score - a.score;
if (Math.abs(scoreDelta) > 0.000001) return scoreDelta;
return slotPriority(b.item) - slotPriority(a.item)
|| Number(a.item.template?.price || 0) - Number(b.item.template?.price || 0)
|| Number(a.item.selfId) - Number(b.item.selfId);
return slotPriority(b.candidate.item) - slotPriority(a.candidate.item)
|| Number(a.candidate.item.template?.price || 0) - Number(b.candidate.item.template?.price || 0)
|| Number(a.candidate.item.selfId) - Number(b.candidate.item.selfId);
});
return candidates[0] || null;
return candidates[0]?.candidate || null;
}

function preferredDropTarget(state = {}) {
Expand Down Expand Up @@ -508,32 +513,53 @@ function sourceIndexFor(spots = []) {
]));
const spotByNpc = new Map();
const spotByName = new Map();
const appendSpot = (index, key, spot) => {
if (!key || !spot) return;
const existing = index.get(key) || [];
if (!existing.some((candidate) => candidate.id === spot.id)) existing.push(spot);
index.set(key, existing);
};
(spots || []).forEach((spot) => (spot.npcEntries || []).forEach((entry) => {
if (entry.selfId) spotByNpc.set(Number(entry.selfId), spot);
if (entry.name) spotByName.set(String(entry.name).trim().toLowerCase(), spot);
if (entry.selfId) appendSpot(spotByNpc, Number(entry.selfId), spot);
if (entry.name) appendSpot(spotByName, String(entry.name).trim().toLowerCase(), spot);
}));

const byItemId = new Map();
rewards.forEach((reward) => {
const spot = spotByNpc.get(Number(reward.selfId))
|| spotByName.get(String(reward.template?.name || '').trim().toLowerCase());
if (!spot) return;
const spotsForNpc = [...new Map([
...(spotByNpc.get(Number(reward.selfId)) || []),
...(spotByName.get(String(reward.template?.name || '').trim().toLowerCase()) || [])
].map((spot) => [spot.id, spot])).values()];
if (!spotsForNpc.length) return;
const itemIds = new Set((reward.rewards || []).flatMap((group) => (
(group.items || []).map((item) => Number(item.selfId || 0)).filter(Boolean)
)));
itemIds.forEach((id) => {
spotsForNpc.forEach((spot) => itemIds.forEach((id) => {
const entries = byItemId.get(id) || [];
entries.push({ reward, spot, npcLevel: npcLevels.get(Number(reward.selfId)) || 0 });
if (!entries.some((entry) => entry.reward === reward && entry.spot.id === spot.id)) {
entries.push({ reward, spot, npcLevel: npcLevels.get(Number(reward.selfId)) || 0 });
}
byItemId.set(id, entries);
});
}));
});

sourceIndexCache = { spots, rewards, byItemId };
sourceIndexCache = { spots, rewards, byItemId, resolved: new Map() };
return byItemId;
}

function sourceForItem(itemId, spots = [], state = {}) {
return (sourceIndexFor(spots).get(Number(itemId)) || []).map(({ reward, spot, npcLevel }) => {
function sourceForItem(itemId, spots = [], state = {}, options = {}) {
const sourceCache = options.sourceCache;
const cacheKey = `${Number(itemId)}:${Number(state.level || 0)}`;
if (sourceCache?.has(cacheKey)) return sourceCache.get(cacheKey);
const sourceIndex = sourceIndexFor(spots);
const rates = ProgressionRates.profile();
const resolvedKey = `${cacheKey}:${rates.drop}:${rates.adena}`;
if (sourceIndexCache.resolved.has(resolvedKey)) {
const cached = sourceIndexCache.resolved.get(resolvedKey);
sourceCache?.set(cacheKey, cached);
return cached;
}
const sources = (sourceIndex.get(Number(itemId)) || []).map(({ reward, spot, npcLevel }) => {
const sourceLevel = Number(npcLevel || spot?.avgLevel || 1);
const { chance, expectedYield } = itemDropYield(reward, itemId, 'drop', {
npcLevel: sourceLevel,
Expand All @@ -542,6 +568,12 @@ function sourceForItem(itemId, spots = [], state = {}) {
if (!chance) return null;
return { npcId: Number(reward.selfId), npcName: reward.template?.name || `NPC ${reward.selfId}`, kind: 'drop', chance, expectedYield, spotId: spot.id, spotLevel: Number(spot.avgLevel || 1), npcLevel: sourceLevel };
}).filter(Boolean).sort((a, b) => b.expectedYield - a.expectedYield);
if (sourceIndexCache.resolved.size >= MAX_RESOLVED_SOURCE_CACHE) {
sourceIndexCache.resolved.delete(sourceIndexCache.resolved.keys().next().value);
}
sourceIndexCache.resolved.set(resolvedKey, sources);
sourceCache?.set(cacheKey, sources);
return sources;
}

function stationRecipeIds() {
Expand All @@ -552,8 +584,8 @@ function stationRecipeIds() {
)));
}

function farmSourceForMaterial(itemId, state, spots, allowedRecipeIds, requiredAmount = 1, visited = new Set()) {
const direct = bestSourceForState(sourceForItem(itemId, spots, state), state);
function farmSourceForMaterial(itemId, state, spots, allowedRecipeIds, requiredAmount = 1, visited = new Set(), options = {}) {
const direct = bestSourceForState(sourceForItem(itemId, spots, state, options), state);
if (direct) return { ...direct, itemId: Number(itemId) };
if (visited.has(Number(itemId))) return null;

Expand All @@ -565,7 +597,7 @@ function farmSourceForMaterial(itemId, state, spots, allowedRecipeIds, requiredA
const owned = Number(inventoryMap(state.inventory).get(Number(ingredient.selfId)) || 0);
const required = Number(ingredient.amount || 0) * componentCrafts;
if (owned >= required || CraftSupplementMaterials.isSupplementalMaterial(ingredient.selfId)) continue;
const source = farmSourceForMaterial(ingredient.selfId, state, spots, allowedRecipeIds, required - owned, nextVisited);
const source = farmSourceForMaterial(ingredient.selfId, state, spots, allowedRecipeIds, required - owned, nextVisited, options);
if (source) return source;
}
return null;
Expand Down Expand Up @@ -635,18 +667,23 @@ function planFor(state = {}, options = {}) {
recipeId: null, materials: [], next: { ...source, itemId: Number(target.selfId) }
} : { status: 'no_grade_drop_only', grade: 'none', role: roleFor(state), strategy: 'direct_drop', rateModelVersion: RATE_MODEL_VERSION, recipeId: null, materials: [], next: null };
}
const target = preferredTarget(state, options);
const planningOptions = {
...options,
allowedRecipeIds: options.allowedRecipeIds || stationRecipeIds(),
sourceCache: options.sourceCache || new Map()
};
const target = preferredTarget(state, planningOptions);
if (!target) return { status: 'complete', reason: 'no_missing_craftable_upgrade' };

const spots = options.spots || [];
const directSources = sourceForItem(target.item.selfId, spots, state);
const directSources = sourceForItem(target.item.selfId, spots, state, planningOptions);
const direct = bestSourceForState(directSources, state);
const materials = target.recipe ? missingMaterials(target.recipe, state.inventory) : [];
const allowedRecipeIds = stationRecipeIds();
const allowedRecipeIds = planningOptions.allowedRecipeIds;
const materialPlans = materials.map((material) => ({
...material,
source: material.missing > 0
? farmSourceForMaterial(material.selfId, state, spots, allowedRecipeIds, material.missing)
? farmSourceForMaterial(material.selfId, state, spots, allowedRecipeIds, material.missing, new Set(), planningOptions)
: null
}));
const missingMaterialPlans = materialPlans.filter((material) => material.missing > 0 && !CraftSupplementMaterials.isSupplementalMaterial(material.selfId));
Expand All @@ -657,7 +694,7 @@ function planFor(state = {}, options = {}) {
const craftKills = target.recipe
? missingMaterialPlans.reduce((sum, material) => sum + material.missing / Math.max(material.source?.expectedYield || 0.000001, 0.000001), 0)
: Infinity;
const offer = marketOfferForTarget(target.item, state, options);
const offer = marketOfferForTarget(target.item, state, planningOptions);
const buy = offer && marketEffort(offer, state) <= Math.min(directKills, craftKills);
const directAssessment = direct ? partyNeedAssessmentForSource(state, direct) : null;
const soloSafe = direct && directAssessment.need === 'solo_ok';
Expand Down
3 changes: 2 additions & 1 deletion src/GameServer/Bot/AI/LevelingRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const ROUTES = [
maxLevel: 20,
modes: ['solo', 'duo', 'party'],
roles: ['dps', 'tank', 'dagger', 'archer', 'mage', 'healer', 'buffer', 'spoiler', 'crafter'],
requiredTags: ['starter'],
preferredTags: ['starter', 'local'],
reason: 'starter_leveling'
},
Expand Down Expand Up @@ -212,7 +213,7 @@ function tagsForSpot(spot = {}) {
.filter(([, pattern]) => pattern.test(text))
.map(([tag]) => tag);

if (Number(spot.minLevel || 0) <= 18) tags.push('starter');
if (Number(spot.minLevel || 0) <= 18 && Number(spot.maxLevel || 0) <= 20) tags.push('starter');
if (Number(spot.maxLevel || 0) - Number(spot.minLevel || 0) <= 5) tags.push('normal_hp');

return uniq(tags);
Expand Down
Loading
Loading