From 35e67a65dae2253b397df06112e456db52068b2f Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:00:15 -0400 Subject: [PATCH 1/2] Fix bot party formation backlog --- .../Bot/AI/GearAcquisitionPlanner.js | 58 +- src/GameServer/Bot/Economy/CraftTelemetry.js | 1 + .../Bot/Population/BackgroundPartyState.js | 21 +- src/GameServer/Bot/Population/BotLifeState.js | 226 +++++++- .../Bot/Population/PersonaPartyPolicy.js | 2 +- .../Bot/Population/PopulationConfig.js | 37 +- .../Bot/Population/PopulationMetrics.js | 41 +- .../Bot/Population/PopulationService.js | 534 +++++++++++++++--- .../Bot/Population/PopulationStatus.js | 11 +- .../test_bot_background_party_recruitment.js | 16 + tests/test_bot_craft_telemetry.js | 1 + tests/test_bot_gear_acquisition.js | 18 +- tests/test_bot_party_wait.js | 163 +++++- tests/test_bot_population_state.js | 107 +++- 14 files changed, 1079 insertions(+), 157 deletions(-) diff --git a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js index 56ed8cb1..9c08a397 100644 --- a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js +++ b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js @@ -117,6 +117,8 @@ function combatReadiness(state = {}) { return { role, + hasWeapon: Boolean(weapon), + armorCount: armor.length, weaponRank, armorRank, effectiveLevel: Math.max(1, Number(state.level || 1)) @@ -451,13 +453,49 @@ function soloSafeForSource(state = {}, source = {}) { // A target can be much stronger than the average of a mixed-level grid. // Safety must be evaluated against the NPC that actually drops the item, // not against incidental low-level mobs around it. - return combatReadiness(state).effectiveLevel >= Number(source.npcLevel || source.spotLevel || Infinity) + 2; + return partyNeedForSource(state, source) === 'solo_ok'; +} + +function partyNeedAssessmentForSource(state = {}, source = {}) { + const readiness = combatReadiness(state); + const targetLevel = Number(source?.npcLevel || source?.spotLevel || Infinity); + const margin = readiness.effectiveLevel - targetLevel; + + // A support with no weapon/armour cannot be treated as a safe solo farmer, + // even when the level arithmetic happens to look favourable. This is a + // hard party need, while a normally equipped bot near the target level can + // still progress alone and merely advertise a preferred party. + const unpreparedSupport = ['healer', 'buffer'].includes(readiness.role) + && (!readiness.hasWeapon || readiness.armorCount < 2); + if (!readiness.hasWeapon) return { need: 'required', reason: 'missing_weapon' }; + if (unpreparedSupport) return { need: 'required', reason: 'unprepared_support' }; + if (margin < -2) return { need: 'required', reason: 'underleveled' }; + if (margin < 0) return { need: 'preferred', reason: 'tight_level_margin' }; + return { need: 'solo_ok', reason: 'solo_ready' }; +} + +function partyNeedForSource(state = {}, source = {}) { + return partyNeedAssessmentForSource(state, source).need; +} + +function partyNeedReasonForSource(state = {}, source = {}) { + return partyNeedAssessmentForSource(state, source).reason; } function bestSourceForState(sources = [], state = {}) { return sources.find((source) => soloSafeForSource(state, source)) || sources[0] || null; } +function safeFallbackForPlan(state = {}, plan = {}, spots = []) { + if (!plan || !['active', 'blocked'].includes(plan.status)) return null; + const itemId = plan.strategy === 'direct_drop' + ? Number(plan.target?.selfId || 0) + : Number(plan.next?.itemId || 0); + if (!itemId) return null; + return sourceForItem(itemId, spots, state) + .find((source) => partyNeedForSource(state, source) === 'solo_ok') || null; +} + function sourceIndexFor(spots = []) { const rewards = DataCache.npcRewards || []; if (sourceIndexCache.spots === spots && sourceIndexCache.rewards === rewards) { @@ -586,7 +624,10 @@ function planFor(state = {}, options = {}) { market: { town: offer.town || 'Giran', price: Number(offer.price), sourceType: offer.sourceType }, recipeId: null, materials: [], next: null } : source ? { - status: 'active', grade: 'none', role: roleFor(state), strategy: 'direct_drop', soloSafe: soloSafeForSource(state, source), requiresParty: !soloSafeForSource(state, source), + status: 'active', grade: 'none', role: roleFor(state), strategy: 'direct_drop', soloSafe: soloSafeForSource(state, source), + partyNeed: partyNeedForSource(state, source), + partyNeedReason: partyNeedReasonForSource(state, source), + requiresParty: partyNeedForSource(state, source) === 'required', rateModelVersion: RATE_MODEL_VERSION, expectedKills: Math.ceil(1 / Math.max(source.expectedYield, 0.000001)), target: { selfId: Number(target.selfId), name: target.template?.name || `Item ${target.selfId}`, slot: Number(target.etc?.slot || 0) }, @@ -635,8 +676,13 @@ function planFor(state = {}, options = {}) { // A ready final recipe or component is a station action, not a request to // fight at the next (possibly unsafe) material source. Let it leave the // party gate and finish the prepared manufacture first. - const requiresParty = !readyToCraft && !componentReady - && Boolean(next && !soloSafeForSource(state, next)); + const partyNeed = !readyToCraft && !componentReady && next + ? partyNeedForSource(state, next) + : 'solo_ok'; + const partyNeedReason = !readyToCraft && !componentReady && next + ? partyNeedReasonForSource(state, next) + : 'solo_ready'; + const requiresParty = partyNeed === 'required'; return { status: readyToCraft ? 'ready_to_craft' : componentReady ? 'component_ready' : strategy === 'market' || next ? 'active' : 'blocked', @@ -648,6 +694,8 @@ function planFor(state = {}, options = {}) { recipeId: target.recipe ? Number(target.recipe.recipeId) : null, strategy, soloSafe, + partyNeed, + partyNeedReason, requiresParty, expectedKills: next ? Math.ceil(strategy === 'direct_drop' ? directKills : craftKills) : 0, market: buy ? { town: offer.town || 'Giran', price: Number(offer.price), sourceType: offer.sourceType } : null, @@ -677,4 +725,4 @@ function sameObjective(left, right) { ); } -module.exports = { RATE_MODEL_VERSION, gradeForLevel, isCraftService, roleFor, itemScore, isRealCatalogItem, suitable, isSlotUpgrade, combatReadiness, progressionPriceCap, equipInventoryUpgrades, preferredTarget, preferredDropTarget, preferredNoGradeTarget, marketOfferForTarget, itemDropChance, itemDropYield, soloSafeForSource, bestSourceForState, sourceForItem, farmSourceForMaterial, missingMaterials, planFor, shouldFinishPreviousPlan, scoreSpot, sameObjective }; +module.exports = { RATE_MODEL_VERSION, gradeForLevel, isCraftService, roleFor, itemScore, isRealCatalogItem, suitable, isSlotUpgrade, combatReadiness, progressionPriceCap, equipInventoryUpgrades, preferredTarget, preferredDropTarget, preferredNoGradeTarget, marketOfferForTarget, itemDropChance, itemDropYield, partyNeedForSource, partyNeedReasonForSource, soloSafeForSource, bestSourceForState, safeFallbackForPlan, sourceForItem, farmSourceForMaterial, missingMaterials, planFor, shouldFinishPreviousPlan, scoreSpot, sameObjective }; diff --git a/src/GameServer/Bot/Economy/CraftTelemetry.js b/src/GameServer/Bot/Economy/CraftTelemetry.js index b863b3c3..44882751 100644 --- a/src/GameServer/Bot/Economy/CraftTelemetry.js +++ b/src/GameServer/Bot/Economy/CraftTelemetry.js @@ -93,6 +93,7 @@ function progressEvents(before = {}, plan = {}, after = {}) { } function stationTravelEvent(state, travel = {}) { + travel = travel || {}; return { type: 'craft_station_travel', summary: `${state.name} is traveling to ${travel.stationId} to ${travel.reason === 'component_craft' ? 'craft a component' : 'craft equipment'}`, diff --git a/src/GameServer/Bot/Population/BackgroundPartyState.js b/src/GameServer/Bot/Population/BackgroundPartyState.js index 503dc889..a20ea76f 100644 --- a/src/GameServer/Bot/Population/BackgroundPartyState.js +++ b/src/GameServer/Bot/Population/BackgroundPartyState.js @@ -1,4 +1,5 @@ const Database = invoke('Database'); +const Config = invoke('GameServer/Bot/Population/PopulationConfig'); const TABLE = 'bot_background_parties'; const cache = new Map(); @@ -23,19 +24,35 @@ function parseJson(raw, fallback) { } } +function rotationExpiry(partyId, startedAt) { + const maxAge = Math.max(0, Number(Config.partySessionMaxMs) || 0); + const jitter = Math.min(maxAge, Math.max(0, Number(Config.partySessionJitterMs) || 0)); + if (!maxAge || !startedAt) return 0; + let hash = 0; + for (const char of String(partyId || '')) hash = ((hash * 31) + char.charCodeAt(0)) | 0; + const span = jitter * 2 + 1; + const offset = jitter ? Math.abs(hash) % span - jitter : 0; + return Number(startedAt) + maxAge + offset; +} + function normalize(row) { + const startedAt = Number(row.startedAt || 0); + const stats = parseJson(row.statsJson, {}); + if (row.status === 'active' && startedAt && !Number(stats.sessionExpiresAt || 0)) { + stats.sessionExpiresAt = rotationExpiry(row.partyId, startedAt); + } return { partyId: row.partyId || '', leaderId: Number(row.leaderId || 0), memberIds: parseJson(row.memberIdsJson, []).map((id) => Number(id)).filter(Boolean), spotId: row.spotId || null, - startedAt: Number(row.startedAt || 0), + startedAt, nextResolveAt: row.nextResolveAt ? Number(row.nextResolveAt) : null, cohesion: Number(row.cohesion || 0), risk: Number(row.risk || 0), status: row.status || 'active', roleCoverage: parseJson(row.roleCoverageJson, {}), - stats: parseJson(row.statsJson, {}), + stats, updatedAt: Number(row.updatedAt || 0) }; } diff --git a/src/GameServer/Bot/Population/BotLifeState.js b/src/GameServer/Bot/Population/BotLifeState.js index 2170194a..1c683162 100644 --- a/src/GameServer/Bot/Population/BotLifeState.js +++ b/src/GameServer/Bot/Population/BotLifeState.js @@ -627,25 +627,25 @@ function recoverStaleCraftWaits() { function migrateAcquisitionPartyWaits() { const timestamp = now(); - const replanAt = timestamp + Config.partyWaitReplanMs; + const replanAt = timestamp + 30000; return Database.execute([ `UPDATE ${TABLE} - SET activity = 'party_wait', + SET activity = 'hunting', activityStartedAt = ?, nextResolveAt = ?, statsJson = json_set( COALESCE(statsJson, '{}'), - '$.partyWaitUntil', CAST(? AS INTEGER), + '$.partyWaitUntil', NULL, '$.restUntil', NULL, - '$.lastReason', 'acquisition_party_wait' + '$.lastReason', 'party_request_recovery' ), updatedAt = ? WHERE phase = 'cold' - AND activity = 'resting' + AND activity IN ('resting', 'party_wait') AND (partyId IS NULL OR partyId = '') AND json_extract(statsJson, '$.lastReason') = 'acquisition_party_wait' AND COALESCE(CAST(json_extract(statsJson, '$.restUntil') AS INTEGER), 0) = 0`, - [timestamp, replanAt, replanAt, timestamp] + [timestamp, replanAt, timestamp] ]).then((result) => { const migrated = Number(result?.affectedRows || 0); if (migrated > 0) { @@ -655,6 +655,111 @@ function migrateAcquisitionPartyWaits() { }); } +function clearPassivePartyRequests() { + const timestamp = now(); + return Database.execute([ + `UPDATE ${TABLE} + SET statsJson = json_remove(COALESCE(statsJson, '{}'), '$.partyRequest'), + updatedAt = ? + WHERE phase = 'cold' + AND (partyId IS NULL OR partyId = '') + AND activity IN ('traveling', 'shopping', 'merchant', 'crafting', 'dead') + AND json_extract(statsJson, '$.partyRequest.status') = 'open'`, + [timestamp] + ]).then((result) => { + const cleared = Number(result?.affectedRows || 0); + if (cleared > 0) { + utils.infoWarn('BotLife', 'cleared %d passive party requests on startup', cleared); + } + return cleared; + }); +} + +function expireStalePartyRequests(limit = 0) { + const timestamp = now(); + const requiredMaxAge = Math.max(30000, Number(Config.partyRequestMaxAgeMs) || 15 * 60 * 1000); + const preferredMaxAge = Math.max(30000, Number(Config.partyPreferredMaxAgeMs) || 5 * 60 * 1000); + const cooldownMs = Math.max(30000, Number(Config.partyRequestCooldownMs) || 5 * 60 * 1000); + // Spread the next eligible formation attempts over at most two minutes so + // a restart cannot turn one historical queue into a new SQLite spike. + const staggerMs = Math.min(120000, Math.max(0, Math.floor(cooldownMs / 2))); + const safeLimit = Math.max(0, Math.min(500, Number(limit) || 0)); + const staleSelection = `phase = 'cold' + AND (partyId IS NULL OR partyId = '') + AND activity IN ('hunting', 'resting', 'party_wait') + AND json_extract(statsJson, '$.partyRequest.status') = 'open' + AND CAST(json_extract(statsJson, '$.partyRequest.requestedAt') AS INTEGER) <= + CASE WHEN json_extract(statsJson, '$.partyRequest.priority') = 'required' THEN ? ELSE ? END`; + const target = safeLimit > 0 + ? `characterId IN ( + SELECT characterId FROM ${TABLE} + WHERE ${staleSelection} + ORDER BY updatedAt ASC, characterId ASC + LIMIT ${safeLimit} + )` + : staleSelection; + const sql = `UPDATE ${TABLE} + SET statsJson = json_set( + COALESCE(statsJson, '{}'), + '$.partyRequest.status', 'deferred', + '$.partyRequest.deferredUntil', ? + (ABS(characterId) % ?), + '$.partyRequest.expiredAt', ?, + '$.partyRequest.attempts', COALESCE(CAST(json_extract(statsJson, '$.partyRequest.attempts') AS INTEGER), 0) + 1 + ), + updatedAt = ? + WHERE ${target}`; + const params = [ + timestamp + cooldownMs, + Math.max(1, staggerMs), + timestamp, + timestamp, + timestamp - requiredMaxAge, + timestamp - preferredMaxAge + ]; + const staleParams = [timestamp - requiredMaxAge, timestamp - preferredMaxAge]; + const selectStale = () => Database.execute([ + `SELECT characterId, statsJson FROM ${TABLE} WHERE ${target}`, + staleParams + ]); + const updateCache = (rows) => { + (rows || []).forEach((row) => { + const characterId = Number(row.characterId || 0); + const cached = cache.get(characterId); + if (!cached) return; + const request = parseJson(row.statsJson, {}).partyRequest; + if (request?.status !== 'open') return; + cache.set(characterId, { + ...cached, + updatedAt: timestamp, + stats: { + ...(cached.stats || {}), + partyRequest: { + ...request, + status: 'deferred', + deferredUntil: timestamp + cooldownMs + (Math.abs(characterId) % Math.max(1, staggerMs)), + expiredAt: timestamp, + attempts: Number(request.attempts || 0) + 1 + } + } + }); + }); + }; + const waitForPending = (rows) => Promise.all((rows || []) + .map((row) => pendingWrites.get(Number(row.characterId || 0))) + .filter(Boolean) + .map((pending) => pending.catch(() => null))); + return selectStale() + .then((rows) => waitForPending(rows).then(() => selectStale())) + .then((rows) => Database.execute([sql, params]).then((result) => { + if (Number(result?.affectedRows || 0) > 0) updateCache(rows); + const expired = Number(result?.affectedRows || 0); + if (expired > 0) { + utils.infoWarn('BotLife', 'deferred %d stale party requests', expired); + } + return expired; + })); +} + function discardInvalidEquipmentPlans() { const timestamp = now(); return Database.execute([ @@ -682,7 +787,7 @@ const BotLifeState = { if (initStarted) return initPromise; initStarted = true; - initPromise = Database.execute(['SELECT 1', []], 'schema:bot-life').then(() => recoverStaleHotStates()).then(() => recoverDissolvedPartyMembers()).then(() => recoverStaleCraftWaits()).then(() => migrateAcquisitionPartyWaits()).then(() => discardInvalidEquipmentPlans()).then(() => hydrateCache()).then((count) => { + initPromise = Database.execute(['SELECT 1', []], 'schema:bot-life').then(() => recoverStaleHotStates()).then(() => recoverDissolvedPartyMembers()).then(() => recoverStaleCraftWaits()).then(() => migrateAcquisitionPartyWaits()).then(() => clearPassivePartyRequests()).then(() => expireStalePartyRequests()).then(() => discardInvalidEquipmentPlans()).then(() => hydrateCache()).then((count) => { const repairs = [...cache.values()] .map(recoverOrphanedGiranState) .filter((state) => state !== cache.get(state.characterId)); @@ -1145,24 +1250,33 @@ const BotLifeState = { if (!initialized) return Promise.resolve([]); const safeLimit = Math.max(1, Math.min(500, Number(limit) || 80)); const activityClause = partyRequiredOnly - ? "activity = 'party_wait'" + ? `activity IN ('hunting', 'resting', 'party_wait') + AND json_extract(statsJson, '$.partyRequest.status') = 'open' + AND json_extract(statsJson, '$.partyRequest.priority') = 'required'` : "activity IN ('hunting', 'resting', 'party_wait')"; + const stateActivityClause = partyRequiredOnly + ? `states.activity IN ('hunting', 'resting', 'party_wait') + AND json_extract(states.statsJson, '$.partyRequest.status') = 'open' + AND json_extract(states.statsJson, '$.partyRequest.priority') = 'required'` + : "states.activity IN ('hunting', 'resting', 'party_wait')"; + const objectiveSpot = "COALESCE(json_extract(statsJson, '$.partyRequest.spotId'), json_extract(statsJson, '$.equipmentPlan.next.spotId'), spotId)"; + const stateObjectiveSpot = "COALESCE(json_extract(states.statsJson, '$.partyRequest.spotId'), json_extract(states.statsJson, '$.equipmentPlan.next.spotId'), states.spotId)"; return Database.execute([ `SELECT states.* FROM ${TABLE} states INNER JOIN ( - SELECT spotId, COUNT(*) AS candidateCount, MIN(updatedAt) AS oldestAt + SELECT ${objectiveSpot} AS candidateSpot, COUNT(*) AS candidateCount, MIN(updatedAt) AS oldestAt FROM ${TABLE} WHERE phase = 'cold' AND (partyId IS NULL OR partyId = '') AND spotId IS NOT NULL AND ${activityClause} - GROUP BY spotId - ) party_spots ON party_spots.spotId = states.spotId + GROUP BY candidateSpot + ) party_spots ON party_spots.candidateSpot = ${stateObjectiveSpot} WHERE states.phase = 'cold' AND (states.partyId IS NULL OR states.partyId = '') AND states.spotId IS NOT NULL - AND states.${activityClause} + AND ${stateActivityClause} ORDER BY party_spots.candidateCount DESC, party_spots.oldestAt ASC, states.level ASC, states.updatedAt ASC LIMIT ${safeLimit}`, [] @@ -1176,10 +1290,39 @@ const BotLifeState = { }); }, + statesForParties(partyIds = []) { + const ids = [...new Set((partyIds || []).map((partyId) => String(partyId || '')).filter(Boolean))]; + if (!initialized || !ids.length) return Promise.resolve(new Map()); + + const placeholders = ids.map(() => '?').join(', '); + return Database.execute([ + `SELECT * FROM ${TABLE} + WHERE phase = 'cold' + AND partyId IN (${placeholders}) + ORDER BY partyId ASC, level DESC, characterId ASC`, + ids + ]).then((rows) => { + const grouped = new Map(ids.map((partyId) => [partyId, []])); + rows.forEach((row) => { + const state = normalize(row); + cache.set(state.characterId, state); + const partyId = String(row.partyId || ''); + if (!grouped.has(partyId)) grouped.set(partyId, []); + grouped.get(partyId).push(state); + }); + return grouped; + }).catch((err) => { + utils.infoWarn('BotLife', 'failed to fetch %d parties: %s', ids.length, err.message); + return new Map(ids.map((partyId) => [partyId, []])); + }); + }, + coldPartyCandidateCount(partyRequiredOnly = false) { if (!initialized) return Promise.resolve(0); const activityClause = partyRequiredOnly - ? "activity = 'party_wait'" + ? `activity IN ('hunting', 'resting', 'party_wait') + AND json_extract(statsJson, '$.partyRequest.status') = 'open' + AND json_extract(statsJson, '$.partyRequest.priority') = 'required'` : "activity IN ('hunting', 'resting', 'party_wait')"; return Database.execute([ @@ -1202,20 +1345,23 @@ const BotLifeState = { const safeLimit = Math.max(1, Math.min(100, Number(limitPerSpot) || 40)); const placeholders = uniqueSpots.map(() => '?').join(', '); const activityClause = partyRequiredOnly - ? "states.activity = 'party_wait'" + ? `states.activity IN ('hunting', 'resting', 'party_wait') + AND json_extract(states.statsJson, '$.partyRequest.status') = 'open' + AND json_extract(states.statsJson, '$.partyRequest.priority') = 'required'` : "states.activity IN ('hunting', 'resting', 'party_wait')"; + const objectiveSpot = "COALESCE(json_extract(states.statsJson, '$.partyRequest.spotId'), json_extract(states.statsJson, '$.equipmentPlan.next.spotId'), states.spotId)"; return Database.execute([ `SELECT * FROM ( SELECT states.*, ROW_NUMBER() OVER ( - PARTITION BY states.spotId + PARTITION BY ${objectiveSpot} ORDER BY states.updatedAt ASC, states.level ASC, states.characterId ASC ) AS candidateRank FROM ${TABLE} states WHERE states.phase = 'cold' AND (states.partyId IS NULL OR states.partyId = '') - AND states.spotId IN (${placeholders}) + AND ${objectiveSpot} IN (${placeholders}) AND ${activityClause} ) ranked WHERE candidateRank <= ${safeLimit} @@ -1261,8 +1407,10 @@ const BotLifeState = { assignParty(state, partyId, role = 'dps', leaderId = 0) { if (!state || !partyId) return Promise.resolve(null); + const hasPartyRequest = state.stats?.partyRequest?.status === 'open'; const wasWaitingForParty = state.activity === 'party_wait' - || state.stats?.lastReason === 'acquisition_party_wait'; + || state.stats?.lastReason === 'acquisition_party_wait' + || (hasPartyRequest && state.activity !== 'resting'); const timestamp = now(); const nextState = { ...state, @@ -1278,9 +1426,10 @@ const BotLifeState = { role, leaderId, backgroundPartyId: partyId, - partyWaitUntil: wasWaitingForParty ? null : state.stats?.partyWaitUntil || null, + partyWaitUntil: null, restUntil: wasWaitingForParty ? null : state.stats?.restUntil || null, - lastReason: wasWaitingForParty ? 'party_assigned' : state.stats?.lastReason + lastReason: wasWaitingForParty ? 'party_assigned' : state.stats?.lastReason, + partyRequest: null }, timing: { ...(state.timing || {}), @@ -1424,6 +1573,7 @@ const BotLifeState = { // Keep lifecycle telemetry from this resolve authoritative over // that snapshot, which still contains the previous tick's data. ...(targetCombat ? { targetCombat } : {}), + ...(nextActivity === 'dead' ? { partyRequest: null } : {}), lastResolveDebug: compactResolveDebug(result.debug), equipment: equipmentSummaryFromInventory(equippedInventory) }, @@ -1549,10 +1699,23 @@ const BotLifeState = { leaveParty(state, reason = 'party_break') { if (!state?.characterId) return Promise.resolve(null); + const releasedFromObjective = ['party_objective_complete', 'party_session_rotation'].includes(reason); + const nextActivity = releasedFromObjective && state.activity === 'grouped' + ? 'hunting' + : state.activity; const nextState = { ...state, + activity: nextActivity, party: { ...(state.party || {}), partyId: null, leaderId: null }, - stats: { ...(state.stats || {}), backgroundPartyId: null, partyBreakReason: reason }, + stats: { + ...(state.stats || {}), + backgroundPartyId: null, + partyBreakReason: reason, + partyRequest: null + }, + timing: releasedFromObjective + ? { ...(state.timing || {}), activityStartedAt: now(), nextResolveAt: now() + 30000 } + : state.timing, updatedAt: now() }; const row = rowFromState(nextState); @@ -1804,6 +1967,29 @@ const BotLifeState = { }, { bots: 0, resolves: 0, defeated: 0, targetKills: 0, interruptions: 0 }); }, + partyRequestSummary(timestamp = now()) { + return Array.from(cache.values()).reduce((summary, state) => { + const request = state.stats?.partyRequest; + if (request?.status !== 'open') return summary; + const priority = request.priority === 'required' ? 'required' : 'preferred'; + summary.total += 1; + summary[priority] += 1; + if (priority === 'required') { + const reason = request.partyNeedReason || 'unknown'; + summary.requiredReasons[reason] = (summary.requiredReasons[reason] || 0) + 1; + } + if (state.activity === 'party_wait') summary.blocked += 1; + const ageMs = Math.max(0, timestamp - Number(request.requestedAt || timestamp)); + summary.maxAgeMs = Math.max(summary.maxAgeMs, ageMs); + return summary; + }, { total: 0, required: 0, preferred: 0, blocked: 0, maxAgeMs: 0, requiredReasons: {} }); + }, + + expireStalePartyRequests(limit = 0) { + if (!initialized) return Promise.resolve(0); + return expireStalePartyRequests(limit); + }, + cachedState(characterId) { return cache.get(Number(characterId)) || null; }, diff --git a/src/GameServer/Bot/Population/PersonaPartyPolicy.js b/src/GameServer/Bot/Population/PersonaPartyPolicy.js index 554ac77f..d9113713 100644 --- a/src/GameServer/Bot/Population/PersonaPartyPolicy.js +++ b/src/GameServer/Bot/Population/PersonaPartyPolicy.js @@ -12,7 +12,7 @@ function profileFor(state) { function backgroundIntent(state = {}) { const persona = profileFor(state); if (!persona) return { accept: true, reason: 'no_persona', score: null, persona: null }; - if (state.activity === 'party_wait') { + if (state.activity === 'party_wait' || state.stats?.partyRequest?.priority === 'required') { return { accept: true, reason: 'goal_requires_party', score: 100, persona }; } diff --git a/src/GameServer/Bot/Population/PopulationConfig.js b/src/GameServer/Bot/Population/PopulationConfig.js index a4c196ff..126a528e 100644 --- a/src/GameServer/Bot/Population/PopulationConfig.js +++ b/src/GameServer/Bot/Population/PopulationConfig.js @@ -9,6 +9,14 @@ const DEFAULTS = { // Cold simulation is invisible to players. Keep its total throughput, // but let sockets and hot AI run between bounded pieces of work. schedulerSliceMs: 12, + // The scheduler is a background tenant. A count-only limit is unsafe + // because one party resolve can cost much more than one solo resolve. + schedulerBudgetMs: 750, + schedulerPlayerBudgetMs: 250, + schedulerLagAbortMs: 120, + partyFormationBudgetMs: 1500, + partyFormationPlayerBudgetMs: 600, + partyFormationSliceMs: 12, // Existing cold population predates full class progression. Reconcile it // in small batches so restart never becomes a database migration spike. classProgressionMigrationIntervalMs: 10000, @@ -22,9 +30,8 @@ const DEFAULTS = { marketExpiryCleanupIntervalMs: 10000, marketExpiryCleanupBatchSize: 10, partyFormationIntervalMs: 45000, - // Waiting for a compatible party is not rest. Formation sees these - // candidates independently every 45 seconds; this is only the rare - // fallback that rebuilds a stale acquisition plan. + // Party requests are orthogonal to activity. This is the slow safety + // replan/review cadence, not a period during which the bot is blocked. partyWaitReplanMs: 5 * 60 * 1000, phasePolicyIntervalMs: 10000, directorIntervalMs: 30000, @@ -54,13 +61,25 @@ const DEFAULTS = { // about 27 of the 36 bounded resolves available each minute. This opens // enough party-wait capacity without increasing work in a scheduler tick. maxBackgroundParties: 40, - // A sustained party-wait queue can use the spare party-resolve headroom, - // but the expansion is deliberately capped so it cannot grow unbounded. - partyBacklogCapacityThreshold: 250, - partyBacklogCapacityStep: 3, - partyBacklogCapacityMaxExtra: 12, + // A required request is actionable work, not a reason to wait forever. + // Open a few bounded party slots as soon as several compatible requests + // accumulate; the old 250-request threshold was unreachable for this + // population and left persistent parties above the nominal base cap. + partyBacklogCapacityThreshold: 10, + partyBacklogCapacityStep: 4, + partyBacklogCapacityMaxExtra: 16, + partyRequestMaxAgeMs: 15 * 60 * 1000, + partyPreferredMaxAgeMs: 5 * 60 * 1000, + partyRequestCooldownMs: 5 * 60 * 1000, + partyRequestCleanupIntervalMs: 30000, + partyRequestCleanupBatchSize: 100, + // A background party is a time slice, not a permanent ownership claim on + // its members. Rotate old groups so stale objectives and role gaps can be + // re-matched without increasing the number of simultaneous groups. + partySessionMaxMs: 20 * 60 * 1000, partyRequirementRefreshMs: 5 * 60 * 1000, - partyRequirementRefreshBatchSize: 8, + partyRequirementRefreshBatchSize: 2, + partySessionJitterMs: 5 * 60 * 1000, cooldownGraceMs: 120000, cooldownBatchSize: 20, cooldownRadius: 11000, diff --git a/src/GameServer/Bot/Population/PopulationMetrics.js b/src/GameServer/Bot/Population/PopulationMetrics.js index c3a3bb7d..fc04dcce 100644 --- a/src/GameServer/Bot/Population/PopulationMetrics.js +++ b/src/GameServer/Bot/Population/PopulationMetrics.js @@ -21,6 +21,8 @@ function emptyCounters() { dbFlushes: 0, schedulerRuns: 0, schedulerSkips: 0, + schedulerBudgetStops: 0, + partyFormationBudgetStops: 0, schedulerYields: 0, schedulerOverruns: 0, slowResolves: 0 @@ -56,7 +58,9 @@ const PopulationMetrics = { interval: { resolveDurationsMs: [], schedulerDurationsMs: [], - schedulerSliceDurationsMs: [] + schedulerSliceDurationsMs: [], + partyFormationDurationsMs: [], + partyFormationStageDurationsMs: new Map() }, timer: null, @@ -177,6 +181,34 @@ const PopulationMetrics = { this.counters.schedulerSkips += 1; }, + recordSchedulerBudgetStop() { + this.counters.schedulerBudgetStops += 1; + }, + + recordPartyFormationBudgetStop() { + this.counters.partyFormationBudgetStops += 1; + }, + + recordPartyFormationDuration(ms) { + const value = Math.max(0, Number(ms) || 0); + this.interval.partyFormationDurationsMs.push(value); + if (this.interval.partyFormationDurationsMs.length > Config.resolveSampleLimit) { + this.interval.partyFormationDurationsMs.shift(); + } + }, + + recordPartyFormationStage(stage, ms) { + const key = String(stage || 'unknown'); + const values = this.interval.partyFormationStageDurationsMs.get(key) || []; + values.push(Math.max(0, Number(ms) || 0)); + if (values.length > Config.resolveSampleLimit) values.shift(); + this.interval.partyFormationStageDurationsMs.set(key, values); + }, + + currentEventLoopLag() { + return Number(this.eventLoop.lagMs || 0); + }, + snapshot() { const elapsedMs = Math.max(1, now() - (this.startedAt || now())); const delta = {}; @@ -189,9 +221,14 @@ const PopulationMetrics = { const resolveStats = stats(this.interval.resolveDurationsMs); const schedulerStats = stats(this.interval.schedulerDurationsMs); const schedulerSliceStats = stats(this.interval.schedulerSliceDurationsMs); + const partyFormationStats = stats(this.interval.partyFormationDurationsMs); + const partyFormationStages = Object.fromEntries(Array.from(this.interval.partyFormationStageDurationsMs.entries()) + .map(([stage, values]) => [stage, stats(values)])); this.interval.resolveDurationsMs = []; this.interval.schedulerDurationsMs = []; this.interval.schedulerSliceDurationsMs = []; + this.interval.partyFormationDurationsMs = []; + this.interval.partyFormationStageDurationsMs = new Map(); return { uptimeMs: elapsedMs, @@ -201,6 +238,8 @@ const PopulationMetrics = { resolve: resolveStats, scheduler: schedulerStats, schedulerSlice: schedulerSliceStats, + partyFormation: partyFormationStats, + partyFormationStages, memory: process.memoryUsage ? process.memoryUsage() : null }; } diff --git a/src/GameServer/Bot/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js index 0a7400a0..b4645d3d 100644 --- a/src/GameServer/Bot/Population/PopulationService.js +++ b/src/GameServer/Bot/Population/PopulationService.js @@ -44,10 +44,15 @@ function groupBySpot(states, options = {}) { .map(([spotId, group]) => ({ spotId, states: group.sort((a, b) => Number(a.level || 1) - Number(b.level || 1)), - partyWaiters: group.filter((state) => state.activity === 'party_wait').length, + partyWaiters: group.filter((state) => state.activity === 'party_wait' + || state.stats?.partyRequest?.status === 'open').length, oldestPartyWaitAt: Math.min(...group - .filter((state) => state.activity === 'party_wait') - .map((state) => Number(state.timing?.activityStartedAt || state.updatedAt || Date.now()))) + .filter((state) => state.activity === 'party_wait' + || state.stats?.partyRequest?.status === 'open') + .map((state) => Number(state.stats?.partyRequest?.requestedAt + || state.timing?.activityStartedAt + || state.updatedAt + || Date.now()))) })) .sort((a, b) => { if (options.prioritizePartyWait) { @@ -65,7 +70,11 @@ function groupBySpot(states, options = {}) { .map((group) => group.states); } -function partySpotForLeader(leader) { +function partySpotForLeader(leader, objectiveSpotId = null) { + if (objectiveSpotId) { + const objectiveSpot = SpotProfiles.findById(objectiveSpotId); + if (objectiveSpot) return objectiveSpot; + } const preserveStarterSpot = SpotProfiles.isProtectedStarterCohort(leader); return SpotProfiles.findForState({ ...leader, @@ -95,12 +104,167 @@ function acquisitionRequirementKey(plan) { return JSON.stringify({ status: plan?.status || null, strategy: plan?.strategy || null, + partyNeed: plan?.partyNeed || (plan?.requiresParty ? 'required' : 'solo_ok'), + partyNeedReason: plan?.partyNeedReason || null, requiresParty: Boolean(plan?.requiresParty), target: Number(plan?.target?.selfId || 0), - nextSpot: plan?.next?.spotId || null + nextSpot: plan?.next?.spotId || null, + nextNpc: Number(plan?.next?.npcId || 0), + nextItem: Number(plan?.next?.itemId || 0) }); } +function partyObjectiveForPlan(plan) { + if (!plan || !['active', 'blocked'].includes(plan.status) || !plan.next?.spotId) return null; + const partyNeed = plan.partyNeed || (plan.requiresParty ? 'required' : 'solo_ok'); + if (!['required', 'preferred'].includes(partyNeed)) return null; + const strategy = plan.strategy || 'acquisition'; + const targetItemId = Number(plan.next.itemId || plan.target?.selfId || 0); + const npcId = Number(plan.next.npcId || 0); + // A party hunts a route/NPC, not one item at a time. The item remains in + // the request for personal reward tracking, but it must not fragment all + // bots killing the same dropper into incompatible groups. + const objectiveKey = npcId > 0 + ? [strategy, plan.next.spotId, npcId].join(':') + : [strategy, plan.next.spotId, npcId, targetItemId].join(':'); + return { + status: 'open', + priority: partyNeed, + objectiveKey, + reason: strategy === 'craft' ? 'craft_material' : 'gear_acquisition', + partyNeedReason: plan.partyNeedReason || null, + strategy, + spotId: plan.next.spotId, + npcId: npcId || null, + itemId: targetItemId || null, + targetId: Number(plan.target?.selfId || 0) || null + }; +} + +function partyRequestEligible(state) { + return !state?.party?.partyId + && ['hunting', 'resting', 'party_wait'].includes(state?.activity); +} + +function partyRequestForPlan(state, plan, timestamp = Date.now()) { + if (!partyRequestEligible(state)) return null; + const objective = partyObjectiveForPlan(plan); + if (!objective) return null; + const previous = state.stats?.partyRequest; + const sameRequest = ['open', 'deferred'].includes(previous?.status) + && previous.objectiveKey === objective.objectiveKey + && Number(previous.itemId || 0) === Number(objective.itemId || 0) + && Number(previous.targetId || 0) === Number(objective.targetId || 0); + const maxAge = objective.priority === 'required' + ? Math.max(30000, Number(Config.partyRequestMaxAgeMs) || 15 * 60 * 1000) + : Math.max(30000, Number(Config.partyPreferredMaxAgeMs) || 5 * 60 * 1000); + const cooldownMs = Math.max(30000, Number(Config.partyRequestCooldownMs) || 5 * 60 * 1000); + const previousRequestedAt = sameRequest ? Number(previous.requestedAt || timestamp) : timestamp; + const previousAttempts = sameRequest ? Number(previous.attempts || 0) : 0; + + if (sameRequest && previous.status === 'deferred' && Number(previous.deferredUntil || 0) > timestamp) { + return { + ...objective, + status: 'deferred', + requestedAt: previousRequestedAt, + deferredUntil: Number(previous.deferredUntil), + expiredAt: Number(previous.expiredAt || 0) || null, + attempts: previousAttempts, + lastMatchedAt: previous.lastMatchedAt || null + }; + } + + if (sameRequest && previous.status === 'open' && timestamp - previousRequestedAt >= maxAge) { + return { + ...objective, + status: 'deferred', + requestedAt: previousRequestedAt, + deferredUntil: timestamp + cooldownMs, + expiredAt: timestamp, + attempts: previousAttempts + 1, + lastMatchedAt: previous.lastMatchedAt || null + }; + } + + return { + ...objective, + status: 'open', + requestedAt: sameRequest && previous.status === 'open' ? previousRequestedAt : timestamp, + reviewAt: timestamp + Math.max(30000, Number(Config.partyWaitReplanMs) || 5 * 60 * 1000), + attempts: sameRequest ? previousAttempts : 0, + lastMatchedAt: sameRequest ? previous.lastMatchedAt || null : null + }; +} + +function partyObjectiveForState(state) { + if (state?.stats?.partyRequest) { + return state.stats.partyRequest.status === 'open' ? state.stats.partyRequest : null; + } + return partyObjectiveForPlan(state?.stats?.equipmentPlan); +} + +function partyRequestActive(request) { + return request?.status === 'open' || request?.status === 'deferred'; +} + +function partyObjectivesShareRoute(left, right) { + return Boolean(left && right + && String(left.spotId || '') === String(right.spotId || '') + && Number(left.npcId || 0) > 0 + && Number(left.npcId || 0) === Number(right.npcId || 0)); +} + +function partySessionExpired(party, timestamp = Date.now()) { + const sessionExpiresAt = Number(party?.stats?.sessionExpiresAt || 0); + if (sessionExpiresAt > 0) return timestamp >= sessionExpiresAt; + const maxAge = Math.max(0, Number(Config.partySessionMaxMs) || 0); + const startedAt = Number(party?.stats?.formedAt || party?.startedAt || 0); + return maxAge > 0 && startedAt > 0 && timestamp - startedAt >= maxAge; +} + +function statesForParties(partyIds = []) { + const ids = [...new Set((partyIds || []).map((partyId) => String(partyId || '')).filter(Boolean))]; + if (typeof LifeState.statesForParties === 'function') { + return LifeState.statesForParties(ids); + } + return Promise.all(ids.map((partyId) => LifeState.statesForParty(partyId))) + .then((groups) => new Map(ids.map((partyId, index) => [partyId, groups[index] || []]))); +} + +function expirePartyRequestForState(state, timestamp = Date.now()) { + const request = state?.stats?.partyRequest; + if (request?.status !== 'open') return state; + const maxAge = request.priority === 'required' + ? Math.max(30000, Number(Config.partyRequestMaxAgeMs) || 15 * 60 * 1000) + : Math.max(30000, Number(Config.partyPreferredMaxAgeMs) || 5 * 60 * 1000); + if (timestamp - Number(request.requestedAt || timestamp) < maxAge) return state; + return { + ...state, + stats: { + ...(state.stats || {}), + partyRequest: { + ...request, + status: 'deferred', + deferredUntil: timestamp + Math.max(30000, Number(Config.partyRequestCooldownMs) || 5 * 60 * 1000), + expiredAt: timestamp, + attempts: Number(request.attempts || 0) + 1 + } + } + }; +} + +function partyObjectiveKeyForState(state) { + return partyObjectiveForState(state)?.objectiveKey + || `spot:${state?.spotId || 'unknown'}`; +} + +function partyObjectiveSpotForState(state) { + return partyObjectiveForState(state)?.spotId + || state?.stats?.equipmentPlan?.next?.spotId + || state?.spotId + || null; +} + function directDropTargetNpcId(...plans) { for (const plan of plans) { if (plan?.status !== 'active' || plan?.strategy !== 'direct_drop') continue; @@ -110,6 +274,13 @@ function directDropTargetNpcId(...plans) { return 0; } +function partyTargetNpcId(party, leader) { + const objectiveNpcId = Number(party.stats?.objective?.npcId || 0); + return objectiveNpcId > 0 + ? objectiveNpcId + : directDropTargetNpcId(leader.stats?.equipmentPlan, party.stats?.acquisitionGoal); +} + function joinedBackgroundParty(state) { const current = LifeState.cachedState(state?.characterId); return !!current?.party?.partyId; @@ -173,6 +344,7 @@ function nearbyHotCount(sessions, player) { const PopulationService = { groupBySpot, partySpotForLeader, + partyTargetNpcId, initialized: false, started: false, summaryTimer: null, @@ -185,6 +357,7 @@ const PopulationService = { marketTownMigrationTimer: null, nextColdCombatProfileMigrationAt: 0, nextMarketTownMigrationAt: 0, + nextPartyRequestCleanupAt: 0, marketExpiryCleanupTimer: null, personaBackfillTimer: null, personaBackfillRunning: false, @@ -683,12 +856,35 @@ const PopulationService = { } this.partyFormationRunning = true; - return LifeState.coldPartyCandidateCount(true) - .then((partyWaitCount) => LifeState.coldPartyCandidates(Config.partyFormationCandidateLimit, true) - .then((partyWaitStates) => (partyWaitStates.length - ? { states: partyWaitStates, partyWaitBacklog: true } - : LifeState.coldPartyCandidates(Config.partyFormationCandidateLimit) - .then((states) => ({ states, partyWaitBacklog: false })))) + const startedAt = Date.now(); + const deadlineAt = startedAt + this.partyFormationBudgetMs(); + let budgetStopRecorded = false; + const budgetReached = () => { + if (Date.now() < deadlineAt) return false; + if (!budgetStopRecorded) { + budgetStopRecorded = true; + Metrics.recordPartyFormationBudgetStop(); + } + return true; + }; + const timedStage = (name, work) => { + const stageStartedAt = Date.now(); + return Promise.resolve().then(work).finally(() => { + Metrics.recordPartyFormationStage(name, Date.now() - stageStartedAt); + }); + }; + const formationWork = () => { + const timestamp = Date.now(); + const cleanupInterval = Math.max(5000, Number(Config.partyRequestCleanupIntervalMs) || 30000); + const cleanup = timestamp >= this.nextPartyRequestCleanupAt + ? LifeState.expireStalePartyRequests(Config.partyRequestCleanupBatchSize).finally(() => { + this.nextPartyRequestCleanupAt = Date.now() + cleanupInterval; + }) + : Promise.resolve(0); + return timedStage('cleanup', () => cleanup) + .then(() => timedStage('candidate_count', () => LifeState.coldPartyCandidateCount(true))) + .then((partyWaitCount) => timedStage('candidate_query', () => LifeState.coldPartyCandidates(Config.partyFormationCandidateLimit) + .then((states) => ({ states, partyWaitBacklog: partyWaitCount > 0 })) .then(({ states, partyWaitBacklog }) => { const activeParties = BackgroundPartyState.active(); const recruitSpots = activeParties @@ -697,7 +893,7 @@ const PopulationService = { const fairCandidates = LifeState.coldPartyCandidatesForSpots( recruitSpots, Config.partyRecruitmentCandidateLimit, - partyWaitBacklog + false ); return fairCandidates.then((spotCandidates) => { const byId = new Map((states || []).map((state) => [Number(state.characterId), state])); @@ -708,11 +904,17 @@ const PopulationService = { partyWaitCount }; }); - })) + }))) .then(({ states, partyWaitBacklog, partyWaitCount }) => { const willingStates = states.filter((state) => PersonaPartyPolicy.backgroundIntent(state).accept); - return this.reclaimBackgroundPartyCapacity(partyWaitBacklog ? willingStates : [], partyWaitCount) - .then(() => this.recruitBackgroundMembers(willingStates)).then((recruitedIds) => ({ + return this.reclaimBackgroundPartyCapacity(partyWaitBacklog ? willingStates : [], partyWaitCount, { + deadlineAt, + markBudgetStop: () => budgetReached() + }) + .then(() => this.recruitBackgroundMembers(willingStates, { + deadlineAt, + markBudgetStop: () => budgetReached() + })).then((recruitedIds) => ({ states: willingStates.filter((state) => !recruitedIds.has(Number(state.characterId))), partyWaitBacklog, partyWaitCount @@ -728,11 +930,11 @@ const PopulationService = { if (spotId) counts.set(spotId, Number(counts.get(spotId) || 0) + 1); return counts; }, new Map()); - const groups = this.groupPartyCandidatesBySpot(states, { prioritizePartyWait: partyWaitBacklog, activePartiesBySpot }); + const groups = this.groupPartyCandidatesByObjective(states, { prioritizePartyWait: partyWaitBacklog, activePartiesBySpot }); const created = []; return groups.reduce((chain, group) => chain.then(() => { - if (created.length >= maxNewParties) return null; + if (created.length >= maxNewParties || budgetReached()) return null; if (group.length < Config.partyMinSize) return null; const members = PartyComposition.selectMembers(group, { @@ -742,7 +944,11 @@ const PopulationService = { if (members.length < Config.partyMinSize) return null; const leader = PartyComposition.chooseLeader(members); - const partySpot = partySpotForLeader(leader); + const objectiveMember = members.find((member) => ( + member.stats?.partyRequest?.priority === 'required' + )) || members.find((member) => partyObjectiveForState(member)) || leader; + const objective = partyObjectiveForState(objectiveMember); + const partySpot = partySpotForLeader(leader, objective?.spotId || null); const partyId = `bgp_${Date.now().toString(36)}_${leader.characterId}`; const nextResolveAt = Date.now() + 45000 + Math.round(Math.random() * 90000); const party = { @@ -763,8 +969,9 @@ const PopulationService = { PersonaPartyPolicy.explain(member, members.filter((peer) => peer !== member), PartyComposition.roleCoverage(members)) ])), route: partySpot?.route || null, - acquisitionGoal: leader.stats?.equipmentPlan?.status === 'active' - ? leader.stats.equipmentPlan + objective: objective || null, + acquisitionGoal: objectiveMember.stats?.equipmentPlan?.status === 'active' + ? objectiveMember.stats.equipmentPlan : null } }; @@ -798,12 +1005,15 @@ const PopulationService = { }); }); }), Promise.resolve()).then(() => created); - }) + }); + }; + return Database.cooperatively(formationWork, Config.partyFormationSliceMs) .catch((err) => { utils.infoWarn('BotPopulation', 'background party formation failed: %s', err.message); return []; }) .finally(() => { + Metrics.recordPartyFormationDuration(Date.now() - startedAt); this.partyFormationRunning = false; }); }, @@ -812,10 +1022,77 @@ const PopulationService = { return groupBySpot(states, options); }, + partyFormationBudgetMs() { + let players = 0; + try { + players = this.realPlayerSessions().length; + } catch (err) { + players = 0; + } + const configured = players > 0 + ? Config.partyFormationPlayerBudgetMs + : Config.partyFormationBudgetMs; + return Math.max(50, Number(configured) || 500); + }, + + schedulerBudgetMs() { + let players = 0; + try { + players = this.realPlayerSessions().length; + } catch (err) { + // Keep the background scheduler usable in startup/test harnesses + // where the world session registry is not available yet. + players = 0; + } + const configured = players > 0 ? Config.schedulerPlayerBudgetMs : Config.schedulerBudgetMs; + const budget = Math.max(25, Number(configured) || 250); + const lagAbort = Math.max(0, Number(Config.schedulerLagAbortMs) || 0); + return lagAbort > 0 && Metrics.currentEventLoopLag() >= lagAbort + ? 0 + : Math.min(budget, Math.max(25, Config.schedulerIntervalMs - 25)); + }, + + groupPartyCandidatesByObjective(states = [], options = {}) { + const grouped = new Map(); + (states || []).forEach((state) => { + const key = partyObjectiveKeyForState(state); + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push(state); + }); + return Array.from(grouped.entries()) + .map(([key, group]) => ({ + key, + spotId: partyObjectiveSpotForState(group[0]), + states: group.sort((a, b) => Number(a.level || 1) - Number(b.level || 1)), + partyWaiters: group.filter((state) => state.activity === 'party_wait' + || state.stats?.partyRequest?.status === 'open').length + })) + .sort((a, b) => { + if (options.prioritizePartyWait && a.partyWaiters !== b.partyWaiters) { + return b.partyWaiters - a.partyWaiters; + } + const aActive = Number(options.activePartiesBySpot?.get(a.spotId) || 0); + const bActive = Number(options.activePartiesBySpot?.get(b.spotId) || 0); + if (aActive !== bActive) return aActive - bActive; + return b.states.length - a.states.length; + }) + .map((group) => group.states); + }, + maxBackgroundPartiesForBacklog, + partySessionExpired, + partyRequestForPlan, + partyObjectiveForState, + expirePartyRequestForState, - refreshBackgroundPartyRequirements(parties = []) { + refreshBackgroundPartyRequirements(parties = [], options = {}) { const timestamp = Date.now(); + const deadlineAt = Number(options.deadlineAt || Infinity); + const budgetReached = () => { + if (Date.now() < deadlineAt) return false; + options.markBudgetStop?.(); + return true; + }; const refreshMs = Math.max(1000, Number(Config.partyRequirementRefreshMs) || 5 * 60 * 1000); const batchSize = Math.max(1, Number(Config.partyRequirementRefreshBatchSize) || 8); const refreshable = (parties || []) @@ -834,10 +1111,13 @@ const PopulationService = { utils.infoWarn('BotPopulation', 'party requirement refresh spot index unavailable: %s', err.message); return Promise.resolve([]); } - return refreshable.reduce((chain, party) => chain.then(async (refreshed) => { - const members = await LifeState.statesForParty(party.partyId); + return statesForParties(refreshable.map((party) => party.partyId)).then((membersByParty) => refreshable.reduce((chain, party) => chain.then(async (refreshed) => { + if (budgetReached()) return refreshed; + const members = membersByParty.get(String(party.partyId)) || []; let changed = false; + const refreshedPlans = new Map(); for (const member of members) { + if (budgetReached()) return refreshed; const previousPlan = member.stats?.equipmentPlan; let nextPlan; try { @@ -846,6 +1126,7 @@ const PopulationService = { utils.infoWarn('BotPopulation', 'party requirement refresh failed for %s: %s', member.name, err.message); continue; } + refreshedPlans.set(Number(member.characterId), nextPlan); if (acquisitionRequirementKey(previousPlan) === acquisitionRequirementKey(nextPlan)) continue; const nextState = { ...member, @@ -854,15 +1135,48 @@ const PopulationService = { const saved = await LifeState.upsertState(nextState, 'party_requirement_refresh'); changed = changed || !!saved; } + const refreshedMembers = members.map((member) => { + const nextPlan = refreshedPlans.get(Number(member.characterId)) || member.stats?.equipmentPlan; + return nextPlan ? { ...member, stats: { ...(member.stats || {}), equipmentPlan: nextPlan } } : member; + }); + const releasable = party.stats?.objective?.priority === 'required' + ? refreshedMembers.filter((member) => ( + member.stats?.equipmentPlan?.partyNeed !== 'required' + && member.stats?.equipmentPlan?.requiresParty !== true + )) + : []; + const departures = refreshedMembers.length - releasable.length >= Config.partyMinSize + ? releasable + : []; + await departures.reduce((chain, member) => chain.then(() => ( + LifeState.leaveParty(member, 'party_objective_complete') + )), Promise.resolve()); + const retainedMembers = refreshedMembers.filter((member) => ( + !departures.some((departure) => Number(departure.characterId) === Number(member.characterId)) + )); + const objectiveMember = retainedMembers.find((member) => ( + member.stats?.equipmentPlan?.partyNeed === 'required' + )) || retainedMembers.find((member) => partyObjectiveForState(member)); + const objective = objectiveMember ? partyObjectiveForState(objectiveMember) : null; await BackgroundPartyState.createOrUpdate({ ...party, - stats: { ...(party.stats || {}), lastRequirementRefreshAt: timestamp } + memberIds: retainedMembers.map((member) => member.characterId), + roleCoverage: PartyComposition.roleCoverage(retainedMembers), + spotId: objective?.spotId || party.spotId, + stats: { + ...(party.stats || {}), + objective: objective || null, + acquisitionGoal: objectiveMember?.stats?.equipmentPlan?.status === 'active' + ? objectiveMember.stats.equipmentPlan + : null, + lastRequirementRefreshAt: timestamp + } }); - return changed ? [...refreshed, party.partyId] : refreshed; - }), Promise.resolve([])); + return changed || departures.length ? [...refreshed, party.partyId] : refreshed; + }), Promise.resolve([]))); }, - reclaimBackgroundPartyCapacity(partyWaitStates = [], partyWaitCount = partyWaitStates.length) { + reclaimBackgroundPartyCapacity(partyWaitStates = [], partyWaitCount = partyWaitStates.length, options = {}) { if (!partyWaitStates.length) return Promise.resolve([]); const activeParties = BackgroundPartyState.active(); const availableSlots = Math.max(0, maxBackgroundPartiesForBacklog(partyWaitCount) - activeParties.length); @@ -873,9 +1187,16 @@ const PopulationService = { const reclaimCount = Math.max(0, wantedSlots - availableSlots); if (!reclaimCount || !activeParties.length) return Promise.resolve([]); - return this.refreshBackgroundPartyRequirements(activeParties) - .then(() => LifeState.partyRequirementCounts(activeParties.map((party) => party.partyId))) + return this.refreshBackgroundPartyRequirements(activeParties, options) + .then(() => { + if (Number(options.deadlineAt || Infinity) <= Date.now()) { + options.markBudgetStop?.(); + return null; + } + return LifeState.partyRequirementCounts(activeParties.map((party) => party.partyId)); + }) .then((counts) => { + if (!counts) return []; const countByPartyId = new Map(counts.map((count) => [count.partyId, count])); return activeParties .filter((party) => Number(countByPartyId.get(party.partyId)?.requiredMembers || 0) === 0) @@ -888,18 +1209,31 @@ const PopulationService = { ), Promise.resolve([]))); }, - recruitBackgroundMembers(candidates = []) { + recruitBackgroundMembers(candidates = [], options = {}) { + const deadlineAt = Number(options.deadlineAt || Infinity); + const budgetReached = () => { + if (Date.now() < deadlineAt) return false; + options.markBudgetStop?.(); + return true; + }; const claimed = new Set(); const parties = BackgroundPartyState.active() .filter((party) => (party.memberIds || []).length < Config.partyMaxSize) .sort((a, b) => (a.memberIds || []).length - (b.memberIds || []).length); - return parties.reduce((chain, party) => chain.then(() => LifeState.statesForParty(party.partyId) - .then((members) => { + return statesForParties(parties.map((party) => party.partyId)).then((membersByParty) => parties.reduce((chain, party) => chain.then(() => { + if (budgetReached()) return null; + const members = membersByParty.get(String(party.partyId)) || []; if (members.length < Config.partyMinSize) return null; + const partyObjective = party.stats?.objective || null; const nearby = candidates.filter((state) => ( !claimed.has(Number(state.characterId)) && - state.spotId === party.spotId + partyObjective + ? (partyObjectiveKeyForState(state) === partyObjective.objectiveKey + || partyObjectivesShareRoute(partyObjective, partyObjectiveForState(state)) + || (state.stats?.partyRequest?.priority !== 'required' + && partyObjectiveSpotForState(state) === partyObjective.spotId)) + : state.spotId === party.spotId )); const recruits = PartyComposition.selectRecruits(members, nearby, { maxSize: Config.partyMaxSize }); if (!recruits.length) return null; @@ -933,7 +1267,7 @@ const PopulationService = { return updatedParty; })); }); - })), Promise.resolve()).then(() => claimed); + }), Promise.resolve())).then(() => claimed); }, tickBudgeted() { @@ -945,19 +1279,25 @@ const PopulationService = { } const startedAt = Date.now(); + const budgetMs = this.schedulerBudgetMs(); + if (budgetMs <= 0) { + Metrics.recordSchedulerBudgetStop(); + return Promise.resolve([]); + } + const deadlineAt = startedAt + budgetMs; this.resolving = true; - return Database.cooperatively(() => this.resolveDueParties() - .then(() => this.reconcileMarketGoals()) + return Database.cooperatively(() => this.resolveDueParties(deadlineAt) + .then(() => this.reconcileMarketGoals(deadlineAt)) .then(() => LifeState.dueCold(Config.maxResolvesPerTick)) .then((states) => this.runInSchedulerSlices(states, (state) => this.resolveColdState(state) - .catch((error) => { - // A single bot may lose a race with a market or - // craft transaction. It must not abort every - // remaining cold resolve in this scheduler tick. - utils.infoWarn('BotPopulation', 'cold resolve failed for %s: %s', state.name, error?.message || error); - Metrics.recordSkippedResolve(); - return { ok: false, reason: 'resolve_rejected', state }; - }))), Config.schedulerSliceMs) + .catch((error) => { + // A single bot may lose a race with a market or + // craft transaction. It must not abort every + // remaining cold resolve in this scheduler tick. + utils.infoWarn('BotPopulation', 'cold resolve failed for %s: %s', state.name, error?.message || error); + Metrics.recordSkippedResolve(); + return { ok: false, reason: 'resolve_rejected', state }; + }), deadlineAt)) .catch((err) => { utils.infoWarn('BotPopulation', 'background scheduler failed: %s', err.message); return []; @@ -987,17 +1327,17 @@ const PopulationService = { this.partyFormationPending = false; this.formBackgroundParties(); } - }); + })); }, - resolveDueParties() { + resolveDueParties(deadlineAt = Infinity) { if (Config.backgroundPartyEnabled === false) return Promise.resolve([]); return BackgroundPartyState.due(Config.maxPartyResolvesPerTick) - .then((parties) => this.runInSchedulerSlices(parties, (party) => this.resolveBackgroundParty(party))); + .then((parties) => this.runInSchedulerSlices(parties, (party) => this.resolveBackgroundParty(party), deadlineAt)); }, - reconcileMarketGoals() { + reconcileMarketGoals(deadlineAt = Infinity) { return LifeState.marketGoalCandidates(Config.maxMarketGoalReconcilesPerTick) .then((states) => this.runInSchedulerSlices(states, (state) => { const spot = SpotProfiles.findForState(state); @@ -1011,7 +1351,7 @@ const PopulationService = { return saved; }); }); - }).then((results) => results.filter(Boolean))) + }, deadlineAt).then((results) => results.filter(Boolean))) .catch((err) => { utils.infoWarn('BotPopulation', 'market-goal reconcile failed: %s', err.message); return []; @@ -1023,13 +1363,21 @@ const PopulationService = { return new Promise((resolve) => setImmediate(resolve)); }, - async runInSchedulerSlices(items, work) { + async runInSchedulerSlices(items, work, deadlineAt = Infinity) { const results = []; let sliceStartedAt = Date.now(); const sliceMs = Math.max(1, Number(Config.schedulerSliceMs) || 12); for (const item of items || []) { + if (Date.now() >= deadlineAt) { + Metrics.recordSchedulerBudgetStop(); + break; + } results.push(await work(item)); + if (Date.now() >= deadlineAt) { + Metrics.recordSchedulerBudgetStop(); + break; + } if (Date.now() - sliceStartedAt >= sliceMs) { await this.yieldSchedulerSlice(sliceStartedAt); sliceStartedAt = Date.now(); @@ -1041,6 +1389,9 @@ const PopulationService = { resolveBackgroundParty(party) { const startedAt = Date.now(); + if (partySessionExpired(party, startedAt)) { + return dissolveBackgroundParty(party, 'party_session_rotation', party.memberIds?.length || 0); + } return LifeState.statesForParty(party.partyId).then((members) => { if (members.length < Config.partyMinSize) { const recordedIds = new Set((party.memberIds || []).map(Number)); @@ -1049,7 +1400,9 @@ const PopulationService = { } const leader = members.find((state) => state.characterId === party.leaderId) || members[0]; - const spot = SpotProfiles.findForState({ + const objectiveSpotId = party.stats?.objective?.spotId || null; + const objectiveSpot = objectiveSpotId ? SpotProfiles.findById(objectiveSpotId) : null; + const spot = objectiveSpot || SpotProfiles.findForState({ ...leader, spotId: party.spotId, party: { @@ -1068,7 +1421,7 @@ const PopulationService = { } const elapsedMs = party.stats?.lastResolveAt ? Math.max(1000, Date.now() - party.stats.lastResolveAt) : 60000; - const targetNpcId = directDropTargetNpcId(leader.stats?.equipmentPlan, party.stats?.acquisitionGoal); + const targetNpcId = partyTargetNpcId(party, leader); const result = BackgroundPartyResolver.resolve({ party, members, @@ -1163,8 +1516,9 @@ const PopulationService = { // These transitions have no planning, market search, or inventory work // between their persisted deadline and the next state change. if (state.activity === 'traveling' || (state.activity === 'resting' && Number(state.stats?.restUntil || 0) > 0)) { + const requestLifecycleState = expirePartyRequestForState(state, startedAt); const result = BackgroundResolver.resolveSolo({ - state, + state: requestLifecycleState, spot: null, pressure: Director.pressureForState(state), elapsedMs, @@ -1174,7 +1528,7 @@ const PopulationService = { Metrics.recordSkippedResolve(); return Promise.resolve({ ok: false, reason: 'joined_party', state }); } - return LifeState.applyResolve(state, result).then((updatedState) => { + return LifeState.applyResolve(requestLifecycleState, result).then((updatedState) => { if (!updatedState) { Metrics.recordSkippedResolve(); return { ok: false, reason: 'apply_failed', state }; @@ -1208,8 +1562,15 @@ const PopulationService = { // gear plan still needs the complete atlas. Passing [] here turned every // in-progress craft route into `blocked` on its first travel tick. const spots = SpotProfiles.ensure(); - const upgradedPlan = GearAcquisitionPlanner.planFor(state, { spots }); + const reusablePartyRequest = !state.party?.partyId + && previousPlan?.next + && state.stats?.partyRequest?.status === 'open' + && Number(state.stats.partyRequest.reviewAt || 0) > startedAt; + const upgradedPlan = reusablePartyRequest + ? previousPlan + : GearAcquisitionPlanner.planFor(state, { spots }); const previousRefresh = previousPlan?.recipeId + && !reusablePartyRequest ? GearAcquisitionPlanner.planFor(state, { spots, recipeId: previousPlan.recipeId }) : null; const rawAcquisitionPlan = GearAcquisitionPlanner.shouldFinishPreviousPlan(previousPlan, previousRefresh) @@ -1223,33 +1584,15 @@ const PopulationService = { marketFallback: rawAcquisitionPlan.status === 'active' && rawAcquisitionPlan.strategy === 'craft' && planStartedAt + 20 * 60 * 1000 <= Date.now() }; + const partyRequest = partyRequestForPlan(state, acquisitionPlan, startedAt); + const plannedStats = { ...(state.stats || {}), equipmentPlan: acquisitionPlan }; + if (partyRequest) plannedStats.partyRequest = partyRequest; + else delete plannedStats.partyRequest; const plannedState = { ...state, - stats: { ...(state.stats || {}), equipmentPlan: acquisitionPlan } + stats: plannedStats }; const planEvents = CraftTelemetry.planEvents(state, previousPlan, acquisitionPlan); - if (acquisitionPlan.requiresParty && !state.party?.partyId) { - const partyWaitUntil = Date.now() + Config.partyWaitReplanMs; - const partyWaitState = { - ...plannedState, - // Party formation reads these candidates independently of the - // combat scheduler. Do not disguise the wait as recovery and - // consume a resolve every 30 seconds. - activity: 'party_wait', - spotId: acquisitionPlan.next?.spotId || state.spotId, - timing: { ...(state.timing || {}), nextResolveAt: partyWaitUntil }, - stats: { ...(plannedState.stats || {}), partyWaitUntil, restUntil: null } - }; - return LifeState.upsertState(partyWaitState, 'acquisition_party_wait') - .then((saved) => Promise.all(planEvents.map((event) => ( - LifeEvents.record(state.characterId, event.type, event.summary, event.meta, event.weight) - ))).then(() => ({ - ok: true, - state: saved || partyWaitState, - debug: { activity: 'acquisition_party_wait', next: acquisitionPlan.next } - }))) - .finally(() => Metrics.recordResolveDuration(Date.now() - startedAt)); - } if (plannedState.activity === 'crafting') { return ColdCraftingService.craft(plannedState).then((craft) => { const completed = craft.reason === 'crafted' || craft.reason === 'component_crafted'; @@ -1298,11 +1641,29 @@ const PopulationService = { }) .finally(() => Metrics.recordResolveDuration(Date.now() - startedAt)); } - const travellingState = ColdCraftingService.beginTravel(plannedState) || plannedState; - const travelEvents = travellingState !== plannedState - ? [CraftTelemetry.stationTravelEvent(plannedState, travellingState.stats?.travel)] + const requiredPartyRequest = partyRequest?.priority === 'required'; + const partyFallback = requiredPartyRequest && !passiveActivity + ? GearAcquisitionPlanner.safeFallbackForPlan(state, acquisitionPlan, spots) + : null; + const fallbackSpot = requiredPartyRequest && !passiveActivity + ? (partyFallback && SpotProfiles.findById(partyFallback.spotId)) || SpotProfiles.findForState({ + ...plannedState, + spotId: null, + stats: Object.fromEntries(Object.entries(plannedState.stats || {}) + .filter(([key]) => key !== 'equipmentPlan')) + }) + : null; + const routedState = fallbackSpot + ? { ...plannedState, activity: 'hunting', spotId: fallbackSpot.id } + : plannedState; + const travellingState = ColdCraftingService.beginTravel(routedState) || routedState; + const travel = travellingState.stats?.travel; + const travelEvents = travellingState !== plannedState && travel?.stationId + ? [CraftTelemetry.stationTravelEvent(plannedState, travel)] : []; - const spot = passiveActivity ? null : SpotProfiles.findForState(travellingState); + const spot = passiveActivity + ? null + : fallbackSpot || SpotProfiles.findForState(travellingState); if (!spot && !passiveActivity) { Metrics.recordSkippedResolve(); Metrics.recordResolveDuration(Date.now() - startedAt); @@ -1313,7 +1674,9 @@ const PopulationService = { state: travellingState, spot, pressure: Director.pressureForState(state), - targetNpcId: directDropTargetNpcId(acquisitionPlan), + targetNpcId: requiredPartyRequest + ? Number(partyFallback?.npcId || 0) + : directDropTargetNpcId(acquisitionPlan), elapsedMs }); @@ -1322,8 +1685,7 @@ const PopulationService = { return Promise.resolve({ ok: false, reason: 'joined_party', state }); } - return LifeState.applyResolve(travellingState, result).then((updatedState) => LifeState.refreshInventory(updatedState) - .then((refreshedState) => LifeState.upsertState(refreshedState, 'inventory_refresh').then((saved) => saved || refreshedState))) + return LifeState.applyResolve(travellingState, result) .then((updatedState) => { if (!updatedState) { Metrics.recordSkippedResolve(); diff --git a/src/GameServer/Bot/Population/PopulationStatus.js b/src/GameServer/Bot/Population/PopulationStatus.js index 6b03253b..7e6fbd62 100644 --- a/src/GameServer/Bot/Population/PopulationStatus.js +++ b/src/GameServer/Bot/Population/PopulationStatus.js @@ -17,6 +17,7 @@ const PopulationStatus = { const lifeCounts = LifeState.counts(); const partyCounts = PartyState.counts(); const targetCombat = LifeState.targetCombatSummary(); + const partyRequests = LifeState.partyRequestSummary(); return { hot, @@ -26,7 +27,8 @@ const PopulationStatus = { merchants, total: Math.max(hot, lifeCounts.total || 0), persisted: lifeCounts.total || 0, - targetCombat + targetCombat, + partyRequests }; }, @@ -39,14 +41,19 @@ const PopulationStatus = { const resolve = metrics.resolve || {}; const scheduler = metrics.scheduler || {}; const schedulerSlice = metrics.schedulerSlice || {}; + const partyFormation = metrics.partyFormation || {}; + const partyFormationStages = metrics.partyFormationStages || {}; const market = MarketTelemetry.snapshot(); + const partyRequiredReasons = Object.entries(counts.partyRequests.requiredReasons || {}) + .map(([reason, count]) => `${reason}:${count}`) + .join('|') || 'none'; return { ...counts, metrics, director: Director.snapshot(), market, - line: `hot=${counts.hot} warm=${counts.warm} cold=${counts.cold} parties=${counts.parties} persisted=${counts.persisted} merchants=${counts.merchants} marketListings=${market.delta.listingsOpened} marketBuys=${market.delta.purchases} marketItems=${market.delta.itemsSold} marketAdena=${market.delta.adenaTraded} staticBuyerSales=${market.delta.staticBuyerSales} staticBuyerItems=${market.delta.staticBuyerItems} staticBuyerAdena=${market.delta.staticBuyerAdena} marketNoOffer=${market.delta.noOffer} marketSoldOut=${market.delta.soldOut} marketExpired=${market.delta.expired} ticks=${metrics.delta.hotTicks} resolves=${metrics.delta.backgroundResolves} partyResolves=${metrics.delta.partyResolves} combatActions=${metrics.delta.combatActions} skillUses=${metrics.delta.skillUses} heals=${metrics.delta.heals} skipped=${metrics.delta.skippedResolves} activations=${metrics.delta.activations} cooldowns=${metrics.delta.cooldowns} partyForms=${metrics.delta.partyFormations} partyRecruits=${metrics.delta.partyRecruits} partyDissolves=${metrics.delta.partyDissolutions} dbFlushes=${metrics.delta.dbFlushes} resolveAvg=${resolve.avgMs || 0}ms resolveP95=${resolve.p95Ms || 0}ms schedulerP95=${scheduler.p95Ms || 0}ms sliceP95=${schedulerSlice.p95Ms || 0}ms schedulerYields=${metrics.delta.schedulerYields || 0} schedulerSkips=${metrics.delta.schedulerSkips || 0} schedulerOverruns=${metrics.delta.schedulerOverruns || 0} slowResolves=${metrics.delta.slowResolves || 0} heap=${heapMb}MB lag=${lag}ms maxLag=${maxLag}ms ${Director.statusLine()}` + line: `hot=${counts.hot} warm=${counts.warm} cold=${counts.cold} parties=${counts.parties} persisted=${counts.persisted} merchants=${counts.merchants} partyRequests=${counts.partyRequests.total} partyRequired=${counts.partyRequests.required} partyPreferred=${counts.partyRequests.preferred} partyBlocked=${counts.partyRequests.blocked} partyMaxAge=${Math.round(counts.partyRequests.maxAgeMs / 1000)}s partyRequiredReasons=${partyRequiredReasons} marketListings=${market.delta.listingsOpened} marketBuys=${market.delta.purchases} marketItems=${market.delta.itemsSold} marketAdena=${market.delta.adenaTraded} staticBuyerSales=${market.delta.staticBuyerSales} staticBuyerItems=${market.delta.staticBuyerItems} staticBuyerAdena=${market.delta.staticBuyerAdena} marketNoOffer=${market.delta.noOffer} marketSoldOut=${market.delta.soldOut} marketExpired=${market.delta.expired} ticks=${metrics.delta.hotTicks} resolves=${metrics.delta.backgroundResolves} partyResolves=${metrics.delta.partyResolves} combatActions=${metrics.delta.combatActions} skillUses=${metrics.delta.skillUses} heals=${metrics.delta.heals} skipped=${metrics.delta.skippedResolves} activations=${metrics.delta.activations} cooldowns=${metrics.delta.cooldowns} partyForms=${metrics.delta.partyFormations} partyRecruits=${metrics.delta.partyRecruits} partyDissolves=${metrics.delta.partyDissolutions} partyFormP95=${partyFormation.p95Ms || 0}ms partyFormBudgetStops=${metrics.delta.partyFormationBudgetStops || 0} partyFormStages=${Object.entries(partyFormationStages).map(([stage, value]) => `${stage}:${value.p95Ms || 0}`).join('|') || 'none'} dbFlushes=${metrics.delta.dbFlushes} resolveAvg=${resolve.avgMs || 0}ms resolveP95=${resolve.p95Ms || 0}ms schedulerP95=${scheduler.p95Ms || 0}ms sliceP95=${schedulerSlice.p95Ms || 0}ms schedulerYields=${metrics.delta.schedulerYields || 0} schedulerSkips=${metrics.delta.schedulerSkips || 0} schedulerBudgetStops=${metrics.delta.schedulerBudgetStops || 0} schedulerOverruns=${metrics.delta.schedulerOverruns || 0} slowResolves=${metrics.delta.slowResolves || 0} heap=${heapMb}MB lag=${lag}ms maxLag=${maxLag}ms ${Director.statusLine()}` }; } }; diff --git a/tests/test_bot_background_party_recruitment.js b/tests/test_bot_background_party_recruitment.js index 35a0e356..377e0fa5 100644 --- a/tests/test_bot_background_party_recruitment.js +++ b/tests/test_bot_background_party_recruitment.js @@ -14,6 +14,7 @@ const SpotService = invoke('GameServer/Bot/AI/SpotService'); const originals = { active: PartyState.active, statesForParty: LifeState.statesForParty, + statesForParties: LifeState.statesForParties, assignParty: LifeState.assignParty, partyRequirementCounts: LifeState.partyRequirementCounts, clearParty: LifeState.clearParty, @@ -49,6 +50,7 @@ async function run() { PartyState.active = () => [party]; LifeState.statesForParty = () => Promise.resolve(members); + LifeState.statesForParties = () => Promise.resolve(new Map([['bgp_1', members]])); LifeState.assignParty = (state, partyId, role, leaderId) => { assigned.push({ state, partyId, role, leaderId }); return Promise.resolve(state); @@ -81,6 +83,19 @@ async function run() { }); assert.strictEqual(fairGroups[0][0].spotId, 'under_served', 'party-wait groups must prefer a ground with no existing party over a larger but already saturated queue'); + const objectiveGroups = PopulationService.groupPartyCandidatesByObjective([ + { characterId: 111, level: 25, spotId: 'fallback', activity: 'hunting', stats: { partyRequest: { status: 'open', priority: 'required', objectiveKey: 'direct_drop:cruma:701:88', spotId: 'cruma' } } }, + { characterId: 112, level: 26, spotId: 'fallback', activity: 'hunting', stats: { partyRequest: { status: 'open', priority: 'required', objectiveKey: 'direct_drop:cruma:701:88', spotId: 'cruma' } } }, + { characterId: 113, level: 25, spotId: 'fallback', activity: 'hunting', stats: { partyRequest: { status: 'open', priority: 'required', objectiveKey: 'craft:cruma:701:1988', spotId: 'cruma' } } } + ], { prioritizePartyWait: true }); + assert.strictEqual(objectiveGroups.length, 2, 'party formation must keep different acquisition objectives separate even on one spot'); + assert.strictEqual(objectiveGroups[0].length, 2, 'compatible requesters must share an objective group'); + assert.strictEqual( + PopulationService.partyTargetNpcId({ stats: { objective: { strategy: 'craft', npcId: 701 } } }, { stats: {} }), + 701, + 'craft acquisition objectives must forward their material NPC to party combat' + ); + const electiveParty = { partyId: 'bgp_elective', leaderId: 11, memberIds: [11, 12], spotId: 'cruma', startedAt: 1 }; const requiredParty = { partyId: 'bgp_required', leaderId: 21, memberIds: [21, 22], spotId: 'dion', startedAt: 2 }; const reclaimed = []; @@ -193,6 +208,7 @@ run().catch((err) => { }).finally(() => { PartyState.active = originals.active; LifeState.statesForParty = originals.statesForParty; + LifeState.statesForParties = originals.statesForParties; LifeState.assignParty = originals.assignParty; LifeState.partyRequirementCounts = originals.partyRequirementCounts; LifeState.clearParty = originals.clearParty; diff --git a/tests/test_bot_craft_telemetry.js b/tests/test_bot_craft_telemetry.js index 6b3f976e..e1ca5578 100644 --- a/tests/test_bot_craft_telemetry.js +++ b/tests/test_bot_craft_telemetry.js @@ -44,5 +44,6 @@ assert(component[0].summary.includes('intermediate resource'), 'component readin const travel = CraftTelemetry.stationTravelEvent({ ...state, stats: { equipmentPlan: initial } }, { stationId: 'resource_core', reason: 'component_craft' }); assert.strictEqual(travel.type, 'craft_station_travel'); assert.strictEqual(travel.meta.stationId, 'resource_core'); +assert.doesNotThrow(() => CraftTelemetry.stationTravelEvent(state, null), 'telemetry must tolerate a cleared travel state'); console.log('Bot craft telemetry checks passed'); diff --git a/tests/test_bot_gear_acquisition.js b/tests/test_bot_gear_acquisition.js index 6308753c..7098c1c1 100644 --- a/tests/test_bot_gear_acquisition.js +++ b/tests/test_bot_gear_acquisition.js @@ -33,10 +33,21 @@ const handAxeSource = GearAcquisitionPlanner.sourceForItem(handAxe.selfId, [were assert(handAxeSource, 'a direct equipment source must retain its real dropper'); assert.strictEqual(handAxeSource.npcLevel, 28, 'a direct equipment source must retain its NPC level instead of its mixed-spot average'); assert.strictEqual(GearAcquisitionPlanner.soloSafeForSource({ level: 20 }, handAxeSource), false, 'a level-20 bot must not solo a level-28 item target just because its grid also contains lower-level mobs'); -assert.strictEqual(GearAcquisitionPlanner.soloSafeForSource({ level: 30 }, { spotLevel: 28 }), true, 'a bot should solo only sources below its combat safety margin'); -assert.strictEqual(GearAcquisitionPlanner.soloSafeForSource({ level: 30 }, { spotLevel: 29 }), false, 'a bot must not call an equal-level source solo-safe'); +const gearedLevel30 = { + level: 30, + inventory: Object.fromEntries([7, 10, 11].map((slot) => { + const item = DataCache.items.find((entry) => Number(entry.etc?.slot) === slot && entry.template?.name && entry.template.name !== '0'); + return [item.selfId, { selfId: item.selfId, amount: 1, equipped: true }]; + })) +}; +assert.strictEqual(GearAcquisitionPlanner.soloSafeForSource(gearedLevel30, { spotLevel: 28 }), true, 'a geared bot should solo a source below its combat safety margin'); +assert.strictEqual(GearAcquisitionPlanner.partyNeedForSource(gearedLevel30, { spotLevel: 32 }), 'preferred', 'a near-level source should advertise a party without blocking progress'); +assert.strictEqual(GearAcquisitionPlanner.partyNeedReasonForSource(gearedLevel30, { spotLevel: 32 }), 'tight_level_margin', 'party telemetry must explain a preferred level-margin request'); +assert.strictEqual(GearAcquisitionPlanner.partyNeedForSource(gearedLevel30, { spotLevel: 36 }), 'required', 'a materially stronger source must still require a party'); +assert.strictEqual(GearAcquisitionPlanner.partyNeedReasonForSource(gearedLevel30, { spotLevel: 36 }), 'underleveled', 'party telemetry must explain a required level-gap request'); +assert.strictEqual(GearAcquisitionPlanner.soloSafeForSource(gearedLevel30, { spotLevel: 30 }), true, 'a normally equipped bot must not wait for a same-level source'); assert.strictEqual( - GearAcquisitionPlanner.bestSourceForState([{ spotLevel: 32, id: 'dangerous' }, { spotLevel: 27, id: 'safe' }], { level: 30 }).id, + GearAcquisitionPlanner.bestSourceForState([{ spotLevel: 32, id: 'dangerous' }, { spotLevel: 27, id: 'safe' }], gearedLevel30).id, 'safe', 'material planning must prefer a viable lower-yield solo source over a dangerous one' ); @@ -148,6 +159,7 @@ const healerReadiness = GearAcquisitionPlanner.combatReadiness({ level: 20, stat assert(tankReadiness.effectiveLevel > healerReadiness.effectiveLevel, 'readiness must recognise that a geared tank can take safer solo routes than an unprepared support'); assert.strictEqual(GearAcquisitionPlanner.soloSafeForSource({ level: 20, stats: { role: 'tank' }, inventory: { 1: { selfId: 1, amount: 1, equipped: true } } }, lowDSource), true, 'a tank may solo an entry D route when its actual kit supports it'); assert.strictEqual(GearAcquisitionPlanner.soloSafeForSource({ level: 20, stats: { role: 'healer' }, inventory: {} }, lowDSource), false, 'an unprepared support must wait for party help at the same route'); +assert.strictEqual(GearAcquisitionPlanner.partyNeedReasonForSource({ level: 20, stats: { role: 'healer' }, inventory: {} }, lowDSource), 'missing_weapon', 'missing equipment must be visible as the party requirement reason'); assert(Number(target.item.template.price) <= 2290000, 'a new C-grade bot must begin with an entry-tier weapon target'); const station = ColdCraftingService.stationForRecipe(target.recipe.recipeId); assert(station, 'a selected equipment recipe must be published by a Giran crafting station'); diff --git a/tests/test_bot_party_wait.js b/tests/test_bot_party_wait.js index 0bd58a1f..d8d46bd9 100644 --- a/tests/test_bot_party_wait.js +++ b/tests/test_bot_party_wait.js @@ -7,11 +7,30 @@ const LifeState = invoke('GameServer/Bot/Population/BotLifeState'); const SpotProfiles = invoke('GameServer/Bot/Population/SpotProfiles'); const GearPlanner = invoke('GameServer/Bot/AI/GearAcquisitionPlanner'); const PopulationService = invoke('GameServer/Bot/Population/PopulationService'); +const BackgroundResolver = invoke('GameServer/Bot/Population/BackgroundResolver'); +const ColdMarketListingService = invoke('GameServer/Bot/Economy/ColdMarketListingService'); +const ColdMarketService = invoke('GameServer/Bot/Economy/ColdMarketService'); +const ColdMarketTradeChat = invoke('GameServer/Bot/Economy/ColdMarketTradeChat'); +const GoalService = invoke('GameServer/Bot/Goals/GoalService'); +const GoalExecutor = invoke('GameServer/Bot/Goals/GoalExecutor'); +const LifeEvents = invoke('GameServer/Bot/Population/BotLifeEvents'); const originals = { ensure: SpotProfiles.ensure, + findForState: SpotProfiles.findForState, planFor: GearPlanner.planFor, upsertState: LifeState.upsertState, + applyResolve: LifeState.applyResolve, + refreshInventory: LifeState.refreshInventory, + resolveSolo: BackgroundResolver.resolveSolo, + reconcileInventory: ColdMarketListingService.reconcileInventory, + resolveListing: ColdMarketListingService.resolve, + currentGoal: GoalService.current, + tryPurchase: ColdMarketService.tryPurchase, + announceTrade: ColdMarketTradeChat.maybeAnnounce, + reviewGoal: GoalService.review, + beginMarketTravel: GoalExecutor.beginMarketTravel, + recordMany: LifeEvents.recordMany, partyWaitReplanMs: Config.partyWaitReplanMs }; @@ -25,31 +44,139 @@ async function run() { activity: 'hunting', spotId: 'cruma', timing: { nextResolveAt: Date.now() - 1 }, - stats: {}, + stats: { travel: null }, party: {}, inventory: {} }; - let saved = null; + let applied = null; + let resolverOptions = null; + const fallbackSpot = { + id: 'safe_fallback', + name: 'Safe fallback', + avgLevel: 20, + minLevel: 18, + maxLevel: 22, + density: 3, + npcSelfIds: [1], + npcEntries: [{ selfId: 1, count: 1 }], + rewards: { exp: 10, sp: 1, adenaMin: 1, adenaMax: 1 }, + mob: { hp: 1, damage: 1 } + }; SpotProfiles.ensure = () => []; + SpotProfiles.findForState = () => fallbackSpot; GearPlanner.planFor = () => ({ status: 'active', + partyNeed: 'required', requiresParty: true, - next: { spotId: 'cruma' }, + target: { selfId: 88 }, + next: { spotId: 'unsafe_target', npcId: 77, itemId: 88 }, strategy: 'farm' }); - LifeState.upsertState = (next, reason) => { - saved = { state: next, reason }; - return Promise.resolve(next); + BackgroundResolver.resolveSolo = (options) => { + resolverOptions = options; + return { + patch: { activity: 'hunting', spotId: fallbackSpot.id, vitals: options.state.vitals || {} }, + materialize: { exp: 1, sp: 1, adena: 1, items: [] }, + nextResolveAt: Date.now() + 60000, + debug: { fights: 1, wins: 1, losses: 0, deaths: 0, defeatedNpcIds: [1] }, + events: [] + }; + }; + LifeState.applyResolve = (current, result) => { + applied = { + ...current, + ...result.patch, + timing: { ...(current.timing || {}), nextResolveAt: result.nextResolveAt }, + stats: { ...(current.stats || {}) } + }; + return Promise.resolve(applied); }; + LifeState.refreshInventory = (current) => Promise.resolve(current); + LifeState.upsertState = (next) => Promise.resolve(next); + ColdMarketListingService.reconcileInventory = (current) => Promise.resolve({ state: current, closed: false }); + ColdMarketListingService.resolve = (lifecycle) => Promise.resolve({ state: lifecycle?.state || applied || state, closed: false }); + GoalService.current = () => Promise.resolve({ current: null }); + ColdMarketService.tryPurchase = (current) => Promise.resolve({ state: current, purchased: false }); + ColdMarketTradeChat.maybeAnnounce = (current) => Promise.resolve({ state: current }); + GoalService.review = () => Promise.resolve({ current: null }); + GoalExecutor.beginMarketTravel = () => null; + LifeEvents.recordMany = () => Promise.resolve(); const result = await PopulationService.resolveColdState(state); assert.strictEqual(result.ok, true); - assert.strictEqual(saved.reason, 'acquisition_party_wait'); - assert.strictEqual(saved.state.activity, 'party_wait'); - assert.strictEqual(saved.state.stats.restUntil, null, 'party wait must not pretend to be HP/MP recovery'); - assert(saved.state.stats.partyWaitUntil >= Date.now() + Config.partyWaitReplanMs - 1000); - assert.strictEqual(saved.state.timing.nextResolveAt, saved.state.stats.partyWaitUntil, 'only the rare replan deadline belongs to the cold queue'); - console.log('Bot party wait scheduling checks passed'); + assert.strictEqual(applied.activity, 'hunting', 'a required party request must keep the bot progressing solo'); + assert.strictEqual(applied.spotId, fallbackSpot.id, 'an unmatched requester must use a safe fallback spot'); + assert.strictEqual(applied.stats.partyRequest.priority, 'required'); + assert.strictEqual(applied.stats.partyWaitUntil, undefined, 'the non-blocking request must not create a wait deadline'); + assert(applied.timing.nextResolveAt > Date.now(), 'the fallback hunt must retain a normal combat deadline'); + assert.strictEqual(resolverOptions.targetNpcId, 0, 'fallback combat must not pretend to farm the unsafe acquisition target'); + + GearPlanner.planFor = () => ({ + status: 'active', + partyNeed: 'preferred', + requiresParty: false, + target: { selfId: 88 }, + next: { spotId: 'preferred_target', npcId: 77, itemId: 88 }, + strategy: 'direct_drop' + }); + resolverOptions = null; + const preferredResult = await PopulationService.resolveColdState({ ...state, characterId: 9102 }); + assert.strictEqual(preferredResult.ok, true); + assert.strictEqual(resolverOptions.state.spotId, 'cruma', 'a preferred request must keep its planned route while looking for a party'); + assert.strictEqual(resolverOptions.targetNpcId, 77, 'a preferred request must continue targeting its planned dropper'); + + const timestamp = Date.now(); + const requestPlan = { + status: 'active', + partyNeed: 'required', + requiresParty: true, + target: { selfId: 88 }, + next: { spotId: 'unsafe_target', npcId: 77, itemId: 88 }, + strategy: 'farm' + }; + const oldRequestState = { + activity: 'hunting', + stats: { + partyRequest: { + status: 'open', + priority: 'required', + objectiveKey: 'farm:unsafe_target:77', + spotId: 'unsafe_target', + npcId: 77, + itemId: 88, + targetId: 88, + requestedAt: timestamp - Config.partyRequestMaxAgeMs - 1, + attempts: 2 + } + } + }; + const deferred = PopulationService.partyRequestForPlan(oldRequestState, requestPlan, timestamp); + assert.strictEqual(deferred.status, 'deferred', 'an unmatched request must leave the open queue after its TTL'); + assert(deferred.deferredUntil > timestamp, 'deferred request must carry a cooldown deadline'); + assert.strictEqual( + PopulationService.partyObjectiveForState({ stats: { partyRequest: deferred, equipmentPlan: requestPlan } }), + null, + 'deferred requests must not keep a formation objective alive' + ); + const duringCooldown = PopulationService.partyRequestForPlan({ + ...oldRequestState, + stats: { ...oldRequestState.stats, partyRequest: deferred } + }, requestPlan, timestamp + 1000); + assert.strictEqual(duringCooldown.status, 'deferred', 'a request must stay deferred during its cooldown'); + const reopened = PopulationService.partyRequestForPlan({ + ...oldRequestState, + stats: { ...oldRequestState.stats, partyRequest: deferred } + }, requestPlan, deferred.deferredUntil + 1); + assert.strictEqual(reopened.status, 'open', 'a deferred request must be eligible for a fresh formation attempt'); + + assert.strictEqual(PopulationService.partySessionExpired({ startedAt: timestamp - Config.partySessionMaxMs - 1 }, timestamp), true); + assert.strictEqual(PopulationService.partySessionExpired({ startedAt: timestamp - 1000 }, timestamp), false); + assert.strictEqual(PopulationService.partySessionExpired({ + startedAt: timestamp - Config.partySessionMaxMs - 60000, + stats: { sessionExpiresAt: timestamp + 60000 } + }, timestamp), false, 'a staggered session expiry must override the nominal party age'); + assert.strictEqual(PopulationService.partySessionExpired({ stats: { sessionExpiresAt: timestamp - 1 } }, timestamp), true, 'an explicit staggered session expiry must rotate the party'); + console.log('Bot party request fallback checks passed'); } run().catch((err) => { @@ -57,7 +184,19 @@ run().catch((err) => { process.exitCode = 1; }).finally(() => { SpotProfiles.ensure = originals.ensure; + SpotProfiles.findForState = originals.findForState; GearPlanner.planFor = originals.planFor; LifeState.upsertState = originals.upsertState; + LifeState.applyResolve = originals.applyResolve; + LifeState.refreshInventory = originals.refreshInventory; + BackgroundResolver.resolveSolo = originals.resolveSolo; + ColdMarketListingService.reconcileInventory = originals.reconcileInventory; + ColdMarketListingService.resolve = originals.resolveListing; + GoalService.current = originals.currentGoal; + ColdMarketService.tryPurchase = originals.tryPurchase; + ColdMarketTradeChat.maybeAnnounce = originals.announceTrade; + GoalService.review = originals.reviewGoal; + GoalExecutor.beginMarketTravel = originals.beginMarketTravel; + LifeEvents.recordMany = originals.recordMany; Config.partyWaitReplanMs = originals.partyWaitReplanMs; }); diff --git a/tests/test_bot_population_state.js b/tests/test_bot_population_state.js index 8867854c..5d12612b 100644 --- a/tests/test_bot_population_state.js +++ b/tests/test_bot_population_state.js @@ -20,6 +20,7 @@ const originalUpdateSkillLevel = Database.updateSkillLevel; const originalUpdateCharacterClassId = Database.updateCharacterClassId; const statements = []; const classUpdates = []; +let stalePartyRows = []; try { Database.execute = ([sql, params]) => { @@ -27,6 +28,9 @@ try { if (String(sql).startsWith('SELECT id, classId, level, exp, sp FROM characters')) { return Promise.resolve([{ id: 42, classId: 31, level: 42, exp: 0, sp: 0 }]); } + if (String(sql).startsWith('SELECT characterId, statsJson FROM bot_life_state')) { + return Promise.resolve(stalePartyRows); + } if (String(sql).startsWith('UPDATE bot_life_state')) { return Promise.resolve({ affectedRows: 2 }); } @@ -67,28 +71,65 @@ try { assert(craftRecovery, 'bot life init must release stale craft waits after a restart'); assert(craftRecovery.sql.includes("AND activity = 'crafting'"), 'only stale station waits should be recovered as hunters'); assert.strictEqual(craftRecovery.params[1], craftRecovery.params[0], 'recovered craft waits must be due immediately for their replan'); - const partyWaitMigration = statements.find((entry) => entry.sql.includes("migrated %d acquisition party waits") || entry.sql.includes("activity = 'party_wait'")); - assert(partyWaitMigration, 'startup must move legacy acquisition waits out of the rest scheduler'); + const partyWaitMigration = statements.find((entry) => entry.sql.includes("SET activity = 'hunting'") + && entry.sql.includes("lastReason') = 'acquisition_party_wait'")); + assert(partyWaitMigration, 'startup must move legacy acquisition requests back to actionable hunting'); + assert(partyWaitMigration.sql.includes("'$.partyWaitUntil', NULL"), 'startup must clear the obsolete blocking wait deadline'); + const passivePartyRequestCleanup = statements.find((entry) => entry.sql.includes("json_remove(COALESCE(statsJson, '{}'), '$.partyRequest')") + && entry.sql.includes("activity IN ('traveling', 'shopping', 'merchant', 'crafting', 'dead')")); + assert(passivePartyRequestCleanup, 'startup must clear party requests from passive activities that cannot join formation'); + const stalePartyRequestCleanup = statements.find((entry) => entry.sql.includes("'$.partyRequest.deferredUntil'") + && entry.sql.includes("'$.partyRequest.requestedAt'") + && entry.sql.includes("'$.partyRequest.status', 'deferred'")); + assert(stalePartyRequestCleanup, 'startup must defer party requests that already exceeded their priority-specific TTL'); const invalidPlanMigration = statements.find((entry) => entry.sql.includes("json_remove(COALESCE(statsJson, '{}'), '$.equipmentPlan')")); assert(invalidPlanMigration, 'startup must discard malformed persisted equipment plans that passive bots would not otherwise replan'); assert(invalidPlanMigration.sql.includes("'$.equipmentPlan.target.selfId'"), 'the invalid-plan migration must validate the persisted target identity'); return BotLifeState.upsertState({ characterId: 42, name: 'PersistenceProbe', level: 42, phase: 'cold', activity: 'hunting', timing: { activityStartedAt: 1, nextResolveAt: 2, lastResolvedAt: 1 }, - vitals: {}, stats: { classId: 31 }, inventory: {} + vitals: {}, stats: { + classId: 31, + partyRequest: { + status: 'open', + priority: 'required', + requestedAt: Date.now() - 60 * 60 * 1000, + objectiveKey: 'farm:probe:99' + } + }, inventory: {} }, 'persistence_probe').then(() => { const save = statements.find((entry) => entry.sql.includes('ON CONFLICT(characterId) DO UPDATE')); assert(save.sql.includes('nextResolveAt = excluded.nextResolveAt'), 'persisted cold resolve timing must advance after every tick'); assert(save.sql.includes('lastResolvedAt = excluded.lastResolvedAt'), 'persisted cold resolve history must survive an upsert'); assert(save.sql.includes('inventorySummary = excluded.inventorySummary'), 'background drop rewards must persist after an upsert'); - return BotLifeState.migrateLegacyClassProgression(1).then((migrated) => { + stalePartyRows = [{ + characterId: 42, + statsJson: JSON.stringify({ + partyRequest: { + status: 'open', + priority: 'required', + requestedAt: Date.now() - 60 * 60 * 1000, + objectiveKey: 'farm:probe:99' + } + }) + }]; + const cleanupStart = statements.length; + return BotLifeState.expireStalePartyRequests(100).then((expired) => { + assert.strictEqual(expired, 2, 'TTL cleanup should report the affected rows'); + const cleanupUpdate = statements.slice(cleanupStart).find((entry) => entry.sql.startsWith('UPDATE bot_life_state')); + assert(cleanupUpdate, 'periodic TTL cleanup must execute its update'); + assert.strictEqual(cleanupUpdate.params.length, 6, 'bounded TTL cleanup must bind only the predicates present in its subquery'); + assert.strictEqual(BotLifeState.cachedState(42).stats.partyRequest.status, 'deferred', 'TTL cleanup must refresh the lifecycle cache'); + assert.strictEqual(BotLifeState.partyRequestSummary().total, 0, 'TTL cleanup must remove expired requests from telemetry'); + }); + }).then(() => BotLifeState.migrateLegacyClassProgression(1).then((migrated) => { assert.strictEqual(migrated.length, 1, 'legacy cold bots without progression markers must be migrated'); const classUpdate = classUpdates.at(-1); assert(classUpdate, 'migration must persist the profession on the physical character'); assert.ok([36, 37].includes(classUpdate.classId), 'migration must use the physical character class as its source of truth'); return BotLifeState.dueCold(5, 1000); - }); - }).then(() => { + })) + .then(() => { const due = statements.find((entry) => entry.sql.includes("WHEN activity IN ('traveling', 'shopping', 'crafting') THEN 1")); assert(due.sql.includes('rateModelVersion'), 'due cold states must prioritize persisted plans from an older drop-rate model'); assert(due.sql.includes(`< ${GearPlanner.RATE_MODEL_VERSION}`), 'due cold states must prioritize plans from the current model rollout rather than a stale hard-coded version'); @@ -109,20 +150,37 @@ try { }, 'bgp_probe', 'healer', 42).then((assigned) => { assert.strictEqual(assigned.activity, 'grouped', 'a formed party must release its waiting member into the group lifecycle'); assert.strictEqual(assigned.stats.partyWaitUntil, null, 'assigned members must not retain an obsolete wait deadline'); - return BotLifeState.coldPartyCandidates(5); + assert.strictEqual(assigned.stats.partyRequest, null, 'assigned members must clear the outstanding party request'); + return BotLifeState.assignParty({ + characterId: 46, + name: 'RestingPartyProbe', + phase: 'cold', + activity: 'resting', + timing: { nextResolveAt: 9000 }, + stats: { + restUntil: Date.now() + 60000, + partyRequest: { status: 'open', priority: 'required' } + }, + vitals: {}, + inventory: {} + }, 'bgp_probe', 'dps', 42).then((restingAssigned) => { + assert.strictEqual(restingAssigned.activity, 'resting', 'assigning a resting requester must not wake it into combat'); + assert(restingAssigned.stats.restUntil > Date.now(), 'assigning a resting requester must preserve its recovery deadline'); + return BotLifeState.coldPartyCandidates(5); + }); }).then(() => { const candidates = statements.find((entry) => entry.sql.includes("activity IN ('hunting', 'resting', 'party_wait')")); assert(candidates, 'party formation must see event-scheduled party waits without making them combat-due'); return BotLifeState.coldPartyCandidates(5, true); }).then(() => { - const requiredCandidates = statements.find((entry) => entry.sql.includes("states.activity = 'party_wait'")); - assert(requiredCandidates, 'a real party-wait backlog must reserve formation capacity ahead of elective hunting parties'); + const requiredCandidates = statements.find((entry) => entry.sql.includes("$.partyRequest.priority") && entry.sql.includes("'required'")); + assert(requiredCandidates, 'required party requests must reserve formation capacity ahead of elective hunting parties'); return BotLifeState.coldPartyCandidateCount(true).then(() => { const count = statements.find((entry) => entry.sql.includes('COUNT(*) AS candidateCount')); assert(count, 'party capacity planning must be able to measure the full wait backlog'); return BotLifeState.coldPartyCandidatesForSpots(['cruma', 'dion'], 3, true); }).then(() => { - const fairCandidates = statements.find((entry) => entry.sql.includes('ROW_NUMBER() OVER') && entry.sql.includes('PARTITION BY states.spotId')); + const fairCandidates = statements.find((entry) => entry.sql.includes('ROW_NUMBER() OVER') && entry.sql.includes('PARTITION BY')); assert(fairCandidates, 'party recruitment must load a bounded fair sample per active spot'); }).then(() => { const member = { @@ -159,12 +217,29 @@ try { } }); }); - }).then(() => { - const partySave = statements.filter((entry) => entry.sql.includes('ON CONFLICT(characterId) DO UPDATE')).at(-1); - const persistedStats = JSON.parse(partySave.params[27]); - assert.strictEqual(persistedStats.lastResolveDebug.partyId, 'bgp_probe', 'a party result must not be replaced by its previous solo debug snapshot'); - assert.strictEqual(persistedStats.targetCombat.populationTargets['93'].targetKills, 1, 'a party result must retain its shared target telemetry'); - }); + }).then(() => { + const partySave = statements.filter((entry) => entry.sql.includes('ON CONFLICT(characterId) DO UPDATE')).at(-1); + const persistedStats = JSON.parse(partySave.params[27]); + assert.strictEqual(persistedStats.lastResolveDebug.partyId, 'bgp_probe', 'a party result must not be replaced by its previous solo debug snapshot'); + assert.strictEqual(persistedStats.targetCombat.populationTargets['93'].targetKills, 1, 'a party result must retain its shared target telemetry'); + return BotLifeState.applyResolve({ + characterId: 45, + name: 'DeadPartyRequestProbe', + phase: 'cold', + activity: 'hunting', + stats: { partyRequest: { status: 'open', priority: 'required' } }, + timing: {}, + vitals: {}, + inventory: {} + }, { + patch: { activity: 'dead', vitals: {} }, + materialize: { exp: 0, sp: 0, adena: 0, items: [] }, + nextResolveAt: 10000, + debug: { fights: 1, deaths: 1 } + }).then((deadState) => { + assert.strictEqual(deadState.stats.partyRequest, null, 'dead bots must not retain open party requests'); + }); + }); }).then(() => { console.log('Bot population state checks passed'); }); From 89e266e3307768dbb8ff083383eb0b8444e69c37 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:27:34 -0400 Subject: [PATCH 2/2] Address party formation review feedback --- database/sql/sqlite.sql | 19 +++++++++++++ .../Bot/AI/GearAcquisitionPlanner.js | 25 +++++++++-------- src/GameServer/Bot/Population/BotLifeState.js | 1 + .../Bot/Population/PopulationService.js | 20 ++++++------- .../test_bot_background_party_recruitment.js | 28 +++++++++++++++++++ 5 files changed, 71 insertions(+), 22 deletions(-) diff --git a/database/sql/sqlite.sql b/database/sql/sqlite.sql index d756bbf4..12d0faa1 100644 --- a/database/sql/sqlite.sql +++ b/database/sql/sqlite.sql @@ -178,6 +178,25 @@ CREATE INDEX IF NOT EXISTS bot_life_state_phase_nextResolveAt ON bot_life_state( CREATE INDEX IF NOT EXISTS bot_life_state_phase_partyId ON bot_life_state(phase, partyId); CREATE INDEX IF NOT EXISTS bot_life_state_accountName ON bot_life_state(accountName); CREATE INDEX IF NOT EXISTS bot_life_state_characterName ON bot_life_state(characterName COLLATE NOCASE); +CREATE INDEX IF NOT EXISTS bot_life_state_party_request_filter + ON bot_life_state( + phase, + partyId, + activity, + json_extract(statsJson, '$.partyRequest.status'), + json_extract(statsJson, '$.partyRequest.priority') + ); +CREATE INDEX IF NOT EXISTS bot_life_state_party_objective_spot + ON bot_life_state( + phase, + partyId, + activity, + COALESCE( + json_extract(statsJson, '$.partyRequest.spotId'), + json_extract(statsJson, '$.equipmentPlan.next.spotId'), + spotId + ) + ); CREATE TABLE IF NOT EXISTS bot_goal_state ( characterId INTEGER PRIMARY KEY REFERENCES characters(id) ON DELETE CASCADE, diff --git a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js index 9c08a397..f9ae81c8 100644 --- a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js +++ b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js @@ -466,7 +466,7 @@ function partyNeedAssessmentForSource(state = {}, source = {}) { // hard party need, while a normally equipped bot near the target level can // still progress alone and merely advertise a preferred party. const unpreparedSupport = ['healer', 'buffer'].includes(readiness.role) - && (!readiness.hasWeapon || readiness.armorCount < 2); + && readiness.armorCount < 2; if (!readiness.hasWeapon) return { need: 'required', reason: 'missing_weapon' }; if (unpreparedSupport) return { need: 'required', reason: 'unprepared_support' }; if (margin < -2) return { need: 'required', reason: 'underleveled' }; @@ -616,6 +616,7 @@ function planFor(state = {}, options = {}) { const offer = marketOfferForTarget(target, state, options); const directKills = source ? 1 / Math.max(source.expectedYield, 0.000001) : Infinity; const buy = offer && marketEffort(offer, state) <= directKills; + const sourceAssessment = source ? partyNeedAssessmentForSource(state, source) : null; return target && buy ? { status: 'active', phase: GearLifecycle.phaseFor(state), grade: 'none', role: roleFor(state), strategy: 'market', soloSafe: true, requiresParty: false, rateModelVersion: RATE_MODEL_VERSION, @@ -624,10 +625,10 @@ function planFor(state = {}, options = {}) { market: { town: offer.town || 'Giran', price: Number(offer.price), sourceType: offer.sourceType }, recipeId: null, materials: [], next: null } : source ? { - status: 'active', grade: 'none', role: roleFor(state), strategy: 'direct_drop', soloSafe: soloSafeForSource(state, source), - partyNeed: partyNeedForSource(state, source), - partyNeedReason: partyNeedReasonForSource(state, source), - requiresParty: partyNeedForSource(state, source) === 'required', + status: 'active', grade: 'none', role: roleFor(state), strategy: 'direct_drop', soloSafe: sourceAssessment.need === 'solo_ok', + partyNeed: sourceAssessment.need, + partyNeedReason: sourceAssessment.reason, + requiresParty: sourceAssessment.need === 'required', rateModelVersion: RATE_MODEL_VERSION, expectedKills: Math.ceil(1 / Math.max(source.expectedYield, 0.000001)), target: { selfId: Number(target.selfId), name: target.template?.name || `Item ${target.selfId}`, slot: Number(target.etc?.slot || 0) }, @@ -658,7 +659,8 @@ function planFor(state = {}, options = {}) { : Infinity; const offer = marketOfferForTarget(target.item, state, options); const buy = offer && marketEffort(offer, state) <= Math.min(directKills, craftKills); - const soloSafe = direct && soloSafeForSource(state, direct); + const directAssessment = direct ? partyNeedAssessmentForSource(state, direct) : null; + const soloSafe = direct && directAssessment.need === 'solo_ok'; const strategy = buy ? 'market' : direct && (!target.recipe || soloSafe && directKills <= craftKills * 0.8) ? 'direct_drop' : target.recipe ? 'craft' : 'blocked'; @@ -676,12 +678,11 @@ function planFor(state = {}, options = {}) { // A ready final recipe or component is a station action, not a request to // fight at the next (possibly unsafe) material source. Let it leave the // party gate and finish the prepared manufacture first. - const partyNeed = !readyToCraft && !componentReady && next - ? partyNeedForSource(state, next) - : 'solo_ok'; - const partyNeedReason = !readyToCraft && !componentReady && next - ? partyNeedReasonForSource(state, next) - : 'solo_ready'; + const nextAssessment = !readyToCraft && !componentReady && next + ? partyNeedAssessmentForSource(state, next) + : { need: 'solo_ok', reason: 'solo_ready' }; + const partyNeed = nextAssessment.need; + const partyNeedReason = nextAssessment.reason; const requiresParty = partyNeed === 'required'; return { diff --git a/src/GameServer/Bot/Population/BotLifeState.js b/src/GameServer/Bot/Population/BotLifeState.js index 1c683162..6754f4d9 100644 --- a/src/GameServer/Bot/Population/BotLifeState.js +++ b/src/GameServer/Bot/Population/BotLifeState.js @@ -728,6 +728,7 @@ function expireStalePartyRequests(limit = 0) { if (!cached) return; const request = parseJson(row.statsJson, {}).partyRequest; if (request?.status !== 'open') return; + if (cached.stats?.partyRequest?.status !== 'open') return; cache.set(characterId, { ...cached, updatedAt: timestamp, diff --git a/src/GameServer/Bot/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js index b4645d3d..2967f1cd 100644 --- a/src/GameServer/Bot/Population/PopulationService.js +++ b/src/GameServer/Bot/Population/PopulationService.js @@ -203,10 +203,6 @@ function partyObjectiveForState(state) { return partyObjectiveForPlan(state?.stats?.equipmentPlan); } -function partyRequestActive(request) { - return request?.status === 'open' || request?.status === 'deferred'; -} - function partyObjectivesShareRoute(left, right) { return Boolean(left && right && String(left.spotId || '') === String(right.spotId || '') @@ -877,12 +873,15 @@ const PopulationService = { const timestamp = Date.now(); const cleanupInterval = Math.max(5000, Number(Config.partyRequestCleanupIntervalMs) || 30000); const cleanup = timestamp >= this.nextPartyRequestCleanupAt - ? LifeState.expireStalePartyRequests(Config.partyRequestCleanupBatchSize).finally(() => { + ? LifeState.expireStalePartyRequests(Config.partyRequestCleanupBatchSize).catch((error) => { + utils.infoWarn('BotPopulation', 'party request cleanup failed: %s', error?.message || error); + return 0; + }).finally(() => { this.nextPartyRequestCleanupAt = Date.now() + cleanupInterval; }) : Promise.resolve(0); return timedStage('cleanup', () => cleanup) - .then(() => timedStage('candidate_count', () => LifeState.coldPartyCandidateCount(true))) + .then(() => timedStage('candidate_count', () => LifeState.coldPartyCandidateCount(false))) .then((partyWaitCount) => timedStage('candidate_query', () => LifeState.coldPartyCandidates(Config.partyFormationCandidateLimit) .then((states) => ({ states, partyWaitBacklog: partyWaitCount > 0 })) .then(({ states, partyWaitBacklog }) => { @@ -945,7 +944,8 @@ const PopulationService = { const leader = PartyComposition.chooseLeader(members); const objectiveMember = members.find((member) => ( - member.stats?.partyRequest?.priority === 'required' + member.stats?.partyRequest?.status === 'open' + && member.stats?.partyRequest?.priority === 'required' )) || members.find((member) => partyObjectiveForState(member)) || leader; const objective = partyObjectiveForState(objectiveMember); const partySpot = partySpotForLeader(leader, objective?.spotId || null); @@ -1227,13 +1227,13 @@ const PopulationService = { if (members.length < Config.partyMinSize) return null; const partyObjective = party.stats?.objective || null; const nearby = candidates.filter((state) => ( - !claimed.has(Number(state.characterId)) && - partyObjective + !claimed.has(Number(state.characterId)) + && (partyObjective ? (partyObjectiveKeyForState(state) === partyObjective.objectiveKey || partyObjectivesShareRoute(partyObjective, partyObjectiveForState(state)) || (state.stats?.partyRequest?.priority !== 'required' && partyObjectiveSpotForState(state) === partyObjective.spotId)) - : state.spotId === party.spotId + : state.spotId === party.spotId) )); const recruits = PartyComposition.selectRecruits(members, nearby, { maxSize: Config.partyMaxSize }); if (!recruits.length) return null; diff --git a/tests/test_bot_background_party_recruitment.js b/tests/test_bot_background_party_recruitment.js index 377e0fa5..628757fc 100644 --- a/tests/test_bot_background_party_recruitment.js +++ b/tests/test_bot_background_party_recruitment.js @@ -71,6 +71,34 @@ async function run() { assert.deepStrictEqual(saved.roleCoverage, { tank: 1, healer: 1, buffer: 1, dps: 1 }); assert.strictEqual(events.length, 1); + const sharedParties = [ + { partyId: 'bgp_shared_a', leaderId: 50, memberIds: [50, 51, 52, 53], spotId: 'cruma', stats: {} }, + { partyId: 'bgp_shared_b', leaderId: 54, memberIds: [54, 55, 56, 57], spotId: 'cruma', stats: {} } + ]; + const sharedMembers = new Map(sharedParties.map((sharedParty) => [ + sharedParty.partyId, + sharedParty.memberIds.map((characterId, index) => ({ + characterId, + name: `Member${characterId}`, + level: 15, + spotId: 'cruma', + party: { role: ['tank', 'healer', 'buffer', 'dps'][index] } + })) + ])); + const sharedAssignments = []; + PartyState.active = () => sharedParties; + LifeState.statesForParties = () => Promise.resolve(sharedMembers); + LifeState.assignParty = (state, partyId) => { + sharedAssignments.push({ characterId: state.characterId, partyId }); + return Promise.resolve(state); + }; + const sharedCandidates = await PopulationService.recruitBackgroundMembers([ + { characterId: 60, name: 'SharedOne', level: 15, spotId: 'cruma', party: { role: 'dps' } }, + { characterId: 61, name: 'SharedTwo', level: 15, spotId: 'cruma', party: { role: 'dps' } } + ]); + assert.deepStrictEqual([...sharedCandidates].sort((a, b) => a - b), [60, 61], 'a candidate must be claimed by only one active party per formation pass'); + assert.deepStrictEqual(sharedAssignments.map((entry) => entry.characterId).sort((a, b) => a - b), [60, 61]); + const fairGroups = PopulationService.groupPartyCandidatesBySpot([ { characterId: 101, level: 10, spotId: 'crowded', activity: 'party_wait', timing: { activityStartedAt: 20 } }, { characterId: 102, level: 10, spotId: 'crowded', activity: 'party_wait', timing: { activityStartedAt: 20 } },