From 0edf2b9db5680946c136c668ffda14a0baf45400 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:01:12 -0400 Subject: [PATCH 01/59] Add OpenRouter gateway and truthful hot bot AI --- config/default.ini | 4 + src/GameServer/Bot/AI/BotAgentTools.js | 26 +- src/GameServer/Bot/AI/BotBrain.js | 134 +++---- src/GameServer/Bot/AI/BotRemoteChat.js | 133 +++---- src/GameServer/Bot/AI/OpenRouterGateway.js | 363 +++++++++++++++++++ tests/test_bot_agent_support_confirmation.js | 99 +++++ tests/test_bot_party_chat.js | 3 + tests/test_openrouter_gateway.js | 177 +++++++++ 8 files changed, 757 insertions(+), 182 deletions(-) create mode 100644 src/GameServer/Bot/AI/OpenRouterGateway.js create mode 100644 tests/test_bot_agent_support_confirmation.js create mode 100644 tests/test_openrouter_gateway.js diff --git a/config/default.ini b/config/default.ini index 6bc6fac4..90864686 100644 --- a/config/default.ini +++ b/config/default.ini @@ -36,6 +36,10 @@ chatCooldownMs = 12000 visibilityRadius = 6000 maxPromptPrice = 0.15 maxCompletionPrice = 0.60 +usageInclude = true +requireProviderParameters = true +circuitBreakerFailureThreshold = 3 +circuitBreakerOpenMs = 30000 debug = false [BotPopulation] diff --git a/src/GameServer/Bot/AI/BotAgentTools.js b/src/GameServer/Bot/AI/BotAgentTools.js index 5b1ad817..4d011cc8 100644 --- a/src/GameServer/Bot/AI/BotAgentTools.js +++ b/src/GameServer/Bot/AI/BotAgentTools.js @@ -149,9 +149,15 @@ function applyBuffTarget(session, bot, decision, targetSession) { if (bot.fetchMp() < skill.fetchConsumedMp()) return { applied: false, reason: 'low_mp_for_buff' }; if (distance2d(bot, target) > 900) return { applied: false, reason: 'target_too_far' }; + const BotPartyChat = invoke('GameServer/Bot/AI/BotPartyChat'); + BotPartyChat.expectSkillResult(session, { + target, + targetSession, + skill, + kind: 'support' + }); invoke(path.actor).skillExec(session, bot, { id: target.fetchId(), selfId: skill.fetchSelfId(), ctrl: false }); - say(session, decision.reply || `${BotBuffs.SUPPORT_BUFFS[buffType].name} on ${target.fetchName()}.`, targetSession); - return { applied: true, reason: `buff:${buffType}` }; + return { applied: true, reason: `buff_requested:${buffType}` }; } function applyHealTarget(session, bot, decision, targetSession) { @@ -163,10 +169,16 @@ function applyHealTarget(session, bot, decision, targetSession) { if (bot.fetchMp() < skill.fetchConsumedMp()) return { applied: false, reason: 'low_mp_for_heal' }; if (distance2d(bot, target) > 900) return { applied: false, reason: 'target_too_far' }; + const BotPartyChat = invoke('GameServer/Bot/AI/BotPartyChat'); + BotPartyChat.expectSkillResult(session, { + target, + targetSession, + skill, + kind: 'heal' + }); invoke(path.actor).skillExec(session, bot, { id: target.fetchId(), selfId: skill.fetchSelfId(), ctrl: false }); - say(session, decision.reply || `Healing you, ${target.fetchName()}.`, targetSession); - return { applied: true, reason: 'heal_target' }; + return { applied: true, reason: 'heal_requested' }; } function execute(session, decision, visiblePlayers) { @@ -291,8 +303,10 @@ function remember(session, decision, result, model) { at: Date.now(), model, usage: decision.usage ? { - promptTokens: decision.usage.prompt_tokens, - completionTokens: decision.usage.completion_tokens, + promptTokens: decision.usage.promptTokens ?? decision.usage.prompt_tokens, + completionTokens: decision.usage.completionTokens ?? decision.usage.completion_tokens, + cachedPromptTokens: decision.usage.cachedPromptTokens ?? decision.usage.prompt_tokens_details?.cached_tokens, + totalTokens: decision.usage.totalTokens ?? decision.usage.total_tokens, cost: decision.usage.cost } : null }; diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 8ba6393f..6b70e1cc 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -1,37 +1,12 @@ const SpotService = invoke('GameServer/Bot/AI/SpotService'); const BotBrainContext = invoke('GameServer/Bot/AI/BotBrainContext'); const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); +const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); const ALLOWED_PLANS = ['hunting', 'following', 'resting', 'shopping', 'pk_hunting', 'merchant']; -const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; - -function bool(value, fallback = false) { - if (value === undefined || value === null || value === '') return fallback; - if (typeof value === 'boolean') return value; - return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase()); -} - -function num(value, fallback) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : fallback; -} function config() { - const optn = options.default.OpenRouter || {}; - return { - enabled: bool(optn.enabled, false), - apiKey: process.env.OPENROUTER_API_KEY || optn.apiKey || '', - model: process.env.OPENROUTER_MODEL || optn.model || 'google/gemini-2.5-flash-lite', - temperature: num(optn.temperature, 0.35), - maxTokens: num(optn.maxTokens, 160), - timeoutMs: num(optn.timeoutMs, 3500), - cooldownMs: num(optn.cooldownMs, 45000), - chatCooldownMs: num(optn.chatCooldownMs, 12000), - visibilityRadius: num(optn.visibilityRadius, 6000), - maxPromptPrice: num(optn.maxPromptPrice, 0), - maxCompletionPrice: num(optn.maxCompletionPrice, 0), - debug: bool(optn.debug, false) - }; + return OpenRouterGateway.config({ maxTokens: 160 }); } function debugSkip(session, cfg, reason) { @@ -191,78 +166,53 @@ function userPayload(event, session, status, visiblePlayers, text) { }; } -async function requestDecision(payload, cfg) { - if (typeof fetch !== 'function') { - utils.infoWarn('BotBrain', 'global fetch is unavailable; OpenRouter brain disabled for this runtime'); - return null; - } - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), cfg.timeoutMs); - - const provider = {}; - if (cfg.maxPromptPrice > 0 || cfg.maxCompletionPrice > 0) { - provider.max_price = {}; - if (cfg.maxPromptPrice > 0) provider.max_price.prompt = cfg.maxPromptPrice; - if (cfg.maxCompletionPrice > 0) provider.max_price.completion = cfg.maxCompletionPrice; - } - - const body = { - model: cfg.model, +async function requestDecision(payload, cfg, session, requestContext, visiblePlayers) { + const botId = session?.actor?.fetchId?.() || session?.accountId || 'unknown'; + const playerId = requestContext?.playerSession?.actor?.fetchId?.() || + requestContext?.playerId || + visiblePlayers?.[0]?.id || + 'nearby'; + const result = await OpenRouterGateway.request({ + config: cfg, + circuitKey: 'hot', + requestId: requestContext?.requestId || `hot-${botId}-${Date.now()}`, + sessionId: `hot-bot:${botId}:player:${playerId}`, messages: [ { role: 'system', content: systemPrompt() }, { role: 'user', content: JSON.stringify(payload) } ], - temperature: cfg.temperature, - max_tokens: cfg.maxTokens, - response_format: { - type: 'json_schema', - json_schema: { - name: 'bot_brain_decision', - strict: true, - schema: schema() - } + responseSchema: { + name: 'bot_brain_decision', + schema: schema() } - }; + }); - if (Object.keys(provider).length > 0) { - body.provider = provider; - } - - try { - const response = await fetch(OPENROUTER_URL, { - method: 'POST', - headers: { - Authorization: `Bearer ${cfg.apiKey}`, - 'Content-Type': 'application/json', - 'HTTP-Referer': 'http://localhost', - 'X-OpenRouter-Title': 'L2Node Bots' - }, - body: JSON.stringify(body), - signal: controller.signal - }); - - if (!response.ok) { - const detail = await response.text().catch(() => ''); - utils.infoWarn('BotBrain', 'OpenRouter request failed: %d %s', response.status, detail.slice(0, 180)); - return null; - } - - const json = await response.json(); - const content = json.choices?.[0]?.message?.content; - if (!content) return null; + if (!result.ok) return result; + return { + ...result.data, + usage: result.usage, + llmTelemetry: result.telemetry + }; +} - const decision = JSON.parse(content); - decision.usage = json.usage || null; - return decision; - } catch (err) { - if (err.name !== 'AbortError') { - utils.infoWarn('BotBrain', 'OpenRouter error: %s', err.message); - } - return null; - } finally { - clearTimeout(timeout); - } +function recordConversationReply(session, decision, result, requestContext) { + const turn = requestContext?.conversationTurn; + const action = decision?.action; + if (!turn || !result?.applied || !decision?.reply || ['none', 'buff_target', 'heal_target'].includes(action)) return; + const BotChatText = invoke('GameServer/Bot/AI/BotChatText'); + const reply = BotChatText.normalize(decision.reply) + .slice(0, BotChatText.DEFAULT_LINE_LIMIT * BotChatText.DEFAULT_MAX_LINES); + if (!reply) return; + + invoke('GameServer/Bot/AI/BotConversationService').recordBotReply({ + playerSession: requestContext.playerSession, + botSession: session, + turnId: turn.turnId, + channel: turn.channel, + text: reply, + requestId: requestContext.requestId, + meta: { action, reason: result.reason || null } + }).catch(() => {}); } function applyDecision(session, decision, visiblePlayers) { diff --git a/src/GameServer/Bot/AI/BotRemoteChat.js b/src/GameServer/Bot/AI/BotRemoteChat.js index 53d42675..6ce86db2 100644 --- a/src/GameServer/Bot/AI/BotRemoteChat.js +++ b/src/GameServer/Bot/AI/BotRemoteChat.js @@ -2,33 +2,12 @@ const BotAvailability = invoke('GameServer/Bot/AI/BotAvailability'); const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); const LifeEvents = invoke('GameServer/Bot/Population/BotLifeEvents'); const BotPersona = invoke('GameServer/Bot/AI/BotPersona'); +const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); -const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; const cooldowns = new Map(); -function bool(value, fallback = false) { - if (value === undefined || value === null || value === '') return fallback; - if (typeof value === 'boolean') return value; - return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase()); -} - -function num(value, fallback) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : fallback; -} - function config() { - const optn = options.default.OpenRouter || {}; - return { - enabled: bool(optn.enabled, false), - apiKey: process.env.OPENROUTER_API_KEY || optn.apiKey || '', - model: process.env.OPENROUTER_MODEL || optn.model || 'google/gemini-2.5-flash-lite', - temperature: num(optn.temperature, 0.35), - maxTokens: num(optn.maxTokens, 120), - timeoutMs: num(optn.timeoutMs, 3500), - remoteChatCooldownMs: num(optn.remoteChatCooldownMs, 10000), - debug: bool(optn.debug, false) - }; + return OpenRouterGateway.config({ maxTokens: 120 }); } function playerSummary(playerSession) { @@ -165,70 +144,44 @@ function systemPrompt() { ].join(' '); } -async function requestLlmReply(payload, cfg) { - if (typeof fetch !== 'function') return null; - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), cfg.timeoutMs); - - try { - const response = await fetch(OPENROUTER_URL, { - method: 'POST', - headers: { - Authorization: `Bearer ${cfg.apiKey}`, - 'Content-Type': 'application/json', - 'HTTP-Referer': 'http://localhost', - 'X-OpenRouter-Title': 'L2Node Bots' - }, - body: JSON.stringify({ - model: cfg.model, - messages: [ - { role: 'system', content: systemPrompt() }, - { role: 'user', content: JSON.stringify(payload) } - ], - temperature: cfg.temperature, - max_tokens: cfg.maxTokens, - response_format: { - type: 'json_schema', - json_schema: { - name: 'bot_remote_chat', - strict: true, - schema: schema() - } - } - }), - signal: controller.signal - }); - - if (!response.ok) { - const detail = await response.text().catch(() => ''); - utils.infoWarn('BotRemoteChat', 'OpenRouter request failed: %d %s', response.status, detail.slice(0, 180)); - return null; +async function requestLlmReply(payload, cfg, sessionId) { + const result = await OpenRouterGateway.request({ + config: cfg, + circuitKey: 'cold', + requestId: `cold-${sessionId || 'bot'}-${Date.now()}`, + sessionId, + messages: [ + { role: 'system', content: systemPrompt() }, + { role: 'user', content: JSON.stringify(payload) } + ], + responseSchema: { + name: 'bot_remote_chat', + schema: schema() } - - const json = await response.json(); - const content = json.choices?.[0]?.message?.content; - if (!content) return null; - - const parsed = JSON.parse(content); - const BotChatText = invoke('GameServer/Bot/AI/BotChatText'); - const reply = BotChatText.normalize(parsed.reply).slice(0, BotChatText.DEFAULT_LINE_LIMIT * BotChatText.DEFAULT_MAX_LINES); - if (!reply || Number(parsed.confidence || 0) < 0.35) return null; - + }); + if (!result.ok) { return { - reply, - intent: parsed.intent || 'none', - reason: parsed.reason || 'llm', - llm: true + providerFailure: true, + providerOutcome: result.reason, + usage: result.usage, + llmTelemetry: result.telemetry }; - } catch (err) { - if (err.name !== 'AbortError') { - utils.infoWarn('BotRemoteChat', 'OpenRouter error: %s', err.message); - } - return null; - } finally { - clearTimeout(timeout); } + if (!result.data) return null; + + const parsed = result.data; + const BotChatText = invoke('GameServer/Bot/AI/BotChatText'); + const reply = BotChatText.normalize(parsed.reply).slice(0, BotChatText.DEFAULT_LINE_LIMIT * BotChatText.DEFAULT_MAX_LINES); + if (!reply || Number(parsed.confidence || 0) < 0.35) return null; + + return { + reply, + intent: parsed.intent || 'none', + reason: parsed.reason || 'llm', + llm: true, + usage: result.usage, + llmTelemetry: result.telemetry + }; } const BotRemoteChat = { @@ -289,7 +242,19 @@ const BotRemoteChat = { const llmReady = cfg.enabled && !!cfg.apiKey; if (!llmReady) return fallback; - return requestLlmReply(payload, cfg).then((result) => result || fallback); + const sessionId = `cold-bot:${state.characterId}:player:${playerSession.actor.fetchId()}`; + return requestLlmReply(payload, cfg, sessionId).then((result) => { + if (result?.providerFailure) { + state.lastRemoteChatTelemetry = result.llmTelemetry || null; + return { + ...fallback, + providerOutcome: result.providerOutcome, + usage: result.usage || null, + llmTelemetry: result.llmTelemetry || null + }; + } + return result || fallback; + }); }).then((result) => { BotSocialMemory.recordEvent(playerSession, state, 'chat', result.reason || 'remote_chat'); return result; diff --git a/src/GameServer/Bot/AI/OpenRouterGateway.js b/src/GameServer/Bot/AI/OpenRouterGateway.js new file mode 100644 index 00000000..dc1abfbe --- /dev/null +++ b/src/GameServer/Bot/AI/OpenRouterGateway.js @@ -0,0 +1,363 @@ +const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; + +const DEFAULTS = Object.freeze({ + enabled: false, + apiKey: '', + model: 'google/gemini-2.5-flash-lite', + temperature: 0.35, + maxTokens: 160, + timeoutMs: 3500, + cooldownMs: 45000, + chatCooldownMs: 12000, + remoteChatCooldownMs: 10000, + visibilityRadius: 6000, + maxPromptPrice: 0, + maxCompletionPrice: 0, + usageInclude: true, + requireProviderParameters: true, + circuitBreakerFailureThreshold: 3, + circuitBreakerOpenMs: 30000, + debug: false +}); + +let transport = null; +let requestSequence = 0; +const circuits = new Map(); + +const metrics = { + total: 0, + success: 0, + fallback: 0, + timeout: 0, + providerError: 0, + schemaError: 0, + disabled: 0, + missingApiKey: 0, + circuitOpen: 0, + totalLatencyMs: 0, + last: null +}; + +function bool(value, fallback = false) { + if (value === undefined || value === null || value === '') return fallback; + if (typeof value === 'boolean') return value; + return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase()); +} + +function num(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function config(overrides = {}) { + const optn = options.default.OpenRouter || {}; + const source = { + ...DEFAULTS, + enabled: bool(optn.enabled, DEFAULTS.enabled), + apiKey: process.env.OPENROUTER_API_KEY || optn.apiKey || DEFAULTS.apiKey, + model: process.env.OPENROUTER_MODEL || optn.model || DEFAULTS.model, + temperature: num(optn.temperature, DEFAULTS.temperature), + maxTokens: num(optn.maxTokens, DEFAULTS.maxTokens), + timeoutMs: num(optn.timeoutMs, DEFAULTS.timeoutMs), + cooldownMs: num(optn.cooldownMs, DEFAULTS.cooldownMs), + chatCooldownMs: num(optn.chatCooldownMs, DEFAULTS.chatCooldownMs), + remoteChatCooldownMs: num(optn.remoteChatCooldownMs, DEFAULTS.remoteChatCooldownMs), + visibilityRadius: num(optn.visibilityRadius, DEFAULTS.visibilityRadius), + maxPromptPrice: num(optn.maxPromptPrice, DEFAULTS.maxPromptPrice), + maxCompletionPrice: num(optn.maxCompletionPrice, DEFAULTS.maxCompletionPrice), + usageInclude: bool(optn.usageInclude, DEFAULTS.usageInclude), + requireProviderParameters: bool(optn.requireProviderParameters, DEFAULTS.requireProviderParameters), + circuitBreakerFailureThreshold: num( + optn.circuitBreakerFailureThreshold, + DEFAULTS.circuitBreakerFailureThreshold + ), + circuitBreakerOpenMs: num(optn.circuitBreakerOpenMs, DEFAULTS.circuitBreakerOpenMs), + debug: bool(optn.debug, DEFAULTS.debug) + }; + + return { + ...source, + ...overrides, + enabled: bool(overrides.enabled, source.enabled), + apiKey: overrides.apiKey !== undefined ? String(overrides.apiKey || '') : source.apiKey, + model: overrides.model || source.model, + temperature: num(overrides.temperature, source.temperature), + maxTokens: num(overrides.maxTokens, source.maxTokens), + timeoutMs: num(overrides.timeoutMs, source.timeoutMs), + maxPromptPrice: num(overrides.maxPromptPrice, source.maxPromptPrice), + maxCompletionPrice: num(overrides.maxCompletionPrice, source.maxCompletionPrice), + usageInclude: bool(overrides.usageInclude, source.usageInclude), + requireProviderParameters: bool( + overrides.requireProviderParameters, + source.requireProviderParameters + ), + circuitBreakerFailureThreshold: Math.max( + 1, + num(overrides.circuitBreakerFailureThreshold, source.circuitBreakerFailureThreshold) + ), + circuitBreakerOpenMs: Math.max(0, num(overrides.circuitBreakerOpenMs, source.circuitBreakerOpenMs)), + debug: bool(overrides.debug, source.debug) + }; +} + +function requestId(value) { + if (value) return String(value).slice(0, 128); + requestSequence += 1; + return `or-${Date.now()}-${requestSequence}`; +} + +function sessionId(value) { + if (!value) return null; + return String(value).slice(0, 256); +} + +function providerOptions(cfg, extra = {}) { + const provider = { ...extra }; + if (cfg.requireProviderParameters) provider.require_parameters = true; + + if (cfg.maxPromptPrice > 0 || cfg.maxCompletionPrice > 0) { + provider.max_price = { + ...(provider.max_price || {}) + }; + if (cfg.maxPromptPrice > 0) provider.max_price.prompt = cfg.maxPromptPrice; + if (cfg.maxCompletionPrice > 0) provider.max_price.completion = cfg.maxCompletionPrice; + } + + return Object.keys(provider).length > 0 ? provider : null; +} + +function circuitState(key = 'default') { + if (!circuits.has(key)) circuits.set(key, { failureStreak: 0, openedAt: 0 }); + return circuits.get(key); +} + +function circuitIsOpen(cfg, key = 'default', now = Date.now()) { + const state = circuitState(key); + if (!state.openedAt) return false; + if (now - state.openedAt >= cfg.circuitBreakerOpenMs) { + state.openedAt = 0; + state.failureStreak = 0; + return false; + } + return true; +} + +function recordMetric(outcome, latencyMs, meta = {}) { + metrics.total += 1; + if (outcome === 'success') metrics.success += 1; + else metrics.fallback += 1; + if (outcome === 'timeout') metrics.timeout += 1; + if (outcome === 'provider_error') metrics.providerError += 1; + if (outcome === 'schema_error') metrics.schemaError += 1; + if (outcome === 'disabled') metrics.disabled += 1; + if (outcome === 'missing_api_key') metrics.missingApiKey += 1; + if (outcome === 'circuit_open') metrics.circuitOpen += 1; + metrics.totalLatencyMs += Number(latencyMs || 0); + metrics.last = { + outcome, + latencyMs: Number(latencyMs || 0), + requestId: meta.requestId || null, + model: meta.model || null, + at: Date.now() + }; +} + +function telemetry(request, cfg, outcome, startedAt, extra = {}) { + return { + requestId: request.requestId, + sessionId: request.sessionId || null, + circuitKey: request.circuitKey, + model: cfg.model, + outcome, + latencyMs: Date.now() - startedAt, + status: extra.status || null, + usage: extra.usage || null + }; +} + +function complete(request, cfg, outcome, startedAt, extra = {}) { + const meta = telemetry(request, cfg, outcome, startedAt, extra); + recordMetric(outcome, meta.latencyMs, meta); + if (typeof request.onTelemetry === 'function') { + try { + request.onTelemetry(meta); + } catch (err) { + if (cfg.debug) utils.infoWarn('OpenRouter', 'telemetry callback failed: %s', err.message); + } + } + + return { + ok: outcome === 'success', + reason: outcome, + data: extra.data || null, + usage: extra.usage || null, + telemetry: meta, + status: extra.status || null + }; +} + +function markFailure(cfg, key = 'default') { + const state = circuitState(key); + state.failureStreak += 1; + if (state.failureStreak >= cfg.circuitBreakerFailureThreshold) { + state.openedAt = Date.now(); + } +} + +function markSuccess(key = 'default') { + const state = circuitState(key); + state.failureStreak = 0; + state.openedAt = 0; +} + +function normalizeUsage(usage) { + if (!usage || typeof usage !== 'object') return null; + return { + promptTokens: Number(usage.prompt_tokens || 0), + completionTokens: Number(usage.completion_tokens || 0), + totalTokens: Number(usage.total_tokens || 0), + cachedPromptTokens: Number(usage.prompt_tokens_details?.cached_tokens || 0), + cacheWriteTokens: Number(usage.prompt_tokens_details?.cache_write_tokens || 0), + cost: Number.isFinite(Number(usage.cost)) ? Number(usage.cost) : null + }; +} + +function parseContent(content) { + if (content && typeof content === 'object') return content; + if (typeof content !== 'string' || !content.trim()) return null; + return JSON.parse(content); +} + +async function request(spec = {}) { + const cfg = config(spec.config || {}); + const requestData = { + ...spec, + requestId: requestId(spec.requestId), + sessionId: sessionId(spec.sessionId), + circuitKey: String(spec.circuitKey || 'default').slice(0, 64) + }; + const startedAt = Date.now(); + + if (!cfg.enabled) return complete(requestData, cfg, 'disabled', startedAt); + if (!cfg.apiKey) return complete(requestData, cfg, 'missing_api_key', startedAt); + if (circuitIsOpen(cfg, requestData.circuitKey)) return complete(requestData, cfg, 'circuit_open', startedAt); + + const fetcher = transport || global.fetch; + if (typeof fetcher !== 'function') return complete(requestData, cfg, 'provider_error', startedAt); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), Math.max(1, cfg.timeoutMs)); + const body = { + model: cfg.model, + messages: Array.isArray(requestData.messages) ? requestData.messages : [], + temperature: cfg.temperature, + max_tokens: cfg.maxTokens + }; + + if (requestData.responseSchema) { + body.response_format = { + type: 'json_schema', + json_schema: { + name: requestData.responseSchema.name, + strict: true, + schema: requestData.responseSchema.schema + } + }; + } + + if (requestData.sessionId) body.session_id = requestData.sessionId; + if (cfg.usageInclude) body.usage = { include: true }; + + const provider = providerOptions(cfg, requestData.provider); + if (provider) body.provider = provider; + + try { + const response = await fetcher(requestData.url || OPENROUTER_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${cfg.apiKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': requestData.referer || 'http://localhost', + 'X-OpenRouter-Title': requestData.title || 'L2Node Bots' + }, + body: JSON.stringify(body), + signal: controller.signal + }); + + if (!response?.ok) { + markFailure(cfg, requestData.circuitKey); + return complete(requestData, cfg, 'provider_error', startedAt, { + status: Number(response?.status || 0) || null + }); + } + + const json = await response.json(); + const content = json.choices?.[0]?.message?.content; + let data; + try { + data = parseContent(content); + } catch (_) { + markFailure(cfg, requestData.circuitKey); + return complete(requestData, cfg, 'schema_error', startedAt, { + usage: normalizeUsage(json.usage) + }); + } + + if (!data) { + markFailure(cfg, requestData.circuitKey); + return complete(requestData, cfg, 'schema_error', startedAt, { + usage: normalizeUsage(json.usage) + }); + } + + markSuccess(requestData.circuitKey); + return complete(requestData, cfg, 'success', startedAt, { + data, + usage: normalizeUsage(json.usage) + }); + } catch (err) { + const outcome = err?.name === 'AbortError' ? 'timeout' : 'provider_error'; + markFailure(cfg, requestData.circuitKey); + return complete(requestData, cfg, outcome, startedAt); + } finally { + clearTimeout(timeout); + } +} + +const OpenRouterGateway = { + OPENROUTER_URL, + DEFAULTS, + config, + request, + + setTransport(nextTransport) { + transport = typeof nextTransport === 'function' ? nextTransport : null; + }, + + resetTransport() { + transport = null; + }, + + resetCircuit() { + circuits.clear(); + }, + + metrics() { + return { + ...metrics, + averageLatencyMs: metrics.total > 0 ? metrics.totalLatencyMs / metrics.total : 0, + circuits: Object.fromEntries([...circuits.entries()].map(([key, state]) => [key, { ...state }])), + failureStreak: [...circuits.values()].reduce((sum, state) => sum + state.failureStreak, 0), + circuitOpenedAt: [...circuits.values()].find((state) => state.openedAt)?.openedAt || 0 + }; + }, + + resetMetrics() { + Object.keys(metrics).forEach((key) => { + if (key === 'last') metrics[key] = null; + else metrics[key] = 0; + }); + } +}; + +module.exports = OpenRouterGateway; diff --git a/tests/test_bot_agent_support_confirmation.js b/tests/test_bot_agent_support_confirmation.js new file mode 100644 index 00000000..7bd9ddc6 --- /dev/null +++ b/tests/test_bot_agent_support_confirmation.js @@ -0,0 +1,99 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); +const BotPartyChat = invoke('GameServer/Bot/AI/BotPartyChat'); +const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); +const BotSkillCapabilities = invoke('GameServer/Bot/AI/BotSkillCapabilities'); +const BotAI = invoke('GameServer/Bot/BotAI'); +const World = invoke('GameServer/World/World'); +const Generics = invoke(path.actor); + +function actor(id, name, x = 0, y = 0) { + return { + fetchId: () => id, + fetchName: () => name, + fetchIsOnline: () => true, + fetchLocX: () => x, + fetchLocY: () => y, + fetchLocZ: () => 0, + fetchMp: () => 100, + fetchClassId: () => 17 + }; +} + +const originalWorldUser = World.user; +const originalSkillExec = Generics.skillExec; +const originalCanBuff = BotRoles.canBuff; +const originalIsHealer = BotRoles.isHealer; +const originalBuffSkill = BotSkillCapabilities.buffSkill; +const originalHealSkill = BotSkillCapabilities.healSkill; +const originalTell = BotAI.tell; +const messages = []; + +try { + const bot = actor(10, 'Aria', 0, 0); + const target = actor(20, 'Slava', 100, 0); + const targetSession = { actor: target, accountId: 'player_slava' }; + const botSession = { actor: bot, accountId: 'bot_aria', plan: 'following' }; + World.user = { sessions: [targetSession] }; + Generics.skillExec = () => {}; + BotAI.tell = (_session, _targetSession, text) => { + messages.push(text); + return true; + }; + + const buffSkill = { + fetchSelfId: () => buffSkill.id, + fetchConsumedMp: () => 10, + fetchName: () => 'Might', + id: 1068 + }; + BotRoles.canBuff = () => true; + BotSkillCapabilities.buffSkill = () => buffSkill; + + const buff = BotAgentTools.execute(botSession, { + action: 'buff_target', + targetPlayerName: 'Slava', + buffType: 'might', + confidence: 0.95, + reply: 'I will buff you.' + }, [{ id: 20, name: 'Slava' }]); + assert.strictEqual(buff.applied, true); + assert.strictEqual(buff.reason, 'buff_requested:might'); + assert.strictEqual(messages.length, 0, 'a buff request must not speak before native effect confirmation'); + assert.strictEqual(botSession.pendingPartyChatResult.skillId, 1068); + + botSession.pendingPartyChatResult = undefined; + const healSkill = { + fetchSelfId: () => healSkill.id, + fetchConsumedMp: () => 10, + fetchName: () => 'Heal', + id: 1011 + }; + BotRoles.isHealer = () => true; + BotSkillCapabilities.healSkill = () => healSkill; + + const heal = BotAgentTools.execute(botSession, { + action: 'heal_target', + targetPlayerName: 'Slava', + confidence: 0.95, + reply: 'Healing you.' + }, [{ id: 20, name: 'Slava' }]); + assert.strictEqual(heal.applied, true); + assert.strictEqual(heal.reason, 'heal_requested'); + assert.strictEqual(messages.length, 0, 'a heal request must not speak before native effect confirmation'); + assert.strictEqual(botSession.pendingPartyChatResult.skillId, 1011); + + console.log('Bot agent support confirmation checks passed'); +} finally { + World.user = originalWorldUser; + Generics.skillExec = originalSkillExec; + BotRoles.canBuff = originalCanBuff; + BotRoles.isHealer = originalIsHealer; + BotSkillCapabilities.buffSkill = originalBuffSkill; + BotSkillCapabilities.healSkill = originalHealSkill; + BotAI.tell = originalTell; + BotPartyChat.cancelExpectedSkillResult({ pendingPartyChatResult: undefined }); +} diff --git a/tests/test_bot_party_chat.js b/tests/test_bot_party_chat.js index 3c571f5d..94ff1883 100644 --- a/tests/test_bot_party_chat.js +++ b/tests/test_bot_party_chat.js @@ -79,12 +79,15 @@ try { fetchSelfId: () => 1068, fetchName: () => 'Might' }; + const messagesBeforeSupportRequest = messages.length; assert.strictEqual(BotPartyChat.expectSkillResult(companionSession, { target, targetSession, skill, kind: 'support' }), true, 'a requested support cast should wait for a native result'); + assert.strictEqual(messages.length, messagesBeforeSupportRequest, + 'registering a support cast must not announce success before the native effect exists'); assert.strictEqual(BotPartyChat.confirmSkillResult(companionSession, companionSession.actor, target, skill, { effect: { key: 'might' } }), true, 'only a landed effect may confirm the requested buff'); diff --git a/tests/test_openrouter_gateway.js b/tests/test_openrouter_gateway.js new file mode 100644 index 00000000..5806340d --- /dev/null +++ b/tests/test_openrouter_gateway.js @@ -0,0 +1,177 @@ +const assert = require('assert'); + +require('../src/Global'); + +const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); + +function response(body, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body + }; +} + +const baseConfig = { + enabled: true, + apiKey: 'test-key', + model: 'test/model', + timeoutMs: 1000, + circuitBreakerFailureThreshold: 3, + circuitBreakerOpenMs: 5000 +}; + +const schema = { + name: 'gateway_test', + schema: { + type: 'object', + properties: { reply: { type: 'string' } }, + required: ['reply'], + additionalProperties: false + } +}; + +async function main() { + try { + OpenRouterGateway.resetMetrics(); + OpenRouterGateway.resetCircuit(); + + let captured; + OpenRouterGateway.setTransport(async (_url, init) => { + captured = { + headers: init.headers, + body: JSON.parse(init.body) + }; + return response({ + choices: [{ message: { content: JSON.stringify({ reply: 'hello' }) } }], + usage: { + prompt_tokens: 10, + completion_tokens: 3, + total_tokens: 13, + prompt_tokens_details: { cached_tokens: 4, cache_write_tokens: 5 }, + cost: 0.01 + } + }); + }); + + const success = await OpenRouterGateway.request({ + config: baseConfig, + requestId: 'gateway-success', + sessionId: 'hot-bot:1:player:2', + messages: [{ role: 'user', content: 'hello' }], + responseSchema: schema + }); + + assert.strictEqual(success.ok, true); + assert.deepStrictEqual(success.data, { reply: 'hello' }); + assert.deepStrictEqual(success.usage, { + promptTokens: 10, + completionTokens: 3, + totalTokens: 13, + cachedPromptTokens: 4, + cacheWriteTokens: 5, + cost: 0.01 + }); + assert.strictEqual(success.telemetry.requestId, 'gateway-success'); + assert.strictEqual(success.telemetry.sessionId, 'hot-bot:1:player:2'); + assert.strictEqual(captured.headers.Authorization, 'Bearer test-key'); + assert.strictEqual(captured.body.session_id, 'hot-bot:1:player:2'); + assert.deepStrictEqual(captured.body.usage, { include: true }); + assert.strictEqual(captured.body.provider.require_parameters, true); + assert.strictEqual(captured.body.response_format.type, 'json_schema'); + assert.strictEqual(captured.body.response_format.json_schema.strict, true); + assert.deepStrictEqual(captured.body.response_format.json_schema.schema, schema.schema); + + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.setTransport(async () => response({ error: { message: 'unavailable' } }, 503)); + for (let index = 0; index < 3; index += 1) { + const failed = await OpenRouterGateway.request({ config: baseConfig, requestId: `failure-${index}` }); + assert.strictEqual(failed.ok, false); + assert.strictEqual(failed.reason, 'provider_error'); + } + const circuitOpen = await OpenRouterGateway.request({ config: baseConfig, requestId: 'failure-circuit' }); + assert.strictEqual(circuitOpen.reason, 'circuit_open'); + + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.setTransport(async () => response({ error: { message: 'hot unavailable' } }, 503)); + for (let index = 0; index < 3; index += 1) { + await OpenRouterGateway.request({ + config: { ...baseConfig, circuitBreakerFailureThreshold: 3 }, + circuitKey: 'hot', + requestId: `hot-failure-${index}`, + messages: [] + }); + } + OpenRouterGateway.setTransport(async () => response({ + choices: [{ message: { content: JSON.stringify({ reply: 'cold is still available' }) } }] + })); + const coldScope = await OpenRouterGateway.request({ + config: baseConfig, + circuitKey: 'cold', + requestId: 'cold-scope', + messages: [] + }); + assert.strictEqual(coldScope.ok, true, 'hot failures must not change cold chat circuit behavior'); + + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.setTransport(async () => response({ + choices: [{ message: { content: JSON.stringify({ reply: 'no usage' }) } }] + })); + const noUsage = await OpenRouterGateway.request({ config: baseConfig, requestId: 'missing-usage' }); + assert.strictEqual(noUsage.ok, true); + assert.strictEqual(noUsage.usage, null, 'missing provider usage must remain an explicit null'); + assert.strictEqual(noUsage.telemetry.usage, null); + + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.setTransport((_url, init) => new Promise((_resolve, reject) => { + init.signal.addEventListener('abort', () => { + const error = new Error('aborted by timeout'); + error.name = 'AbortError'; + reject(error); + }, { once: true }); + })); + const timedOut = await OpenRouterGateway.request({ + config: { ...baseConfig, timeoutMs: 5, circuitBreakerFailureThreshold: 10 }, + requestId: 'timeout' + }); + assert.strictEqual(timedOut.reason, 'timeout'); + + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.setTransport(async () => response({ + choices: [{ message: { content: '{not-json' } }] + })); + const invalid = await OpenRouterGateway.request({ config: baseConfig, requestId: 'schema-error' }); + assert.strictEqual(invalid.reason, 'schema_error'); + + OpenRouterGateway.setTransport(async () => { + throw new Error('transport should not be called'); + }); + const disabled = await OpenRouterGateway.request({ + config: { ...baseConfig, enabled: false }, + requestId: 'disabled' + }); + assert.strictEqual(disabled.reason, 'disabled'); + const missingKey = await OpenRouterGateway.request({ + config: { ...baseConfig, apiKey: '' }, + requestId: 'missing-key' + }); + assert.strictEqual(missingKey.reason, 'missing_api_key'); + + const metrics = OpenRouterGateway.metrics(); + assert.ok(metrics.success >= 1); + assert.ok(metrics.timeout >= 1); + assert.ok(metrics.providerError >= 3); + assert.ok(metrics.schemaError >= 1); + assert.ok(metrics.circuitOpen >= 1); + + console.log('OpenRouter gateway checks passed'); + } finally { + OpenRouterGateway.resetTransport(); + OpenRouterGateway.resetCircuit(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); From a53695060ce08d3eee5454c2074fb4f8f7c82914 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:02:54 -0400 Subject: [PATCH 02/59] Persist hot bot conversations and arbitrate dialogue --- database/sql/sqlite.sql | 46 +++ scripts/run-tests.js | 5 + src/Database.js | 23 ++ src/GameServer/Bot/AI/BotBrain.js | 101 +++++- .../Bot/AI/BotConversationService.js | 131 +++++++ src/GameServer/Bot/AI/BotConversationStore.js | 341 ++++++++++++++++++ src/GameServer/Bot/AI/BotDialogueArbiter.js | 89 +++++ src/GameServer/Bot/BotManager.js | 13 +- src/GameServer/World/World.js | 19 +- tests/test_bot_conversation_store.js | 129 +++++++ tests/test_bot_dialogue_arbiter.js | 145 ++++++++ tests/test_hot_bot_conversation_flow.js | 131 +++++++ 12 files changed, 1153 insertions(+), 20 deletions(-) create mode 100644 src/GameServer/Bot/AI/BotConversationService.js create mode 100644 src/GameServer/Bot/AI/BotConversationStore.js create mode 100644 src/GameServer/Bot/AI/BotDialogueArbiter.js create mode 100644 tests/test_bot_conversation_store.js create mode 100644 tests/test_bot_dialogue_arbiter.js create mode 100644 tests/test_hot_bot_conversation_flow.js diff --git a/database/sql/sqlite.sql b/database/sql/sqlite.sql index 12d0faa1..83a870b4 100644 --- a/database/sql/sqlite.sql +++ b/database/sql/sqlite.sql @@ -239,6 +239,52 @@ CREATE TABLE IF NOT EXISTS bot_social_memory ( PRIMARY KEY(playerId, botId) ); +CREATE TABLE IF NOT EXISTS bot_conversations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playerId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + botId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + summary TEXT NOT NULL DEFAULT '', + summaryThroughId INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL DEFAULT 0, + createdAt INTEGER NOT NULL DEFAULT 0, + updatedAt INTEGER NOT NULL DEFAULT 0, + UNIQUE(playerId, botId) +); +CREATE INDEX IF NOT EXISTS bot_conversations_bot_updated ON bot_conversations(botId, updatedAt DESC); + +CREATE TABLE IF NOT EXISTS bot_conversation_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversationId INTEGER NOT NULL REFERENCES bot_conversations(id) ON DELETE CASCADE, + turnId TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('player', 'bot', 'system')), + channel TEXT NOT NULL DEFAULT 'local', + text TEXT NOT NULL DEFAULT '', + requestId TEXT, + delivered INTEGER NOT NULL DEFAULT 1, + createdAt INTEGER NOT NULL DEFAULT 0, + metaJson TEXT, + UNIQUE(conversationId, turnId, role) +); +CREATE INDEX IF NOT EXISTS bot_conversation_messages_recent ON bot_conversation_messages(conversationId, id DESC); +CREATE INDEX IF NOT EXISTS bot_conversation_messages_turn ON bot_conversation_messages(conversationId, turnId, role); + +CREATE TABLE IF NOT EXISTS bot_activity_journal ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playerId INTEGER REFERENCES characters(id) ON DELETE CASCADE, + botId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + eventType TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + weight INTEGER NOT NULL DEFAULT 1, + dedupeKey TEXT, + count INTEGER NOT NULL DEFAULT 1, + createdAt INTEGER NOT NULL DEFAULT 0, + updatedAt INTEGER NOT NULL DEFAULT 0, + metaJson TEXT +); +CREATE INDEX IF NOT EXISTS bot_activity_journal_pair_recent ON bot_activity_journal(playerId, botId, updatedAt DESC); +CREATE INDEX IF NOT EXISTS bot_activity_journal_bot_recent ON bot_activity_journal(botId, updatedAt DESC); +CREATE INDEX IF NOT EXISTS bot_activity_journal_coalesce ON bot_activity_journal(playerId, botId, eventType, dedupeKey, updatedAt); + CREATE TABLE IF NOT EXISTS bot_friendships ( playerId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, botId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 878af3c9..64f3334d 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -6,10 +6,15 @@ const tests = [ 'tests/test_attack_hit_flags.js', 'tests/test_armor_stats.js', 'tests/test_bot_ai_visibility.js', + 'tests/test_openrouter_gateway.js', + 'tests/test_bot_conversation_store.js', + 'tests/test_bot_dialogue_arbiter.js', + 'tests/test_hot_bot_conversation_flow.js', 'tests/test_bot_availability.js', 'tests/test_bot_chat_commands.js', 'tests/test_bot_chat_text.js', 'tests/test_bot_party_chat.js', + 'tests/test_bot_agent_support_confirmation.js', 'tests/test_bot_combat_skill_selection.js', 'tests/test_bot_conversation.js', 'tests/test_bot_death_respawn.js', diff --git a/src/Database.js b/src/Database.js index 7a3fb4f0..1f5b4094 100644 --- a/src/Database.js +++ b/src/Database.js @@ -199,6 +199,29 @@ function applySchemaMigrations() { [2, () => connection.exec(` CREATE UNIQUE INDEX IF NOT EXISTS accounts_username_nocase ON accounts(username COLLATE NOCASE); CREATE INDEX IF NOT EXISTS characters_username_nocase ON characters(username COLLATE NOCASE); + `)], + [3, () => connection.exec(` + CREATE INDEX IF NOT EXISTS bot_conversations_bot_updated ON bot_conversations(botId, updatedAt DESC); + CREATE INDEX IF NOT EXISTS bot_conversation_messages_recent ON bot_conversation_messages(conversationId, id DESC); + CREATE INDEX IF NOT EXISTS bot_conversation_messages_turn ON bot_conversation_messages(conversationId, turnId, role); + `)], + [4, () => connection.exec(` + CREATE TABLE IF NOT EXISTS bot_activity_journal ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playerId INTEGER REFERENCES characters(id) ON DELETE CASCADE, + botId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + eventType TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + weight INTEGER NOT NULL DEFAULT 1, + dedupeKey TEXT, + count INTEGER NOT NULL DEFAULT 1, + createdAt INTEGER NOT NULL DEFAULT 0, + updatedAt INTEGER NOT NULL DEFAULT 0, + metaJson TEXT + ); + CREATE INDEX IF NOT EXISTS bot_activity_journal_pair_recent ON bot_activity_journal(playerId, botId, updatedAt DESC); + CREATE INDEX IF NOT EXISTS bot_activity_journal_bot_recent ON bot_activity_journal(botId, updatedAt DESC); + CREATE INDEX IF NOT EXISTS bot_activity_journal_coalesce ON bot_activity_journal(playerId, botId, eventType, dedupeKey, updatedAt); `)] ]; const applied = new Set(connection.prepare('SELECT version FROM schema_migrations').all().map((row) => Number(row.version))); diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 6b70e1cc..f853094e 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -142,16 +142,18 @@ function systemPrompt() { 'For buff_target and heal_target, choose a visible player and let the server validate class, learned skill, MP, range, and safety.', 'Do not claim that buffs or heals are ready in a plain chat reply. Use buff_target or heal_target; only the validated server action may confirm a cast.', 'The persona describes tone and high-level preferences only. It never overrides safety, current game state, or the allowed actions.', + 'The contextFragments field is bounded and includes recent authoritative events; treat summaries as memory, never as permission to perform an action.', 'Do not offer trading, selling, price negotiation, or private stores; those tools are intentionally unavailable for now.', 'Never invent unavailable actions, players, items, or spells.' ].join(' '); } -function userPayload(event, session, status, visiblePlayers, text) { +function userPayload(event, session, status, visiblePlayers, text, requestContext = null) { + const assembled = requestContext?.assembledContext; return { event, playerMessage: text || '', - bot: BotBrainContext.compactStatus(session, status, text), + bot: assembled?.bot || BotBrainContext.compactStatus(session, status, text), visiblePlayers, candidateSpots: candidateSpots(status), allowedActions: BotAgentTools.ACTIONS, @@ -162,6 +164,9 @@ function userPayload(event, session, status, visiblePlayers, text) { avoidSpam: true, noCombatMicromanagement: true }, + conversation: requestContext?.conversation || null, + contextFragments: assembled?.fragments || null, + contextTelemetry: assembled?.telemetry || null, lastDecision: session.lastBrainDecision || null }; } @@ -215,12 +220,47 @@ function recordConversationReply(session, decision, result, requestContext) { }).catch(() => {}); } -function applyDecision(session, decision, visiblePlayers) { +function applyDecision(session, decision, visiblePlayers, requestContext) { const result = BotAgentTools.execute(session, decision, visiblePlayers); BotAgentTools.remember(session, decision, result, config().model); + recordConversationReply(session, decision, result, requestContext); return result.applied; } +function rememberTelemetry(session, result) { + const telemetry = result?.llmTelemetry || result?.telemetry; + if (!telemetry) return; + + session.lastBrainTelemetry = { + ...telemetry, + usage: result.usage || telemetry.usage || null + }; +} + +function fallbackReply(session, requestContext, outcome) { + const playerSession = requestContext?.playerSession; + if (!playerSession?.actor || !session?.actor) return false; + + const BotManager = invoke('GameServer/Bot/BotManager'); + const plan = session.plan || 'hunting'; + const reply = outcome === 'timeout' + ? 'Give me a moment. I am still sorting things out.' + : `I am ${plan} right now.`; + + BotManager.botTell(session, playerSession, reply); + if (requestContext?.conversationTurn) { + invoke('GameServer/Bot/AI/BotConversationService').recordFallback({ + playerSession, + botSession: session, + turnId: requestContext.conversationTurn.turnId, + channel: requestContext.conversationTurn.channel, + text: reply, + reason: outcome || 'fallback' + }).catch(() => {}); + } + return true; +} + const BotBrain = { isEnabled() { const cfg = config(); @@ -229,7 +269,7 @@ const BotBrain = { visibleRealPlayers, - maybeThink(session, event, status, text = '') { + maybeThink(session, event, status, text = '', requestContext = null) { const cfg = config(); const bot = session.actor; if (!bot) return false; @@ -246,6 +286,16 @@ const BotBrain = { return false; } if (session.brainInFlight) { + if (requestContext?.conversationTurn) { + session.pendingBrainTurn = { + event, + status, + text, + requestContext + }; + debugSkip(session, cfg, 'request_queued'); + return true; + } debugSkip(session, cfg, 'request_in_flight'); return false; } @@ -274,7 +324,7 @@ const BotBrain = { const cooldown = event === 'player_chat' ? cfg.chatCooldownMs : cfg.cooldownMs; const lastAt = event === 'player_chat' ? session.lastBrainChatAt : session.lastBrainThinkAt; - if (lastAt && Date.now() - lastAt < cooldown) { + if (lastAt && requestContext?.queued !== true && Date.now() - lastAt < cooldown) { debugSkip(session, cfg, `cooldown:${event}`); return false; } @@ -291,15 +341,50 @@ const BotBrain = { } session.brainInFlight = true; - const payload = userPayload(event, session, status, visiblePlayers, text); + const payload = userPayload(event, session, status, visiblePlayers, text, requestContext); + if (requestContext?.assembledContext?.telemetry) { + session.lastBrainContextTelemetry = { + ...requestContext.assembledContext.telemetry, + estimatedTokens: requestContext.assembledContext.estimatedTokens, + budget: requestContext.assembledContext.budget, + hardMaxTokens: requestContext.assembledContext.hardMaxTokens, + at: Date.now() + }; + } if (cfg.debug) { utils.infoSuccess('BotBrain', '%s requesting %s decision via %s', bot.fetchName(), event, cfg.model); } - requestDecision(payload, cfg).then((decision) => { - applyDecision(session, decision, visiblePlayers); + requestDecision(payload, cfg, session, requestContext, visiblePlayers).then((result) => { + rememberTelemetry(session, result); + if (result?.ok === false) { + fallbackReply(session, requestContext, result.reason); + return; + } + applyDecision(session, result, visiblePlayers, requestContext); + }).catch((err) => { + utils.infoWarn('BotBrain', 'decision request failed for %s: %s', bot.fetchName(), err.message); + fallbackReply(session, requestContext, 'provider_error'); }).finally(() => { session.brainInFlight = false; + const pending = session.pendingBrainTurn; + session.pendingBrainTurn = null; + if (pending) { + const waitMs = Math.max( + 0, + cfg.chatCooldownMs - (Date.now() - Number(session.lastBrainChatAt || 0)) + ); + setTimeout(() => { + const started = BotBrain.maybeThink( + session, + pending.event, + pending.status, + pending.text, + { ...pending.requestContext, queued: true } + ); + if (!started) fallbackReply(session, pending.requestContext, 'queued_not_started'); + }, waitMs); + } }); return true; diff --git a/src/GameServer/Bot/AI/BotConversationService.js b/src/GameServer/Bot/AI/BotConversationService.js new file mode 100644 index 00000000..7146006e --- /dev/null +++ b/src/GameServer/Bot/AI/BotConversationService.js @@ -0,0 +1,131 @@ +const BotConversationStore = invoke('GameServer/Bot/AI/BotConversationStore'); + +const DEFAULT_RECENT_TURNS = BotConversationStore.DEFAULT_RECENT_TURNS; +let turnSequence = 0; + +function actorId(session) { + return Number(session?.actor?.fetchId?.() || session?.characterId || 0); +} + +function normalizeChannel(value) { + return String(value || 'local').toLowerCase().replace(/[^a-z0-9_:-]/g, '_').slice(0, 32) || 'local'; +} + +function normalizeText(value) { + return String(value || '').replace(/\s+/g, ' ').trim().slice(0, BotConversationStore.MAX_TEXT_CHARS); +} + +function validPair(playerSession, botSession) { + return !!( + playerSession?.actor && + botSession?.actor && + actorId(playerSession) > 0 && + actorId(botSession) > 0 && + !String(playerSession.accountId || '').startsWith('bot_') && + String(botSession.accountId || '').startsWith('bot_') + ); +} + +function nextTurnId(input = {}) { + if (input.turnId) return String(input.turnId).slice(0, 128); + turnSequence += 1; + return `${normalizeChannel(input.channel)}:${actorId(input.playerSession)}:${actorId(input.botSession)}:${Date.now()}:${turnSequence}`; +} + +function contextView(context) { + return { + summary: context?.summary || null, + recentTurns: (context?.recentTurns || []).map((turn) => ({ + turnId: turn.turnId, + role: turn.role, + channel: turn.channel, + text: turn.text, + createdAt: turn.createdAt + })), + version: Number(context?.version || 0) + }; +} + +function beginTurn(input = {}) { + if (!validPair(input.playerSession, input.botSession)) { + return Promise.reject(new Error('invalid hot dialogue pair')); + } + + const playerId = actorId(input.playerSession); + const botId = actorId(input.botSession); + const channel = normalizeChannel(input.channel); + const turnId = nextTurnId(input); + const playerText = normalizeText(input.text); + if (!playerText) return Promise.reject(new Error('empty hot dialogue text')); + + return BotConversationStore.appendTurn({ + playerId, + botId, + turnId, + role: 'player', + channel, + text: playerText, + requestId: input.requestId, + meta: { + source: input.source || channel, + playerName: input.playerSession.actor.fetchName?.() || null, + botName: input.botSession.actor.fetchName?.() || null + } + }).then((stored) => BotConversationStore.context(playerId, botId, { + limit: input.recentTurns || DEFAULT_RECENT_TURNS + }).then((context) => ({ + playerId, + botId, + turnId, + channel, + playerText, + inserted: stored.inserted, + conversation: context.conversation, + context: contextView(context) + }))); +} + +function recordBotReply(input = {}) { + if (!validPair(input.playerSession, input.botSession)) return Promise.resolve(false); + const text = normalizeText(input.text); + if (!text || !input.turnId) return Promise.resolve(false); + + return BotConversationStore.appendTurn({ + playerId: actorId(input.playerSession), + botId: actorId(input.botSession), + turnId: input.turnId, + role: 'bot', + channel: normalizeChannel(input.channel), + text, + requestId: input.requestId, + delivered: input.delivered !== false, + meta: input.meta || null + }).then(() => true).catch(() => false); +} + +function recordFallback(input = {}) { + return recordBotReply({ + ...input, + meta: { ...(input.meta || {}), fallback: true, reason: input.reason || 'fallback' } + }); +} + +function contextFor(playerSession, botSession, options = {}) { + if (!validPair(playerSession, botSession)) return Promise.reject(new Error('invalid hot dialogue pair')); + return BotConversationStore.context(actorId(playerSession), actorId(botSession), options) + .then(contextView); +} + +const BotConversationService = { + DEFAULT_RECENT_TURNS, + validPair, + beginTurn, + recordBotReply, + recordFallback, + contextFor, + resetSequence() { + turnSequence = 0; + } +}; + +module.exports = BotConversationService; diff --git a/src/GameServer/Bot/AI/BotConversationStore.js b/src/GameServer/Bot/AI/BotConversationStore.js new file mode 100644 index 00000000..e56bf50f --- /dev/null +++ b/src/GameServer/Bot/AI/BotConversationStore.js @@ -0,0 +1,341 @@ +const Database = invoke('Database'); + +const DEFAULT_RECENT_TURNS = 8; +const MAX_TEXT_CHARS = 360; + +const memory = new Map(); +let memoryConversationSequence = 0; +let memoryMessageSequence = 0; +let schemaPromise = null; + +function now() { + return Date.now(); +} + +function numericId(value) { + const id = Number(value); + return Number.isInteger(id) && id > 0 ? id : 0; +} + +function pairKey(playerId, botId) { + return `${numericId(playerId)}:${numericId(botId)}`; +} + +function text(value, max = MAX_TEXT_CHARS) { + return String(value || '').replace(/\s+/g, ' ').trim().slice(0, max); +} + +function normalizeMeta(value) { + if (!value) return null; + if (typeof value === 'object') return value; + try { + return JSON.parse(String(value)); + } catch (_) { + return null; + } +} + +function normalizeConversation(row) { + if (!row) return null; + return { + id: row.id, + playerId: numericId(row.playerId), + botId: numericId(row.botId), + summary: text(row.summary, 1600), + summaryThroughId: Number(row.summaryThroughId || 0), + version: Number(row.version || 0), + createdAt: Number(row.createdAt || 0), + updatedAt: Number(row.updatedAt || 0) + }; +} + +function normalizeTurn(row) { + if (!row) return null; + return { + id: Number(row.id || 0), + conversationId: row.conversationId, + turnId: String(row.turnId || ''), + role: row.role, + channel: row.channel || 'local', + text: text(row.text), + requestId: row.requestId || null, + delivered: Number(row.delivered || 0) === 1, + createdAt: Number(row.createdAt || 0), + meta: normalizeMeta(row.metaJson || row.meta) + }; +} + +function memoryEntry(playerId, botId) { + const key = pairKey(playerId, botId); + let entry = memory.get(key); + if (!entry) { + const createdAt = now(); + entry = { + conversation: { + id: `memory:${++memoryConversationSequence}`, + playerId: numericId(playerId), + botId: numericId(botId), + summary: '', + summaryThroughId: 0, + version: 0, + createdAt, + updatedAt: createdAt + }, + turns: [] + }; + memory.set(key, entry); + } + return entry; +} + +function copyConversation(conversation) { + return conversation ? { ...conversation } : null; +} + +function copyTurn(turn) { + return turn ? { ...turn, meta: turn.meta ? { ...turn.meta } : null } : null; +} + +function databaseReady() { + return typeof Database.isReady === 'function' && Database.isReady(); +} + +function ensureSchema() { + if (!databaseReady()) return Promise.resolve(false); + if (!schemaPromise) { + schemaPromise = Database.execute([ + 'SELECT 1 FROM bot_conversations LIMIT 1', + [] + ], 'schema:bot-conversations').then(() => true).catch(() => false); + } + return schemaPromise; +} + +async function loadFromDatabase(playerId, botId) { + if (!(await ensureSchema())) return null; + const rows = await Database.execute([ + `SELECT id, playerId, botId, summary, summaryThroughId, version, createdAt, updatedAt + FROM bot_conversations WHERE playerId = ? AND botId = ? LIMIT 1`, + [numericId(playerId), numericId(botId)] + ], 'bot-conversation:load'); + return normalizeConversation(rows[0]); +} + +async function ensureConversation(playerId, botId) { + const player = numericId(playerId); + const bot = numericId(botId); + if (!player || !bot) throw new Error('invalid conversation pair'); + + const key = pairKey(player, bot); + const cached = memory.get(key); + if (cached) return cached; + + let conversation = null; + try { + conversation = await loadFromDatabase(player, bot); + } catch (_) { + conversation = null; + } + + if (!conversation && databaseReady() && await ensureSchema()) { + const createdAt = now(); + try { + await Database.execute([ + `INSERT INTO bot_conversations (playerId, botId, summary, summaryThroughId, version, createdAt, updatedAt) + VALUES (?, ?, '', 0, 0, ?, ?) + ON CONFLICT(playerId, botId) DO NOTHING`, + [player, bot, createdAt, createdAt] + ], 'bot-conversation:create'); + conversation = await loadFromDatabase(player, bot); + } catch (_) { + conversation = null; + } + } + + const entry = memoryEntry(player, bot); + if (conversation) { + entry.conversation = conversation; + } + return entry; +} + +async function loadTurns(entry, limit = DEFAULT_RECENT_TURNS, includeCompacted = false) { + if (!entry?.conversation) return []; + const conversationId = entry.conversation.id; + if (String(conversationId).startsWith('memory:')) { + return entry.turns + .filter((turn) => includeCompacted || turn.id > Number(entry.conversation.summaryThroughId || 0)) + .slice(-Math.max(1, Number(limit) || DEFAULT_RECENT_TURNS)) + .map(copyTurn); + } + + try { + const rows = await Database.execute([ + `SELECT id, conversationId, turnId, role, channel, text, requestId, delivered, createdAt, metaJson + FROM bot_conversation_messages + WHERE conversationId = ? ${includeCompacted ? '' : 'AND id > ?'} + ORDER BY id DESC LIMIT ?`, + includeCompacted + ? [conversationId, Math.max(1, Number(limit) || DEFAULT_RECENT_TURNS)] + : [conversationId, Number(entry.conversation.summaryThroughId || 0), Math.max(1, Number(limit) || DEFAULT_RECENT_TURNS)] + ], 'bot-conversation:recent'); + return rows.reverse().map(normalizeTurn); + } catch (_) { + return entry.turns + .filter((turn) => includeCompacted || turn.id > Number(entry.conversation.summaryThroughId || 0)) + .slice(-Math.max(1, Number(limit) || DEFAULT_RECENT_TURNS)) + .map(copyTurn); + } +} + +async function appendTurn(input = {}) { + const entry = await ensureConversation(input.playerId, input.botId); + const conversation = entry.conversation; + const turnId = text(input.turnId || `turn-${now()}-${++memoryMessageSequence}`, 128); + const role = ['player', 'bot', 'system'].includes(input.role) ? input.role : 'system'; + const channel = text(input.channel || 'local', 32) || 'local'; + const value = text(input.text); + const existing = entry.turns.find((turn) => turn.turnId === turnId && turn.role === role); + if (existing) { + return { conversation: copyConversation(conversation), turn: copyTurn(existing), inserted: false }; + } + + const createdAt = Number(input.createdAt || now()); + const turn = { + id: ++memoryMessageSequence, + conversationId: conversation.id, + turnId, + role, + channel, + text: value, + requestId: input.requestId ? text(input.requestId, 128) : null, + delivered: input.delivered !== false, + createdAt, + meta: input.meta && typeof input.meta === 'object' ? { ...input.meta } : null + }; + entry.turns.push(turn); + conversation.updatedAt = createdAt; + + if (!String(conversation.id).startsWith('memory:')) { + try { + const result = await Database.execute([ + `INSERT OR IGNORE INTO bot_conversation_messages + (conversationId, turnId, role, channel, text, requestId, delivered, createdAt, metaJson) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + conversation.id, + turn.turnId, + turn.role, + turn.channel, + turn.text, + turn.requestId, + turn.delivered ? 1 : 0, + turn.createdAt, + turn.meta ? JSON.stringify(turn.meta) : null + ] + ], 'bot-conversation:append'); + if (Number(result.affectedRows || 0) === 0) { + entry.turns.pop(); + const rows = await Database.execute([ + `SELECT id, conversationId, turnId, role, channel, text, requestId, delivered, createdAt, metaJson + FROM bot_conversation_messages WHERE conversationId = ? AND turnId = ? AND role = ? LIMIT 1`, + [conversation.id, turn.turnId, turn.role] + ], 'bot-conversation:dedupe'); + const existingRow = normalizeTurn(rows[0]); + if (existingRow) entry.turns.push(existingRow); + return { conversation: copyConversation(conversation), turn: copyTurn(existingRow), inserted: false }; + } + const rows = await Database.execute([ + `SELECT id, conversationId, turnId, role, channel, text, requestId, delivered, createdAt, metaJson + FROM bot_conversation_messages WHERE conversationId = ? AND turnId = ? AND role = ? LIMIT 1`, + [conversation.id, turn.turnId, turn.role] + ], 'bot-conversation:append-row'); + const persisted = normalizeTurn(rows[0]); + if (persisted) { + entry.turns.pop(); + entry.turns.push(persisted); + turn.id = persisted.id; + turn.conversationId = persisted.conversationId; + } + await Database.execute([ + 'UPDATE bot_conversations SET updatedAt = ? WHERE id = ?', + [createdAt, conversation.id] + ], 'bot-conversation:touch'); + } catch (_) { + // Keep the in-memory turn usable if persistence is temporarily unavailable. + } + } + + return { conversation: copyConversation(conversation), turn: copyTurn(turn), inserted: true }; +} + +async function context(playerId, botId, options = {}) { + const entry = await ensureConversation(playerId, botId); + const recentTurns = await loadTurns( + entry, + options.limit || DEFAULT_RECENT_TURNS, + options.includeCompacted === true + ); + return { + conversation: copyConversation(entry.conversation), + recentTurns, + summary: entry.conversation.summary || null, + summaryThroughId: Number(entry.conversation.summaryThroughId || 0), + version: Number(entry.conversation.version || 0) + }; +} + +async function setSummary(input = {}) { + const entry = await ensureConversation(input.playerId, input.botId); + const conversation = entry.conversation; + const expectedVersion = Number(input.expectedVersion ?? conversation.version); + if (expectedVersion !== Number(conversation.version || 0)) { + return { ok: false, reason: 'version_conflict', conversation: copyConversation(conversation) }; + } + + const summary = text(input.summary, 1600); + const throughId = Math.max(0, Number(input.summaryThroughId || 0)); + const nextVersion = expectedVersion + 1; + if (!String(conversation.id).startsWith('memory:')) { + try { + const result = await Database.execute([ + `UPDATE bot_conversations + SET summary = ?, summaryThroughId = ?, version = ?, updatedAt = ? + WHERE id = ? AND version = ?`, + [summary, throughId, nextVersion, now(), conversation.id, expectedVersion] + ], 'bot-conversation:summary'); + if (Number(result.affectedRows || 0) === 0) { + return { ok: false, reason: 'version_conflict', conversation: copyConversation(conversation) }; + } + } catch (_) { + return { ok: false, reason: 'persistence_error', conversation: copyConversation(conversation) }; + } + } + + conversation.summary = summary; + conversation.summaryThroughId = throughId; + conversation.version = nextVersion; + conversation.updatedAt = now(); + return { ok: true, conversation: copyConversation(conversation) }; +} + +const BotConversationStore = { + DEFAULT_RECENT_TURNS, + MAX_TEXT_CHARS, + ensureSchema, + ensureConversation, + appendTurn, + context, + setSummary, + resetMemory() { + memory.clear(); + schemaPromise = null; + memoryConversationSequence = 0; + memoryMessageSequence = 0; + }, + memorySize() { + return memory.size; + } +}; + +module.exports = BotConversationStore; diff --git a/src/GameServer/Bot/AI/BotDialogueArbiter.js b/src/GameServer/Bot/AI/BotDialogueArbiter.js new file mode 100644 index 00000000..bb83437c --- /dev/null +++ b/src/GameServer/Bot/AI/BotDialogueArbiter.js @@ -0,0 +1,89 @@ +const BotConversationService = invoke('GameServer/Bot/AI/BotConversationService'); + +function fallbackText(botSession, reason) { + const plan = botSession?.plan || 'hunting'; + if (reason === 'timeout') return 'Give me a moment. I am still sorting things out.'; + if (reason === 'no_visible_real_players') return `I cannot hear you clearly from here. I am ${plan} right now.`; + return `I hear you. I am ${plan} right now.`; +} + +function deliverFallback(input, turn, reason) { + const BotManager = invoke('GameServer/Bot/BotManager'); + const reply = fallbackText(input.botSession, reason); + BotManager.botTell(input.botSession, input.playerSession, reply); + return BotConversationService.recordFallback({ + playerSession: input.playerSession, + botSession: input.botSession, + turnId: turn.turnId, + channel: turn.channel, + text: reply, + reason + }).then(() => ({ + ok: true, + started: false, + reason: reason || 'fallback', + reply + })); +} + +function route(input = {}) { + if (!BotConversationService.validPair(input.playerSession, input.botSession)) { + return Promise.resolve({ ok: false, reason: 'invalid_hot_pair' }); + } + + return BotConversationService.beginTurn(input).then((turn) => { + const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); + Promise.resolve(BotSocialMemory.recordEvent( + input.playerSession, + input.botSession, + 'chat', + input.channel || input.source || 'hot_dialogue' + )).catch(() => {}); + + const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); + const BotAI = invoke('GameServer/Bot/BotAI'); + const status = BotAI.getStatus(input.botSession); + const requestContext = { + playerSession: input.playerSession, + source: input.source || input.channel || 'hot_dialogue', + channel: turn.channel, + requestId: input.requestId, + conversation: turn.context, + conversationTurn: turn, + allowFallback: input.allowFallback !== false, + queued: input.queued === true + }; + + const started = BotBrain.maybeThink( + input.botSession, + 'player_chat', + status, + turn.playerText, + requestContext + ); + + if (!started && input.allowFallback !== false) { + return deliverFallback(input, turn, 'not_started'); + } + return { ok: true, started, queued: input.queued === true, turn }; + }).catch((error) => { + if (input.allowFallback === false || !input.playerSession?.actor || !input.botSession?.actor) { + return { ok: false, reason: 'conversation_error' }; + } + const BotManager = invoke('GameServer/Bot/BotManager'); + const reply = fallbackText(input.botSession, 'conversation_error'); + BotManager.botTell(input.botSession, input.playerSession, reply); + return { + ok: true, + started: false, + reason: 'conversation_error', + reply, + error: error.message + }; + }); +} + +module.exports = { + route, + fallbackText +}; diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js index bcb840f1..7abb549d 100644 --- a/src/GameServer/Bot/BotManager.js +++ b/src/GameServer/Bot/BotManager.js @@ -4,7 +4,6 @@ const DataCache = invoke('GameServer/DataCache'); const World = invoke('GameServer/World/World'); const BotSession = invoke('GameServer/Bot/BotSession'); const BotAI = invoke('GameServer/Bot/BotAI'); -const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); const BotPersona = invoke('GameServer/Bot/AI/BotPersona'); const GeodataEngine = invoke('GameServer/Geodata/GeodataEngine'); const MerchantConfigs = invoke('GameServer/Bot/MerchantStoreConfigs'); @@ -21,6 +20,7 @@ const PopulationService = invoke('GameServer/Bot/Population/PopulationService'); const SimulationKernel = invoke('GameServer/Bot/Simulation/SimulationKernel'); const GoalService = invoke('GameServer/Bot/Goals/GoalService'); const BotConversation = invoke('GameServer/Bot/AI/BotConversation'); +const BotDialogueArbiter = invoke('GameServer/Bot/AI/BotDialogueArbiter'); const BotSupportPlanner = invoke('GameServer/Bot/AI/BotSupportPlanner'); const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); const BotClassProgression = invoke('GameServer/Bot/BotClassProgression'); @@ -850,7 +850,16 @@ const BotManager = { if (groupAddress && !addressedToBot && !selectedBot && !companionBot) { brainGroupResponderPicked = true; } - BotBrain.maybeThink(session, 'player_chat', BotAI.getStatus(session), rawText); + BotDialogueArbiter.route({ + playerSession, + botSession: session, + text: rawText, + channel: 'local_chat', + source: 'local_chat', + allowFallback: true + }).catch((error) => { + utils.infoWarn('BotDialogue', 'local chat route failed: %s', error.message); + }); } } }); diff --git a/src/GameServer/World/World.js b/src/GameServer/World/World.js index addacc90..5baf54f4 100644 --- a/src/GameServer/World/World.js +++ b/src/GameServer/World/World.js @@ -273,21 +273,20 @@ const World = { } const BotManager = invoke('GameServer/Bot/BotManager'); - const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); const LifeState = invoke('GameServer/Bot/Population/BotLifeState'); const BotRemoteChat = invoke('GameServer/Bot/AI/BotRemoteChat'); + const BotDialogueArbiter = invoke('GameServer/Bot/AI/BotDialogueArbiter'); const hotSession = BotManager.findSessionByName(lookup); if (hotSession) { - BotSocialMemory.recordEvent(session, hotSession, 'chat', source); - const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); - const BotAI = invoke('GameServer/Bot/BotAI'); - const started = BotBrain.maybeThink(hotSession, 'player_chat', BotAI.getStatus(hotSession), message); - if (!started) { - const plan = hotSession.plan || 'hunting'; - BotManager.botTell(hotSession, session, `I hear you. I'm ${plan} right now.`); - } - return Promise.resolve(true); + return BotDialogueArbiter.route({ + playerSession: session, + botSession: hotSession, + text: message, + channel: source, + source, + allowFallback: true + }).then((result) => result?.ok !== false); } return LifeState.findByName(lookup).then((state) => { diff --git a/tests/test_bot_conversation_store.js b/tests/test_bot_conversation_store.js new file mode 100644 index 00000000..7a968ae2 --- /dev/null +++ b/tests/test_bot_conversation_store.js @@ -0,0 +1,129 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +require('../src/Global'); + +const Database = invoke('Database'); +const BotConversationStore = invoke('GameServer/Bot/AI/BotConversationStore'); +const BotConversationService = invoke('GameServer/Bot/AI/BotConversationService'); + +function actor(id, name) { + return { + fetchId: () => id, + fetchName: () => name + }; +} + +async function main() { + const databasePath = path.join(process.cwd(), 'tmp', 'test-bot-conversations.sqlite'); + fs.rmSync(databasePath, { force: true }); + options.default.Database.path = path.relative(process.cwd(), databasePath); + Database.init(); + BotConversationStore.resetMemory(); + + await Database.createAccount('dialogue_player', 'secret'); + await Database.createAccount('dialogue_player_two', 'secret'); + await Database.createAccount('bot_dialogue', 'secret'); + await Database.createCharacter('dialogue_player', { + name: 'DialoguePlayer', race: 0, classId: 0, maxHp: 50, maxMp: 25, + sex: 0, face: 0, hair: 0, hairColor: 0, locX: 0, locY: 0, locZ: 0 + }); + await Database.createCharacter('dialogue_player_two', { + name: 'DialoguePlayerTwo', race: 0, classId: 0, maxHp: 50, maxMp: 25, + sex: 0, face: 0, hair: 0, hairColor: 0, locX: 0, locY: 0, locZ: 0 + }); + await Database.createCharacter('bot_dialogue', { + name: 'DialogueBot', race: 0, classId: 0, maxHp: 50, maxMp: 25, + sex: 0, face: 0, hair: 0, hairColor: 0, locX: 0, locY: 0, locZ: 0 + }); + + const playerId = (await Database.fetchCharacterName('DialoguePlayer'))[0].id; + const playerTwoId = (await Database.fetchCharacterName('DialoguePlayerTwo'))[0].id; + const botId = (await Database.fetchCharacterName('DialogueBot'))[0].id; + const playerSession = { accountId: 'dialogue_player', actor: actor(playerId, 'DialoguePlayer') }; + const playerTwoSession = { accountId: 'dialogue_player_two', actor: actor(playerTwoId, 'DialoguePlayerTwo') }; + const botSession = { accountId: 'bot_dialogue', actor: actor(botId, 'DialogueBot') }; + + const first = await BotConversationService.beginTurn({ + playerSession, + botSession, + channel: 'client_tell', + turnId: 'turn-1', + text: 'Hello, do you remember me?' + }); + assert.strictEqual(first.inserted, true); + assert.deepStrictEqual(first.context.recentTurns.map((turn) => turn.text), ['Hello, do you remember me?']); + assert.strictEqual(await BotConversationService.recordBotReply({ + playerSession, + botSession, + turnId: 'turn-1', + channel: 'client_tell', + text: 'I remember this conversation.' + }), true); + + const second = await BotConversationService.beginTurn({ + playerSession, + botSession, + channel: 'client_tell', + turnId: 'turn-2', + text: 'What did I ask you?' + }); + assert.deepStrictEqual( + second.context.recentTurns.map((turn) => turn.text), + ['Hello, do you remember me?', 'I remember this conversation.', 'What did I ask you?'] + ); + + const secondPair = await BotConversationService.beginTurn({ + playerSession: playerTwoSession, + botSession, + channel: 'client_tell', + turnId: 'other-1', + text: 'This is a different player.' + }); + assert.deepStrictEqual(secondPair.context.recentTurns.map((turn) => turn.text), ['This is a different player.']); + + const duplicate = await BotConversationService.beginTurn({ + playerSession, + botSession, + channel: 'client_tell', + turnId: 'turn-2', + text: 'What did I ask you?' + }); + assert.strictEqual(duplicate.inserted, false, 'the same turn identity must not be stored twice'); + + const stored = await BotConversationStore.context(playerId, botId, { limit: 10 }); + const throughId = stored.recentTurns[1].id; + const summary = await BotConversationStore.setSummary({ + playerId, + botId, + summary: 'The player asked whether the bot remembers the conversation.', + summaryThroughId: throughId, + expectedVersion: stored.version + }); + assert.strictEqual(summary.ok, true); + const compacted = await BotConversationStore.context(playerId, botId, { limit: 10 }); + assert.match(compacted.summary, /remembers/); + assert.deepStrictEqual(compacted.recentTurns.map((turn) => turn.text), ['What did I ask you?']); + const conflict = await BotConversationStore.setSummary({ + playerId, + botId, + summary: 'stale summary', + summaryThroughId: throughId, + expectedVersion: stored.version + }); + assert.strictEqual(conflict.ok, false); + assert.strictEqual(conflict.reason, 'version_conflict'); + + BotConversationStore.resetMemory(); + const afterRestart = await BotConversationService.contextFor(playerSession, botSession, { limit: 10 }); + assert.match(afterRestart.summary, /remembers/); + assert.deepStrictEqual(afterRestart.recentTurns.map((turn) => turn.text), ['What did I ask you?']); + + console.log('Bot conversation store checks passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_bot_dialogue_arbiter.js b/tests/test_bot_dialogue_arbiter.js new file mode 100644 index 00000000..1f52cd31 --- /dev/null +++ b/tests/test_bot_dialogue_arbiter.js @@ -0,0 +1,145 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotConversationStore = invoke('GameServer/Bot/AI/BotConversationStore'); +const BotConversationService = invoke('GameServer/Bot/AI/BotConversationService'); +const BotDialogueArbiter = invoke('GameServer/Bot/AI/BotDialogueArbiter'); +const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); +const BotAI = invoke('GameServer/Bot/BotAI'); +const BotManager = invoke('GameServer/Bot/BotManager'); +const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); + +function actor(id, name, x = 0) { + return { + fetchId: () => id, + fetchName: () => name, + fetchLocX: () => x, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchIsOnline: () => true, + fetchDestId: () => 0 + }; +} + +function session(accountId, actorValue) { + return { + accountId, + actor: actorValue, + dataSendToMe() {}, + dataSendToOthers() {} + }; +} + +async function main() { + BotConversationStore.resetMemory(); + + const originalBrain = BotBrain.maybeThink; + const originalStatus = BotAI.getStatus; + const originalBotTell = BotManager.botTell; + const originalRecordEvent = BotSocialMemory.recordEvent; + const originalSessions = BotManager.sessions; + const originalRoute = BotDialogueArbiter.route; + const originalWorldUser = invoke('GameServer/World/World').user; + + try { + const player = session('player_dialogue', actor(101, 'Slava')); + const playerTwo = session('player_dialogue_two', actor(102, 'OtherPlayer')); + const bot = session('bot_dialogue_arbiter', actor(201, 'Aria')); + const captured = []; + BotSocialMemory.recordEvent = () => Promise.resolve(null); + BotAI.getStatus = () => ({ available: true, mode: 'hunting', level: 10, name: 'Aria' }); + BotBrain.maybeThink = (_bot, _event, _status, text, requestContext) => { + captured.push({ text, requestContext }); + return true; + }; + + const first = await BotDialogueArbiter.route({ + playerSession: player, + botSession: bot, + channel: 'client_tell', + source: 'client_tell', + turnId: 'arbiter-1', + text: 'Hello, Aria.' + }); + assert.strictEqual(first.ok, true); + assert.strictEqual(first.started, true); + assert.strictEqual(captured[0].requestContext.conversation.recentTurns[0].text, 'Hello, Aria.'); + + await BotConversationService.recordBotReply({ + playerSession: player, + botSession: bot, + turnId: 'arbiter-1', + channel: 'client_tell', + text: 'Hello, Slava.' + }); + await BotDialogueArbiter.route({ + playerSession: player, + botSession: bot, + channel: 'client_tell', + source: 'client_tell', + turnId: 'arbiter-2', + text: 'Do you remember me?' + }); + assert.deepStrictEqual( + captured[1].requestContext.conversation.recentTurns.map((turn) => turn.text), + ['Hello, Aria.', 'Hello, Slava.', 'Do you remember me?'] + ); + + await BotDialogueArbiter.route({ + playerSession: playerTwo, + botSession: bot, + channel: 'client_tell', + source: 'client_tell', + turnId: 'arbiter-other', + text: 'This history must stay separate.' + }); + assert.deepStrictEqual( + captured[2].requestContext.conversation.recentTurns.map((turn) => turn.text), + ['This history must stay separate.'] + ); + + const fallbackReplies = []; + BotManager.botTell = (_botSession, _playerSession, text) => fallbackReplies.push(text); + BotBrain.maybeThink = () => false; + const fallback = await BotDialogueArbiter.route({ + playerSession: player, + botSession: bot, + channel: 'client_tell', + source: 'client_tell', + turnId: 'arbiter-fallback', + text: 'Are you still hunting?' + }); + assert.strictEqual(fallback.started, false); + assert.strictEqual(fallbackReplies.length, 1); + const fallbackContext = await BotConversationService.contextFor(player, bot, { limit: 10 }); + assert.ok(fallbackContext.recentTurns.some((turn) => turn.text === fallback.reply)); + + const world = invoke('GameServer/World/World'); + world.user = { sessions: [player, bot] }; + const botTwo = session('bot_dialogue_two', actor(202, 'Belen', 100)); + BotManager.sessions = [bot, botTwo]; + const routes = []; + BotDialogueArbiter.route = (input) => { + routes.push(input.botSession.actor.fetchName()); + return Promise.resolve({ ok: true, started: true }); + }; + BotManager.handlePlayerSpeak(player, { text: 'bots, can anyone help?' }); + assert.deepStrictEqual(routes, ['Aria'], 'a group message must select one hot responder'); + } finally { + BotBrain.maybeThink = originalBrain; + BotAI.getStatus = originalStatus; + BotManager.botTell = originalBotTell; + BotSocialMemory.recordEvent = originalRecordEvent; + BotManager.sessions = originalSessions; + BotDialogueArbiter.route = originalRoute; + invoke('GameServer/World/World').user = originalWorldUser; + } + + console.log('Bot dialogue arbiter checks passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_hot_bot_conversation_flow.js b/tests/test_hot_bot_conversation_flow.js new file mode 100644 index 00000000..2a5160f9 --- /dev/null +++ b/tests/test_hot_bot_conversation_flow.js @@ -0,0 +1,131 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); +const BotBrainContext = invoke('GameServer/Bot/AI/BotBrainContext'); +const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); +const World = invoke('GameServer/World/World'); + +function actor(id, name, x = 0) { + return { + fetchId: () => id, + fetchName: () => name, + fetchLevel: () => 10, + fetchHp: () => 100, + fetchMaxHp: () => 100, + fetchLocX: () => x, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchKarma: () => 0, + fetchDestId: () => 0, + fetchIsOnline: () => true, + isDead: () => false + }; +} + +function response(body) { + return { ok: true, status: 200, json: async () => body }; +} + +async function main() { + const originalConfig = options.default.OpenRouter; + const originalWorldUser = World.user; + const originalCompactStatus = BotBrainContext.compactStatus; + let firstRequestRelease; + const requests = []; + + try { + options.default.OpenRouter = { + ...originalConfig, + enabled: true, + apiKey: 'conversation-test-key', + model: 'test/conversation', + chatCooldownMs: 0, + timeoutMs: 1000, + circuitBreakerFailureThreshold: 5 + }; + const playerSession = { accountId: 'player_flow', actor: actor(301, 'FlowPlayer') }; + const botSession = { + accountId: 'bot_flow', + actor: actor(302, 'FlowBot', 100), + plan: 'resting' + }; + World.user = { sessions: [playerSession, botSession] }; + BotBrainContext.compactStatus = (_session, status) => status; + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.setTransport(async (_url, init) => { + requests.push(JSON.parse(init.body)); + if (requests.length === 1) { + await new Promise((resolve) => { firstRequestRelease = resolve; }); + } + return response({ + choices: [{ message: { content: JSON.stringify({ + action: 'none', + reply: '', + targetPlayerName: '', + spotId: '', + buffType: '', + reason: 'queued_test', + confidence: 0.9 + }) } }] + }); + }); + + const firstContext = { + summary: null, + recentTurns: [{ turnId: 'flow-1', role: 'player', channel: 'local_chat', text: 'first', createdAt: 1 }], + version: 0 + }; + const secondContext = { + summary: null, + recentTurns: [ + ...firstContext.recentTurns, + { turnId: 'flow-2', role: 'player', channel: 'local_chat', text: 'second', createdAt: 2 } + ], + version: 0 + }; + const firstTurn = { turnId: 'flow-1', channel: 'local_chat' }; + const secondTurn = { turnId: 'flow-2', channel: 'local_chat' }; + const firstStarted = BotBrain.maybeThink( + botSession, + 'player_chat', + { available: true, mode: 'resting', level: 10, name: 'FlowBot' }, + 'first', + { playerSession, conversation: firstContext, conversationTurn: firstTurn, requestId: 'flow-1' } + ); + assert.strictEqual(firstStarted, true); + + const secondStarted = BotBrain.maybeThink( + botSession, + 'player_chat', + { available: true, mode: 'resting', level: 10, name: 'FlowBot' }, + 'second', + { playerSession, conversation: secondContext, conversationTurn: secondTurn, requestId: 'flow-2' } + ); + assert.strictEqual(secondStarted, true, 'one additional dialogue turn should queue while the first is in flight'); + assert.strictEqual(botSession.pendingBrainTurn.text, 'second'); + + firstRequestRelease(); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.strictEqual(requests.length, 2, 'the queued turn should be submitted after the first turn completes'); + const secondPayload = JSON.parse(requests[1].messages[1].content); + assert.deepStrictEqual( + secondPayload.conversation.recentTurns.map((turn) => turn.text), + ['first', 'second'] + ); + } finally { + options.default.OpenRouter = originalConfig; + World.user = originalWorldUser; + BotBrainContext.compactStatus = originalCompactStatus; + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.resetTransport(); + } + + console.log('Hot bot conversation flow checks passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From a06e31778e4be96993e43eff43d26342e29f9f84 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:04:12 -0400 Subject: [PATCH 03/59] Bound hot bot context and summarize activity --- scripts/run-tests.js | 3 + src/GameServer/Actor/Attack.js | 12 + src/GameServer/Actor/Backpack.js | 16 ++ src/GameServer/Actor/Generics/Die.js | 10 + src/GameServer/Actor/Generics/LevelUp.js | 11 + src/GameServer/Actor/Generics/NpcDied.js | 12 + src/GameServer/Actor/Generics/Revive.js | 10 + src/GameServer/Bot/AI/BotBrainContext.js | 10 +- src/GameServer/Bot/AI/BotContextAssembler.js | 164 ++++++++++++++ .../Bot/AI/BotConversationService.js | 22 +- .../Bot/AI/BotConversationSummarizer.js | 121 ++++++++++ src/GameServer/Bot/AI/BotDialogueArbiter.js | 26 ++- src/GameServer/Bot/AI/BotEventJournal.js | 211 ++++++++++++++++++ src/GameServer/Bot/AI/BotLootEtiquette.js | 22 ++ src/GameServer/Bot/AI/BotTownTravel.js | 10 + .../Bot/AI/PartyCompanionService.js | 47 +++- src/GameServer/Bot/AI/States/ShoppingState.js | 19 ++ src/GameServer/Network/Request/TradeDone.js | 10 + tests/test_bot_activity_journal.js | 41 ++++ tests/test_bot_context_assembler.js | 53 +++++ tests/test_bot_conversation_summary.js | 92 ++++++++ 21 files changed, 907 insertions(+), 15 deletions(-) create mode 100644 src/GameServer/Bot/AI/BotContextAssembler.js create mode 100644 src/GameServer/Bot/AI/BotConversationSummarizer.js create mode 100644 src/GameServer/Bot/AI/BotEventJournal.js create mode 100644 tests/test_bot_activity_journal.js create mode 100644 tests/test_bot_context_assembler.js create mode 100644 tests/test_bot_conversation_summary.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 64f3334d..6a9912fc 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -10,6 +10,9 @@ const tests = [ 'tests/test_bot_conversation_store.js', 'tests/test_bot_dialogue_arbiter.js', 'tests/test_hot_bot_conversation_flow.js', + 'tests/test_bot_activity_journal.js', + 'tests/test_bot_context_assembler.js', + 'tests/test_bot_conversation_summary.js', 'tests/test_bot_availability.js', 'tests/test_bot_chat_commands.js', 'tests/test_bot_chat_text.js', diff --git a/src/GameServer/Actor/Attack.js b/src/GameServer/Actor/Attack.js index 8a0617df..9d0c2efe 100644 --- a/src/GameServer/Actor/Attack.js +++ b/src/GameServer/Actor/Attack.js @@ -268,6 +268,18 @@ class Attack { // skill result exists. A queued, interrupted, resisted, or // stack-rejected cast must never claim success to the party. invoke('GameServer/Bot/AI/BotPartyChat').confirmSkillResult(session, actor, target, skill, outcome); + if (outcome?.applied && session?.accountId?.startsWith?.('bot_') && target?.session?.accountId && !target.session.accountId.startsWith('bot_')) { + Promise.resolve(invoke('GameServer/Bot/AI/BotEventJournal').record({ + playerId: target.session.actor?.fetchId?.(), + botId: actor.fetchId?.(), + eventType: 'support_result', + summary: `${actor.fetchName?.() || 'Bot'} successfully used ${skill.model?.name || 'a support skill'} on ${target.fetchName?.() || 'the player'}.`, + weight: 3, + dedupeKey: `support:${actor.fetchId?.()}:${target.fetchId?.()}:${skill.fetchSelfId?.()}`, + coalesceWindowMs: 5000, + meta: { skillId: skill.fetchSelfId?.(), outcome: outcome.type || null } + })).catch(() => {}); + } if (outcome.damage > 0) { this.hit(session, actor, target, outcome.damage); diff --git a/src/GameServer/Actor/Backpack.js b/src/GameServer/Actor/Backpack.js index 4f5fc884..7f9dbe54 100644 --- a/src/GameServer/Actor/Backpack.js +++ b/src/GameServer/Actor/Backpack.js @@ -22,6 +22,7 @@ const C4BeastItems = invoke('GameServer/Items/C4BeastItems'); const C4SkillRules = invoke('GameServer/Skills/C4SkillRules'); const ManorData = invoke('GameServer/Manor/ManorData'); const SpeckMath = invoke('GameServer/SpeckMath'); +const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); const FISHING_ROD_GRADES = { 6529: 'none', @@ -33,6 +34,19 @@ const FISHING_ROD_GRADES = { 7560: 'none' }; +function recordEquipmentEvent(session, item, action) { + if (!session?.accountId?.startsWith?.('bot_') || !item) return; + Promise.resolve(BotEventJournal.record({ + botId: session.actor?.fetchId?.(), + eventType: action === 'equip' ? 'equipment_change' : 'equipment_removed', + summary: `${session.actor?.fetchName?.() || 'Bot'} ${action === 'equip' ? 'equipped' : 'unequipped'} ${item.fetchName?.() || 'an item'}.`, + weight: 2, + dedupeKey: `${action}:${session.actor?.fetchId?.()}:${item.fetchSelfId?.()}:${item.fetchSlot?.()}`, + coalesceWindowMs: 15000, + meta: { selfId: item.fetchSelfId?.(), slot: item.fetchSlot?.(), action } + })).catch(() => {}); +} + const COMMON_CRAFT_LEVELS = [5, 20, 28, 36, 43, 49, 55, 62, 70]; const MANUFACTURE_STORE_TYPES = [5, 6]; @@ -1490,6 +1504,7 @@ class Backpack extends BackpackModel { // Recalculate invoke(path.actor).calculateStats(session, session.actor); + recordEquipmentEvent(session, item, 'equip'); } unequipGear(session, slot) { @@ -1527,6 +1542,7 @@ class Backpack extends BackpackModel { // Recalculate once for the complete slot change. invoke(path.actor).calculateStats(session, session.actor); + equippedItems.forEach((item) => recordEquipmentEvent(session, item, 'unequip')); } updateDatabaseTimer(characterId, changedItems = this.items.filter((ob) => ob.isWearable())) { diff --git a/src/GameServer/Actor/Generics/Die.js b/src/GameServer/Actor/Generics/Die.js index 5ca1ace5..eed008b2 100644 --- a/src/GameServer/Actor/Generics/Die.js +++ b/src/GameServer/Actor/Generics/Die.js @@ -29,6 +29,16 @@ function die(session, actor) { actor.state.destructor(); actor.state.setDead(true); session.dataSendToMeAndOthers(ServerResponse.die(actor.fetchId()), actor); + if (session?.accountId?.startsWith?.('bot_')) { + Promise.resolve(invoke('GameServer/Bot/AI/BotEventJournal').record({ + botId: actor.fetchId(), + eventType: 'death', + summary: `${actor.fetchName?.() || 'Bot'} died.`, + weight: 5, + dedupeKey: `death:${actor.fetchId()}`, + coalesceWindowMs: 5000 + })).catch(() => {}); + } } module.exports = die; diff --git a/src/GameServer/Actor/Generics/LevelUp.js b/src/GameServer/Actor/Generics/LevelUp.js index 23820cd9..990319f2 100644 --- a/src/GameServer/Actor/Generics/LevelUp.js +++ b/src/GameServer/Actor/Generics/LevelUp.js @@ -53,6 +53,17 @@ function levelUp(session, actor, nextLevel) { // Level up effect session.dataSendToMeAndOthers(ServerResponse.socialAction(id, 15), actor); ConsoleText.transmit(session, ConsoleText.caption.levelUp); + if (isBot) { + Promise.resolve(invoke('GameServer/Bot/AI/BotEventJournal').record({ + botId: id, + eventType: 'level_up', + summary: `${actor.fetchName?.() || 'Bot'} reached level ${nextLevel}.`, + weight: 4, + dedupeKey: `level:${nextLevel}`, + coalesceWindowMs: 60 * 60 * 1000, + meta: { level: Number(nextLevel) } + })).catch(() => {}); + } // Update database with new hp, mp CharacterWriteQueue.vitals(id, hp, maxHp, mp, maxMp); diff --git a/src/GameServer/Actor/Generics/NpcDied.js b/src/GameServer/Actor/Generics/NpcDied.js index c1befeaf..cc063d56 100644 --- a/src/GameServer/Actor/Generics/NpcDied.js +++ b/src/GameServer/Actor/Generics/NpcDied.js @@ -1,4 +1,5 @@ const World = invoke('GameServer/World/World'); +const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); const PARTY_REWARD_RADIUS = 2500; // C4/L2J party reward curve. The total reward grows with the eligible party, @@ -137,6 +138,17 @@ function npcDied(session, actor, npc) { const rewardActor = ownerSession?.actor || actor; const participants = rewardParticipants(session, rewardActor, npc); + if (session?.accountId?.startsWith?.('bot_')) { + Promise.resolve(BotEventJournal.record({ + botId: session.actor?.fetchId?.(), + eventType: 'kill', + summary: `${session.actor?.fetchName?.() || 'Bot'} defeated ${npc.fetchName?.() || 'a monster'}.`, + weight: 1, + dedupeKey: `kill:${session.actor?.fetchId?.()}:${npc.fetchTemplateId?.() || npc.fetchId?.()}`, + coalesceWindowMs: 30000, + meta: { npcId: npc.fetchId?.(), npcName: npc.fetchName?.() || null } + })).catch(() => {}); + } const rewards = partyRewardShares(participants, npc.fetchAcquiredExp(), npc.fetchRewardSp()); // C4's ordinary quest callback is attributed to the actual killer, not to diff --git a/src/GameServer/Actor/Generics/Revive.js b/src/GameServer/Actor/Generics/Revive.js index 7aca1cdd..4fef5111 100644 --- a/src/GameServer/Actor/Generics/Revive.js +++ b/src/GameServer/Actor/Generics/Revive.js @@ -6,6 +6,16 @@ function finishRevive(session, actor) { // in-place resurrection must release it so a later death is counted and // announced instead of looking like the same corpse forever. session.deathTimerStart = undefined; + if (session?.accountId?.startsWith?.('bot_')) { + Promise.resolve(invoke('GameServer/Bot/AI/BotEventJournal').record({ + botId: actor.fetchId(), + eventType: 'revive', + summary: `${actor.fetchName?.() || 'Bot'} revived and is recovering.`, + weight: 4, + dedupeKey: `revive:${actor.fetchId()}`, + coalesceWindowMs: 5000 + })).catch(() => {}); + } } function revive(session, actor, { delayMs = 2500, restoreFullVitals = false } = {}) { diff --git a/src/GameServer/Bot/AI/BotBrainContext.js b/src/GameServer/Bot/AI/BotBrainContext.js index 2ed5f6f4..1f4b0b89 100644 --- a/src/GameServer/Bot/AI/BotBrainContext.js +++ b/src/GameServer/Bot/AI/BotBrainContext.js @@ -247,10 +247,12 @@ function skillsSnapshot(actor, text = '') { }; } -function compactStatus(session, status, text = '') { +function compactStatus(session, status, text = '', options = {}) { if (!status || !status.available) return status; const actor = session?.actor; + const includeInventory = options.includeInventory !== false; + const includeSkills = options.includeSkills !== false; return { name: status.name, level: status.level, @@ -268,9 +270,9 @@ function compactStatus(session, status, text = '') { blockers: status.blockers, spot: compactSpot(status.spot), buffs: buffSnapshot(actor, status), - equipment: equipmentSnapshot(actor), - inventory: inventorySnapshot(actor, text), - skills: skillsSnapshot(actor, text), + equipment: options.includeEquipment === false ? null : equipmentSnapshot(actor), + inventory: includeInventory ? inventorySnapshot(actor, text) : null, + skills: includeSkills ? skillsSnapshot(actor, text) : null, roleDecision: status.roleDecision || null, persona: status.persona || null, social: status.social || null diff --git a/src/GameServer/Bot/AI/BotContextAssembler.js b/src/GameServer/Bot/AI/BotContextAssembler.js new file mode 100644 index 00000000..6812c618 --- /dev/null +++ b/src/GameServer/Bot/AI/BotContextAssembler.js @@ -0,0 +1,164 @@ +const BotBrainContext = invoke('GameServer/Bot/AI/BotBrainContext'); +const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); + +const DEFAULT_BUDGET = 1200; +const HARD_MAX_TOKENS = 1800; + +function estimateTokens(value) { + if (value === null || value === undefined) return 0; + const serialized = typeof value === 'string' ? value : JSON.stringify(value); + return Math.max(1, Math.ceil(String(serialized || '').length / 4)); +} + +function textWants(text, expression) { + return expression.test(String(text || '').toLowerCase()); +} + +function trimText(value, max) { + return String(value || '').replace(/\s+/g, ' ').trim().slice(0, max); +} + +function compactRecentTurns(turns, max = 8) { + return (turns || []).slice(-max).map((turn) => ({ + role: turn.role, + channel: turn.channel, + text: trimText(turn.text, 240), + createdAt: turn.createdAt + })); +} + +function compactJournal(events, max = 10) { + return (events || []).slice(-max).map((event) => ({ + type: event.eventType, + summary: trimText(event.summary, 220), + count: Number(event.count || 1), + weight: Number(event.weight || 1), + updatedAt: event.updatedAt + })); +} + +function fitString(value, tokenBudget) { + const maxChars = Math.max(32, tokenBudget * 4); + return trimText(value, maxChars); +} + +function fitFragment(fragment, remainingTokens) { + if (estimateTokens(fragment.value) <= remainingTokens) return fragment; + if (typeof fragment.value === 'string') { + return { ...fragment, value: fitString(fragment.value, remainingTokens) }; + } + const serialized = JSON.stringify(fragment.value); + return { + ...fragment, + value: fitString(serialized, remainingTokens) + }; +} + +async function assemble(input = {}) { + const session = input.session; + const status = input.status; + const text = input.text || ''; + const requestContext = input.requestContext || {}; + const budget = Math.max(240, Number(input.budget || DEFAULT_BUDGET)); + const hardMaxTokens = Math.max(budget, Number(input.hardMaxTokens || HARD_MAX_TOKENS)); + const itemIntent = textWants(text, /\b(item|items|inventory|gear|weapon|armor|adena|shot|trade|loot|give|sell|buy)\b|инвент|вещ|шмот|оруж|брон|аден|сос|трейд|лут|дай|прод/); + const skillIntent = textWants(text, /\b(skill|skills|heal|buff|haste|shield|might|wind walk|windwalk|spoil|sweep)\b|скилл|хил|баф|хаст|щит|майт|винд|спойл|свип/); + let bot; + try { + bot = BotBrainContext.compactStatus(session, status, text, { + includeInventory: itemIntent, + includeSkills: skillIntent, + includeEquipment: itemIntent || skillIntent + }); + } catch (_) { + bot = status || { available: false }; + } + + let journal = []; + if (session?.actor?.fetchId) { + journal = await BotEventJournal.recent({ + playerId: requestContext.playerSession?.actor?.fetchId?.() || requestContext.playerId, + botId: session.actor.fetchId(), + limit: input.journalLimit || 10 + }); + } + + const conversation = requestContext.conversation || null; + const fragments = [ + { id: 'bot_state', priority: 100, value: bot }, + { + id: 'conversation_summary', + priority: 95, + value: conversation?.summary ? trimText(conversation.summary, 1200) : null + }, + { + id: 'recent_dialogue', + priority: 90, + value: compactRecentTurns(conversation?.recentTurns, input.recentTurns || 8) + }, + { + id: 'authoritative_events', + priority: 85, + value: compactJournal(journal, input.journalLimit || 10) + } + ].filter((fragment) => fragment.value !== null && fragment.value !== undefined); + + const selected = []; + let used = 0; + fragments.sort((a, b) => b.priority - a.priority).forEach((fragment) => { + const cost = estimateTokens(fragment.value); + if (used + cost <= budget) { + selected.push(fragment); + used += cost; + return; + } + const remaining = budget - used; + if (remaining >= 48) { + const fitted = fitFragment(fragment, remaining); + const fittedCost = estimateTokens(fitted.value); + if (fittedCost <= remaining) { + selected.push(fitted); + used += fittedCost; + } + } + }); + + // The status is kept as a separate compatibility field, but the bounded + // fragments are the canonical prompt input. Never let a malformed fixture + // or a long player name break the hard cap. + let serializedCost = estimateTokens(selected.map((fragment) => ({ id: fragment.id, value: fragment.value }))); + if (serializedCost > hardMaxTokens) { + const overflow = serializedCost - hardMaxTokens; + const last = selected[selected.length - 1]; + if (last) { + const fitted = fitFragment(last, Math.max(48, estimateTokens(last.value) - overflow)); + selected[selected.length - 1] = fitted; + serializedCost = estimateTokens(selected.map((fragment) => ({ id: fragment.id, value: fragment.value }))); + } + } + + return { + bot, + conversation, + fragments: selected.map((fragment) => ({ id: fragment.id, value: fragment.value })), + journal, + estimatedTokens: Math.min(hardMaxTokens, serializedCost), + budget, + hardMaxTokens, + telemetry: { + fragmentCount: selected.length, + included: selected.map((fragment) => fragment.id), + itemIntent, + skillIntent, + journalCount: journal.length, + estimatedTokens: Math.min(hardMaxTokens, serializedCost) + } + }; +} + +module.exports = { + DEFAULT_BUDGET, + HARD_MAX_TOKENS, + estimateTokens, + assemble +}; diff --git a/src/GameServer/Bot/AI/BotConversationService.js b/src/GameServer/Bot/AI/BotConversationService.js index 7146006e..b52df068 100644 --- a/src/GameServer/Bot/AI/BotConversationService.js +++ b/src/GameServer/Bot/AI/BotConversationService.js @@ -1,4 +1,5 @@ const BotConversationStore = invoke('GameServer/Bot/AI/BotConversationStore'); +const BotConversationSummarizer = invoke('GameServer/Bot/AI/BotConversationSummarizer'); const DEFAULT_RECENT_TURNS = BotConversationStore.DEFAULT_RECENT_TURNS; let turnSequence = 0; @@ -100,7 +101,14 @@ function recordBotReply(input = {}) { requestId: input.requestId, delivered: input.delivered !== false, meta: input.meta || null - }).then(() => true).catch(() => false); + }).then(() => { + BotConversationSummarizer.summarize({ + playerId: actorId(input.playerSession), + botId: actorId(input.botSession), + requestId: input.requestId + }).catch(() => {}); + return true; + }).catch(() => false); } function recordFallback(input = {}) { @@ -123,6 +131,18 @@ const BotConversationService = { recordBotReply, recordFallback, contextFor, + maybeSummarize(input = {}) { + if (!validPair(input.playerSession, input.botSession)) { + return Promise.resolve({ ok: false, reason: 'invalid_hot_dialogue_pair' }); + } + return BotConversationSummarizer.summarize({ + playerId: actorId(input.playerSession), + botId: actorId(input.botSession), + requestId: input.requestId, + threshold: input.threshold, + limit: input.limit + }); + }, resetSequence() { turnSequence = 0; } diff --git a/src/GameServer/Bot/AI/BotConversationSummarizer.js b/src/GameServer/Bot/AI/BotConversationSummarizer.js new file mode 100644 index 00000000..11a19ec4 --- /dev/null +++ b/src/GameServer/Bot/AI/BotConversationSummarizer.js @@ -0,0 +1,121 @@ +const BotConversationStore = invoke('GameServer/Bot/AI/BotConversationStore'); +const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); + +const SUMMARY_THRESHOLD_MESSAGES = 24; +const SUMMARY_RECENT_TURNS = BotConversationStore.DEFAULT_RECENT_TURNS; +const SUMMARY_MAX_TOKENS = 220; +const inFlight = new Map(); + +function pairKey(playerId, botId) { + return `${Number(playerId)}:${Number(botId)}`; +} + +function compactTurns(turns) { + return (turns || []).map((turn) => ({ + id: turn.id, + role: turn.role, + channel: turn.channel, + text: turn.text + })); +} + +function schema() { + return { + name: 'bot_conversation_summary', + schema: { + type: 'object', + properties: { + summary: { type: 'string' }, + openTopics: { type: 'array', items: { type: 'string' } }, + promises: { type: 'array', items: { type: 'string' } } + }, + required: ['summary', 'openTopics', 'promises'], + additionalProperties: false + } + }; +} + +function normalizeSummary(data) { + if (!data || typeof data !== 'object') return ''; + const summary = String(data.summary || '').replace(/\s+/g, ' ').trim(); + const openTopics = Array.isArray(data.openTopics) + ? data.openTopics.map((value) => String(value || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 4) + : []; + const promises = Array.isArray(data.promises) + ? data.promises.map((value) => String(value || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 4) + : []; + if (!summary && openTopics.length === 0 && promises.length === 0) return ''; + const sections = [summary || 'No stable facts recorded.']; + if (openTopics.length) sections.push(`Open topics: ${openTopics.join('; ')}`); + if (promises.length) sections.push(`Promises: ${promises.join('; ')}`); + return sections.join(' ').slice(0, 1500); +} + +async function summarize(input = {}) { + const playerId = Number(input.playerId || 0); + const botId = Number(input.botId || 0); + if (!playerId || !botId) return { ok: false, reason: 'invalid_pair' }; + const key = pairKey(playerId, botId); + if (inFlight.has(key)) return inFlight.get(key); + + const work = (async () => { + const current = await BotConversationStore.context(playerId, botId, { + limit: input.limit || 64, + includeCompacted: true + }); + const turns = current.recentTurns || []; + const summaryThroughId = Number(current.summaryThroughId || 0); + const uncompacted = turns.filter((turn) => Number(turn.id || 0) > summaryThroughId); + if (uncompacted.length < Math.max(4, Number(input.threshold || SUMMARY_THRESHOLD_MESSAGES))) { + return { ok: false, reason: 'below_threshold', conversation: current.conversation }; + } + + const compactUntilIndex = Math.max(0, uncompacted.length - SUMMARY_RECENT_TURNS); + const compacted = uncompacted.slice(0, compactUntilIndex); + const throughId = Number(compacted[compacted.length - 1]?.id || 0); + if (!throughId || throughId <= summaryThroughId) { + return { ok: false, reason: 'nothing_to_compact', conversation: current.conversation }; + } + + const cfg = OpenRouterGateway.config({ maxTokens: SUMMARY_MAX_TOKENS, temperature: 0.1 }); + const result = await OpenRouterGateway.request({ + config: cfg, + circuitKey: 'hot-summary', + requestId: input.requestId || `summary-${botId}-${playerId}-${Date.now()}`, + sessionId: `hot-summary:${botId}:player:${playerId}`, + messages: [ + { + role: 'system', + content: 'Summarize a game conversation for the same bot and player. Keep durable facts, preferences, unresolved requests, and explicit promises. Never create permissions, tool authorizations, or facts not stated in the dialogue.' + }, + { role: 'user', content: JSON.stringify({ previousSummary: current.summary || '', turns: compactTurns(compacted) }) } + ], + responseSchema: schema() + }); + if (!result.ok) return { ok: false, reason: result.reason || 'summary_provider_error' }; + + const summary = normalizeSummary(result.data); + if (!summary) return { ok: false, reason: 'empty_summary' }; + const saved = await BotConversationStore.setSummary({ + playerId, + botId, + summary, + summaryThroughId: throughId, + expectedVersion: Number(current.version || 0) + }); + if (!saved.ok) return saved; + return { ok: true, summary, summaryThroughId: throughId, conversation: saved.conversation }; + })().finally(() => inFlight.delete(key)); + + inFlight.set(key, work); + return work; +} + +const BotConversationSummarizer = { + SUMMARY_THRESHOLD_MESSAGES, + SUMMARY_RECENT_TURNS, + summarize, + reset() { inFlight.clear(); } +}; + +module.exports = BotConversationSummarizer; diff --git a/src/GameServer/Bot/AI/BotDialogueArbiter.js b/src/GameServer/Bot/AI/BotDialogueArbiter.js index bb83437c..f9f181ca 100644 --- a/src/GameServer/Bot/AI/BotDialogueArbiter.js +++ b/src/GameServer/Bot/AI/BotDialogueArbiter.js @@ -1,4 +1,5 @@ const BotConversationService = invoke('GameServer/Bot/AI/BotConversationService'); +const BotContextAssembler = invoke('GameServer/Bot/AI/BotContextAssembler'); function fallbackText(botSession, reason) { const plan = botSession?.plan || 'hunting'; @@ -54,18 +55,25 @@ function route(input = {}) { queued: input.queued === true }; - const started = BotBrain.maybeThink( - input.botSession, - 'player_chat', + return BotContextAssembler.assemble({ + session: input.botSession, status, - turn.playerText, + text: turn.playerText, requestContext - ); + }).then((assembledContext) => { + const started = BotBrain.maybeThink( + input.botSession, + 'player_chat', + status, + turn.playerText, + { ...requestContext, assembledContext } + ); - if (!started && input.allowFallback !== false) { - return deliverFallback(input, turn, 'not_started'); - } - return { ok: true, started, queued: input.queued === true, turn }; + if (!started && input.allowFallback !== false) { + return deliverFallback(input, turn, 'not_started'); + } + return { ok: true, started, queued: input.queued === true, turn, assembledContext }; + }); }).catch((error) => { if (input.allowFallback === false || !input.playerSession?.actor || !input.botSession?.actor) { return { ok: false, reason: 'conversation_error' }; diff --git a/src/GameServer/Bot/AI/BotEventJournal.js b/src/GameServer/Bot/AI/BotEventJournal.js new file mode 100644 index 00000000..885661c4 --- /dev/null +++ b/src/GameServer/Bot/AI/BotEventJournal.js @@ -0,0 +1,211 @@ +const Database = invoke('Database'); + +const DEFAULT_LIMIT = 12; +const DEFAULT_COALESCE_WINDOW_MS = 60 * 1000; +const MAX_SUMMARY_CHARS = 280; +const MAX_META_CHARS = 1200; + +const memory = []; +let memorySequence = 0; +let schemaPromise = null; + +function numberId(value) { + const id = Number(value); + return Number.isInteger(id) && id > 0 ? id : null; +} + +function text(value, max = MAX_SUMMARY_CHARS) { + return String(value || '').replace(/\s+/g, ' ').trim().slice(0, max); +} + +function normalizeMeta(value) { + if (!value) return null; + const source = typeof value === 'object' ? value : (() => { + try { return JSON.parse(String(value)); } catch (_) { return null; } + })(); + if (!source) return null; + try { + return JSON.parse(JSON.stringify(source)); + } catch (_) { + return null; + } +} + +function normalizeRow(row) { + if (!row) return null; + let meta = null; + try { meta = row.metaJson ? JSON.parse(row.metaJson) : null; } catch (_) { meta = null; } + return { + id: Number(row.id || 0), + playerId: numberId(row.playerId), + botId: numberId(row.botId), + eventType: text(row.eventType, 64), + summary: text(row.summary), + weight: Math.max(1, Number(row.weight || 1)), + dedupeKey: row.dedupeKey ? text(row.dedupeKey, 96) : null, + count: Math.max(1, Number(row.count || 1)), + createdAt: Number(row.createdAt || 0), + updatedAt: Number(row.updatedAt || 0), + meta + }; +} + +function databaseReady() { + return typeof Database.isReady === 'function' && Database.isReady(); +} + +function ensureSchema() { + if (!databaseReady()) return Promise.resolve(false); + if (!schemaPromise) { + schemaPromise = Database.execute([ + 'SELECT 1 FROM bot_activity_journal LIMIT 1', + [] + ], 'schema:bot-activity-journal').then(() => true).catch(() => false); + } + return schemaPromise; +} + +function memoryMatch(input, row, now) { + return row.botId === input.botId && + row.playerId === input.playerId && + row.eventType === input.eventType && + row.dedupeKey === input.dedupeKey && + input.dedupeKey && now - row.updatedAt <= input.coalesceWindowMs; +} + +function copy(row) { + return row ? { ...row, meta: row.meta ? { ...row.meta } : null } : null; +} + +async function record(input = {}) { + const botId = numberId(input.botId); + if (!botId) return { ok: false, reason: 'invalid_bot' }; + const playerId = numberId(input.playerId); + const eventType = text(input.eventType, 64); + const summary = text(input.summary); + if (!eventType || !summary) return { ok: false, reason: 'invalid_event' }; + + const createdAt = Number(input.createdAt || Date.now()); + const dedupeKey = input.dedupeKey ? text(input.dedupeKey, 96) : null; + const coalesceWindowMs = Math.max(0, Number(input.coalesceWindowMs ?? DEFAULT_COALESCE_WINDOW_MS)); + const weight = Math.max(1, Math.min(10, Number(input.weight || 1))); + const meta = normalizeMeta(input.meta); + const normalized = { playerId, botId, eventType, summary, weight, dedupeKey, coalesceWindowMs, createdAt, meta }; + + const existingMemory = memory.find((row) => memoryMatch(normalized, row, createdAt)); + if (existingMemory) { + existingMemory.count += 1; + existingMemory.summary = summary; + existingMemory.weight = Math.max(existingMemory.weight, weight); + existingMemory.updatedAt = createdAt; + existingMemory.meta = meta || existingMemory.meta; + return { ok: true, inserted: false, coalesced: true, event: copy(existingMemory) }; + } + + if (databaseReady() && await ensureSchema()) { + try { + if (dedupeKey && coalesceWindowMs > 0) { + const rows = await Database.execute([ + `SELECT id, playerId, botId, eventType, summary, weight, dedupeKey, count, createdAt, updatedAt, metaJson + FROM bot_activity_journal + WHERE botId = ? AND playerId IS ? AND eventType = ? AND dedupeKey = ? AND updatedAt >= ? + ORDER BY updatedAt DESC LIMIT 1`, + [botId, playerId, eventType, dedupeKey, createdAt - coalesceWindowMs] + ], 'bot-activity:coalesce-find'); + const current = normalizeRow(rows[0]); + if (current) { + await Database.execute([ + `UPDATE bot_activity_journal + SET summary = ?, weight = ?, count = count + 1, updatedAt = ?, metaJson = ? + WHERE id = ?`, + [summary, weight, createdAt, meta ? JSON.stringify(meta).slice(0, MAX_META_CHARS) : null, current.id] + ], 'bot-activity:coalesce-update'); + current.summary = summary; + current.weight = Math.max(current.weight, weight); + current.count += 1; + current.updatedAt = createdAt; + current.meta = meta || current.meta; + return { ok: true, inserted: false, coalesced: true, event: current }; + } + } + + const result = await Database.execute([ + `INSERT INTO bot_activity_journal + (playerId, botId, eventType, summary, weight, dedupeKey, count, createdAt, updatedAt, metaJson) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, + [playerId, botId, eventType, summary, weight, dedupeKey, createdAt, createdAt, meta ? JSON.stringify(meta).slice(0, MAX_META_CHARS) : null] + ], 'bot-activity:insert'); + const rows = await Database.execute([ + `SELECT id, playerId, botId, eventType, summary, weight, dedupeKey, count, createdAt, updatedAt, metaJson + FROM bot_activity_journal WHERE id = ? LIMIT 1`, + [Number(result.insertId || 0)] + ], 'bot-activity:insert-row'); + const event = normalizeRow(rows[0]) || { + id: Number(result.insertId || 0), playerId, botId, eventType, summary, weight, + dedupeKey, count: 1, createdAt, updatedAt: createdAt, meta + }; + return { ok: true, inserted: true, event }; + } catch (_) { + // A transient DB issue must not make a bot lose the event needed for + // its next hot decision. Keep a bounded in-memory copy instead. + } + } + + const event = { + id: ++memorySequence, + playerId, + botId, + eventType, + summary, + weight, + dedupeKey, + count: 1, + createdAt, + updatedAt: createdAt, + meta + }; + memory.push(event); + while (memory.length > 2000) memory.shift(); + return { ok: true, inserted: true, event: copy(event) }; +} + +async function recent(input = {}) { + const botId = numberId(input.botId); + if (!botId) return []; + const playerId = numberId(input.playerId); + const limit = Math.max(1, Math.min(50, Number(input.limit || DEFAULT_LIMIT))); + if (databaseReady() && await ensureSchema()) { + try { + const rows = await Database.execute([ + `SELECT id, playerId, botId, eventType, summary, weight, dedupeKey, count, createdAt, updatedAt, metaJson + FROM bot_activity_journal + WHERE botId = ? AND (playerId IS ? OR playerId IS NULL) + ORDER BY updatedAt DESC, id DESC LIMIT ?`, + [botId, playerId, limit] + ], 'bot-activity:recent'); + return rows.map(normalizeRow).filter(Boolean).reverse(); + } catch (_) { /* use memory fallback */ } + } + return memory + .filter((row) => row.botId === botId && (row.playerId === playerId || row.playerId === null)) + .sort((a, b) => b.updatedAt - a.updatedAt || b.id - a.id) + .slice(0, limit) + .reverse() + .map(copy); +} + +const BotEventJournal = { + DEFAULT_LIMIT, + DEFAULT_COALESCE_WINDOW_MS, + ensureSchema, + record, + recent, + resetMemory() { + memory.length = 0; + memorySequence = 0; + schemaPromise = null; + }, + memorySize() { return memory.length; } +}; + +module.exports = BotEventJournal; diff --git a/src/GameServer/Bot/AI/BotLootEtiquette.js b/src/GameServer/Bot/AI/BotLootEtiquette.js index 8c46ae6d..ec97b05e 100644 --- a/src/GameServer/Bot/AI/BotLootEtiquette.js +++ b/src/GameServer/Bot/AI/BotLootEtiquette.js @@ -1,6 +1,7 @@ const DataCache = invoke('GameServer/DataCache'); const SpeckMath = invoke('GameServer/SpeckMath'); const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); +const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); const ADENA_ID = 57; @@ -245,6 +246,17 @@ const BotLootEtiquette = { botSession.lastLootRequestAt = current; playerSession.lastLootRequestAt = current; + Promise.resolve(BotEventJournal.record({ + playerId: playerSession.actor.fetchId(), + botId: botSession.actor.fetchId(), + eventType: 'loot_request', + summary: `${botSession.actor.fetchName?.() || 'Bot'} asked for ${amount} ${info.name}.`, + weight: 3, + dedupeKey: `loot:${botSession.actor.fetchId()}:${selfId}`, + coalesceWindowMs: REQUEST_TTL_MS, + meta: { itemId: selfId, amount, reason: demand.reason } + })).catch(() => {}); + const BotManager = invoke('GameServer/Bot/BotManager'); BotManager.botTell(botSession, playerSession, `If you don't need ${info.name}, could you trade it to me? I can use it for ${request.reason}.`); console.info("BotLoot :: %s requested %d %s from %s (%s, score %d)", actorName(botSession), amount, info.name, actorName(playerSession), request.reason, demand.score); @@ -270,6 +282,16 @@ const BotLootEtiquette = { request.fulfilled = true; removeRequest(playerSession, botSession, request); + Promise.resolve(BotEventJournal.record({ + playerId: playerSession.actor?.fetchId?.(), + botId: botSession.actor?.fetchId?.(), + eventType: 'loot_received', + summary: `${botSession.actor?.fetchName?.() || 'Bot'} received ${request.amount} ${request.itemName}.`, + weight: 4, + dedupeKey: `loot_received:${botSession.actor?.fetchId?.()}:${request.selfId}`, + coalesceWindowMs: 30000, + meta: { itemId: request.selfId, amount: request.amount } + })).catch(() => {}); console.info("BotLoot :: %s fulfilled %s request from %s", actorName(playerSession), request.itemName, actorName(botSession)); return request; } diff --git a/src/GameServer/Bot/AI/BotTownTravel.js b/src/GameServer/Bot/AI/BotTownTravel.js index b5d7d5cb..ba7d0a99 100644 --- a/src/GameServer/Bot/AI/BotTownTravel.js +++ b/src/GameServer/Bot/AI/BotTownTravel.js @@ -1,4 +1,5 @@ const ServerResponse = invoke('GameServer/Network/Response'); +const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); const SOE_SKILL_ID = 2013; const SOE_CAST_MS = 20000; @@ -59,6 +60,15 @@ function beginEscape(session, bot, town) { session.townEscape = undefined; const TeleportTo = invoke('GameServer/Actor/Generics/TeleportTo'); TeleportTo(session, bot, { locX: town.x, locY: town.y, locZ: town.z }); + Promise.resolve(BotEventJournal.record({ + botId: bot.fetchId(), + eventType: 'travel_complete', + summary: `${bot.fetchName?.() || 'Bot'} arrived in ${town.name}.`, + weight: 3, + dedupeKey: `travel:${bot.fetchId()}:${town.name}`, + coalesceWindowMs: 30000, + meta: { town: town.name, mode: 'scroll_of_escape' } + })).catch(() => {}); }, SOE_CAST_MS); } diff --git a/src/GameServer/Bot/AI/PartyCompanionService.js b/src/GameServer/Bot/AI/PartyCompanionService.js index 769c3e4b..f55316e9 100644 --- a/src/GameServer/Bot/AI/PartyCompanionService.js +++ b/src/GameServer/Bot/AI/PartyCompanionService.js @@ -2,6 +2,7 @@ const ServerResponse = invoke('GameServer/Network/Response'); const PartyAwareness = invoke('GameServer/Bot/AI/PartyAwareness'); const PartyCombatState = invoke('GameServer/Bot/AI/PartyCombatState'); const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); +const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); const DEFAULT_PARTY_DISTRIBUTION = 1; const DEFAULT_PARTY_SETTINGS = { @@ -105,7 +106,20 @@ function updateSettings(leaderSession, patch = {}) { settings[key] = patch[key]; } }); - return getSettings(leaderSession); + const next = getSettings(leaderSession); + membersForLeader(leaderSession).forEach((companionSession) => { + Promise.resolve(BotEventJournal.record({ + playerId: leaderSession?.actor?.fetchId?.(), + botId: companionSession.actor?.fetchId?.(), + eventType: 'policy_change', + summary: `${leaderSession.actor?.fetchName?.() || 'Leader'} changed party policy for ${companionSession.actor?.fetchName?.() || 'the companion'}.`, + weight: 2, + dedupeKey: `policy:${leaderSession.actor?.fetchId?.()}:${JSON.stringify(patch)}`, + coalesceWindowMs: 15000, + meta: { patch, settings: next } + })).catch(() => {}); + }); + return next; } function distributionForLeader(leaderSession) { @@ -119,6 +133,18 @@ function setDistribution(leaderSession, distribution) { settings.distribution = next; // Turn order is meaningful only for the currently selected rule. settings.itemLastLootIndex = -1; + membersForLeader(leaderSession).forEach((companionSession) => { + Promise.resolve(BotEventJournal.record({ + playerId: leaderSession?.actor?.fetchId?.(), + botId: companionSession.actor?.fetchId?.(), + eventType: 'policy_change', + summary: `${leaderSession.actor?.fetchName?.() || 'Leader'} changed loot distribution to ${next}.`, + weight: 2, + dedupeKey: `distribution:${leaderSession.actor?.fetchId?.()}:${next}`, + coalesceWindowMs: 15000, + meta: { distribution: next } + })).catch(() => {}); + }); } return settings.distribution; } @@ -732,6 +758,15 @@ const PartyCompanionService = { } refreshLeaderView(leaderSession); + Promise.resolve(invoke('GameServer/Bot/AI/BotEventJournal').record({ + playerId: leader.fetchId(), + botId: bot.fetchId(), + eventType: 'party_join', + summary: `${bot.fetchName?.() || 'Companion'} joined ${leader.fetchName?.() || 'the player'}'s party.`, + weight: 5, + dedupeKey: `party_join:${leader.fetchId()}:${bot.fetchId()}`, + coalesceWindowMs: 5000 + })).catch(() => {}); return true; }, @@ -755,6 +790,16 @@ const PartyCompanionService = { } refreshLeaderView(leaderSession, options); + Promise.resolve(invoke('GameServer/Bot/AI/BotEventJournal').record({ + playerId: leaderSession.actor?.fetchId?.(), + botId: companionSession.actor?.fetchId?.(), + eventType: 'party_leave', + summary: `${companionSession.actor?.fetchName?.() || 'Companion'} left the party.`, + weight: 5, + dedupeKey: `party_leave:${leaderSession.actor?.fetchId?.()}:${companionSession.actor?.fetchId?.()}`, + coalesceWindowMs: 5000, + meta: { event: event || null, source } + })).catch(() => {}); return true; }, diff --git a/src/GameServer/Bot/AI/States/ShoppingState.js b/src/GameServer/Bot/AI/States/ShoppingState.js index 140a1e44..3d6a61f4 100644 --- a/src/GameServer/Bot/AI/States/ShoppingState.js +++ b/src/GameServer/Bot/AI/States/ShoppingState.js @@ -8,6 +8,7 @@ const BotEquipmentUpgrade = invoke('GameServer/Bot/AI/BotEquipmentUpgrade'); const LifeState = invoke('GameServer/Bot/Population/BotLifeState'); const GoalExecutor = invoke('GameServer/Bot/Goals/GoalExecutor'); const Cooldown = invoke('GameServer/Bot/Population/Cooldown'); +const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); function findStoreSession(actorId) { const BotManager = invoke('GameServer/Bot/BotManager'); @@ -103,6 +104,15 @@ module.exports = { // In town! Wait and pretend to shop if (!session.shoppingDoneAnnounced) { session.shoppingDoneAnnounced = true; + Promise.resolve(BotEventJournal.record({ + botId: bot.fetchId(), + eventType: 'shopping_started', + summary: `${bot.fetchName?.() || 'Bot'} reached ${target.town || 'town'} to shop and restock.`, + weight: 2, + dedupeKey: `shopping:${bot.fetchId()}:${target.town || 'town'}`, + coalesceWindowMs: 30000, + meta: { town: target.town || null } + })).catch(() => {}); this.sellAndRestock(session, bot, Generics, BotAI); } }, @@ -241,6 +251,15 @@ module.exports = { const returningToCompanion = session.partyCompanion === true && companionResume?.followPlayerSession?.actor?.fetchIsOnline?.(); BotAI.say(session, returningToCompanion ? "All set. Returning to you." : "All stocked up! Returning to the hunting spot."); session.plan = session.partyCompanion === true && session.followPlayerSession ? 'following' : 'hunting'; + Promise.resolve(BotEventJournal.record({ + botId: bot.fetchId(), + eventType: 'shopping_completed', + summary: `${bot.fetchName?.() || 'Bot'} finished shopping and returned to ${session.plan}.`, + weight: 2, + dedupeKey: `shopping_done:${bot.fetchId()}`, + coalesceWindowMs: 30000, + meta: { plan: session.plan } + })).catch(() => {}); session.shoppingDoneAnnounced = false; session.shoppingTarget = undefined; session.companionShopping = undefined; diff --git a/src/GameServer/Network/Request/TradeDone.js b/src/GameServer/Network/Request/TradeDone.js index dea1b950..7033edfc 100644 --- a/src/GameServer/Network/Request/TradeDone.js +++ b/src/GameServer/Network/Request/TradeDone.js @@ -38,6 +38,16 @@ async function tradeDone(session, buffer) { lootRequest ? 'gave_useful_loot' : 'trade_completed', detail ); + Promise.resolve(invoke('GameServer/Bot/AI/BotEventJournal').record({ + playerId: session.actor?.fetchId?.(), + botId: result.partnerSession?.actor?.fetchId?.(), + eventType: 'trade_completed', + summary: `${session.actor?.fetchName?.() || 'Player'} traded ${detail}.`, + weight: 4, + dedupeKey: `trade:${session.actor?.fetchId?.()}:${result.partnerSession?.actor?.fetchId?.()}:${detail}`, + coalesceWindowMs: 30 * 1000, + meta: { itemCount: result.moved?.length || 0 } + })).catch(() => {}); BotManager.botTell( result.partnerSession, session, diff --git a/tests/test_bot_activity_journal.js b/tests/test_bot_activity_journal.js new file mode 100644 index 00000000..670c02d9 --- /dev/null +++ b/tests/test_bot_activity_journal.js @@ -0,0 +1,41 @@ +const assert = require('assert'); +require('../src/Global'); + +const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); + +async function main() { + BotEventJournal.resetMemory(); + const first = await BotEventJournal.record({ + playerId: 10, + botId: 20, + eventType: 'kill_streak', + summary: 'Defeated a goblin.', + dedupeKey: 'spot:goblin', + coalesceWindowMs: 60000 + }); + const second = await BotEventJournal.record({ + playerId: 10, + botId: 20, + eventType: 'kill_streak', + summary: 'Defeated another goblin.', + dedupeKey: 'spot:goblin', + coalesceWindowMs: 60000, + createdAt: first.event.updatedAt + 1000 + }); + assert.strictEqual(first.ok, true); + assert.strictEqual(second.coalesced, true); + assert.strictEqual(second.event.count, 2); + + await BotEventJournal.record({ playerId: 11, botId: 20, eventType: 'chat', summary: 'Other player event.' }); + await BotEventJournal.record({ botId: 20, eventType: 'level_up', summary: 'Reached level 12.', createdAt: first.event.updatedAt + 2000 }); + const pairEvents = await BotEventJournal.recent({ playerId: 10, botId: 20, limit: 10 }); + assert.deepStrictEqual(pairEvents.map((event) => event.eventType), ['kill_streak', 'level_up']); + assert.strictEqual(pairEvents[0].count, 2); + assert.strictEqual(BotEventJournal.memorySize(), 3); + console.log('Bot activity journal checks passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_bot_context_assembler.js b/tests/test_bot_context_assembler.js new file mode 100644 index 00000000..3b6c94cf --- /dev/null +++ b/tests/test_bot_context_assembler.js @@ -0,0 +1,53 @@ +const assert = require('assert'); +require('../src/Global'); + +const BotBrainContext = invoke('GameServer/Bot/AI/BotBrainContext'); +const BotContextAssembler = invoke('GameServer/Bot/AI/BotContextAssembler'); +const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); + +async function main() { + const originalCompact = BotBrainContext.compactStatus; + BotEventJournal.resetMemory(); + BotBrainContext.compactStatus = () => ({ + available: true, + name: 'BoundedBot', + level: 20, + mode: 'hunting', + party: { members: [] }, + inventory: null, + skills: null + }); + try { + await BotEventJournal.record({ botId: 20, playerId: 10, eventType: 'level_up', summary: 'Reached level 20.', weight: 4 }); + await BotEventJournal.record({ botId: 20, playerId: 10, eventType: 'trade_completed', summary: 'Received healing potions from the player.', weight: 4 }); + const assembled = await BotContextAssembler.assemble({ + session: { actor: { fetchId: () => 20 } }, + status: { available: true }, + text: 'What skills and items do you have?', + requestContext: { + playerId: 10, + conversation: { + summary: 'Player prefers short answers and asked for healing potions.', + recentTurns: [ + { role: 'player', channel: 'tell', text: 'What skills do you have?' }, + { role: 'bot', channel: 'tell', text: 'I can support the party.' } + ] + } + } + }); + assert(assembled.fragments.some((fragment) => fragment.id === 'bot_state')); + assert(assembled.fragments.some((fragment) => fragment.id === 'recent_dialogue')); + assert(assembled.fragments.some((fragment) => fragment.id === 'authoritative_events')); + assert(assembled.telemetry.skillIntent, 'skill intent should include the skill fragment path'); + assert(assembled.estimatedTokens <= assembled.hardMaxTokens); + assert(BotContextAssembler.estimateTokens(assembled.fragments) <= 1800); + console.log('Bot context assembler checks passed'); + } finally { + BotBrainContext.compactStatus = originalCompact; + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_bot_conversation_summary.js b/tests/test_bot_conversation_summary.js new file mode 100644 index 00000000..36c61ac4 --- /dev/null +++ b/tests/test_bot_conversation_summary.js @@ -0,0 +1,92 @@ +const assert = require('assert'); +require('../src/Global'); + +const BotConversationStore = invoke('GameServer/Bot/AI/BotConversationStore'); +const BotConversationSummarizer = invoke('GameServer/Bot/AI/BotConversationSummarizer'); +const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); + +async function main() { + BotConversationStore.resetMemory(); + BotConversationSummarizer.reset(); + options.default.OpenRouter = { + enabled: true, + apiKey: 'test-key', + model: 'test/summary', + maxTokens: 220, + timeoutMs: 500 + }; + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.setTransport(async () => ({ + ok: true, + status: 200, + async json() { + return { + choices: [{ message: { content: JSON.stringify({ + summary: 'The player asked for support and prefers concise answers.', + openTopics: ['confirm the next support action'], + promises: ['reply truthfully after a validated cast'] + }) } }], + usage: { prompt_tokens: 80, completion_tokens: 30, total_tokens: 110 } + }; + } + })); + + try { + for (let i = 1; i <= 30; i += 1) { + await BotConversationStore.appendTurn({ + playerId: 10, + botId: 20, + turnId: `turn-${i}`, + role: i % 2 ? 'player' : 'bot', + channel: 'tell', + text: i % 2 ? `Please help with support request ${i}.` : `I will check request ${i}.` + }); + } + const first = await BotConversationSummarizer.summarize({ playerId: 10, botId: 20, threshold: 24 }); + assert.strictEqual(first.ok, true); + assert.match(first.summary, /Open topics/); + const compacted = await BotConversationStore.context(10, 20, { limit: 20 }); + assert.match(compacted.summary, /concise answers/); + assert.strictEqual(compacted.recentTurns.length, 8); + + const stale = await BotConversationStore.setSummary({ + playerId: 10, + botId: 20, + summary: 'stale overwrite', + summaryThroughId: first.summaryThroughId, + expectedVersion: 0 + }); + assert.strictEqual(stale.ok, false); + assert.strictEqual(stale.reason, 'version_conflict'); + + // A provider failure must leave the uncompacted messages usable. + BotConversationStore.resetMemory(); + OpenRouterGateway.resetCircuit(); + for (let i = 1; i <= 30; i += 1) { + await BotConversationStore.appendTurn({ + playerId: 30, + botId: 40, + turnId: `failed-${i}`, + role: i % 2 ? 'player' : 'bot', + channel: 'tell', + text: `Uncompacted message ${i}` + }); + } + OpenRouterGateway.setTransport(async () => ({ ok: false, status: 503, async json() { return {}; } })); + const failed = await BotConversationSummarizer.summarize({ playerId: 30, botId: 40, threshold: 24 }); + assert.strictEqual(failed.ok, false); + const raw = await BotConversationStore.context(30, 40, { limit: 40 }); + assert.strictEqual(raw.summary, null); + assert.strictEqual(raw.recentTurns.length, 30); + console.log('Bot conversation summary checks passed'); + } finally { + OpenRouterGateway.resetTransport(); + OpenRouterGateway.resetCircuit(); + options.default.OpenRouter = {}; + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From bed36a040e35b03b3f9f21a681e40dffec10fe34 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:12:13 -0400 Subject: [PATCH 04/59] Register auditable hot bot tools --- database/sql/sqlite.sql | 15 ++ scripts/run-tests.js | 2 + src/Database.js | 16 ++ src/GameServer/Bot/AI/BotAgentTools.js | 95 ++++++++-- src/GameServer/Bot/AI/BotBrain.js | 34 +++- src/GameServer/Bot/AI/BotDialogueArbiter.js | 2 + src/GameServer/Bot/AI/BotToolAudit.js | 103 +++++++++++ src/GameServer/Bot/AI/BotToolRegistry.js | 191 ++++++++++++++++++++ tests/test_bot_tool_authorization.js | 67 +++++++ tests/test_bot_tool_registry.js | 66 +++++++ 10 files changed, 570 insertions(+), 21 deletions(-) create mode 100644 src/GameServer/Bot/AI/BotToolAudit.js create mode 100644 src/GameServer/Bot/AI/BotToolRegistry.js create mode 100644 tests/test_bot_tool_authorization.js create mode 100644 tests/test_bot_tool_registry.js diff --git a/database/sql/sqlite.sql b/database/sql/sqlite.sql index 83a870b4..d2737578 100644 --- a/database/sql/sqlite.sql +++ b/database/sql/sqlite.sql @@ -285,6 +285,21 @@ CREATE INDEX IF NOT EXISTS bot_activity_journal_pair_recent ON bot_activity_jour CREATE INDEX IF NOT EXISTS bot_activity_journal_bot_recent ON bot_activity_journal(botId, updatedAt DESC); CREATE INDEX IF NOT EXISTS bot_activity_journal_coalesce ON bot_activity_journal(playerId, botId, eventType, dedupeKey, updatedAt); +CREATE TABLE IF NOT EXISTS bot_tool_outcomes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playerId INTEGER REFERENCES characters(id) ON DELETE SET NULL, + botId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + turnId TEXT, + toolName TEXT NOT NULL, + outcome TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + worldRevision TEXT, + createdAt INTEGER NOT NULL DEFAULT 0, + metaJson TEXT +); +CREATE INDEX IF NOT EXISTS bot_tool_outcomes_bot_recent ON bot_tool_outcomes(botId, createdAt DESC); +CREATE INDEX IF NOT EXISTS bot_tool_outcomes_turn ON bot_tool_outcomes(botId, turnId, toolName, createdAt DESC); + CREATE TABLE IF NOT EXISTS bot_friendships ( playerId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, botId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 6a9912fc..b27d4f87 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -13,6 +13,8 @@ const tests = [ 'tests/test_bot_activity_journal.js', 'tests/test_bot_context_assembler.js', 'tests/test_bot_conversation_summary.js', + 'tests/test_bot_tool_registry.js', + 'tests/test_bot_tool_authorization.js', 'tests/test_bot_availability.js', 'tests/test_bot_chat_commands.js', 'tests/test_bot_chat_text.js', diff --git a/src/Database.js b/src/Database.js index 1f5b4094..231464c7 100644 --- a/src/Database.js +++ b/src/Database.js @@ -222,6 +222,22 @@ function applySchemaMigrations() { CREATE INDEX IF NOT EXISTS bot_activity_journal_pair_recent ON bot_activity_journal(playerId, botId, updatedAt DESC); CREATE INDEX IF NOT EXISTS bot_activity_journal_bot_recent ON bot_activity_journal(botId, updatedAt DESC); CREATE INDEX IF NOT EXISTS bot_activity_journal_coalesce ON bot_activity_journal(playerId, botId, eventType, dedupeKey, updatedAt); + `)], + [5, () => connection.exec(` + CREATE TABLE IF NOT EXISTS bot_tool_outcomes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playerId INTEGER REFERENCES characters(id) ON DELETE SET NULL, + botId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + turnId TEXT, + toolName TEXT NOT NULL, + outcome TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + worldRevision TEXT, + createdAt INTEGER NOT NULL DEFAULT 0, + metaJson TEXT + ); + CREATE INDEX IF NOT EXISTS bot_tool_outcomes_bot_recent ON bot_tool_outcomes(botId, createdAt DESC); + CREATE INDEX IF NOT EXISTS bot_tool_outcomes_turn ON bot_tool_outcomes(botId, turnId, toolName, createdAt DESC); `)] ]; const applied = new Set(connection.prepare('SELECT version FROM schema_migrations').all().map((row) => Number(row.version))); diff --git a/src/GameServer/Bot/AI/BotAgentTools.js b/src/GameServer/Bot/AI/BotAgentTools.js index 4d011cc8..80ca1920 100644 --- a/src/GameServer/Bot/AI/BotAgentTools.js +++ b/src/GameServer/Bot/AI/BotAgentTools.js @@ -3,6 +3,7 @@ const SpotService = invoke('GameServer/Bot/AI/SpotService'); const BotBuffs = invoke('GameServer/Bot/AI/BotBuffs'); const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); const BotSkillCapabilities = invoke('GameServer/Bot/AI/BotSkillCapabilities'); +const BotToolRegistry = invoke('GameServer/Bot/AI/BotToolRegistry'); const ACTIONS = [ 'none', @@ -181,7 +182,7 @@ function applyHealTarget(session, bot, decision, targetSession) { return { applied: true, reason: 'heal_requested' }; } -function execute(session, decision, visiblePlayers) { +function executeLegacy(session, decision, visiblePlayers) { const bot = session.actor; if (!bot || !decision || Number(decision.confidence || 0) < 0.45) { return { applied: false, reason: 'low_confidence_or_missing_context' }; @@ -294,6 +295,59 @@ function execute(session, decision, visiblePlayers) { return { applied: false, reason: `unknown_action:${action}` }; } +function registerTools() { + const descriptions = { + none: 'Do nothing when no useful response is needed.', + say: 'Send a short in-character reply to the target visible player.', + follow_player: 'Approach a visible player. Real party follow still requires an invite.', + stay_here: 'Hold the current position.', + hunt: 'Return to independent hunting.', + rest: 'Sit and recover.', + shop: 'Go to town for normal restock behavior.', + move_to_spot: 'Move to one of the provided candidate spot ids.', + buff_target: 'Apply a supported buff to a visible player if class, MP, and range allow it.', + heal_target: 'Heal a visible player if class, MP, and range allow it.' + }; + + ACTIONS.forEach((name) => { + BotToolRegistry.register({ + name, + description: descriptions[name], + mutating: !['none', 'say'].includes(name), + available(session) { + if (!session) return true; + if (session.plan === 'merchant' || session.plan === 'getting_buffed') { + return ['none', 'say'].includes(name); + } + if (session.plan === 'pk_hunting' && PK_LOCKED_ACTIONS.has(name)) return false; + if (name === 'buff_target') { + try { if (!BotRoles.canBuff(session.actor)) return false; } catch (_) { return true; } + } + if (name === 'heal_target') { + try { if (!BotRoles.isHealer(session.actor)) return false; } catch (_) { return true; } + } + return true; + }, + execute(context) { + return executeLegacy(context.session, context.decision, context.visiblePlayers || []); + } + }); + }); +} + +registerTools(); + +function execute(session, decision, visiblePlayers, requestContext = null) { + const outcome = BotToolRegistry.execute({ + session, + decision, + visiblePlayers, + requestContext, + expectedWorldRevision: requestContext?.worldRevision + }); + return { applied: outcome.applied, reason: outcome.reason }; +} + function remember(session, decision, result, model) { if (!result?.applied) return; session.lastBrainDecision = { @@ -312,24 +366,35 @@ function remember(session, decision, result, model) { }; } -function toolDescriptions() { - return [ - { action: 'none', description: 'Do nothing when no useful response is needed.' }, - { action: 'say', description: 'Send a short in-character reply to the target visible player.' }, - { action: 'follow_player', description: 'Approach a visible player. Real party follow still requires an invite.' }, - { action: 'stay_here', description: 'Hold the current position.' }, - { action: 'hunt', description: 'Return to independent hunting.' }, - { action: 'rest', description: 'Sit and recover.' }, - { action: 'shop', description: 'Go to town for normal restock behavior.' }, - { action: 'move_to_spot', description: 'Move to one of the provided candidate spot ids.' }, - { action: 'buff_target', description: 'Apply a supported buff to a visible player if class, MP, and range allow it.' }, - { action: 'heal_target', description: 'Heal a visible player if class, MP, and range allow it.' } - ]; +function toolDescriptions(session = null) { + return BotToolRegistry.descriptors(session); +} + +function availableActions(session = null) { + return BotToolRegistry.availableNames(session); +} + +function worldRevision(session) { + return BotToolRegistry.worldRevision(session); +} + +function rejectionReply(result = {}) { + switch (result.reason) { + case 'stale_world_state': return 'The situation changed, so I did not make that move.'; + case 'one_mutation_per_turn': return 'I can only change one thing at a time.'; + case 'not_authorized': return 'I cannot change that under the current party authority.'; + case 'pk_hunting_autonomous': return 'I am staying focused on my current fight.'; + case 'tool_unavailable': return 'That action is not available to me right now.'; + default: return 'I cannot do that safely right now.'; + } } module.exports = { ACTIONS, + availableActions, execute, remember, - toolDescriptions + rejectionReply, + toolDescriptions, + worldRevision }; diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index f853094e..589ee57b 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -91,13 +91,13 @@ function candidateSpots(status) { .slice(0, 6); } -function schema() { +function schema(allowedActions = BotAgentTools.ACTIONS) { return { type: 'object', properties: { action: { type: 'string', - enum: BotAgentTools.ACTIONS + enum: allowedActions }, reply: { type: 'string', @@ -124,6 +124,10 @@ function schema() { type: 'number', minimum: 0, maximum: 1 + }, + worldRevision: { + type: 'string', + description: 'Echo the toolContext.worldRevision when selecting a mutating action.' } }, required: ['action', 'reply', 'targetPlayerName', 'spotId', 'buffType', 'reason', 'confidence'], @@ -156,8 +160,11 @@ function userPayload(event, session, status, visiblePlayers, text, requestContex bot: assembled?.bot || BotBrainContext.compactStatus(session, status, text), visiblePlayers, candidateSpots: candidateSpots(status), - allowedActions: BotAgentTools.ACTIONS, - tools: BotAgentTools.toolDescriptions(), + allowedActions: BotAgentTools.availableActions(session), + tools: BotAgentTools.toolDescriptions(session), + toolContext: { + worldRevision: BotAgentTools.worldRevision(session) + }, constraints: { keepReplyShort: true, splitLongRepliesIntoChatLines: true, @@ -188,7 +195,7 @@ async function requestDecision(payload, cfg, session, requestContext, visiblePla ], responseSchema: { name: 'bot_brain_decision', - schema: schema() + schema: schema(BotAgentTools.availableActions(session)) } }); @@ -221,9 +228,24 @@ function recordConversationReply(session, decision, result, requestContext) { } function applyDecision(session, decision, visiblePlayers, requestContext) { - const result = BotAgentTools.execute(session, decision, visiblePlayers); + const result = BotAgentTools.execute(session, decision, visiblePlayers, requestContext); BotAgentTools.remember(session, decision, result, config().model); recordConversationReply(session, decision, result, requestContext); + if (!result.applied && requestContext?.playerSession?.actor) { + const BotManager = invoke('GameServer/Bot/BotManager'); + const reply = BotAgentTools.rejectionReply(result); + BotManager.botTell(session, requestContext.playerSession, reply); + if (requestContext.conversationTurn) { + invoke('GameServer/Bot/AI/BotConversationService').recordFallback({ + playerSession: requestContext.playerSession, + botSession: session, + turnId: requestContext.conversationTurn.turnId, + channel: requestContext.conversationTurn.channel, + text: reply, + reason: `tool_rejected:${result.reason}` + }).catch(() => {}); + } + } return result.applied; } diff --git a/src/GameServer/Bot/AI/BotDialogueArbiter.js b/src/GameServer/Bot/AI/BotDialogueArbiter.js index f9f181ca..60834fbf 100644 --- a/src/GameServer/Bot/AI/BotDialogueArbiter.js +++ b/src/GameServer/Bot/AI/BotDialogueArbiter.js @@ -1,5 +1,6 @@ const BotConversationService = invoke('GameServer/Bot/AI/BotConversationService'); const BotContextAssembler = invoke('GameServer/Bot/AI/BotContextAssembler'); +const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); function fallbackText(botSession, reason) { const plan = botSession?.plan || 'hunting'; @@ -51,6 +52,7 @@ function route(input = {}) { requestId: input.requestId, conversation: turn.context, conversationTurn: turn, + worldRevision: BotAgentTools.worldRevision(input.botSession), allowFallback: input.allowFallback !== false, queued: input.queued === true }; diff --git a/src/GameServer/Bot/AI/BotToolAudit.js b/src/GameServer/Bot/AI/BotToolAudit.js new file mode 100644 index 00000000..5301a899 --- /dev/null +++ b/src/GameServer/Bot/AI/BotToolAudit.js @@ -0,0 +1,103 @@ +const Database = invoke('Database'); + +const MAX_TEXT_CHARS = 240; +const memory = []; +let memorySequence = 0; +let schemaPromise = null; + +function id(value) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +function text(value, max = MAX_TEXT_CHARS) { + return String(value || '').replace(/\s+/g, ' ').trim().slice(0, max); +} + +function databaseReady() { + return typeof Database.isReady === 'function' && Database.isReady(); +} + +function ensureSchema() { + if (!databaseReady()) return Promise.resolve(false); + if (!schemaPromise) { + schemaPromise = Database.execute([ + 'SELECT 1 FROM bot_tool_outcomes LIMIT 1', + [] + ], 'schema:bot-tool-outcomes').then(() => true).catch(() => false); + } + return schemaPromise; +} + +function normalizeMeta(value) { + if (!value) return null; + try { return JSON.parse(JSON.stringify(value)); } catch (_) { return null; } +} + +async function record(input = {}) { + const botId = id(input.botId); + if (!botId) return { ok: false, reason: 'invalid_bot' }; + + const event = { + id: ++memorySequence, + playerId: id(input.playerId), + botId, + turnId: text(input.turnId, 128) || null, + toolName: text(input.toolName, 64), + outcome: text(input.outcome, 32), + reason: text(input.reason, 160), + worldRevision: text(input.worldRevision, 160) || null, + createdAt: Number(input.createdAt || Date.now()), + meta: normalizeMeta(input.meta) + }; + if (!event.toolName || !event.outcome) return { ok: false, reason: 'invalid_outcome' }; + + if (databaseReady() && await ensureSchema()) { + try { + const result = await Database.execute([ + `INSERT INTO bot_tool_outcomes + (playerId, botId, turnId, toolName, outcome, reason, worldRevision, createdAt, metaJson) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + event.playerId, + event.botId, + event.turnId, + event.toolName, + event.outcome, + event.reason, + event.worldRevision, + event.createdAt, + event.meta ? JSON.stringify(event.meta).slice(0, 1200) : null + ] + ], 'bot-tool-outcome:insert'); + event.id = Number(result.insertId || event.id); + return { ok: true, event }; + } catch (_) { + // Keep the audit event available during a transient database outage. + } + } + + memory.push(event); + while (memory.length > 4000) memory.shift(); + return { ok: true, event }; +} + +const BotToolAudit = { + ensureSchema, + record, + recent(input = {}) { + const botId = id(input.botId); + const limit = Math.max(1, Math.min(100, Number(input.limit || 20))); + return memory + .filter((event) => !botId || event.botId === botId) + .slice(-limit) + .map((event) => ({ ...event, meta: event.meta ? { ...event.meta } : null })); + }, + resetMemory() { + memory.length = 0; + memorySequence = 0; + schemaPromise = null; + } +}; + +module.exports = BotToolAudit; diff --git a/src/GameServer/Bot/AI/BotToolRegistry.js b/src/GameServer/Bot/AI/BotToolRegistry.js new file mode 100644 index 00000000..08d33511 --- /dev/null +++ b/src/GameServer/Bot/AI/BotToolRegistry.js @@ -0,0 +1,191 @@ +const BotToolAudit = invoke('GameServer/Bot/AI/BotToolAudit'); + +const definitions = new Map(); + +function text(value, max = 160) { + return String(value || '').replace(/\s+/g, ' ').trim().slice(0, max); +} + +function actorId(session) { + return Number(session?.actor?.fetchId?.() || 0); +} + +function playerId(context) { + return Number(context?.requestContext?.playerSession?.actor?.fetchId?.() || + context?.requestContext?.playerId || 0) || null; +} + +function turnId(context) { + return text( + context?.requestContext?.conversationTurn?.turnId || + context?.requestContext?.requestId || + context?.decision?.turnId, + 128 + ) || null; +} + +function worldRevision(session) { + const actor = session?.actor; + if (!actor) return 'missing'; + const loc = (read) => { + try { return Math.round(Number(read?.() || 0) / 25); } catch (_) { return 0; } + }; + const inventory = actor.backpack?.fetchItems?.() || []; + return [ + actorId(session), + text(session.plan, 32), + loc(actor.fetchLocX), + loc(actor.fetchLocY), + loc(actor.fetchLocZ), + Number(actor.fetchDestId?.() || session.currentTargetId || 0), + Number(actor.isDead?.() ? 1 : 0), + Number(session.partyCompanion === true ? 1 : 0), + Number(session.botStay === true ? 1 : 0), + inventory.length + ].join(':'); +} + +function isPkLocked(session, action) { + return session?.plan === 'pk_hunting' && new Set([ + 'follow_player', 'stay_here', 'hunt', 'rest', 'shop', 'move_to_spot' + ]).has(action); +} + +function isAvailable(definition, session) { + if (typeof definition.available !== 'function') return true; + return definition.available(session) !== false; +} + +function register(definition) { + if (!definition?.name) throw new Error('tool name is required'); + definitions.set(String(definition.name), { + mutating: true, + description: '', + ...definition, + name: String(definition.name) + }); + return definitions.get(String(definition.name)); +} + +function descriptors(session = null) { + return [...definitions.values()] + .filter((definition) => isAvailable(definition, session)) + .map((definition) => ({ + action: definition.name, + description: definition.description + })); +} + +function availableNames(session = null) { + return descriptors(session).map((definition) => definition.action); +} + +function audit(context, outcome, reason, meta = {}) { + BotToolAudit.record({ + playerId: playerId(context), + botId: actorId(context.session), + turnId: turnId(context), + toolName: context.decision?.action, + outcome, + reason, + worldRevision: context.expectedWorldRevision || worldRevision(context.session), + meta + }).catch(() => {}); +} + +function result(applied, reason, extra = {}) { + return { applied: !!applied, reason: text(reason, 160) || 'unknown', ...extra }; +} + +function execute(context = {}) { + const session = context.session; + const decision = context.decision || {}; + const action = text(decision.action, 64); + const definition = definitions.get(action); + const currentRevision = worldRevision(session); + const expectedRevision = context.expectedWorldRevision || decision.worldRevision || null; + const currentTurn = turnId(context); + const mutationKey = currentTurn && action ? `${currentTurn}:${action}` : null; + const mutationStore = session && (session.botToolExecutions ||= new Map()); + + audit({ ...context, decision: { ...decision, action } }, 'requested', 'requested', { + expectedRevision, + currentRevision + }); + + if (!definition) { + const rejected = result(false, 'unknown_tool'); + audit({ ...context, decision: { ...decision, action } }, 'rejected', rejected.reason); + return rejected; + } + if (isPkLocked(session, action)) { + const rejected = result(false, 'pk_hunting_autonomous'); + audit({ ...context, decision: { ...decision, action } }, 'rejected', rejected.reason); + return rejected; + } + if (!isAvailable(definition, session)) { + const rejected = result(false, 'tool_unavailable'); + audit({ ...context, decision: { ...decision, action } }, 'rejected', rejected.reason); + return rejected; + } + if (expectedRevision && expectedRevision !== currentRevision) { + const rejected = result(false, 'stale_world_state'); + audit({ ...context, decision: { ...decision, action } }, 'rejected', rejected.reason, { + expectedRevision, + currentRevision + }); + return rejected; + } + + if (mutationStore && mutationKey && mutationStore.has(mutationKey)) { + const previous = mutationStore.get(mutationKey); + audit({ ...context, decision: { ...decision, action } }, previous.applied ? 'applied' : 'rejected', 'idempotent_replay'); + return { ...previous, idempotent: true }; + } + if (mutationStore && currentTurn && definition.mutating) { + const priorMutation = [...mutationStore.entries()] + .find(([key]) => key.startsWith(`${currentTurn}:`)); + if (priorMutation) { + const rejected = result(false, 'one_mutation_per_turn'); + audit({ ...context, decision: { ...decision, action } }, 'rejected', rejected.reason); + return rejected; + } + } + + if (typeof definition.authorize === 'function' && definition.authorize(context) === false) { + const rejected = result(false, 'not_authorized'); + audit({ ...context, decision: { ...decision, action } }, 'rejected', rejected.reason); + return rejected; + } + if (typeof definition.validate === 'function') { + const validation = definition.validate(context); + if (validation !== true && validation !== undefined) { + const rejected = result(false, validation || 'invalid_arguments'); + audit({ ...context, decision: { ...decision, action } }, 'rejected', rejected.reason); + return rejected; + } + } + + let outcome; + try { + outcome = definition.execute(context); + } catch (error) { + outcome = result(false, `tool_error:${text(error.message, 120)}`); + } + const normalized = result(outcome?.applied, outcome?.reason, outcome); + if (mutationStore && mutationKey) mutationStore.set(mutationKey, normalized); + audit({ ...context, decision: { ...decision, action } }, normalized.applied ? 'applied' : 'rejected', normalized.reason, { + idempotent: false, + currentRevision: worldRevision(session) + }); + return normalized; +} + +module.exports = { + register, + execute, + descriptors, + availableNames, + worldRevision, + reset() { definitions.clear(); } +}; diff --git a/tests/test_bot_tool_authorization.js b/tests/test_bot_tool_authorization.js new file mode 100644 index 00000000..085225eb --- /dev/null +++ b/tests/test_bot_tool_authorization.js @@ -0,0 +1,67 @@ +const assert = require('assert'); +require('../src/Global'); + +const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); +const BotToolAudit = invoke('GameServer/Bot/AI/BotToolAudit'); + +function actor() { + return { + fetchId: () => 201, + fetchName: () => 'GuardedBot', + fetchLocX: () => 0, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchDestId: () => 0, + isDead: () => false, + state: { fetchSeated: () => false, setSeated() {} }, + unselect() {} + }; +} + +function decision(action, turnId, worldRevision) { + return { + action, + confidence: 0.99, + reply: '', + targetPlayerName: '', + spotId: '', + buffType: '', + reason: 'test', + turnId, + worldRevision + }; +} + +function main() { + BotToolAudit.resetMemory(); + const pk = { accountId: 'bot_pk_registry', plan: 'pk_hunting', actor: actor() }; + const pkActions = BotAgentTools.toolDescriptions(pk).map((tool) => tool.action); + assert(!pkActions.includes('follow_player'), 'PK actions must be hidden from model tools'); + assert.deepStrictEqual( + BotAgentTools.execute(pk, decision('follow_player', 'pk-1'), [], null), + { applied: false, reason: 'pk_hunting_autonomous' } + ); + + const session = { accountId: 'bot_stale_registry', plan: 'hunting', actor: actor() }; + const revision = BotAgentTools.worldRevision(session); + session.plan = 'resting'; + const stale = BotAgentTools.execute( + session, + decision('stay_here', 'stale-1', revision), + [], + { worldRevision: revision, conversationTurn: { turnId: 'stale-1' } } + ); + assert.deepStrictEqual(stale, { applied: false, reason: 'stale_world_state' }); + assert.strictEqual(session.botStay, undefined, 'stale tool must not mutate session state'); + + const unknown = BotAgentTools.execute(session, decision('invented_tool', 'unknown-1'), [], null); + assert.deepStrictEqual(unknown, { applied: false, reason: 'unknown_tool' }); + console.log('Bot tool authorization checks passed'); +} + +try { + main(); +} catch (error) { + console.error(error); + process.exitCode = 1; +} diff --git a/tests/test_bot_tool_registry.js b/tests/test_bot_tool_registry.js new file mode 100644 index 00000000..c3dd96d8 --- /dev/null +++ b/tests/test_bot_tool_registry.js @@ -0,0 +1,66 @@ +const assert = require('assert'); +require('../src/Global'); + +const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); +const BotToolAudit = invoke('GameServer/Bot/AI/BotToolAudit'); + +function actor() { + return { + fetchId: () => 200, + fetchName: () => 'RegistryBot', + fetchLocX: () => 0, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchDestId: () => 0, + isDead: () => false, + state: { + fetchSeated: () => false, + setSeated() {} + }, + unselect() {} + }; +} + +function decision(action, turnId) { + return { + action, + confidence: 0.95, + reply: '', + targetPlayerName: '', + spotId: '', + buffType: '', + reason: 'test', + turnId + }; +} + +function context(turnId) { + return { conversationTurn: { turnId } }; +} + +function main() { + BotToolAudit.resetMemory(); + const session = { accountId: 'bot_registry', plan: 'hunting', actor: actor(), dataSendToOthers() {} }; + const first = BotAgentTools.execute(session, decision('stay_here', 'turn-1'), [], context('turn-1')); + assert.deepStrictEqual(first, { applied: true, reason: 'stay_here' }); + assert.strictEqual(session.botStay, true); + + const replay = BotAgentTools.execute(session, decision('stay_here', 'turn-1'), [], context('turn-1')); + assert.deepStrictEqual(replay, first, 'the same tool turn must be idempotent'); + + const secondMutation = BotAgentTools.execute(session, decision('rest', 'turn-1'), [], context('turn-1')); + assert.deepStrictEqual(secondMutation, { applied: false, reason: 'one_mutation_per_turn' }); + + const audit = BotToolAudit.recent({ botId: 200, limit: 10 }); + assert(audit.some((event) => event.outcome === 'requested')); + assert(audit.some((event) => event.outcome === 'applied')); + assert(audit.some((event) => event.reason === 'one_mutation_per_turn')); + console.log('Bot tool registry checks passed'); +} + +try { + main(); +} catch (error) { + console.error(error); + process.exitCode = 1; +} From 5c48663596d9c2521d1338df92172eac40c77921 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:25:10 -0400 Subject: [PATCH 05/59] Add hot bot policy and equipment controls --- scripts/run-tests.js | 4 + src/GameServer/Bot/AI/BotAgentTools.js | 233 +++++++++++++++++- src/GameServer/Bot/AI/BotBrain.js | 46 ++++ src/GameServer/Bot/AI/BotBrainContext.js | 1 + src/GameServer/Bot/AI/BotCombatUtility.js | 40 ++- src/GameServer/Bot/AI/BotEquipmentUpgrade.js | 84 +++++-- src/GameServer/Bot/AI/BotStatus.js | 2 + src/GameServer/Bot/AI/BotToolRegistry.js | 27 +- src/GameServer/Bot/AI/HotBotPolicyOverlay.js | 158 ++++++++++++ .../Bot/AI/PartyCompanionService.js | 2 + src/GameServer/Bot/BotAI.js | 15 +- tests/test_hot_bot_policy_overlay.js | 34 +++ tests/test_llm_equipment_tools.js | 78 ++++++ tests/test_llm_pull_policy_tools.js | 76 ++++++ tests/test_llm_skill_priority_tools.js | 76 ++++++ 15 files changed, 844 insertions(+), 32 deletions(-) create mode 100644 src/GameServer/Bot/AI/HotBotPolicyOverlay.js create mode 100644 tests/test_hot_bot_policy_overlay.js create mode 100644 tests/test_llm_equipment_tools.js create mode 100644 tests/test_llm_pull_policy_tools.js create mode 100644 tests/test_llm_skill_priority_tools.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index b27d4f87..c6eb996b 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -15,6 +15,10 @@ const tests = [ 'tests/test_bot_conversation_summary.js', 'tests/test_bot_tool_registry.js', 'tests/test_bot_tool_authorization.js', + 'tests/test_hot_bot_policy_overlay.js', + 'tests/test_llm_pull_policy_tools.js', + 'tests/test_llm_skill_priority_tools.js', + 'tests/test_llm_equipment_tools.js', 'tests/test_bot_availability.js', 'tests/test_bot_chat_commands.js', 'tests/test_bot_chat_text.js', diff --git a/src/GameServer/Bot/AI/BotAgentTools.js b/src/GameServer/Bot/AI/BotAgentTools.js index 80ca1920..ef191c5e 100644 --- a/src/GameServer/Bot/AI/BotAgentTools.js +++ b/src/GameServer/Bot/AI/BotAgentTools.js @@ -4,6 +4,11 @@ const BotBuffs = invoke('GameServer/Bot/AI/BotBuffs'); const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); const BotSkillCapabilities = invoke('GameServer/Bot/AI/BotSkillCapabilities'); const BotToolRegistry = invoke('GameServer/Bot/AI/BotToolRegistry'); +const BotCombatUtility = invoke('GameServer/Bot/AI/BotCombatUtility'); +const BotEquipmentUpgrade = invoke('GameServer/Bot/AI/BotEquipmentUpgrade'); +const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); +const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); +const Attack = invoke('GameServer/Actor/Attack'); const ACTIONS = [ 'none', @@ -15,10 +20,24 @@ const ACTIONS = [ 'shop', 'move_to_spot', 'buff_target', - 'heal_target' + 'heal_target', + 'set_pull_policy', + 'assign_puller', + 'unassign_puller', + 'set_skill_priority', + 'clear_skill_priority', + 'set_combat_stance', + 'list_safe_loadouts', + 'equip_candidate', + 'optimize_equipment' ]; -const PK_LOCKED_ACTIONS = new Set(['follow_player', 'stay_here', 'hunt', 'rest', 'shop', 'move_to_spot']); +const PK_LOCKED_ACTIONS = new Set([ + 'follow_player', 'stay_here', 'hunt', 'rest', 'shop', 'move_to_spot', + 'set_pull_policy', 'assign_puller', 'unassign_puller', + 'set_skill_priority', 'clear_skill_priority', 'set_combat_stance', + 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment' +]); function clean(text) { const BotChatText = invoke('GameServer/Bot/AI/BotChatText'); @@ -295,6 +314,167 @@ function executeLegacy(session, decision, visiblePlayers) { return { applied: false, reason: `unknown_action:${action}` }; } +function partyLeaderFor(session) { + return session?.partyCompanion === true && session.followPlayerSession + ? session.followPlayerSession + : null; +} + +function isAuthorizedPartyLeader(context) { + const session = context?.session; + const player = context?.requestContext?.playerSession; + const leader = partyLeaderFor(session); + if (!leader?.actor || !player?.actor || !player.actor.fetchIsOnline?.()) return false; + if (String(player.accountId || '').startsWith('bot_')) return false; + return Number(player.actor.fetchId?.()) === Number(leader.actor.fetchId?.()); +} + +function controllerContext(context) { + const player = context?.requestContext?.playerSession; + return { + ownerId: player?.actor?.fetchId?.() || null, + ownerName: player?.actor?.fetchName?.() || null, + reason: context?.decision?.reason || 'player_policy_request', + ttlMs: context?.decision?.policyTtlMs + }; +} + +function policyActionResult(session, patch, context, reason) { + const policy = HotBotPolicyOverlay.set(session, patch, controllerContext(context)); + return { applied: true, reason, policy: HotBotPolicyOverlay.status(session) || policy }; +} + +function applyPullPolicy(context) { + const session = context.session; + const leader = partyLeaderFor(session); + if (!leader) return { applied: false, reason: 'not_a_party_companion' }; + + const current = PartyCompanionService.getSettings(leader); + const requestedPermission = String(context.decision.pullPermission || '').toLowerCase(); + const requestedMode = String(context.decision.pullMode || '').toLowerCase(); + const permission = ['allow', 'deny'].includes(requestedPermission) + ? requestedPermission + : requestedMode === 'off' ? 'deny' : 'allow'; + const mode = permission === 'deny' + ? 'off' + : ['auto', 'leader', 'bot'].includes(requestedMode) ? requestedMode : (current.pullMode || 'auto'); + if (mode === 'bot' && !session.actor) return { applied: false, reason: 'puller_not_available' }; + + const pullerId = mode === 'bot' ? Number(session.actor.fetchId()) : null; + PartyCompanionService.updateSettings(leader, { pullMode: mode, pullerId }); + PartyCompanionService.refreshPanel(leader); + return policyActionResult(session, { + pull: { permission, mode, pullerId } + }, context, `pull_policy:${permission}:${mode}`); +} + +function assignPuller(context) { + const session = context.session; + const leader = partyLeaderFor(session); + if (!leader || !session.actor) return { applied: false, reason: 'not_a_party_companion' }; + const requestedId = Number(context.decision.pullerId || 0); + if (requestedId && requestedId !== Number(session.actor.fetchId())) return { applied: false, reason: 'puller_must_be_target' }; + + PartyCompanionService.updateSettings(leader, { + pullMode: 'bot', + pullerId: session.actor.fetchId() + }); + PartyCompanionService.refreshPanel(leader); + return policyActionResult(session, { + pull: { permission: 'allow', mode: 'bot', pullerId: session.actor.fetchId() } + }, context, 'puller_assigned'); +} + +function unassignPuller(context) { + const session = context.session; + const leader = partyLeaderFor(session); + if (!leader || !session.actor) return { applied: false, reason: 'not_a_party_companion' }; + const settings = PartyCompanionService.getSettings(leader); + if (settings.pullMode !== 'bot' || Number(settings.pullerId || 0) !== Number(session.actor.fetchId())) { + return { applied: true, reason: 'puller_not_assigned' }; + } + + // Unassigning one member returns to the existing automatic policy. It + // never turns pull off globally and never silently assigns another bot. + PartyCompanionService.updateSettings(leader, { pullMode: 'auto', pullerId: null }); + PartyCompanionService.refreshPanel(leader); + return policyActionResult(session, { + pull: { permission: 'allow', mode: 'auto', pullerId: null } + }, context, 'puller_unassigned'); +} + +function learnedSkill(session, skillId) { + const actor = session?.actor; + const skill = actor?.skillset?.fetchSkill?.(Number(skillId)) || + (actor?.skillset?.skills || []).find((candidate) => Number(candidate.fetchSelfId?.()) === Number(skillId)); + if (!skill || skill.fetchPassive?.()) return { skill: null, reason: 'skill_not_learned' }; + const semantic = skill.fetchSemantic?.() || {}; + if (semantic.notUsedInC4 || skill.fetchTargetKind?.() !== 'enemy' || !BotCombatUtility.OFFENSIVE_TYPES.has(skill.fetchSkillType?.())) { + return { skill: null, reason: 'skill_not_combat_eligible' }; + } + const allowedWeapons = Number(semantic.requires?.weaponsAllowed || 0); + if (allowedWeapons && (allowedWeapons & Attack.weaponMaskFor(session.actor)) === 0) { + return { skill: null, reason: 'skill_incompatible' }; + } + return { skill, reason: null }; +} + +function setSkillPriority(context) { + const session = context.session; + const skillId = Number(context.decision.skillId || 0); + const resolved = learnedSkill(session, skillId); + if (resolved.reason) return { applied: false, reason: resolved.reason }; + + const current = HotBotPolicyOverlay.get(session)?.skillPriorities || {}; + const weight = Number(context.decision.skillPriority ?? context.decision.weight); + if (!Number.isFinite(weight) || weight < -50 || weight > 50) return { applied: false, reason: 'invalid_skill_priority' }; + const priorities = { ...current }; + if (weight === 0) delete priorities[String(skillId)]; + else priorities[String(skillId)] = weight; + return policyActionResult(session, { skillPriorities: priorities }, context, `skill_priority:${skillId}:${Math.round(weight)}`); +} + +function clearSkillPriority(context) { + const session = context.session; + const skillId = Number(context.decision.skillId || 0); + if (!skillId) return { applied: false, reason: 'invalid_skill_id' }; + const current = { ...(HotBotPolicyOverlay.get(session)?.skillPriorities || {}) }; + delete current[String(skillId)]; + return policyActionResult(session, { skillPriorities: current }, context, `skill_priority_cleared:${skillId}`); +} + +function setCombatStance(context) { + const stance = String(context.decision.combatStance || '').toLowerCase(); + if (!HotBotPolicyOverlay.STANCES.includes(stance)) return { applied: false, reason: 'invalid_combat_stance' }; + return policyActionResult(context.session, { combatStance: stance }, context, `combat_stance:${stance}`); +} + +function listSafeLoadouts(context) { + const loadouts = BotEquipmentUpgrade.listSafeLoadouts(context.session); + return { applied: true, reason: 'safe_loadouts', loadouts }; +} + +function equipCandidate(context) { + const result = BotEquipmentUpgrade.applyCandidate(context.session, context.decision.itemId); + if (!result.applied) return result; + return { ...result, policy: HotBotPolicyOverlay.status(context.session) }; +} + +function optimizeEquipment(context) { + const upgrades = BotEquipmentUpgrade.applyBestUpgrades(context.session); + if (!upgrades.length) return { applied: false, reason: 'no_safe_upgrade' }; + return { + applied: true, + reason: 'equipment_optimized', + upgrades: upgrades.map(({ item, slot, score }) => ({ + itemId: item.fetchId(), + name: item.fetchName(), + slot, + score + })) + }; +} + function registerTools() { const descriptions = { none: 'Do nothing when no useful response is needed.', @@ -306,20 +486,49 @@ function registerTools() { shop: 'Go to town for normal restock behavior.', move_to_spot: 'Move to one of the provided candidate spot ids.', buff_target: 'Apply a supported buff to a visible player if class, MP, and range allow it.', - heal_target: 'Heal a visible player if class, MP, and range allow it.' + heal_target: 'Heal a visible player if class, MP, and range allow it.', + set_pull_policy: 'Set the party pull permission and mode for this companion, with a bounded hot-session expiry.', + assign_puller: 'Assign this party companion as the dedicated puller without issuing combat commands.', + unassign_puller: 'Release this companion from dedicated pulling and return to the existing automatic policy.', + set_skill_priority: 'Adjust one learned offensive skill preference within a bounded combat score range.', + clear_skill_priority: 'Clear one temporary offensive skill preference.', + set_combat_stance: 'Set a bounded offensive combat stance; safety and support priorities remain authoritative.', + list_safe_loadouts: 'List inventory equipment candidates that are compatible and strictly improve a slot.', + equip_candidate: 'Equip one validated inventory upgrade through the native backpack persistence path.', + optimize_equipment: 'Equip all currently safe inventory upgrades through the native backpack path.' + }; + + const controlActions = new Set([ + 'set_pull_policy', 'assign_puller', 'unassign_puller', + 'set_skill_priority', 'clear_skill_priority', 'set_combat_stance', + 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment' + ]); + const executors = { + set_pull_policy: applyPullPolicy, + assign_puller: assignPuller, + unassign_puller: unassignPuller, + set_skill_priority: setSkillPriority, + clear_skill_priority: clearSkillPriority, + set_combat_stance: setCombatStance, + list_safe_loadouts: listSafeLoadouts, + equip_candidate: equipCandidate, + optimize_equipment: optimizeEquipment }; ACTIONS.forEach((name) => { BotToolRegistry.register({ name, description: descriptions[name], - mutating: !['none', 'say'].includes(name), + kind: controlActions.has(name) && name === 'list_safe_loadouts' ? 'read' : controlActions.has(name) ? 'mutation' : 'intent', + risk: controlActions.has(name) ? 'medium' : 'low', + mutating: !['none', 'say', 'list_safe_loadouts'].includes(name), available(session) { if (!session) return true; if (session.plan === 'merchant' || session.plan === 'getting_buffed') { return ['none', 'say'].includes(name); } if (session.plan === 'pk_hunting' && PK_LOCKED_ACTIONS.has(name)) return false; + if (controlActions.has(name) && !(session.partyCompanion === true && session.followPlayerSession)) return false; if (name === 'buff_target') { try { if (!BotRoles.canBuff(session.actor)) return false; } catch (_) { return true; } } @@ -328,7 +537,9 @@ function registerTools() { } return true; }, + authorize: controlActions.has(name) ? isAuthorizedPartyLeader : undefined, execute(context) { + if (executors[name]) return executors[name](context); return executeLegacy(context.session, context.decision, context.visiblePlayers || []); } }); @@ -345,7 +556,8 @@ function execute(session, decision, visiblePlayers, requestContext = null) { requestContext, expectedWorldRevision: requestContext?.worldRevision }); - return { applied: outcome.applied, reason: outcome.reason }; + const { idempotent, ...publicOutcome } = outcome; + return { ...publicOutcome, applied: outcome.applied, reason: outcome.reason }; } function remember(session, decision, result, model) { @@ -382,9 +594,20 @@ function rejectionReply(result = {}) { switch (result.reason) { case 'stale_world_state': return 'The situation changed, so I did not make that move.'; case 'one_mutation_per_turn': return 'I can only change one thing at a time.'; + case 'low_confidence': return 'I am not certain enough to change that safely.'; case 'not_authorized': return 'I cannot change that under the current party authority.'; case 'pk_hunting_autonomous': return 'I am staying focused on my current fight.'; case 'tool_unavailable': return 'That action is not available to me right now.'; + case 'not_a_party_companion': return 'I can only change hot party policy while I am your companion.'; + case 'incompatible_item': + case 'not_an_upgrade': + case 'no_safe_upgrade': return 'I could not find a safe equipment upgrade for this situation.'; + case 'unsafe_combat_state': return 'I will finish the current action before changing equipment.'; + case 'skill_not_learned': + case 'skill_not_combat_eligible': + case 'skill_incompatible': return 'That skill cannot be used as a combat preference.'; + case 'invalid_skill_priority': + case 'invalid_combat_stance': return 'That preference is outside my safe combat settings.'; default: return 'I cannot do that safely right now.'; } } diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 589ee57b..e5edc2b9 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -116,6 +116,48 @@ function schema(allowedActions = BotAgentTools.ACTIONS) { enum: ['', 'might', 'shield', 'haste', 'windwalk'], description: 'Buff type for buff_target, or empty string.' }, + pullMode: { + type: 'string', + enum: ['', 'auto', 'leader', 'bot', 'off'], + description: 'Temporary party pull mode for set_pull_policy.' + }, + pullPermission: { + type: 'string', + enum: ['', 'allow', 'deny'], + description: 'Temporary party pull permission for set_pull_policy.' + }, + pullerId: { + type: 'number', + minimum: 0, + description: 'Target companion actor id; normally the addressed bot.' + }, + skillId: { + type: 'number', + minimum: 0, + description: 'Learned offensive skill self id for priority tools.' + }, + skillPriority: { + type: 'number', + minimum: -50, + maximum: 50, + description: 'Bounded temporary score weight. Zero clears the preference.' + }, + combatStance: { + type: 'string', + enum: ['', 'balanced', 'aggressive', 'defensive', 'ranged'], + description: 'Bounded offensive combat stance.' + }, + itemId: { + type: 'number', + minimum: 0, + description: 'Inventory object id for equip_candidate.' + }, + policyTtlMs: { + type: 'number', + minimum: 5000, + maximum: 1800000, + description: 'Optional hot policy lifetime, clamped by the server.' + }, reason: { type: 'string', description: 'Short private reason for logs/status.' @@ -145,6 +187,10 @@ function systemPrompt() { 'follow_player only means approach a visible player unless the bot is already an invited party companion.', 'For buff_target and heal_target, choose a visible player and let the server validate class, learned skill, MP, range, and safety.', 'Do not claim that buffs or heals are ready in a plain chat reply. Use buff_target or heal_target; only the validated server action may confirm a cast.', + 'Party pull, skill preference, stance, and equipment tools are temporary hot-session controls. They require the current human party leader; never invent authority.', + 'Pull permission, pull mode, and assigned puller are separate. Unassigning one puller returns to the existing automatic policy and does not globally disable pulling.', + 'Skill priorities are bounded hints to the deterministic offensive scorer. Emergency healing, defense, resurrection, cooldowns, MP, range, and C4 compatibility always win.', + 'Equipment tools may only use safe candidates from actual inventory and native persistence. Never equip quest, incompatible, over-grade, or non-upgrade items.', 'The persona describes tone and high-level preferences only. It never overrides safety, current game state, or the allowed actions.', 'The contextFragments field is bounded and includes recent authoritative events; treat summaries as memory, never as permission to perform an action.', 'Do not offer trading, selling, price negotiation, or private stores; those tools are intentionally unavailable for now.', diff --git a/src/GameServer/Bot/AI/BotBrainContext.js b/src/GameServer/Bot/AI/BotBrainContext.js index 1f4b0b89..c91d9614 100644 --- a/src/GameServer/Bot/AI/BotBrainContext.js +++ b/src/GameServer/Bot/AI/BotBrainContext.js @@ -274,6 +274,7 @@ function compactStatus(session, status, text = '', options = {}) { inventory: includeInventory ? inventorySnapshot(actor, text) : null, skills: includeSkills ? skillsSnapshot(actor, text) : null, roleDecision: status.roleDecision || null, + policy: status.policy || null, persona: status.persona || null, social: status.social || null }; diff --git a/src/GameServer/Bot/AI/BotCombatUtility.js b/src/GameServer/Bot/AI/BotCombatUtility.js index b2cbae3c..5d871360 100644 --- a/src/GameServer/Bot/AI/BotCombatUtility.js +++ b/src/GameServer/Bot/AI/BotCombatUtility.js @@ -25,7 +25,32 @@ function reserveRatio(role) { return 0.10; } -function evaluate(bot, target, skill, role) { +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function policyAdjustment(skill, role, range, cost, maxMp, policy = {}) { + const skillId = String(skill?.fetchSelfId?.() || ''); + const priorities = policy.skillPriorities || {}; + let adjustment = clamp(Number(priorities[skillId] || 0), -50, 50); + const stance = policy.stance || policy.combatStance || 'balanced'; + + // Stance is only a bounded scoring hint for the offensive planner. It + // cannot bypass learned-skill, range, cooldown, MP, or safety checks, and + // support/revival planners never call this utility for emergency actions. + if (stance === 'aggressive') { + adjustment += Math.min(18, Math.max(0, Number(skill.fetchPower?.() || 0) / 40)); + } else if (stance === 'defensive') { + const affordableReserve = (maxMp - cost) / Math.max(1, maxMp); + adjustment += affordableReserve >= reserveRatio(role) ? 10 : -8; + } else if (stance === 'ranged') { + adjustment += range >= 400 ? 18 : -18; + } + + return Math.round(clamp(adjustment, -68, 68)); +} + +function evaluate(bot, target, skill, role, policy = {}) { if (!skill || skill.fetchPassive?.()) return null; // SkillRequest rejects a skill still on reuse after the combat planner has // already committed to it. Treat that as unavailable here so a melee bot @@ -88,19 +113,24 @@ function evaluate(bot, target, skill, role) { score += 90; reasons.push('tank_control'); } - return { skill, score: Math.round(score), reasons, cost, range, power }; + const adjustment = policyAdjustment(skill, role, range, cost, maxMp, policy); + if (adjustment) { + score += adjustment; + reasons.push(`policy_${adjustment > 0 ? 'up' : 'down'}:${adjustment}`); + } + return { skill, score: Math.round(score), reasons, cost, range, power, policyAdjustment: adjustment }; } -function select(bot, target, role) { +function select(bot, target, role, policy = {}) { const skills = bot?.skillset?.skills || []; const candidates = role === 'mage' ? skills.filter((skill) => skill.fetchSpell?.() === true) : skills; return candidates - .map((skill) => evaluate(bot, target, skill, role)) + .map((skill) => evaluate(bot, target, skill, role, policy)) .filter(Boolean) .sort((a, b) => b.score - a.score)[0] || null; } -module.exports = { OFFENSIVE_TYPES, evaluate, select }; +module.exports = { OFFENSIVE_TYPES, evaluate, select, policyAdjustment }; diff --git a/src/GameServer/Bot/AI/BotEquipmentUpgrade.js b/src/GameServer/Bot/AI/BotEquipmentUpgrade.js index 5a6c9066..4bf7d4f2 100644 --- a/src/GameServer/Bot/AI/BotEquipmentUpgrade.js +++ b/src/GameServer/Bot/AI/BotEquipmentUpgrade.js @@ -216,25 +216,20 @@ function canApplyNow(session, options = {}) { return true; } -function applyBestUpgrades(session, options = {}) { - if (!canApplyNow(session, options)) return []; - - const actor = session.actor; - session.lastEquipmentUpgradeCheckAt = Date.now(); - const upgrades = findBestUpgrades(session); - if (upgrades.length === 0) return []; +function safeCandidate(session, itemId) { + const actor = session?.actor; + const items = actor?.backpack?.fetchItems ? actor.backpack.fetchItems() : []; + const item = items.find((candidate) => Number(candidate.fetchId?.()) === Number(itemId || 0)); + if (!item) return { item: null, slot: null, reason: 'item_not_found' }; + if (!isSuitableItem(actor, item)) return { item, slot: null, reason: 'incompatible_item' }; - upgrades.forEach(({ item, slot }) => { - if (Number(item.fetchSlot()) !== Number(slot)) { - item.setSlot(slot); - } - if ([ARMOR_SLOTS.earringRight, ARMOR_SLOTS.ringRight].includes(Number(slot)) && actor.backpack.fetchPaperdollId(slot)) { - actor.backpack.unequipGear(session, slot); - item.setSlot(slot); - } - actor.backpack.equipGear(session, item); - }); + const slot = bestUpgradeSlot(actor, item, new Map()); + if (!slot) return { item, slot: null, reason: 'not_an_upgrade' }; + return { item, slot, score: scoreItem(actor, item), reason: null }; +} +function refreshAfterEquipment(session, upgrades) { + const actor = session.actor; // A weapon upgrade can change both the grade and the compatible shot kind. // Restock and re-enable after equipping; bots cannot send a client hotbar // toggle themselves. @@ -256,12 +251,67 @@ function applyBestUpgrades(session, options = {}) { actor.fetchName(), upgrades.map(({ item }) => item.fetchName()).join(', ') ); +} +function applyUpgradeEntries(session, upgrades) { + const actor = session.actor; + upgrades.forEach(({ item, slot }) => { + if (Number(item.fetchSlot()) !== Number(slot)) { + item.setSlot(slot); + } + if ([ARMOR_SLOTS.earringRight, ARMOR_SLOTS.ringRight].includes(Number(slot)) && actor.backpack.fetchPaperdollId(slot)) { + actor.backpack.unequipGear(session, slot); + item.setSlot(slot); + } + actor.backpack.equipGear(session, item); + }); + refreshAfterEquipment(session, upgrades); return upgrades; } +function applyBestUpgrades(session, options = {}) { + if (!canApplyNow(session, options)) return []; + + const actor = session.actor; + session.lastEquipmentUpgradeCheckAt = Date.now(); + const upgrades = findBestUpgrades(session); + if (upgrades.length === 0) return []; + + return applyUpgradeEntries(session, upgrades); +} + +function listSafeLoadouts(session) { + return findBestUpgrades(session).map(({ item, slot, score }) => ({ + itemId: item.fetchId(), + selfId: item.fetchSelfId(), + name: item.fetchName(), + slot, + score, + rank: item.fetchRank?.() || 'none', + kind: item.fetchKind() + })); +} + +function applyCandidate(session, itemId, options = {}) { + if (!canApplyNow(session, options)) return { applied: false, reason: 'unsafe_combat_state' }; + session.lastEquipmentUpgradeCheckAt = Date.now(); + const candidate = safeCandidate(session, itemId); + if (candidate.reason) return { applied: false, reason: candidate.reason }; + applyUpgradeEntries(session, [{ item: candidate.item, slot: candidate.slot, score: candidate.score }]); + return { + applied: true, + reason: 'equipment_equipped', + itemId: candidate.item.fetchId(), + name: candidate.item.fetchName(), + slot: candidate.slot, + score: candidate.score + }; +} + module.exports = { + applyCandidate, applyBestUpgrades, findBestUpgrades, + listSafeLoadouts, scoreItem }; diff --git a/src/GameServer/Bot/AI/BotStatus.js b/src/GameServer/Bot/AI/BotStatus.js index 55449e60..e4481b29 100644 --- a/src/GameServer/Bot/AI/BotStatus.js +++ b/src/GameServer/Bot/AI/BotStatus.js @@ -7,6 +7,7 @@ const PartyPulling = invoke('GameServer/Bot/AI/PartyPulling'); const PartyCombatState = invoke('GameServer/Bot/AI/PartyCombatState'); const EffectStore = invoke('GameServer/Effects/EffectStore'); const GearSkillHints = invoke('GameServer/Bot/AI/GearSkillHints'); +const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); function ratio(value, max) { if (!max) return 0; @@ -338,6 +339,7 @@ const BotStatus = { nearby: nearbySnapshot(bot), trade: tradeSnapshot(session, bot), persona: personaSnapshot(session), + policy: HotBotPolicyOverlay.status(session), social: session.socialSummary || null, lastSocialEvent: session.lastSocialEvent || null, blockers: [] diff --git a/src/GameServer/Bot/AI/BotToolRegistry.js b/src/GameServer/Bot/AI/BotToolRegistry.js index 08d33511..b1464841 100644 --- a/src/GameServer/Bot/AI/BotToolRegistry.js +++ b/src/GameServer/Bot/AI/BotToolRegistry.js @@ -1,4 +1,5 @@ const BotToolAudit = invoke('GameServer/Bot/AI/BotToolAudit'); +const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); const definitions = new Map(); @@ -31,6 +32,9 @@ function worldRevision(session) { try { return Math.round(Number(read?.() || 0) / 25); } catch (_) { return 0; } }; const inventory = actor.backpack?.fetchItems?.() || []; + const leader = session.partyCompanion === true ? session.followPlayerSession : null; + const partySettings = leader?.partyCompanionSettings || {}; + const overlay = HotBotPolicyOverlay.get(session); return [ actorId(session), text(session.plan, 32), @@ -41,13 +45,19 @@ function worldRevision(session) { Number(actor.isDead?.() ? 1 : 0), Number(session.partyCompanion === true ? 1 : 0), Number(session.botStay === true ? 1 : 0), - inventory.length + inventory.length, + Number(overlay?.updatedAt || 0), + String(partySettings.pullMode || ''), + Number(partySettings.pullerId || 0) ].join(':'); } function isPkLocked(session, action) { return session?.plan === 'pk_hunting' && new Set([ - 'follow_player', 'stay_here', 'hunt', 'rest', 'shop', 'move_to_spot' + 'follow_player', 'stay_here', 'hunt', 'rest', 'shop', 'move_to_spot', + 'set_pull_policy', 'assign_puller', 'unassign_puller', + 'set_skill_priority', 'clear_skill_priority', 'set_combat_stance', + 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment' ]).has(action); } @@ -61,6 +71,9 @@ function register(definition) { definitions.set(String(definition.name), { mutating: true, description: '', + kind: definition.mutating === false ? 'read' : 'mutation', + risk: 'low', + parameters: null, ...definition, name: String(definition.name) }); @@ -72,7 +85,10 @@ function descriptors(session = null) { .filter((definition) => isAvailable(definition, session)) .map((definition) => ({ action: definition.name, - description: definition.description + description: definition.description, + kind: definition.kind, + risk: definition.risk, + parameters: definition.parameters || null })); } @@ -136,6 +152,11 @@ function execute(context = {}) { }); return rejected; } + if (definition.mutating && Number(decision.confidence || 0) < 0.45) { + const rejected = result(false, 'low_confidence'); + audit({ ...context, decision: { ...decision, action } }, 'rejected', rejected.reason); + return rejected; + } if (mutationStore && mutationKey && mutationStore.has(mutationKey)) { const previous = mutationStore.get(mutationKey); diff --git a/src/GameServer/Bot/AI/HotBotPolicyOverlay.js b/src/GameServer/Bot/AI/HotBotPolicyOverlay.js new file mode 100644 index 00000000..3ce1e9de --- /dev/null +++ b/src/GameServer/Bot/AI/HotBotPolicyOverlay.js @@ -0,0 +1,158 @@ +// Temporary policy written by an authorized player through the hot dialogue +// tool layer. This is deliberately session-local: persona and social memory +// are durable, while pull overrides, combat preferences, and skill weights +// must disappear when the hot session ends or the party relationship changes. + +const DEFAULT_TTL_MS = 15 * 60 * 1000; +const MIN_TTL_MS = 5 * 1000; +const MAX_TTL_MS = 30 * 60 * 1000; +const MAX_SKILL_PRIORITIES = 12; +const MAX_SKILL_WEIGHT = 50; +const STANCES = new Set(['balanced', 'aggressive', 'defensive', 'ranged']); + +function now() { + return Date.now(); +} + +function actorId(session) { + return Number(session?.actor?.fetchId?.() || 0) || null; +} + +function actorName(session) { + return session?.actor?.fetchName?.() || session?.name || null; +} + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function ttl(value) { + const requested = Number(value); + if (!Number.isFinite(requested)) return DEFAULT_TTL_MS; + return clamp(Math.floor(requested), MIN_TTL_MS, MAX_TTL_MS); +} + +function normalizePriorities(value) { + if (!value || typeof value !== 'object') return {}; + return Object.entries(value) + .map(([skillId, weight]) => [Number(skillId), clamp(Number(weight) || 0, -MAX_SKILL_WEIGHT, MAX_SKILL_WEIGHT)]) + .filter(([skillId, weight]) => skillId > 0 && weight !== 0) + .sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]) || a[0] - b[0]) + .slice(0, MAX_SKILL_PRIORITIES) + .reduce((result, [skillId, weight]) => { + result[String(skillId)] = weight; + return result; + }, {}); +} + +function normalizeStance(value) { + const stance = String(value || '').toLowerCase(); + return STANCES.has(stance) ? stance : null; +} + +function normalizePull(value) { + if (!value || typeof value !== 'object') return null; + const permission = ['allow', 'deny'].includes(value.permission) ? value.permission : null; + const mode = ['auto', 'leader', 'bot', 'off'].includes(value.mode) ? value.mode : null; + const pullerId = Number(value.pullerId || 0) || null; + if (!permission && !mode && !pullerId) return null; + return { permission, mode, pullerId }; +} + +function prune(session) { + const overlay = session?.hotPolicyOverlay; + if (!overlay) return null; + if (Number(overlay.expiresAt || 0) > now()) return overlay; + delete session.hotPolicyOverlay; + return null; +} + +function get(session) { + return prune(session); +} + +function set(session, patch = {}, context = {}) { + if (!session) return null; + + const previous = prune(session) || {}; + const updated = { + ...previous, + ownerId: Number(context.ownerId || previous.ownerId || 0) || null, + ownerName: context.ownerName || previous.ownerName || null, + reason: String(context.reason || patch.reason || previous.reason || 'player_request').slice(0, 160), + createdAt: Number(previous.createdAt || now()), + updatedAt: now(), + expiresAt: now() + ttl(patch.ttlMs ?? context.ttlMs ?? (previous.expiresAt ? previous.expiresAt - now() : DEFAULT_TTL_MS)), + skillPriorities: normalizePriorities(patch.skillPriorities ?? previous.skillPriorities), + combatStance: normalizeStance(patch.combatStance ?? previous.combatStance), + pull: normalizePull(patch.pull ?? previous.pull) + }; + + // An explicitly cleared field must not be resurrected by the old object. + if (patch.skillPriorities === null) updated.skillPriorities = {}; + if (patch.combatStance === null) updated.combatStance = null; + if (patch.pull === null) updated.pull = null; + + session.hotPolicyOverlay = updated; + return { ...updated, skillPriorities: { ...updated.skillPriorities }, pull: updated.pull && { ...updated.pull } }; +} + +function clear(session, reason = 'lifecycle') { + if (!session?.hotPolicyOverlay) return false; + delete session.hotPolicyOverlay; + session.lastHotPolicyReset = { reason, at: now() }; + return true; +} + +function clearForDeath(session) { + return clear(session, 'death'); +} + +function clearForCold(session) { + return clear(session, 'cold_transition'); +} + +function clearForPartyDetach(session) { + return clear(session, 'party_detached'); +} + +function combatPolicy(session) { + const overlay = get(session); + return { + skillPriorities: { ...(overlay?.skillPriorities || {}) }, + stance: overlay?.combatStance || 'balanced' + }; +} + +function status(session) { + const overlay = get(session); + if (!overlay) return null; + return { + ownerId: overlay.ownerId, + ownerName: overlay.ownerName, + reason: overlay.reason, + createdAt: overlay.createdAt, + updatedAt: overlay.updatedAt, + expiresAt: overlay.expiresAt, + expiresInSec: Math.max(0, Math.ceil((overlay.expiresAt - now()) / 1000)), + pull: overlay.pull ? { ...overlay.pull } : null, + combatStance: overlay.combatStance || null, + skillPriorities: { ...(overlay.skillPriorities || {}) } + }; +} + +module.exports = { + DEFAULT_TTL_MS, + MAX_SKILL_WEIGHT, + STANCES: [...STANCES], + clear, + clearForCold, + clearForDeath, + clearForPartyDetach, + combatPolicy, + get, + normalizePriorities, + normalizeStance, + set, + status +}; diff --git a/src/GameServer/Bot/AI/PartyCompanionService.js b/src/GameServer/Bot/AI/PartyCompanionService.js index f55316e9..8c42f30e 100644 --- a/src/GameServer/Bot/AI/PartyCompanionService.js +++ b/src/GameServer/Bot/AI/PartyCompanionService.js @@ -108,6 +108,7 @@ function updateSettings(leaderSession, patch = {}) { }); const next = getSettings(leaderSession); membersForLeader(leaderSession).forEach((companionSession) => { + companionSession.autoTaunt = next.pullMode !== 'off'; Promise.resolve(BotEventJournal.record({ playerId: leaderSession?.actor?.fetchId?.(), botId: companionSession.actor?.fetchId?.(), @@ -601,6 +602,7 @@ function cancelCompanionAction(companionSession) { } function detachState(companionSession, plan = 'hunting') { + invoke('GameServer/Bot/AI/HotBotPolicyOverlay').clearForPartyDetach(companionSession); cancelCompanionAction(companionSession); companionSession.plan = plan; companionSession.followPlayerSession = null; diff --git a/src/GameServer/Bot/BotAI.js b/src/GameServer/Bot/BotAI.js index 92ddffa6..42d7c196 100644 --- a/src/GameServer/Bot/BotAI.js +++ b/src/GameServer/Bot/BotAI.js @@ -8,6 +8,7 @@ const BotEquipmentUpgrade = invoke('GameServer/Bot/AI/BotEquipmentUpgrade'); const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); const PartyRevivalService = invoke('GameServer/Bot/AI/PartyRevivalService'); const TownRespawn = invoke('GameServer/World/TownRespawn'); +const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); const CHAT_PHRASES = { foundTarget: [ @@ -135,6 +136,7 @@ const BotAI = { stop(session) { session.aiActive = false; + HotBotPolicyOverlay.clearForCold(session); if (session.aiTimeout) { clearTimeout(session.aiTimeout); session.aiTimeout = null; @@ -325,7 +327,14 @@ const BotAI = { PopulationService.recordHotTick(session); const botDead = bot.isDead(); - if (botDead) clearTacticalState(session); + if (botDead) { + clearTacticalState(session); + HotBotPolicyOverlay.clearForDeath(session); + } else { + // TTL expiry is intentionally lazy and bounded to hot ticks; no + // background timer is needed for a session-local preference. + HotBotPolicyOverlay.get(session); + } session.botStatus = BotStatus.getStatus(session); const isCompanion = !!session.followPlayerSession && session.partyCompanion === true; @@ -523,7 +532,9 @@ const BotAI = { // Healers and buffers may assist the party with their weapon, but // their role controller must be able to keep their MP for support. // Do not make that policy depend on the generic combat selector. - const decision = options.basicAttackOnly ? null : BotCombatUtility.select(bot, npc, role); + const decision = options.basicAttackOnly + ? null + : BotCombatUtility.select(bot, npc, role, HotBotPolicyOverlay.combatPolicy(session)); if (decision) { session.lastCombatDecision = { action: 'cast_skill', diff --git a/tests/test_hot_bot_policy_overlay.js b/tests/test_hot_bot_policy_overlay.js new file mode 100644 index 00000000..6ff695bd --- /dev/null +++ b/tests/test_hot_bot_policy_overlay.js @@ -0,0 +1,34 @@ +const assert = require('assert'); +require('../src/Global'); + +const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); + +const originalNow = Date.now; +const session = { actor: { fetchId: () => 901, fetchName: () => 'PolicyBot' } }; + +try { + HotBotPolicyOverlay.clear(session, 'test_reset'); + Date.now = () => 100000; + const created = HotBotPolicyOverlay.set(session, { + combatStance: 'aggressive', + skillPriorities: { 10: 80, 11: -70 }, + ttlMs: 5000 + }, { ownerId: 77, ownerName: 'Leader', reason: 'test' }); + + assert.strictEqual(created.combatStance, 'aggressive'); + assert.deepStrictEqual(created.skillPriorities, { 10: 50, 11: -50 }, 'skill weights must be bounded'); + assert.strictEqual(created.ownerId, 77); + assert.strictEqual(created.expiresAt, 105000); + + Date.now = () => 104999; + assert(HotBotPolicyOverlay.get(session), 'overlay should remain active before expiry'); + Date.now = () => 105001; + assert.strictEqual(HotBotPolicyOverlay.get(session), null, 'expired overlay must be removed'); + + HotBotPolicyOverlay.set(session, { combatStance: 'defensive' }, { ownerId: 77 }); + assert(HotBotPolicyOverlay.clearForDeath(session)); + assert.strictEqual(HotBotPolicyOverlay.status(session), null, 'death must clear hot policy'); + console.log('Hot bot policy overlay checks passed'); +} finally { + Date.now = originalNow; +} diff --git a/tests/test_llm_equipment_tools.js b/tests/test_llm_equipment_tools.js new file mode 100644 index 00000000..6dd95c74 --- /dev/null +++ b/tests/test_llm_equipment_tools.js @@ -0,0 +1,78 @@ +const assert = require('assert'); +require('../src/Global'); + +const BotManager = invoke('GameServer/Bot/BotManager'); +const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); +const Item = invoke('GameServer/Item/Item'); + +function wearable(id, data) { + return new Item(id, { + selfId: data.selfId || id, + name: data.name || `item_${id}`, + kind: data.kind, + price: data.price ?? 100, + rank: data.rank || 'none', + pAtk: data.pAtk || 0, + mAtk: data.mAtk || 0, + pDef: data.pDef || 0, + mDef: data.mDef || 0, + equipped: data.equipped || false, + slot: data.slot + }); +} + +const oldSword = wearable(801, { kind: 'Weapon.Sword', slot: 7, pAtk: 8, equipped: true }); +const newSword = wearable(802, { kind: 'Weapon.Sword', slot: 7, pAtk: 20 }); +const questItem = wearable(803, { kind: 'Other.Quest', slot: 0, price: 1000 }); +const items = [oldSword, newSword, questItem]; +const backpack = { + fetchItems: () => items, + fetchEquippedWeapon: () => oldSword, + fetchPaperdollId: (slot) => Number(slot) === 7 ? 801 : 0, + fetchItemRaw: (id) => items.find((item) => item.fetchId() === id) +}; +const leader = { accountId: 'player_gear_leader', actor: { fetchId: () => 720, fetchName: () => 'GearLeader', fetchIsOnline: () => true } }; +const bot = { + accountId: 'bot_gear_tools', + plan: 'following', + partyCompanion: true, + followPlayerSession: leader, + actor: { + fetchId: () => 721, + fetchName: () => 'GearCompanion', + fetchLevel: () => 10, + fetchClassId: () => 0, + isDead: () => false, + state: { fetchHits: () => false, fetchCasts: () => false, fetchTowards: () => false }, + backpack + } +}; +BotManager.sessions = [bot]; + +function decision(action, turnId, extra = {}) { + return { + action, + confidence: 0.99, + reply: '', + targetPlayerName: '', + spotId: '', + buffType: '', + reason: 'equipment tool test', + turnId, + ...extra + }; +} + +try { + const context = (turnId) => ({ playerSession: leader, conversationTurn: { turnId } }); + const listed = BotAgentTools.execute(bot, decision('list_safe_loadouts', 'gear-1'), [], context('gear-1')); + assert.strictEqual(listed.applied, true); + assert(listed.loadouts.some((entry) => entry.itemId === 802), 'safe loadout should expose a strict weapon upgrade'); + assert(!listed.loadouts.some((entry) => entry.itemId === 803), 'quest items must never be exposed as loadouts'); + + const rejected = BotAgentTools.execute(bot, decision('equip_candidate', 'gear-2', { itemId: 803 }), [], context('gear-2')); + assert.deepStrictEqual(rejected, { applied: false, reason: 'incompatible_item' }); + console.log('LLM equipment tool checks passed'); +} finally { + // no persistent world state is changed by this fixture +} diff --git a/tests/test_llm_pull_policy_tools.js b/tests/test_llm_pull_policy_tools.js new file mode 100644 index 00000000..27c6f3d2 --- /dev/null +++ b/tests/test_llm_pull_policy_tools.js @@ -0,0 +1,76 @@ +const assert = require('assert'); +require('../src/Global'); + +const BotManager = invoke('GameServer/Bot/BotManager'); +const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); +const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); + +function actor(id, name) { + return { + fetchId: () => id, + fetchName: () => name, + fetchLocX: () => 0, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchDestId: () => 0, + isDead: () => false, + state: { fetchSeated: () => false, setSeated() {} }, + unselect() {} + }; +} + +function decision(action, turnId, extra = {}) { + return { + action, + confidence: 0.99, + reply: '', + targetPlayerName: '', + spotId: '', + buffType: '', + reason: 'party policy test', + turnId, + ...extra + }; +} + +const leader = { accountId: 'player_leader', actor: { ...actor(700, 'Leader'), fetchIsOnline: () => true }, dataSendToMe() {} }; +const outsider = { accountId: 'player_other', actor: { ...actor(701, 'Other'), fetchIsOnline: () => true } }; +const bot = { + accountId: 'bot_policy_tools', + plan: 'following', + partyCompanion: true, + followPlayerSession: leader, + actor: actor(702, 'PolicyCompanion') +}; +BotManager.sessions = [bot]; + +const originalRefreshPanel = PartyCompanionService.refreshPanel; +PartyCompanionService.refreshPanel = () => {}; + +try { + const context = (turnId, playerSession = leader) => ({ + playerSession, + conversationTurn: { turnId } + }); + + const assigned = BotAgentTools.execute(bot, decision('assign_puller', 'pull-1'), [], context('pull-1')); + assert.strictEqual(assigned.applied, true); + assert.strictEqual(PartyCompanionService.getSettings(leader).pullMode, 'bot'); + assert.strictEqual(PartyCompanionService.getSettings(leader).pullerId, 702); + + const denied = BotAgentTools.execute(bot, decision('set_pull_policy', 'pull-2', { pullMode: 'off' }), [], context('pull-2', outsider)); + assert.deepStrictEqual(denied, { applied: false, reason: 'not_authorized' }); + assert.strictEqual(PartyCompanionService.getSettings(leader).pullMode, 'bot', 'outsider must not change party policy'); + + const unassigned = BotAgentTools.execute(bot, decision('unassign_puller', 'pull-3'), [], context('pull-3')); + assert.strictEqual(unassigned.applied, true); + assert.strictEqual(PartyCompanionService.getSettings(leader).pullMode, 'auto'); + assert.strictEqual(PartyCompanionService.getSettings(leader).pullerId, null); + + const repeatUnassign = BotAgentTools.execute(bot, decision('unassign_puller', 'pull-4'), [], context('pull-4')); + assert.strictEqual(repeatUnassign.applied, true); + assert.strictEqual(PartyCompanionService.getSettings(leader).pullMode, 'auto', 'unassign must not disable autonomous pulls'); + console.log('LLM pull policy tool checks passed'); +} finally { + PartyCompanionService.refreshPanel = originalRefreshPanel; +} diff --git a/tests/test_llm_skill_priority_tools.js b/tests/test_llm_skill_priority_tools.js new file mode 100644 index 00000000..bb9e5063 --- /dev/null +++ b/tests/test_llm_skill_priority_tools.js @@ -0,0 +1,76 @@ +const assert = require('assert'); +require('../src/Global'); + +const BotManager = invoke('GameServer/Bot/BotManager'); +const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); +const BotCombatUtility = invoke('GameServer/Bot/AI/BotCombatUtility'); +const C4SkillRules = invoke('GameServer/Skills/C4SkillRules'); +const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); + +function skill() { + return { + fetchSelfId: () => 123, + fetchName: () => 'Power Strike', + fetchPassive: () => false, + fetchSemantic: () => ({}), + fetchTargetKind: () => 'enemy', + fetchSkillType: () => C4SkillRules.DAMAGE, + fetchDistance: () => 40, + fetchConsumedMp: () => 4, + fetchPower: () => 80, + fetchSpell: () => false + }; +} + +const learned = skill(); +const leader = { accountId: 'player_skill_leader', actor: { fetchId: () => 710, fetchName: () => 'SkillLeader', fetchIsOnline: () => true } }; +const bot = { + accountId: 'bot_skill_tools', + plan: 'following', + partyCompanion: true, + followPlayerSession: leader, + actor: { + fetchId: () => 711, + fetchName: () => 'SkillCompanion', + fetchIsOnline: () => true, + skillset: { skills: [learned], fetchSkill: (id) => Number(id) === 123 ? learned : null } + } +}; +BotManager.sessions = [bot]; + +function decision(action, turnId, extra = {}) { + return { + action, + confidence: 0.99, + reply: '', + targetPlayerName: '', + spotId: '', + buffType: '', + reason: 'skill policy test', + turnId, + ...extra + }; +} + +try { + const ctx = (turnId) => ({ playerSession: leader, conversationTurn: { turnId } }); + const set = BotAgentTools.execute(bot, decision('set_skill_priority', 'skill-1', { skillId: 123, skillPriority: 50 }), [], ctx('skill-1')); + assert.strictEqual(set.applied, true); + assert.strictEqual(HotBotPolicyOverlay.status(bot).skillPriorities['123'], 50); + + const stance = BotAgentTools.execute(bot, decision('set_combat_stance', 'skill-2', { combatStance: 'ranged' }), [], ctx('skill-2')); + assert.strictEqual(stance.applied, true); + const policy = HotBotPolicyOverlay.combatPolicy(bot); + assert.strictEqual(policy.stance, 'ranged'); + assert(BotCombatUtility.policyAdjustment(learned, 'dps', 40, 4, 100, { stance: policy.stance }) < 0, 'ranged stance must not prefer melee range'); + + const clear = BotAgentTools.execute(bot, decision('clear_skill_priority', 'skill-3', { skillId: 123 }), [], ctx('skill-3')); + assert.strictEqual(clear.applied, true); + assert.deepStrictEqual(HotBotPolicyOverlay.status(bot).skillPriorities, {}); + + const invalid = BotAgentTools.execute(bot, decision('set_skill_priority', 'skill-4', { skillId: 123, skillPriority: 51 }), [], ctx('skill-4')); + assert.deepStrictEqual(invalid, { applied: false, reason: 'invalid_skill_priority' }); + console.log('LLM skill priority tool checks passed'); +} finally { + HotBotPolicyOverlay.clear(bot, 'test_cleanup'); +} From e8add70d48a572cb9d9c72d9796616454ee32df2 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:42:36 -0400 Subject: [PATCH 06/59] Add symmetric native bot trade --- scripts/run-tests.js | 5 + src/Database.js | 59 +++ src/GameServer/Bot/AI/BotAgentTools.js | 78 ++- src/GameServer/Bot/AI/BotBrain.js | 13 +- src/GameServer/Bot/AI/BotStatus.js | 3 + src/GameServer/Bot/AI/BotToolRegistry.js | 8 +- src/GameServer/Bot/BotAI.js | 2 + src/GameServer/Bot/BotTradeService.js | 519 +++++++++++++++----- src/GameServer/Network/Request/TradeDone.js | 9 +- src/GameServer/Session.js | 1 + tests/test_bot_outbound_trade.js | 83 ++++ tests/test_bot_trade_atomicity.js | 46 ++ tests/test_bot_trade_database.js | 59 +++ tests/test_bot_trade_reservations.js | 40 ++ tests/test_llm_trade_tools.js | 89 ++++ 15 files changed, 876 insertions(+), 138 deletions(-) create mode 100644 tests/test_bot_outbound_trade.js create mode 100644 tests/test_bot_trade_atomicity.js create mode 100644 tests/test_bot_trade_database.js create mode 100644 tests/test_bot_trade_reservations.js create mode 100644 tests/test_llm_trade_tools.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index c6eb996b..50d15647 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -19,6 +19,11 @@ const tests = [ 'tests/test_llm_pull_policy_tools.js', 'tests/test_llm_skill_priority_tools.js', 'tests/test_llm_equipment_tools.js', + 'tests/test_llm_trade_tools.js', + 'tests/test_bot_outbound_trade.js', + 'tests/test_bot_trade_reservations.js', + 'tests/test_bot_trade_atomicity.js', + 'tests/test_bot_trade_database.js', 'tests/test_bot_availability.js', 'tests/test_bot_chat_commands.js', 'tests/test_bot_chat_text.js', diff --git a/src/Database.js b/src/Database.js index 231464c7..93b84a02 100644 --- a/src/Database.js +++ b/src/Database.js @@ -393,6 +393,65 @@ const Database = { }, 'inventory:sync-summary')); }, + transferInventoryBetweenCharacters(transfers = []) { + const entries = (transfers || []).map((transfer) => ({ + fromCharacterId: Number(transfer.fromCharacterId), + toCharacterId: Number(transfer.toCharacterId), + sourceItemId: Number(transfer.sourceItemId), + selfId: Number(transfer.selfId), + amount: Math.floor(Number(transfer.amount)), + stackable: transfer.stackable ? 1 : 0, + name: transfer.name || '', + slot: Number(transfer.slot || 0), + petData: transfer.petData + ? (typeof transfer.petData === 'string' ? transfer.petData : JSON.stringify(transfer.petData)) + : null + })); + const characterIds = entries.flatMap((entry) => [entry.fromCharacterId, entry.toCharacterId]); + return withCharacterFlushes(characterIds, () => inTransaction(() => { + if (!entries.length) throw new Error('empty inventory transfer'); + + const sources = entries.map((entry) => { + if (!entry.fromCharacterId || !entry.toCharacterId || !entry.sourceItemId || !entry.selfId || entry.amount <= 0) { + throw new Error('invalid inventory transfer'); + } + const source = one('SELECT id, selfId, name, amount, equipped, slot, petData FROM items WHERE id = ? AND characterId = ?', [entry.sourceItemId, entry.fromCharacterId]); + if (!source || Number(source.selfId) !== entry.selfId || Number(source.amount) < entry.amount || Number(source.equipped) !== 0) { + throw new Error('inventory item changed'); + } + return { entry, source }; + }); + + const moved = []; + sources.forEach(({ entry, source }) => { + const remaining = Number(source.amount) - entry.amount; + if (remaining <= 0) write('DELETE FROM items WHERE id = ? AND characterId = ?', [entry.sourceItemId, entry.fromCharacterId]); + else write('UPDATE items SET amount = ? WHERE id = ? AND characterId = ?', [remaining, entry.sourceItemId, entry.fromCharacterId]); + + let target = null; + if (entry.stackable) { + target = one('SELECT id, amount FROM items WHERE characterId = ? AND selfId = ? ORDER BY id LIMIT 1', [entry.toCharacterId, entry.selfId]); + } + let targetItemId; + if (target) { + targetItemId = Number(target.id); + write('UPDATE items SET amount = ? WHERE id = ? AND characterId = ?', [Number(target.amount) + entry.amount, targetItemId, entry.toCharacterId]); + } else { + targetItemId = write( + 'INSERT INTO items (selfId, name, amount, equipped, slot, petData, characterId) VALUES (?, ?, ?, 0, ?, ?, ?)', + [entry.selfId, entry.name || source.name || `Item ${entry.selfId}`, entry.amount, entry.slot, entry.petData || source.petData || null, entry.toCharacterId] + ).insertId; + } + moved.push({ + ...entry, + targetItemId: Number(targetItemId), + remaining + }); + }); + return moved; + }, 'trade:inventory-transfer')); + }, + createAccount(username, password) { return insert('accounts', { username, password }, 'account:create'); }, diff --git a/src/GameServer/Bot/AI/BotAgentTools.js b/src/GameServer/Bot/AI/BotAgentTools.js index ef191c5e..8233c76e 100644 --- a/src/GameServer/Bot/AI/BotAgentTools.js +++ b/src/GameServer/Bot/AI/BotAgentTools.js @@ -9,6 +9,7 @@ const BotEquipmentUpgrade = invoke('GameServer/Bot/AI/BotEquipmentUpgrade'); const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); const Attack = invoke('GameServer/Actor/Attack'); +const BotTradeService = invoke('GameServer/Bot/BotTradeService'); const ACTIONS = [ 'none', @@ -29,14 +30,19 @@ const ACTIONS = [ 'set_combat_stance', 'list_safe_loadouts', 'equip_candidate', - 'optimize_equipment' + 'optimize_equipment', + 'propose_trade', + 'offer_resources', + 'update_trade_offer', + 'cancel_trade' ]; const PK_LOCKED_ACTIONS = new Set([ 'follow_player', 'stay_here', 'hunt', 'rest', 'shop', 'move_to_spot', 'set_pull_policy', 'assign_puller', 'unassign_puller', 'set_skill_priority', 'clear_skill_priority', 'set_combat_stance', - 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment' + 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment', + 'propose_trade', 'offer_resources', 'update_trade_offer', 'cancel_trade' ]); function clean(text) { @@ -475,6 +481,49 @@ function optimizeEquipment(context) { }; } +function proposeTrade(context) { + const player = context?.requestContext?.playerSession; + const result = BotTradeService.startBotTrade(context.session, player); + if (!result.ok) return { applied: false, reason: result.reason }; + return { + applied: true, + reason: 'trade_proposed', + trade: BotTradeService.activeTradeSummary(context.session) + }; +} + +function offerResources(context) { + const itemId = context.decision.tradeItemId || context.decision.itemId; + const amount = context.decision.tradeAmount || context.decision.amount; + const result = BotTradeService.offerBotItem(context.session, itemId, amount); + if (!result.ok) return { applied: false, reason: result.reason }; + return { + applied: true, + reason: 'resources_offered', + trade: BotTradeService.activeTradeSummary(context.session), + line: { objectId: result.line.objectId, selfId: result.line.selfId, name: result.line.name, count: result.line.count } + }; +} + +function updateTradeOffer(context) { + const itemId = context.decision.tradeItemId || context.decision.itemId; + const amount = context.decision.tradeAmount || context.decision.amount; + const result = BotTradeService.updateOffer(context.session, itemId, amount); + if (!result.ok) return { applied: false, reason: result.reason }; + return { + applied: true, + reason: 'trade_offer_updated', + trade: BotTradeService.activeTradeSummary(context.session), + line: { objectId: result.line.objectId, selfId: result.line.selfId, name: result.line.name, count: result.line.count } + }; +} + +function cancelBotTrade(context) { + if (!context.session?.activeTrade) return { applied: true, reason: 'trade_not_active' }; + BotTradeService.cancel(context.session, 'bot_requested', true); + return { applied: true, reason: 'trade_cancelled' }; +} + function registerTools() { const descriptions = { none: 'Do nothing when no useful response is needed.', @@ -495,13 +544,18 @@ function registerTools() { set_combat_stance: 'Set a bounded offensive combat stance; safety and support priorities remain authoritative.', list_safe_loadouts: 'List inventory equipment candidates that are compatible and strictly improve a slot.', equip_candidate: 'Equip one validated inventory upgrade through the native backpack persistence path.', - optimize_equipment: 'Equip all currently safe inventory upgrades through the native backpack path.' + optimize_equipment: 'Equip all currently safe inventory upgrades through the native backpack path.', + propose_trade: 'Open a native trade window with the authorized party leader before offering any resources.', + offer_resources: 'Reserve and display safe bot inventory resources in the open native trade window.', + update_trade_offer: 'Change one reserved bot trade line after revalidating inventory truth.', + cancel_trade: 'Cancel the open native trade and release every reservation.' }; const controlActions = new Set([ 'set_pull_policy', 'assign_puller', 'unassign_puller', 'set_skill_priority', 'clear_skill_priority', 'set_combat_stance', - 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment' + 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment', + 'propose_trade', 'offer_resources', 'update_trade_offer', 'cancel_trade' ]); const executors = { set_pull_policy: applyPullPolicy, @@ -512,7 +566,11 @@ function registerTools() { set_combat_stance: setCombatStance, list_safe_loadouts: listSafeLoadouts, equip_candidate: equipCandidate, - optimize_equipment: optimizeEquipment + optimize_equipment: optimizeEquipment, + propose_trade: proposeTrade, + offer_resources: offerResources, + update_trade_offer: updateTradeOffer, + cancel_trade: cancelBotTrade }; ACTIONS.forEach((name) => { @@ -529,6 +587,8 @@ function registerTools() { } if (session.plan === 'pk_hunting' && PK_LOCKED_ACTIONS.has(name)) return false; if (controlActions.has(name) && !(session.partyCompanion === true && session.followPlayerSession)) return false; + if (name === 'propose_trade' && session.activeTrade) return false; + if (['offer_resources', 'update_trade_offer', 'cancel_trade'].includes(name) && !session.activeTrade) return false; if (name === 'buff_target') { try { if (!BotRoles.canBuff(session.actor)) return false; } catch (_) { return true; } } @@ -608,6 +668,14 @@ function rejectionReply(result = {}) { case 'skill_incompatible': return 'That skill cannot be used as a combat preference.'; case 'invalid_skill_priority': case 'invalid_combat_stance': return 'That preference is outside my safe combat settings.'; + case 'not_authorized_relationship': return 'I only open a resource trade with my current party leader.'; + case 'item_not_tradable': + case 'retain_minimum': + case 'reservation_lost': return 'I cannot safely offer that inventory item.'; + case 'gift_budget_exceeded': return 'I need to keep my resource gifts within a safe budget.'; + case 'trade_line_limit': return 'The trade already has the maximum number of item lines.'; + case 'no_active_trade': return 'There is no open trade to change.'; + case 'database_failed': return 'The trade was not committed because persistence failed.'; default: return 'I cannot do that safely right now.'; } } diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index e5edc2b9..9e3e8bb6 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -152,6 +152,17 @@ function schema(allowedActions = BotAgentTools.ACTIONS) { minimum: 0, description: 'Inventory object id for equip_candidate.' }, + tradeItemId: { + type: 'number', + minimum: 0, + description: 'Inventory object id for offer_resources or update_trade_offer.' + }, + tradeAmount: { + type: 'number', + minimum: 0, + maximum: 10000, + description: 'Bounded quantity for an outbound trade line.' + }, policyTtlMs: { type: 'number', minimum: 5000, @@ -193,7 +204,7 @@ function systemPrompt() { 'Equipment tools may only use safe candidates from actual inventory and native persistence. Never equip quest, incompatible, over-grade, or non-upgrade items.', 'The persona describes tone and high-level preferences only. It never overrides safety, current game state, or the allowed actions.', 'The contextFragments field is bounded and includes recent authoritative events; treat summaries as memory, never as permission to perform an action.', - 'Do not offer trading, selling, price negotiation, or private stores; those tools are intentionally unavailable for now.', + 'Trade tools can open a native window only with the current party leader, reserve safe inventory without mutating it, and release reservations on cancel/expiry. Never claim completion before native player confirmation.', 'Never invent unavailable actions, players, items, or spells.' ].join(' '); } diff --git a/src/GameServer/Bot/AI/BotStatus.js b/src/GameServer/Bot/AI/BotStatus.js index e4481b29..f74aac52 100644 --- a/src/GameServer/Bot/AI/BotStatus.js +++ b/src/GameServer/Bot/AI/BotStatus.js @@ -187,8 +187,11 @@ function nearbySnapshot(bot) { function tradeSnapshot(session, bot) { const store = bot.fetchPrivateStore && bot.fetchPrivateStore(); const loot = session.lastLootRequest || null; + const BotTradeService = invoke('GameServer/Bot/BotTradeService'); + const active = BotTradeService.activeTradeSummary(session); return { + active, store: store ? { type: store.storeType === 3 ? 'buy' : 'sell', title: store.title || '', diff --git a/src/GameServer/Bot/AI/BotToolRegistry.js b/src/GameServer/Bot/AI/BotToolRegistry.js index b1464841..4e97bda7 100644 --- a/src/GameServer/Bot/AI/BotToolRegistry.js +++ b/src/GameServer/Bot/AI/BotToolRegistry.js @@ -48,7 +48,10 @@ function worldRevision(session) { inventory.length, Number(overlay?.updatedAt || 0), String(partySettings.pullMode || ''), - Number(partySettings.pullerId || 0) + Number(partySettings.pullerId || 0), + String(session.activeTrade?.id || ''), + Number(session.activeTrade?.botItems?.size || 0), + Number(session.activeTrade?.playerItems?.size || 0) ].join(':'); } @@ -57,7 +60,8 @@ function isPkLocked(session, action) { 'follow_player', 'stay_here', 'hunt', 'rest', 'shop', 'move_to_spot', 'set_pull_policy', 'assign_puller', 'unassign_puller', 'set_skill_priority', 'clear_skill_priority', 'set_combat_stance', - 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment' + 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment', + 'propose_trade', 'offer_resources', 'update_trade_offer', 'cancel_trade' ]).has(action); } diff --git a/src/GameServer/Bot/BotAI.js b/src/GameServer/Bot/BotAI.js index 42d7c196..34de7b2e 100644 --- a/src/GameServer/Bot/BotAI.js +++ b/src/GameServer/Bot/BotAI.js @@ -9,6 +9,7 @@ const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); const PartyRevivalService = invoke('GameServer/Bot/AI/PartyRevivalService'); const TownRespawn = invoke('GameServer/World/TownRespawn'); const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); +const BotTradeService = invoke('GameServer/Bot/BotTradeService'); const CHAT_PHRASES = { foundTarget: [ @@ -330,6 +331,7 @@ const BotAI = { if (botDead) { clearTacticalState(session); HotBotPolicyOverlay.clearForDeath(session); + BotTradeService.cleanup(session, 'death'); } else { // TTL expiry is intentionally lazy and bounded to hot ticks; no // background timer is needed for a session-local preference. diff --git a/src/GameServer/Bot/BotTradeService.js b/src/GameServer/Bot/BotTradeService.js index efa1317b..aeefc4db 100644 --- a/src/GameServer/Bot/BotTradeService.js +++ b/src/GameServer/Bot/BotTradeService.js @@ -1,19 +1,36 @@ const DataCache = invoke('GameServer/DataCache'); const Database = invoke('Database'); +const ServerResponse = invoke('GameServer/Network/Response'); const SpeckMath = invoke('GameServer/SpeckMath'); const TRADE_RANGE = 1500; +const TRADE_TTL_MS = 2 * 60 * 1000; +const COMPLETION_REPLAY_TTL_MS = 15 * 1000; +const MAX_TRADE_LINES = 8; +const MAX_ITEM_AMOUNT = 10000; +const MAX_BOT_GIFT_UNITS = 5000; +const MAX_INVENTORY_ITEMS = 80; +const MIN_ADENA_RETAIN = 1000; +let tradeSequence = 0; + +function now() { + return Date.now(); +} function itemTemplate(selfId) { - return DataCache.items.find((ob) => ob.selfId === selfId); + return DataCache.items.find((ob) => Number(ob.selfId) === Number(selfId)); } function isBotSession(session) { return session && (session.constructor.name === 'BotSession' || (session.accountId && String(session.accountId).startsWith('bot_'))); } -function isTradableItem(item) { - return item && !item.fetchEquipped(); +function isRealPlayerSession(session) { + return !!session?.actor && !isBotSession(session) && session.actor.fetchIsOnline?.() !== false; +} + +function actorName(session) { + return session?.actor?.fetchName?.() || session?.accountId || 'unknown'; } function actorDistance(a, b) { @@ -21,157 +38,401 @@ function actorDistance(a, b) { .distance(new SpeckMath.Point3D(b.fetchLocX(), b.fetchLocY(), b.fetchLocZ())); } -function actorName(session) { - return session?.actor?.fetchName?.() || session?.accountId || 'unknown'; +function lineFor(item, count) { + return { + item, + count: Math.max(1, Math.floor(Number(count) || 1)), + objectId: Number(item.fetchId()), + selfId: Number(item.fetchSelfId()), + name: item.fetchName(), + stackable: !!item.fetchStackable?.(), + slot: Number(item.fetchSlot?.() || 0), + petData: item.fetchPetData?.() || null + }; } -function summarizeItems(items) { - return items.map((item) => `${item.count} ${item.name}`).join(', '); +function isSafeOfferItem(item) { + if (!item || item.fetchEquipped?.()) return false; + const kind = String(item.fetchKind?.() || ''); + if (kind === 'Other.Quest' || kind.endsWith('.Quest')) return false; + if (item.model?.quest === true || item.model?.reserved === true) return false; + return Number(item.fetchAmount?.() || 0) > 0; } -function giveItem(actor, sourceItem, amount) { - const selfId = sourceItem.fetchSelfId(); +function minimumRetain(item) { + return Number(item.fetchSelfId?.() || 0) === 57 ? MIN_ADENA_RETAIN : 0; +} - return new Promise((resolve, reject) => { - actor.backpack.stackableExists(selfId).then((existingItem) => { - const total = existingItem.fetchAmount() + amount; - Database.updateItemAmount(actor.fetchId(), existingItem.fetchId(), total).then(() => { - actor.backpack.updateAmount(existingItem.fetchId(), total); - resolve(existingItem); - }).catch(reject); - }).catch(() => { - const details = itemTemplate(selfId); - if (!details) { - reject(new Error(`Unknown item ${selfId}.`)); - return; - } +function botReservations(session) { + if (!session.botTradeReservations) session.botTradeReservations = new Map(); + return session.botTradeReservations; +} - Database.setItem(actor.fetchId(), { - selfId, - name: sourceItem.fetchName() || details.template.name, - amount, - equipped: false, - slot: details.etc?.slot ?? 0 - }).then((packet) => { - actor.backpack.insertItem(Number(packet.insertId), selfId, { amount }); - resolve(actor.backpack.fetchItemRaw(Number(packet.insertId))); - }).catch(reject); - }); - }); +function botGiftLedger(session) { + const ledger = session.botTradeGiftLedger; + if (!ledger || now() - Number(ledger.startedAt || 0) >= 60 * 60 * 1000) { + session.botTradeGiftLedger = { startedAt: now(), units: 0 }; + } + return session.botTradeGiftLedger; } -function takeItem(actor, item, amount) { - return new Promise((resolve, reject) => { - if (!item || item.fetchAmount() < amount) { - reject(new Error('Not enough items.')); - return; - } +function releaseReservations(trade) { + const bot = trade?.botSession; + if (!bot) return; + const reservations = botReservations(bot); + for (const [objectId, reservation] of reservations.entries()) { + if (reservation.tradeId === trade.id) reservations.delete(objectId); + } +} - const total = item.fetchAmount() - amount; - if (total > 0) { - Database.updateItemAmount(actor.fetchId(), item.fetchId(), total).then(() => { - item.setAmount(total); - resolve(); - }).catch(reject); - return; - } +function clearAttachedTrade(trade) { + if (!trade) return; + releaseReservations(trade); + if (trade.playerSession?.activeTrade === trade) trade.playerSession.activeTrade = null; + if (trade.botSession?.activeTrade === trade) trade.botSession.activeTrade = null; +} - Database.deleteItem(actor.fetchId(), item.fetchId()).then(() => { - actor.backpack.items = actor.backpack.items.filter((ob) => ob.fetchId() !== item.fetchId()); - resolve(); - }).catch(reject); - }); +function sendToPlayer(trade, packet) { + if (trade?.playerSession?.dataSendToMe) trade.playerSession.dataSendToMe(packet); } -const BotTradeService = { - startPlayerTrade(playerSession, targetSession) { - if (!playerSession?.actor || !targetSession?.actor || !isBotSession(targetSession)) { - return { ok: false, reason: 'invalid_target' }; - } +function cancelTrade(trade, reason = 'cancelled', notify = true) { + if (!trade || ['cancelled', 'committed'].includes(trade.state)) return false; + trade.state = 'cancelled'; + trade.cancelReason = reason; + releaseReservations(trade); + if (notify) sendToPlayer(trade, ServerResponse.tradeDone(false)); + clearAttachedTrade(trade); + return true; +} - if (targetSession.plan === 'merchant') { - return { ok: false, reason: 'merchant_store' }; - } +function tradeIsOpen(trade) { + if (!trade || trade.state !== 'open') return false; + if (Number(trade.expiresAt || 0) <= now()) { + cancelTrade(trade, 'expired'); + return false; + } + if (!trade.playerSession?.actor || !trade.botSession?.actor) { + cancelTrade(trade, 'missing_actor'); + return false; + } + if (trade.playerSession.actor.fetchIsOnline?.() === false || trade.botSession.actor.fetchIsOnline?.() === false) { + cancelTrade(trade, 'disconnected'); + return false; + } + if (trade.botSession.actor.isDead?.() || actorDistance(trade.playerSession.actor, trade.botSession.actor) > TRADE_RANGE) { + cancelTrade(trade, 'state_changed'); + return false; + } + return true; +} - if (actorDistance(playerSession.actor, targetSession.actor) > TRADE_RANGE) { - return { ok: false, reason: 'too_far' }; - } +function activeTradeFor(session) { + const trade = session?.activeTrade; + if (!tradeIsOpen(trade)) return null; + return trade; +} - playerSession.activeTrade = { - partnerSession: targetSession, - partnerActorId: targetSession.actor.fetchId(), - items: new Map(), - confirmed: false, - createdAt: Date.now() - }; - - console.info("BotTrade :: %s opened trade with %s", actorName(playerSession), actorName(targetSession)); - return { ok: true, trade: playerSession.activeTrade }; - }, - - addItem(playerSession, objectId, amount) { - const trade = playerSession.activeTrade; - if (!trade || trade.confirmed) { - return { ok: false, reason: 'no_active_trade' }; - } +function attachTrade(trade) { + trade.playerSession.activeTrade = trade; + trade.botSession.activeTrade = trade; +} - const item = playerSession.actor.backpack.fetchItemRaw(objectId); - const qty = Math.max(1, Math.floor(Number(amount) || 1)); - if (!isTradableItem(item)) { - return { ok: false, reason: 'item_not_tradable' }; - } +function createTrade(playerSession, botSession, direction) { + return { + id: `bot-trade-${++tradeSequence}`, + direction, + playerSession, + botSession, + playerItems: new Map(), + botItems: new Map(), + playerConfirmed: false, + botConfirmed: false, + state: 'open', + createdAt: now(), + expiresAt: now() + TRADE_TTL_MS + }; +} - const current = trade.items.get(objectId); - const nextCount = Math.min(item.fetchAmount(), (current?.count || 0) + qty); - if (nextCount <= 0) { - return { ok: false, reason: 'bad_count' }; - } +function canStart(playerSession, botSession) { + if (!isRealPlayerSession(playerSession) || !isBotSession(botSession) || !botSession.actor) return 'invalid_target'; + if (botSession.plan === 'merchant') return 'merchant_store'; + if (actorDistance(playerSession.actor, botSession.actor) > TRADE_RANGE) return 'too_far'; + return null; +} - const line = { item, count: nextCount }; - trade.items.set(objectId, line); - console.info("BotTrade :: %s offered %d %s", actorName(playerSession), line.count, item.fetchName()); - return { ok: true, line }; - }, +function startPlayerTrade(playerSession, targetSession) { + const reason = canStart(playerSession, targetSession); + if (reason) return { ok: false, reason }; + cancel(playerSession, 'replaced', false); + cancel(targetSession, 'replaced', false); - cancel(playerSession) { - if (playerSession) { - playerSession.activeTrade = null; - } - }, + const trade = createTrade(playerSession, targetSession, 'player_inbound'); + attachTrade(trade); + console.info("BotTrade :: %s opened trade with %s", actorName(playerSession), actorName(targetSession)); + return { ok: true, trade }; +} - async commit(playerSession) { - const trade = playerSession.activeTrade; - if (!trade || trade.confirmed) { - return { ok: false, reason: 'no_active_trade' }; - } +function startBotTrade(botSession, playerSession) { + const reason = canStart(playerSession, botSession); + if (reason) return { ok: false, reason }; + if (botSession.partyCompanion !== true || botSession.followPlayerSession !== playerSession) { + return { ok: false, reason: 'not_authorized_relationship' }; + } + + cancel(playerSession, 'replaced', false); + cancel(botSession, 'replaced', false); + const trade = createTrade(playerSession, botSession, 'bot_outbound'); + attachTrade(trade); + if (playerSession.dataSendToMe) { + playerSession.dataSendToMe(ServerResponse.tradeStart( + botSession.actor, + playerSession.actor.backpack.fetchItems() + )); + } + console.info("BotTrade :: %s opened outbound trade with %s", actorName(botSession), actorName(playerSession)); + return { ok: true, trade }; +} + +function lineMapFor(trade, session) { + return session === trade.botSession ? trade.botItems : trade.playerItems; +} + +function addPlayerItem(playerSession, objectId, amount) { + const trade = activeTradeFor(playerSession); + if (!trade || trade.playerSession !== playerSession) return { ok: false, reason: 'no_active_trade' }; + const item = playerSession.actor.backpack.fetchItemRaw(objectId); + if (!isSafeOfferItem(item)) return { ok: false, reason: 'item_not_tradable' }; + const current = trade.playerItems.get(Number(objectId)); + if (!current && trade.playerItems.size >= MAX_TRADE_LINES) return { ok: false, reason: 'trade_line_limit' }; + const qty = Math.max(1, Math.min(MAX_ITEM_AMOUNT, Math.floor(Number(amount) || 1))); + const nextCount = Math.min(item.fetchAmount(), (current?.count || 0) + qty); + if (nextCount <= 0 || nextCount + minimumRetain(item) > Number(item.fetchAmount())) return { ok: false, reason: 'insufficient_item' }; + const line = lineFor(item, nextCount); + trade.playerItems.set(Number(objectId), line); + trade.playerConfirmed = false; + console.info("BotTrade :: %s offered %d %s", actorName(playerSession), line.count, item.fetchName()); + return { ok: true, line }; +} + +function offerBotItem(botSession, objectId, amount) { + const trade = activeTradeFor(botSession); + if (!trade || trade.botSession !== botSession) return { ok: false, reason: 'no_active_trade' }; + const item = botSession.actor.backpack.fetchItemRaw(objectId); + if (!isSafeOfferItem(item)) return { ok: false, reason: 'item_not_tradable' }; + const current = trade.botItems.get(Number(objectId)); + if (!current && trade.botItems.size >= MAX_TRADE_LINES) return { ok: false, reason: 'trade_line_limit' }; + + const requested = Math.max(1, Math.min(MAX_ITEM_AMOUNT, Math.floor(Number(amount) || 1))); + const reservations = botReservations(botSession); + const previousReservation = reservations.get(Number(objectId)); + if (previousReservation && previousReservation.tradeId !== trade.id) { + return { ok: false, reason: 'insufficient_item' }; + } + const alreadyReserved = previousReservation?.tradeId === trade.id ? Number(previousReservation.count || 0) : 0; + const available = Number(item.fetchAmount()) - alreadyReserved + (current?.count || 0); + // Bot tools use an absolute desired quantity; the native player packet + // path still accumulates additions in addPlayerItem above. + const nextCount = Math.min(available, requested); + if (nextCount <= 0 || nextCount + minimumRetain(item) > Number(item.fetchAmount())) { + return { ok: false, reason: 'insufficient_item' }; + } + + const ledger = botGiftLedger(botSession); + const delta = Math.max(0, nextCount - (current?.count || 0)); + if (ledger.units + delta > MAX_BOT_GIFT_UNITS) return { ok: false, reason: 'gift_budget_exceeded' }; + ledger.units += delta; + const line = lineFor(item, nextCount); + trade.botItems.set(Number(objectId), line); + reservations.set(Number(objectId), { tradeId: trade.id, count: nextCount }); + trade.botConfirmed = false; + sendToPlayer(trade, ServerResponse.tradeOtherAdd(line)); + console.info("BotTrade :: %s offered %d %s to %s", actorName(botSession), line.count, item.fetchName(), actorName(trade.playerSession)); + return { ok: true, line }; +} + +function updateOffer(session, objectId, amount) { + if (isBotSession(session)) return offerBotItem(session, objectId, amount); + return addPlayerItem(session, objectId, amount); +} + +function validateLine(trade, session, line, { botSide = false } = {}) { + const liveItem = session.actor.backpack.fetchItemRaw(line.objectId); + if (!isSafeOfferItem(liveItem) || Number(liveItem.fetchSelfId()) !== Number(line.selfId)) return { ok: false, reason: 'item_changed' }; + if (Number(liveItem.fetchAmount()) < Number(line.count)) return { ok: false, reason: 'item_changed' }; + if (Number(liveItem.fetchAmount()) - Number(line.count) < minimumRetain(liveItem)) return { ok: false, reason: 'retain_minimum' }; + if (botSide) { + const reservation = botReservations(session).get(line.objectId); + if (!reservation || reservation.tradeId !== trade.id || Number(reservation.count) !== Number(line.count)) return { ok: false, reason: 'reservation_lost' }; + } + return { ok: true, item: liveItem }; +} + +function incomingSlots(session, outgoingLines) { + const inventory = session.actor.backpack.fetchItems(); + const existingSelfIds = new Set(inventory.filter((item) => item.fetchStackable?.()).map((item) => Number(item.fetchSelfId()))); + const incomingNew = outgoingLines.filter((line) => !line.stackable && !existingSelfIds.has(Number(line.selfId))).length; + return inventory.length + incomingNew <= MAX_INVENTORY_ITEMS; +} + +function transferEntries(trade) { + const entries = []; + for (const line of trade.playerItems.values()) { + entries.push({ + direction: 'player_to_bot', + fromSession: trade.playerSession, + toSession: trade.botSession, + line + }); + } + for (const line of trade.botItems.values()) { + entries.push({ + direction: 'bot_to_player', + fromSession: trade.botSession, + toSession: trade.playerSession, + line + }); + } + return entries; +} - const partnerSession = trade.partnerSession; - if (!playerSession.actor || !partnerSession?.actor || trade.items.size === 0) { - return { ok: false, reason: 'empty_or_invalid_trade' }; +function applyLocalTransfers(moved, entries) { + const byKey = new Map(entries.map((entry) => [`${entry.fromSession.actor.fetchId()}:${entry.line.objectId}`, entry])); + moved.forEach((record) => { + const entry = byKey.get(`${record.fromCharacterId}:${record.sourceItemId}`); + if (!entry) return; + const sourceItems = entry.fromSession.actor.backpack.items || entry.fromSession.actor.backpack.fetchItems(); + const sourceItem = sourceItems.find((item) => Number(item.fetchId()) === Number(record.sourceItemId)); + if (sourceItem) { + if (Number(record.remaining) > 0) sourceItem.setAmount(Number(record.remaining)); + else entry.fromSession.actor.backpack.items = sourceItems.filter((item) => Number(item.fetchId()) !== Number(record.sourceItemId)); } - trade.confirmed = true; - const moved = []; - - for (const line of trade.items.values()) { - const liveItem = playerSession.actor.backpack.fetchItemRaw(line.item.fetchId()); - if (!isTradableItem(liveItem) || liveItem.fetchAmount() < line.count) { - return { ok: false, reason: 'item_changed' }; - } - - await takeItem(playerSession.actor, liveItem, line.count); - await giveItem(partnerSession.actor, liveItem, line.count); - moved.push({ - selfId: liveItem.fetchSelfId(), - name: liveItem.fetchName(), - count: line.count + const targetBackpack = entry.toSession.actor.backpack; + const existing = entry.line.stackable + ? targetBackpack.fetchItemFromSelfId?.(entry.line.selfId) + : null; + if (existing) existing.setAmount(Number(existing.fetchAmount()) + Number(entry.line.count)); + else if (targetBackpack.insertItem) { + targetBackpack.insertItem(Number(record.targetItemId), entry.line.selfId, { + name: entry.line.name, + amount: entry.line.count, + equipped: false, + slot: entry.line.slot, + petData: entry.line.petData }); } + }); +} - playerSession.activeTrade = null; - console.info("BotTrade :: %s completed trade with %s: %s", actorName(playerSession), actorName(partnerSession), summarizeItems(moved)); - return { ok: true, partnerSession, moved }; +function publicMoved(entries) { + return entries.map((entry) => ({ + selfId: entry.line.selfId, + name: entry.line.name, + count: entry.line.count, + direction: entry.direction + })); +} + +async function commit(playerSession) { + const replay = playerSession?.lastBotTradeCompletion; + if (!playerSession?.activeTrade && replay && now() - Number(replay.completedAt || 0) <= COMPLETION_REPLAY_TTL_MS) { + return { ...replay.result, idempotent: true }; } -}; -module.exports = BotTradeService; + const trade = activeTradeFor(playerSession); + if (!trade || trade.playerSession !== playerSession) return { ok: false, reason: 'no_active_trade' }; + if (trade.playerItems.size === 0 && trade.botItems.size === 0) return { ok: false, reason: 'empty_or_invalid_trade' }; + + const entries = transferEntries(trade); + for (const entry of entries) { + const validation = validateLine(trade, entry.fromSession, entry.line, { botSide: entry.fromSession === trade.botSession }); + if (!validation.ok) return validation; + } + if (!incomingSlots(trade.playerSession, [...trade.botItems.values()]) || !incomingSlots(trade.botSession, [...trade.playerItems.values()])) { + return { ok: false, reason: 'inventory_capacity' }; + } + + const databaseTransfers = entries.map((entry) => ({ + fromCharacterId: entry.fromSession.actor.fetchId(), + toCharacterId: entry.toSession.actor.fetchId(), + sourceItemId: entry.line.objectId, + selfId: entry.line.selfId, + amount: entry.line.count, + stackable: entry.line.stackable, + name: entry.line.name, + slot: entry.line.slot, + petData: entry.line.petData + })); + + let moved; + try { + moved = await Database.transferInventoryBetweenCharacters(databaseTransfers); + } catch (error) { + return { ok: false, reason: 'database_failed', error }; + } + + applyLocalTransfers(moved, entries); + trade.state = 'committed'; + trade.playerConfirmed = true; + trade.botConfirmed = true; + trade.completedAt = now(); + const result = { + ok: true, + tradeId: trade.id, + direction: trade.direction, + partnerSession: trade.botSession, + moved: publicMoved(entries) + }; + releaseReservations(trade); + clearAttachedTrade(trade); + // Keep replay data serializable and detached from live session graphs. + playerSession.lastBotTradeCompletion = { + completedAt: trade.completedAt, + result: { ...result, partnerSession: null } + }; + return result; +} + +function cancel(session, reason = 'cancelled', notify = true) { + const trade = session?.activeTrade; + return cancelTrade(trade, reason, notify); +} + +function cleanup(session, reason = 'lifecycle') { + return cancel(session, reason, true); +} + +function activeTradeSummary(session) { + const trade = activeTradeFor(session); + if (!trade) return null; + const lineSummary = (line) => ({ objectId: line.objectId, selfId: line.selfId, name: line.name, count: line.count }); + return { + id: trade.id, + direction: trade.direction, + createdAt: trade.createdAt, + expiresAt: trade.expiresAt, + playerConfirmed: trade.playerConfirmed, + botConfirmed: trade.botConfirmed, + playerItems: [...trade.playerItems.values()].map(lineSummary), + botItems: [...trade.botItems.values()].map(lineSummary) + }; +} + +module.exports = { + MAX_BOT_GIFT_UNITS, + MAX_ITEM_AMOUNT, + MAX_TRADE_LINES, + TRADE_RANGE, + TRADE_TTL_MS, + activeTradeSummary, + addItem: addPlayerItem, + cancel, + cleanup, + commit, + isTradableItem: isSafeOfferItem, + offerBotItem, + startBotTrade, + startPlayerTrade, + updateOffer +}; diff --git a/src/GameServer/Network/Request/TradeDone.js b/src/GameServer/Network/Request/TradeDone.js index 7033edfc..04f90351 100644 --- a/src/GameServer/Network/Request/TradeDone.js +++ b/src/GameServer/Network/Request/TradeDone.js @@ -30,8 +30,15 @@ async function tradeDone(session, buffer) { return; } + if (result.idempotent) { + session.dataSendToMe(ServerResponse.tradeDone(true)); + return; + } + const detail = describeMovedItems(result.moved); - const lootRequest = BotLootEtiquette.resolveTrade(session, result.partnerSession, result.moved); + const lootRequest = result.direction === 'bot_outbound' + ? null + : BotLootEtiquette.resolveTrade(session, result.partnerSession, result.moved); BotSocialMemory.recordEvent( session, result.partnerSession, diff --git a/src/GameServer/Session.js b/src/GameServer/Session.js index 8ff639ea..a8a1c6a3 100644 --- a/src/GameServer/Session.js +++ b/src/GameServer/Session.js @@ -282,6 +282,7 @@ class Session { utils.infoWarn('GameServer', 'connection closed'); } if (this.actor) { + invoke('GameServer/Bot/BotTradeService').cleanup(this, 'disconnect'); // Companion social events are persisted asynchronously. Preserve // the identity before the actor is destroyed so a normal network // disconnect cannot overwrite the remembered player name. diff --git a/tests/test_bot_outbound_trade.js b/tests/test_bot_outbound_trade.js new file mode 100644 index 00000000..e40e67d6 --- /dev/null +++ b/tests/test_bot_outbound_trade.js @@ -0,0 +1,83 @@ +const assert = require('assert'); +require('../src/Global'); + +const Database = invoke('Database'); +const BotTradeService = invoke('GameServer/Bot/BotTradeService'); +const Item = invoke('GameServer/Item/Item'); + +function makeItem(id, selfId, amount, name = `Material ${selfId}`) { + return new Item(id, { + selfId, + name, + kind: 'Other.Material', + amount, + stackable: true, + equipped: false, + slot: 0 + }); +} + +function backpack(items) { + return { + items, + fetchItems() { return this.items; }, + fetchItemRaw(id) { return this.items.find((item) => Number(item.fetchId()) === Number(id)); }, + fetchItemFromSelfId(selfId) { return this.items.find((item) => Number(item.fetchSelfId()) === Number(selfId)); }, + insertItem(id, selfId, data) { this.items.push(new Item(id, { selfId, ...data, kind: 'Other.Material', stackable: true })); } + }; +} + +function actor(id, name, bag) { + return { + fetchId: () => id, + fetchName: () => name, + fetchLocX: () => 0, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchIsOnline: () => true, + isDead: () => false, + backpack: bag + }; +} + +const playerPackets = []; +const playerItem = makeItem(101, 1001, 2, 'Player Ore'); +const botItem = makeItem(201, 2001, 6, 'Bot Herb'); +const playerBackpack = backpack([playerItem]); +const botBackpack = backpack([botItem]); +const player = { accountId: 'player_trade', dataSendToMe: (packet) => playerPackets.push(packet), actor: actor(100, 'TradeLeader', playerBackpack) }; +const bot = { accountId: 'bot_trade', partyCompanion: true, followPlayerSession: player, actor: actor(200, 'TradeCompanion', botBackpack) }; + +const originalTransfer = Database.transferInventoryBetweenCharacters; +Database.transferInventoryBetweenCharacters = async (entries) => entries.map((entry, index) => ({ + ...entry, + targetItemId: 900 + index, + remaining: entry.selfId === 2001 ? 3 : 0 +})); + +(async () => { +try { + const opened = BotTradeService.startBotTrade(bot, player); + assert.strictEqual(opened.ok, true); + assert.strictEqual(playerPackets[0][0], 0x1e, 'outbound bot trade must use native TradeStart'); + const offered = BotTradeService.offerBotItem(bot, 201, 3); + assert.strictEqual(offered.ok, true); + assert.strictEqual(playerPackets[1][0], 0x21, 'bot offer must use native TradeOtherAdd'); + assert.strictEqual(botItem.fetchAmount(), 6, 'reservation must not mutate inventory before confirmation'); + + const committed = await BotTradeService.commit(player); + assert.strictEqual(committed.ok, true); + assert.strictEqual(committed.direction, 'bot_outbound'); + assert.strictEqual(botItem.fetchAmount(), 3, 'confirmed trade must deduct the bot resource'); + assert.strictEqual(playerBackpack.fetchItemFromSelfId(2001).fetchAmount(), 3, 'confirmed trade must add the resource to player inventory'); + const replay = await BotTradeService.commit(player); + assert.strictEqual(replay.idempotent, true, 'double confirmation must be idempotent'); + assert.doesNotThrow(() => JSON.stringify(replay), 'idempotent replay must not retain live session cycles'); + console.log('Bot outbound trade checks passed'); +} finally { + Database.transferInventoryBetweenCharacters = originalTransfer; +} +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_bot_trade_atomicity.js b/tests/test_bot_trade_atomicity.js new file mode 100644 index 00000000..f0eb1e32 --- /dev/null +++ b/tests/test_bot_trade_atomicity.js @@ -0,0 +1,46 @@ +const assert = require('assert'); +require('../src/Global'); + +const Database = invoke('Database'); +const BotTradeService = invoke('GameServer/Bot/BotTradeService'); +const Item = invoke('GameServer/Item/Item'); + +function item(id, selfId, amount, name) { + return new Item(id, { selfId, name, kind: 'Other.Material', amount, stackable: true, equipped: false, slot: 0 }); +} +function bag(items) { + return { + items, + fetchItems() { return this.items; }, + fetchItemRaw(id) { return this.items.find((entry) => entry.fetchId() === Number(id)); }, + fetchItemFromSelfId(selfId) { return this.items.find((entry) => entry.fetchSelfId() === Number(selfId)); }, + insertItem(id, selfId, data) { this.items.push(new Item(id, { selfId, ...data, kind: 'Other.Material', stackable: true })); } + }; +} +const playerItem = item(401, 4001, 2, 'Player Token'); +const botItem = item(402, 4002, 3, 'Bot Token'); +const player = { accountId: 'player_atomic', dataSendToMe() {}, actor: { fetchId: () => 410, fetchName: () => 'AtomicLeader', fetchLocX: () => 0, fetchLocY: () => 0, fetchLocZ: () => 0, fetchIsOnline: () => true, isDead: () => false, backpack: bag([playerItem]) } }; +const bot = { accountId: 'bot_atomic', partyCompanion: true, followPlayerSession: player, actor: { fetchId: () => 411, fetchName: () => 'AtomicBot', fetchLocX: () => 0, fetchLocY: () => 0, fetchLocZ: () => 0, fetchIsOnline: () => true, isDead: () => false, backpack: bag([botItem]) } }; + +const originalTransfer = Database.transferInventoryBetweenCharacters; +Database.transferInventoryBetweenCharacters = async () => { throw new Error('forced db failure'); }; + +(async () => { + try { + assert.strictEqual(BotTradeService.startBotTrade(bot, player).ok, true); + assert.strictEqual(BotTradeService.offerBotItem(bot, 402, 1).ok, true); + assert.strictEqual(BotTradeService.addItem(player, 401, 1).ok, true); + const failed = await BotTradeService.commit(player); + assert.strictEqual(failed.ok, false, 'DB failure must reject the commit'); + assert.strictEqual(failed.reason, 'database_failed', 'DB failure must expose a stable reason'); + assert.strictEqual(playerItem.fetchAmount(), 2); + assert.strictEqual(botItem.fetchAmount(), 3); + assert(player.activeTrade, 'failed commit should leave a cancellable trade'); + console.log('Bot trade atomicity checks passed'); + } finally { + Database.transferInventoryBetweenCharacters = originalTransfer; + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_bot_trade_database.js b/tests/test_bot_trade_database.js new file mode 100644 index 00000000..8a92a234 --- /dev/null +++ b/tests/test_bot_trade_database.js @@ -0,0 +1,59 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +require('../src/Global'); + +const Database = invoke('Database'); + +async function main() { + const databasePath = path.join(process.cwd(), 'tmp', 'test-bot-trade.sqlite'); + fs.rmSync(databasePath, { force: true }); + options.default.Database.path = path.relative(process.cwd(), databasePath); + Database.init(); + + await Database.createAccount('trade_db_player', 'secret'); + await Database.createAccount('trade_db_bot', 'secret'); + await Database.createCharacter('trade_db_player', { + name: 'TradeDbPlayer', race: 0, classId: 0, maxHp: 50, maxMp: 25, + sex: 0, face: 0, hair: 0, hairColor: 0, locX: 0, locY: 0, locZ: 0 + }); + await Database.createCharacter('trade_db_bot', { + name: 'TradeDbBot', race: 0, classId: 0, maxHp: 50, maxMp: 25, + sex: 0, face: 0, hair: 0, hairColor: 0, locX: 0, locY: 0, locZ: 0 + }); + + const playerId = Number((await Database.fetchCharacterName('TradeDbPlayer'))[0].id); + const botId = Number((await Database.fetchCharacterName('TradeDbBot'))[0].id); + const playerItemId = Number((await Database.setItem(playerId, { + selfId: 7001, name: 'Player Token', amount: 2, equipped: false, slot: 0 + })).insertId); + const botItemId = Number((await Database.setItem(botId, { + selfId: 7002, name: 'Bot Token', amount: 3, equipped: false, slot: 0 + })).insertId); + + const moved = await Database.transferInventoryBetweenCharacters([ + { fromCharacterId: playerId, toCharacterId: botId, sourceItemId: playerItemId, selfId: 7001, amount: 1, stackable: true, name: 'Player Token' }, + { fromCharacterId: botId, toCharacterId: playerId, sourceItemId: botItemId, selfId: 7002, amount: 2, stackable: true, name: 'Bot Token' } + ]); + assert.strictEqual(moved.length, 2); + assert.strictEqual((await Database.fetchItems(playerId)).find((item) => Number(item.selfId) === 7001).amount, 1); + assert.strictEqual((await Database.fetchItems(playerId)).find((item) => Number(item.selfId) === 7002).amount, 2); + assert.strictEqual((await Database.fetchItems(botId)).find((item) => Number(item.selfId) === 7001).amount, 1); + assert.strictEqual((await Database.fetchItems(botId)).find((item) => Number(item.selfId) === 7002).amount, 1); + + await assert.rejects( + Database.transferInventoryBetweenCharacters([ + { fromCharacterId: playerId, toCharacterId: botId, sourceItemId: playerItemId, selfId: 7001, amount: 1, stackable: true }, + { fromCharacterId: botId, toCharacterId: playerId, sourceItemId: 999999, selfId: 7002, amount: 1, stackable: true } + ]), + /inventory item changed/ + ); + assert.strictEqual((await Database.fetchItems(playerId)).find((item) => Number(item.selfId) === 7001).amount, 1, 'failed transfer must roll back the first side'); + console.log('Bot trade database checks passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_bot_trade_reservations.js b/tests/test_bot_trade_reservations.js new file mode 100644 index 00000000..70e1aaa2 --- /dev/null +++ b/tests/test_bot_trade_reservations.js @@ -0,0 +1,40 @@ +const assert = require('assert'); +require('../src/Global'); + +const BotTradeService = invoke('GameServer/Bot/BotTradeService'); +const Item = invoke('GameServer/Item/Item'); + +function item(id, amount) { + return new Item(id, { selfId: 3001, name: 'Reserved Herb', kind: 'Other.Material', amount, stackable: true, equipped: false, slot: 0 }); +} +function bag(items) { + return { + items, + fetchItems() { return this.items; }, + fetchItemRaw(id) { return this.items.find((entry) => entry.fetchId() === Number(id)); }, + fetchItemFromSelfId(selfId) { return this.items.find((entry) => entry.fetchSelfId() === Number(selfId)); }, + insertItem() {} + }; +} +const player = { accountId: 'player_reserve', dataSendToMe() {}, actor: { fetchId: () => 310, fetchName: () => 'ReserveLeader', fetchLocX: () => 0, fetchLocY: () => 0, fetchLocZ: () => 0, fetchIsOnline: () => true, isDead: () => false, backpack: bag([]) } }; +const botItem = item(311, 5); +const bot = { accountId: 'bot_reserve', partyCompanion: true, followPlayerSession: player, actor: { fetchId: () => 311, fetchName: () => 'ReserveBot', fetchLocX: () => 0, fetchLocY: () => 0, fetchLocZ: () => 0, fetchIsOnline: () => true, isDead: () => false, backpack: bag([botItem]) } }; + +const opened = BotTradeService.startBotTrade(bot, player); +assert.strictEqual(opened.ok, true); +assert.strictEqual(BotTradeService.offerBotItem(bot, 311, 4).ok, true); +assert.strictEqual(BotTradeService.offerBotItem(bot, 311, 2).ok, true, 'updating the active offer uses an absolute desired quantity'); +assert(BotTradeService.activeTradeSummary(bot).botItems.length === 1); +BotTradeService.cancel(bot, 'test_cancel', false); +assert.strictEqual(bot.botTradeReservations.size, 0, 'cancel must release reservations'); + +const reopened = BotTradeService.startBotTrade(bot, player); +assert.strictEqual(reopened.ok, true); +bot.botTradeReservations.set(311, { tradeId: 'other-trade', count: 5 }); +assert.deepStrictEqual(BotTradeService.offerBotItem(bot, 311, 2), { ok: false, reason: 'insufficient_item' }, 'another trade reservation must be excluded from a new offer'); +bot.botTradeReservations.delete(311); +assert.strictEqual(BotTradeService.offerBotItem(bot, 311, 5).ok, true, 'released reservation must be available to a new trade'); +bot.activeTrade.expiresAt = Date.now() - 1; +assert.strictEqual(BotTradeService.activeTradeSummary(bot), null, 'expired trade must close'); +assert.strictEqual(bot.botTradeReservations.size, 0, 'expiry must release reservations'); +console.log('Bot trade reservation checks passed'); diff --git a/tests/test_llm_trade_tools.js b/tests/test_llm_trade_tools.js new file mode 100644 index 00000000..c740aa42 --- /dev/null +++ b/tests/test_llm_trade_tools.js @@ -0,0 +1,89 @@ +const assert = require('assert'); +require('../src/Global'); + +const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); +const Item = invoke('GameServer/Item/Item'); + +function actor(id, name, backpack) { + return { + fetchId: () => id, + fetchName: () => name, + fetchLocX: () => 0, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchDestId: () => 0, + fetchIsOnline: () => true, + isDead: () => false, + state: { fetchSeated: () => false, setSeated() {} }, + backpack + }; +} + +function backpack(items) { + return { + items, + fetchItems() { return this.items; }, + fetchItemRaw(id) { return this.items.find((item) => Number(item.fetchId()) === Number(id)); }, + fetchItemFromSelfId(selfId) { return this.items.find((item) => Number(item.fetchSelfId()) === Number(selfId)); }, + insertItem(id, selfId, data) { + this.items.push(new Item(id, { selfId, ...data, kind: 'Other.Material', stackable: true })); + } + }; +} + +const playerBackpack = backpack([]); +const botBackpack = backpack([new Item(612, { + selfId: 6012, + name: 'Trade Herb', + kind: 'Other.Material', + amount: 3, + stackable: true, + equipped: false, + slot: 0 +})]); +const packets = []; +const player = { + accountId: 'player_trade_tools', + dataSendToMe(packet) { packets.push(packet); }, + actor: actor(610, 'TradeLeader', playerBackpack) +}; +const bot = { + accountId: 'bot_trade_tools', + plan: 'following', + partyCompanion: true, + followPlayerSession: player, + actor: actor(611, 'TradeCompanion', botBackpack) +}; + +function decision(action, turnId, extra = {}) { + return { action, confidence: 0.99, reason: 'trade tool test', turnId, ...extra }; +} + +function context(turnId, session = player) { + return { playerSession: session, conversationTurn: { turnId } }; +} + +try { + const proposed = BotAgentTools.execute(bot, decision('propose_trade', 'trade-tool-1'), [], context('trade-tool-1')); + assert.strictEqual(proposed.applied, true); + assert.strictEqual(proposed.reason, 'trade_proposed'); + assert.strictEqual(packets[0][0], 0x1e, 'propose_trade must open the native trade window'); + + const offered = BotAgentTools.execute(bot, decision('offer_resources', 'trade-tool-2', { tradeItemId: 612, tradeAmount: 2 }), [], context('trade-tool-2')); + assert.strictEqual(offered.applied, true); + assert.strictEqual(offered.line.count, 2); + assert.strictEqual(packets[1][0], 0x21, 'offer_resources must use native TradeOtherAdd'); + + const stranger = { accountId: 'player_stranger', actor: actor(699, 'Stranger', backpack([])) }; + const unauthorized = BotAgentTools.execute(bot, decision('cancel_trade', 'trade-tool-3'), [], context('trade-tool-3', stranger)); + assert.deepStrictEqual(unauthorized, { applied: false, reason: 'not_authorized' }); + assert(bot.activeTrade, 'unauthorized leader must not cancel the open trade'); + + const cancelled = BotAgentTools.execute(bot, decision('cancel_trade', 'trade-tool-4'), [], context('trade-tool-4')); + assert.deepStrictEqual(cancelled, { applied: true, reason: 'trade_cancelled' }); + assert.strictEqual(bot.activeTrade, null); + console.log('LLM trade tool checks passed'); +} catch (error) { + console.error(error); + process.exitCode = 1; +} From 596752a754fc6c05673b2a7c832bb3740b781c2f Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:03:02 -0400 Subject: [PATCH 07/59] Add bounded hot bot price negotiation --- config/default.ini | 1 + config/local.example.ini | 1 + database/sql/sqlite.sql | 24 + scripts/run-tests.js | 4 + src/Database.js | 25 + src/GameServer/Bot/AI/BotAgentTools.js | 126 ++++- src/GameServer/Bot/AI/BotBrain.js | 19 +- src/GameServer/Bot/AI/BotBrainContext.js | 1 + src/GameServer/Bot/AI/BotStatus.js | 3 + src/GameServer/Bot/AI/BotToolRegistry.js | 9 +- .../Bot/AI/PartyCompanionService.js | 1 + src/GameServer/Bot/BotTradeService.js | 55 ++- .../Bot/Economy/BotNegotiationService.js | 432 ++++++++++++++++++ src/GameServer/Bot/Population/Cooldown.js | 2 + src/GameServer/Network/Request/TradeDone.js | 10 +- tests/test_bot_negotiation_database.js | 58 +++ tests/test_bot_negotiation_flow.js | 108 +++++ tests/test_bot_negotiation_policy.js | 87 ++++ tests/test_llm_negotiation_tools.js | 77 ++++ 19 files changed, 1027 insertions(+), 16 deletions(-) create mode 100644 src/GameServer/Bot/Economy/BotNegotiationService.js create mode 100644 tests/test_bot_negotiation_database.js create mode 100644 tests/test_bot_negotiation_flow.js create mode 100644 tests/test_bot_negotiation_policy.js create mode 100644 tests/test_llm_negotiation_tools.js diff --git a/config/default.ini b/config/default.ini index 90864686..bd9a3896 100644 --- a/config/default.ini +++ b/config/default.ini @@ -41,6 +41,7 @@ requireProviderParameters = true circuitBreakerFailureThreshold = 3 circuitBreakerOpenMs = 30000 debug = false +negotiationEnabled = false [BotPopulation] enabled = true diff --git a/config/local.example.ini b/config/local.example.ini index 715b20e3..3cdab0c8 100644 --- a/config/local.example.ini +++ b/config/local.example.ini @@ -3,3 +3,4 @@ enabled = true apiKey = sk-or-v1-your-key-here model = google/gemini-2.5-flash-lite debug = true +negotiationEnabled = false diff --git a/database/sql/sqlite.sql b/database/sql/sqlite.sql index d2737578..6e0ac184 100644 --- a/database/sql/sqlite.sql +++ b/database/sql/sqlite.sql @@ -300,6 +300,30 @@ CREATE TABLE IF NOT EXISTS bot_tool_outcomes ( CREATE INDEX IF NOT EXISTS bot_tool_outcomes_bot_recent ON bot_tool_outcomes(botId, createdAt DESC); CREATE INDEX IF NOT EXISTS bot_tool_outcomes_turn ON bot_tool_outcomes(botId, turnId, toolName, createdAt DESC); +CREATE TABLE IF NOT EXISTS bot_negotiations ( + id TEXT PRIMARY KEY, + playerId INTEGER REFERENCES characters(id) ON DELETE SET NULL, + botId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + itemObjectId INTEGER NOT NULL, + itemSelfId INTEGER NOT NULL, + amount INTEGER NOT NULL, + referenceUnitPrice INTEGER NOT NULL, + desiredUnitPrice INTEGER NOT NULL, + minimumUnitPrice INTEGER NOT NULL, + maximumUnitPrice INTEGER NOT NULL, + currentUnitPrice INTEGER NOT NULL, + agreedTotalPrice INTEGER, + round INTEGER NOT NULL DEFAULT 0, + state TEXT NOT NULL, + createdAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + reason TEXT NOT NULL DEFAULT '', + metaJson TEXT +); +CREATE INDEX IF NOT EXISTS bot_negotiations_pair_recent ON bot_negotiations(playerId, botId, updatedAt DESC); +CREATE INDEX IF NOT EXISTS bot_negotiations_bot_recent ON bot_negotiations(botId, updatedAt DESC); + CREATE TABLE IF NOT EXISTS bot_friendships ( playerId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, botId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 50d15647..afce69eb 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -20,6 +20,10 @@ const tests = [ 'tests/test_llm_skill_priority_tools.js', 'tests/test_llm_equipment_tools.js', 'tests/test_llm_trade_tools.js', + 'tests/test_llm_negotiation_tools.js', + 'tests/test_bot_negotiation_policy.js', + 'tests/test_bot_negotiation_flow.js', + 'tests/test_bot_negotiation_database.js', 'tests/test_bot_outbound_trade.js', 'tests/test_bot_trade_reservations.js', 'tests/test_bot_trade_atomicity.js', diff --git a/src/Database.js b/src/Database.js index 93b84a02..aaa1e09e 100644 --- a/src/Database.js +++ b/src/Database.js @@ -238,6 +238,31 @@ function applySchemaMigrations() { ); CREATE INDEX IF NOT EXISTS bot_tool_outcomes_bot_recent ON bot_tool_outcomes(botId, createdAt DESC); CREATE INDEX IF NOT EXISTS bot_tool_outcomes_turn ON bot_tool_outcomes(botId, turnId, toolName, createdAt DESC); + `)], + [6, () => connection.exec(` + CREATE TABLE IF NOT EXISTS bot_negotiations ( + id TEXT PRIMARY KEY, + playerId INTEGER REFERENCES characters(id) ON DELETE SET NULL, + botId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + itemObjectId INTEGER NOT NULL, + itemSelfId INTEGER NOT NULL, + amount INTEGER NOT NULL, + referenceUnitPrice INTEGER NOT NULL, + desiredUnitPrice INTEGER NOT NULL, + minimumUnitPrice INTEGER NOT NULL, + maximumUnitPrice INTEGER NOT NULL, + currentUnitPrice INTEGER NOT NULL, + agreedTotalPrice INTEGER, + round INTEGER NOT NULL DEFAULT 0, + state TEXT NOT NULL, + createdAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + reason TEXT NOT NULL DEFAULT '', + metaJson TEXT + ); + CREATE INDEX IF NOT EXISTS bot_negotiations_pair_recent ON bot_negotiations(playerId, botId, updatedAt DESC); + CREATE INDEX IF NOT EXISTS bot_negotiations_bot_recent ON bot_negotiations(botId, updatedAt DESC); `)] ]; const applied = new Set(connection.prepare('SELECT version FROM schema_migrations').all().map((row) => Number(row.version))); diff --git a/src/GameServer/Bot/AI/BotAgentTools.js b/src/GameServer/Bot/AI/BotAgentTools.js index 8233c76e..0748534c 100644 --- a/src/GameServer/Bot/AI/BotAgentTools.js +++ b/src/GameServer/Bot/AI/BotAgentTools.js @@ -10,6 +10,7 @@ const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); const Attack = invoke('GameServer/Actor/Attack'); const BotTradeService = invoke('GameServer/Bot/BotTradeService'); +const BotNegotiationService = invoke('GameServer/Bot/Economy/BotNegotiationService'); const ACTIONS = [ 'none', @@ -34,7 +35,12 @@ const ACTIONS = [ 'propose_trade', 'offer_resources', 'update_trade_offer', - 'cancel_trade' + 'cancel_trade', + 'quote_item', + 'counter_offer', + 'accept_price', + 'decline_price', + 'open_negotiated_trade' ]; const PK_LOCKED_ACTIONS = new Set([ @@ -42,7 +48,8 @@ const PK_LOCKED_ACTIONS = new Set([ 'set_pull_policy', 'assign_puller', 'unassign_puller', 'set_skill_priority', 'clear_skill_priority', 'set_combat_stance', 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment', - 'propose_trade', 'offer_resources', 'update_trade_offer', 'cancel_trade' + 'propose_trade', 'offer_resources', 'update_trade_offer', 'cancel_trade', + 'quote_item', 'counter_offer', 'accept_price', 'decline_price', 'open_negotiated_trade' ]); function clean(text) { @@ -524,6 +531,76 @@ function cancelBotTrade(context) { return { applied: true, reason: 'trade_cancelled' }; } +function negotiationPlayer(context) { + return context?.requestContext?.playerSession || null; +} + +function negotiationItemId(decision) { + return decision.negotiationItemId || decision.tradeItemId || decision.itemId; +} + +function negotiationPrice(decision) { + return decision.negotiationPrice ?? decision.price; +} + +function quoteItem(context) { + const result = BotNegotiationService.quoteItem( + context.session, + negotiationPlayer(context), + negotiationItemId(context.decision), + context.decision.negotiationAmount || context.decision.tradeAmount || context.decision.amount + ); + if (!result.ok) return { applied: false, reason: result.reason, negotiation: result.negotiation }; + return { applied: true, reason: 'price_quoted', negotiation: result.negotiation }; +} + +function counterOffer(context) { + const result = BotNegotiationService.counterOffer( + context.session, + negotiationPlayer(context), + negotiationPrice(context.decision) + ); + if (!result.ok) return { applied: false, reason: result.reason, negotiation: result.negotiation }; + return { applied: true, reason: 'price_countered', negotiation: result.negotiation }; +} + +function acceptPrice(context) { + const decision = context.decision; + const totalPrice = Object.prototype.hasOwnProperty.call(decision, 'negotiationPrice') || Object.prototype.hasOwnProperty.call(decision, 'price') + ? negotiationPrice(decision) + : null; + const result = BotNegotiationService.acceptPrice(context.session, negotiationPlayer(context), totalPrice); + if (!result.ok) return { applied: false, reason: result.reason, negotiation: result.negotiation }; + return { applied: true, reason: 'price_accepted', negotiation: result.negotiation }; +} + +function declinePrice(context) { + const result = BotNegotiationService.declinePrice(context.session, negotiationPlayer(context), 'bot_declined'); + if (!result.ok) return { applied: false, reason: result.reason }; + return { applied: true, reason: 'price_declined', negotiation: result.negotiation }; +} + +function openNegotiatedTrade(context) { + const result = BotNegotiationService.openNegotiatedTrade(context.session, negotiationPlayer(context)); + if (!result.ok) return { applied: false, reason: result.reason, negotiation: result.negotiation }; + return { applied: true, reason: 'native_trade_open', trade: result.trade, negotiation: result.negotiation }; +} + +function isAuthorizedNegotiationParticipant(context) { + const session = context?.session; + const player = negotiationPlayer(context); + if (!player?.actor || String(player.accountId || '').startsWith('bot_') || player.actor.fetchIsOnline?.() === false) return false; + if (session?.activeNegotiation && session.activeNegotiation.playerSession !== player) return false; + if (session?.partyCompanion === true) return session.followPlayerSession === player; + return session?.plan === 'merchant' || session?.plan === 'following'; +} + +function economyActionAvailable(session, name) { + if (!BotNegotiationService.enabled()) return false; + if (name === 'quote_item') return !session.activeNegotiation; + return !!session.activeNegotiation; +} + function registerTools() { const descriptions = { none: 'Do nothing when no useful response is needed.', @@ -548,7 +625,12 @@ function registerTools() { propose_trade: 'Open a native trade window with the authorized party leader before offering any resources.', offer_resources: 'Reserve and display safe bot inventory resources in the open native trade window.', update_trade_offer: 'Change one reserved bot trade line after revalidating inventory truth.', - cancel_trade: 'Cancel the open native trade and release every reservation.' + cancel_trade: 'Cancel the open native trade and release every reservation.', + quote_item: 'Quote one safe bot inventory item using server-owned market and persona bounds.', + counter_offer: 'Set a bounded counter price within the active negotiation range.', + accept_price: 'Accept the current server-bounded price before opening native trade.', + decline_price: 'Decline the active negotiation and release its stock reservation.', + open_negotiated_trade: 'Open native trade only after the bounded price has been accepted.' }; const controlActions = new Set([ @@ -557,6 +639,9 @@ function registerTools() { 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment', 'propose_trade', 'offer_resources', 'update_trade_offer', 'cancel_trade' ]); + const economyActions = new Set([ + 'quote_item', 'counter_offer', 'accept_price', 'decline_price', 'open_negotiated_trade' + ]); const executors = { set_pull_policy: applyPullPolicy, assign_puller: assignPuller, @@ -570,7 +655,12 @@ function registerTools() { propose_trade: proposeTrade, offer_resources: offerResources, update_trade_offer: updateTradeOffer, - cancel_trade: cancelBotTrade + cancel_trade: cancelBotTrade, + quote_item: quoteItem, + counter_offer: counterOffer, + accept_price: acceptPrice, + decline_price: declinePrice, + open_negotiated_trade: openNegotiatedTrade }; ACTIONS.forEach((name) => { @@ -582,11 +672,15 @@ function registerTools() { mutating: !['none', 'say', 'list_safe_loadouts'].includes(name), available(session) { if (!session) return true; - if (session.plan === 'merchant' || session.plan === 'getting_buffed') { + if (session.plan === 'merchant') { + return ['none', 'say'].includes(name) || economyActions.has(name) && economyActionAvailable(session, name); + } + if (session.plan === 'getting_buffed') { return ['none', 'say'].includes(name); } if (session.plan === 'pk_hunting' && PK_LOCKED_ACTIONS.has(name)) return false; if (controlActions.has(name) && !(session.partyCompanion === true && session.followPlayerSession)) return false; + if (economyActions.has(name) && !economyActionAvailable(session, name)) return false; if (name === 'propose_trade' && session.activeTrade) return false; if (['offer_resources', 'update_trade_offer', 'cancel_trade'].includes(name) && !session.activeTrade) return false; if (name === 'buff_target') { @@ -597,7 +691,9 @@ function registerTools() { } return true; }, - authorize: controlActions.has(name) ? isAuthorizedPartyLeader : undefined, + authorize: controlActions.has(name) + ? isAuthorizedPartyLeader + : economyActions.has(name) ? isAuthorizedNegotiationParticipant : undefined, execute(context) { if (executors[name]) return executors[name](context); return executeLegacy(context.session, context.decision, context.visiblePlayers || []); @@ -676,6 +772,24 @@ function rejectionReply(result = {}) { case 'trade_line_limit': return 'The trade already has the maximum number of item lines.'; case 'no_active_trade': return 'There is no open trade to change.'; case 'database_failed': return 'The trade was not committed because persistence failed.'; + case 'negotiation_disabled': return 'Price negotiation is not available right now.'; + case 'bot_not_trading': return 'I am not offering a negotiated market trade in this state.'; + case 'item_not_negotiable': + case 'stock_reserved': + case 'insufficient_stock': return 'I cannot safely quote that stock item.'; + case 'negotiation_active': return 'There is already an active price discussion.'; + case 'no_active_negotiation': return 'There is no active price discussion to change.'; + case 'price_out_of_bounds': + case 'price_must_be_whole_unit': + case 'price_mismatch': return 'That price is outside my server-approved negotiation range.'; + case 'round_limit': return 'We have reached the maximum number of counter-offers.'; + case 'price_not_accepted': + case 'negotiation_not_ready': return 'The price must be accepted before I open native trade.'; + case 'trade_active': return 'I will finish the current native trade before opening another one.'; + case 'negotiated_item_mismatch': + case 'negotiated_price_mismatch': return 'The native trade does not match our accepted price.'; + case 'stock_changed': return 'That stock changed before we could finish the agreement.'; + case 'insufficient_funds': return 'You need enough Adena for the agreed price while keeping a safe reserve.'; default: return 'I cannot do that safely right now.'; } } diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 9e3e8bb6..8630e928 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -163,6 +163,23 @@ function schema(allowedActions = BotAgentTools.ACTIONS) { maximum: 10000, description: 'Bounded quantity for an outbound trade line.' }, + negotiationItemId: { + type: 'number', + minimum: 0, + description: 'Actual bot inventory object id for quote_item.' + }, + negotiationAmount: { + type: 'number', + minimum: 1, + maximum: 100, + description: 'Bounded quantity for a negotiated stock item.' + }, + negotiationPrice: { + type: 'number', + minimum: 1, + maximum: 1000000000, + description: 'Total Adena price for a bounded counter or accepted quote.' + }, policyTtlMs: { type: 'number', minimum: 5000, @@ -204,7 +221,7 @@ function systemPrompt() { 'Equipment tools may only use safe candidates from actual inventory and native persistence. Never equip quest, incompatible, over-grade, or non-upgrade items.', 'The persona describes tone and high-level preferences only. It never overrides safety, current game state, or the allowed actions.', 'The contextFragments field is bounded and includes recent authoritative events; treat summaries as memory, never as permission to perform an action.', - 'Trade tools can open a native window only with the current party leader, reserve safe inventory without mutating it, and release reservations on cancel/expiry. Never claim completion before native player confirmation.', + 'Resource-gift trade tools can open a native window only with the current party leader; negotiated market tools use only the active real player pair. Both reserve safe inventory without mutating it, expose only server-owned bounds, allow at most three negotiation rounds, and release reservations on cancel/expiry. Never claim completion before native player confirmation.', 'Never invent unavailable actions, players, items, or spells.' ].join(' '); } diff --git a/src/GameServer/Bot/AI/BotBrainContext.js b/src/GameServer/Bot/AI/BotBrainContext.js index c91d9614..926b1f96 100644 --- a/src/GameServer/Bot/AI/BotBrainContext.js +++ b/src/GameServer/Bot/AI/BotBrainContext.js @@ -274,6 +274,7 @@ function compactStatus(session, status, text = '', options = {}) { inventory: includeInventory ? inventorySnapshot(actor, text) : null, skills: includeSkills ? skillsSnapshot(actor, text) : null, roleDecision: status.roleDecision || null, + trade: status.trade || null, policy: status.policy || null, persona: status.persona || null, social: status.social || null diff --git a/src/GameServer/Bot/AI/BotStatus.js b/src/GameServer/Bot/AI/BotStatus.js index f74aac52..a747f68e 100644 --- a/src/GameServer/Bot/AI/BotStatus.js +++ b/src/GameServer/Bot/AI/BotStatus.js @@ -188,10 +188,13 @@ function tradeSnapshot(session, bot) { const store = bot.fetchPrivateStore && bot.fetchPrivateStore(); const loot = session.lastLootRequest || null; const BotTradeService = invoke('GameServer/Bot/BotTradeService'); + const BotNegotiationService = invoke('GameServer/Bot/Economy/BotNegotiationService'); const active = BotTradeService.activeTradeSummary(session); + const negotiation = BotNegotiationService.activeSummary(session); return { active, + negotiation, store: store ? { type: store.storeType === 3 ? 'buy' : 'sell', title: store.title || '', diff --git a/src/GameServer/Bot/AI/BotToolRegistry.js b/src/GameServer/Bot/AI/BotToolRegistry.js index 4e97bda7..593cd747 100644 --- a/src/GameServer/Bot/AI/BotToolRegistry.js +++ b/src/GameServer/Bot/AI/BotToolRegistry.js @@ -51,7 +51,11 @@ function worldRevision(session) { Number(partySettings.pullerId || 0), String(session.activeTrade?.id || ''), Number(session.activeTrade?.botItems?.size || 0), - Number(session.activeTrade?.playerItems?.size || 0) + Number(session.activeTrade?.playerItems?.size || 0), + String(session.activeNegotiation?.id || ''), + String(session.activeNegotiation?.state || ''), + Number(session.activeNegotiation?.round || 0), + Number(session.activeNegotiation?.currentUnitPrice || 0) ].join(':'); } @@ -61,7 +65,8 @@ function isPkLocked(session, action) { 'set_pull_policy', 'assign_puller', 'unassign_puller', 'set_skill_priority', 'clear_skill_priority', 'set_combat_stance', 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment', - 'propose_trade', 'offer_resources', 'update_trade_offer', 'cancel_trade' + 'propose_trade', 'offer_resources', 'update_trade_offer', 'cancel_trade', + 'quote_item', 'counter_offer', 'accept_price', 'decline_price', 'open_negotiated_trade' ]).has(action); } diff --git a/src/GameServer/Bot/AI/PartyCompanionService.js b/src/GameServer/Bot/AI/PartyCompanionService.js index 8c42f30e..b3e7bdb5 100644 --- a/src/GameServer/Bot/AI/PartyCompanionService.js +++ b/src/GameServer/Bot/AI/PartyCompanionService.js @@ -602,6 +602,7 @@ function cancelCompanionAction(companionSession) { } function detachState(companionSession, plan = 'hunting') { + try { invoke('GameServer/Bot/BotTradeService').cleanup(companionSession, 'party_detach'); } catch (_) { /* optional hot trade modules */ } invoke('GameServer/Bot/AI/HotBotPolicyOverlay').clearForPartyDetach(companionSession); cancelCompanionAction(companionSession); companionSession.plan = plan; diff --git a/src/GameServer/Bot/BotTradeService.js b/src/GameServer/Bot/BotTradeService.js index aeefc4db..374da034 100644 --- a/src/GameServer/Bot/BotTradeService.js +++ b/src/GameServer/Bot/BotTradeService.js @@ -100,6 +100,9 @@ function cancelTrade(trade, reason = 'cancelled', notify = true) { if (!trade || ['cancelled', 'committed'].includes(trade.state)) return false; trade.state = 'cancelled'; trade.cancelReason = reason; + if (trade.negotiationId) { + try { invoke('GameServer/Bot/Economy/BotNegotiationService').cancelForTrade(trade, reason); } catch (_) { /* optional negotiation module */ } + } releaseReservations(trade); if (notify) sendToPlayer(trade, ServerResponse.tradeDone(false)); clearAttachedTrade(trade); @@ -154,9 +157,9 @@ function createTrade(playerSession, botSession, direction) { }; } -function canStart(playerSession, botSession) { +function canStart(playerSession, botSession, { allowMerchant = false } = {}) { if (!isRealPlayerSession(playerSession) || !isBotSession(botSession) || !botSession.actor) return 'invalid_target'; - if (botSession.plan === 'merchant') return 'merchant_store'; + if (botSession.plan === 'merchant' && !allowMerchant) return 'merchant_store'; if (actorDistance(playerSession.actor, botSession.actor) > TRADE_RANGE) return 'too_far'; return null; } @@ -173,16 +176,27 @@ function startPlayerTrade(playerSession, targetSession) { return { ok: true, trade }; } -function startBotTrade(botSession, playerSession) { - const reason = canStart(playerSession, botSession); +function openBotTrade(botSession, playerSession, negotiation = null) { + const reason = canStart(playerSession, botSession, { allowMerchant: !!negotiation }); if (reason) return { ok: false, reason }; - if (botSession.partyCompanion !== true || botSession.followPlayerSession !== playerSession) { + if (!negotiation && (botSession.partyCompanion !== true || botSession.followPlayerSession !== playerSession)) { return { ok: false, reason: 'not_authorized_relationship' }; } + if (negotiation && (negotiation.botSession !== botSession || negotiation.playerSession !== playerSession || negotiation.state !== 'accepted')) { + return { ok: false, reason: 'negotiation_not_ready' }; + } + if (negotiation && (activeTradeFor(playerSession) || activeTradeFor(botSession))) { + return { ok: false, reason: 'trade_active' }; + } cancel(playerSession, 'replaced', false); cancel(botSession, 'replaced', false); const trade = createTrade(playerSession, botSession, 'bot_outbound'); + if (negotiation) { + trade.negotiationId = negotiation.id; + trade.expectedNegotiatedItem = { objectId: negotiation.itemObjectId, count: negotiation.quantity }; + trade.expectedAdena = negotiation.agreedTotalPrice; + } attachTrade(trade); if (playerSession.dataSendToMe) { playerSession.dataSendToMe(ServerResponse.tradeStart( @@ -191,9 +205,24 @@ function startBotTrade(botSession, playerSession) { )); } console.info("BotTrade :: %s opened outbound trade with %s", actorName(botSession), actorName(playerSession)); + if (negotiation) { + const offered = offerBotItem(botSession, negotiation.itemObjectId, negotiation.quantity); + if (!offered.ok) { + cancelTrade(trade, offered.reason, true); + return { ok: false, reason: offered.reason }; + } + } return { ok: true, trade }; } +function startBotTrade(botSession, playerSession) { + return openBotTrade(botSession, playerSession); +} + +function startNegotiatedTrade(botSession, playerSession, negotiation) { + return openBotTrade(botSession, playerSession, negotiation); +} + function lineMapFor(trade, session) { return session === trade.botSession ? trade.botItems : trade.playerItems; } @@ -344,6 +373,11 @@ async function commit(playerSession) { if (!trade || trade.playerSession !== playerSession) return { ok: false, reason: 'no_active_trade' }; if (trade.playerItems.size === 0 && trade.botItems.size === 0) return { ok: false, reason: 'empty_or_invalid_trade' }; + if (trade.negotiationId) { + const validation = invoke('GameServer/Bot/Economy/BotNegotiationService').validateTrade(trade); + if (!validation.ok) return validation; + } + const entries = transferEntries(trade); for (const entry of entries) { const validation = validateLine(trade, entry.fromSession, entry.line, { botSide: entry.fromSession === trade.botSession }); @@ -381,9 +415,13 @@ async function commit(playerSession) { ok: true, tradeId: trade.id, direction: trade.direction, + negotiationId: trade.negotiationId || null, partnerSession: trade.botSession, moved: publicMoved(entries) }; + if (trade.negotiationId) { + try { invoke('GameServer/Bot/Economy/BotNegotiationService').completeTrade(trade); } catch (_) { /* optional negotiation module */ } + } releaseReservations(trade); clearAttachedTrade(trade); // Keep replay data serializable and detached from live session graphs. @@ -400,7 +438,9 @@ function cancel(session, reason = 'cancelled', notify = true) { } function cleanup(session, reason = 'lifecycle') { - return cancel(session, reason, true); + const cancelled = cancel(session, reason, true); + if (cancelled) return true; + try { return invoke('GameServer/Bot/Economy/BotNegotiationService').cleanup(session, reason); } catch (_) { return false; } } function activeTradeSummary(session) { @@ -410,6 +450,8 @@ function activeTradeSummary(session) { return { id: trade.id, direction: trade.direction, + negotiationId: trade.negotiationId || null, + expectedAdena: trade.expectedAdena || null, createdAt: trade.createdAt, expiresAt: trade.expiresAt, playerConfirmed: trade.playerConfirmed, @@ -432,6 +474,7 @@ module.exports = { commit, isTradableItem: isSafeOfferItem, offerBotItem, + startNegotiatedTrade, startBotTrade, startPlayerTrade, updateOffer diff --git a/src/GameServer/Bot/Economy/BotNegotiationService.js b/src/GameServer/Bot/Economy/BotNegotiationService.js new file mode 100644 index 00000000..da0c4250 --- /dev/null +++ b/src/GameServer/Bot/Economy/BotNegotiationService.js @@ -0,0 +1,432 @@ +const DataCache = invoke('GameServer/DataCache'); +const Database = invoke('Database'); +const BotEconomyPricing = invoke('GameServer/Bot/Economy/BotEconomyPricing'); +const BotPersona = invoke('GameServer/Bot/AI/BotPersona'); +const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); +const BotToolAudit = invoke('GameServer/Bot/AI/BotToolAudit'); + +const NEGOTIATION_TTL_MS = 90 * 1000; +const MAX_ROUNDS = 3; +const MAX_QUANTITY = 100; +const MAX_UNIT_PRICE = 10_000_000; +const NEGOTIABLE_BOT_PLANS = new Set(['merchant', 'following']); + +const negotiations = new Map(); +let sequence = 0; + +function now() { return Date.now(); } + +function enabled() { + return options.default.OpenRouter?.negotiationEnabled === true; +} + +function actorId(session) { return Number(session?.actor?.fetchId?.() || 0); } +function actorName(session) { return session?.actor?.fetchName?.() || session?.accountId || 'unknown'; } +function isBot(session) { return !!session?.accountId && String(session.accountId).startsWith('bot_'); } +function isPlayer(session) { return !!session?.actor && !isBot(session) && session.actor.fetchIsOnline?.() !== false; } + +function distance(a, b) { + const dx = Number(a.fetchLocX?.() || 0) - Number(b.fetchLocX?.() || 0); + const dy = Number(a.fetchLocY?.() || 0) - Number(b.fetchLocY?.() || 0); + const dz = Number(a.fetchLocZ?.() || 0) - Number(b.fetchLocZ?.() || 0); + return Math.sqrt(dx * dx + dy * dy + dz * dz); +} + +function templateFor(selfId) { + return (DataCache.items || []).find((entry) => Number(entry.selfId) === Number(selfId)) || null; +} + +function safeItem(item) { + if (!item || item.fetchEquipped?.()) return false; + const kind = String(item.fetchKind?.() || ''); + if (Number(item.fetchSelfId?.() || 0) === 57) return false; + if (kind === 'Other.Quest' || kind.endsWith('.Quest')) return false; + if (item.model?.quest === true || item.model?.reserved === true) return false; + return Number(item.fetchAmount?.() || 0) > 0; +} + +function relationshipFor(player, bot) { + const memory = BotSocialMemory.getSnapshot(player, bot) || {}; + const trust = Number(memory.trust || 0); + const familiarity = Number(memory.familiarity || 0); + if (trust >= 8) return { name: 'trusted', trust, familiarity, modifier: -0.08 }; + if (trust >= 3 || familiarity >= 5) return { name: 'friendly', trust, familiarity, modifier: -0.04 }; + if (trust <= -5) return { name: 'wary', trust, familiarity, modifier: 0.06 }; + if (familiarity > 0) return { name: 'familiar', trust, familiarity, modifier: -0.01 }; + return { name: 'stranger', trust, familiarity, modifier: 0 }; +} + +function personaFor(bot) { + const persona = bot?.persona || BotPersona.generate({ characterId: actorId(bot) }); + const traits = persona?.traits || {}; + return { + primaryDrive: ['progression', 'wealth', 'social'].includes(persona?.primaryDrive) ? persona.primaryDrive : 'progression', + traits: { + caution: Math.max(0, Math.min(1, Number(traits.caution ?? 0.5))), + ambition: Math.max(0, Math.min(1, Number(traits.ambition ?? 0.5))), + assertiveness: Math.max(0, Math.min(1, Number(traits.assertiveness ?? 0.5))) + } + }; +} + +function pricePolicy(bot, player, item, quantity) { + const template = templateFor(item.fetchSelfId()); + const basePrice = Math.max(1, Number(template?.template?.price || item.fetchPrice?.() || item.model?.price || 1)); + const referenceUnitPrice = BotEconomyPricing.scalePrice(basePrice, 1); + const persona = personaFor(bot); + const relation = relationshipFor(player, bot); + const driveModifier = persona.primaryDrive === 'wealth' + ? 0.06 + : persona.primaryDrive === 'social' ? -0.04 : 0.02; + const desiredFactor = 1 + driveModifier + relation.modifier + persona.traits.assertiveness * 0.04; + const minimumFactor = 0.72 + persona.traits.caution * 0.08 + relation.modifier * 0.35; + const maximumFactor = 1.18 + persona.traits.ambition * 0.10 + persona.traits.assertiveness * 0.08 + Math.max(0, relation.modifier); + const minimumUnitPrice = Math.max(1, Math.floor(referenceUnitPrice * minimumFactor)); + const maximumUnitPrice = Math.min(MAX_UNIT_PRICE, Math.max(minimumUnitPrice, Math.ceil(referenceUnitPrice * maximumFactor))); + const desiredUnitPrice = Math.max(minimumUnitPrice, Math.min(maximumUnitPrice, Math.round(referenceUnitPrice * desiredFactor))); + const rationale = relation.name === 'trusted' + ? 'I can offer my trusted companion a meaningful discount.' + : relation.name === 'friendly' + ? 'I can make a small discount for a familiar ally.' + : relation.name === 'wary' + ? 'I need to keep a little margin until we know each other better.' + : persona.primaryDrive === 'wealth' + ? 'I am protecting the value of my stock.' + : 'I am using a steady market reference for this item.'; + + return { + baseUnitPrice: basePrice, + referenceUnitPrice, + desiredUnitPrice, + minimumUnitPrice, + maximumUnitPrice, + quantity, + relation: relation.name, + rationale, + // Keep the factors server-owned; they are not model-controlled inputs. + policyVersion: 1 + }; +} + +function summary(negotiation) { + if (!negotiation) return null; + return { + id: negotiation.id, + state: negotiation.state, + itemObjectId: negotiation.itemObjectId, + itemSelfId: negotiation.itemSelfId, + itemName: negotiation.itemName, + quantity: negotiation.quantity, + referenceUnitPrice: negotiation.referenceUnitPrice, + desiredUnitPrice: negotiation.desiredUnitPrice, + minimumUnitPrice: negotiation.minimumUnitPrice, + maximumUnitPrice: negotiation.maximumUnitPrice, + currentUnitPrice: negotiation.currentUnitPrice, + currentTotalPrice: negotiation.currentUnitPrice * negotiation.quantity, + agreedTotalPrice: negotiation.agreedTotalPrice || null, + round: negotiation.round, + maxRounds: MAX_ROUNDS, + createdAt: negotiation.createdAt, + expiresAt: negotiation.expiresAt, + relation: negotiation.relation, + rationale: negotiation.rationale, + botName: actorName(negotiation.botSession), + playerName: actorName(negotiation.playerSession) + }; +} + +function persist(negotiation) { + if (!Database.isReady?.() || !actorId(negotiation.botSession)) return Promise.resolve(false); + return Database.execute([ + `INSERT INTO bot_negotiations ( + id, playerId, botId, itemObjectId, itemSelfId, amount, + referenceUnitPrice, desiredUnitPrice, minimumUnitPrice, maximumUnitPrice, + currentUnitPrice, agreedTotalPrice, round, state, createdAt, expiresAt, updatedAt, reason, metaJson + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + currentUnitPrice = excluded.currentUnitPrice, + agreedTotalPrice = excluded.agreedTotalPrice, + round = excluded.round, + state = excluded.state, + expiresAt = excluded.expiresAt, + updatedAt = excluded.updatedAt, + reason = excluded.reason, + metaJson = excluded.metaJson`, + [ + negotiation.id, + actorId(negotiation.playerSession), + actorId(negotiation.botSession), + negotiation.itemObjectId, + negotiation.itemSelfId, + negotiation.quantity, + negotiation.referenceUnitPrice, + negotiation.desiredUnitPrice, + negotiation.minimumUnitPrice, + negotiation.maximumUnitPrice, + negotiation.currentUnitPrice, + negotiation.agreedTotalPrice || null, + negotiation.round, + negotiation.state, + negotiation.createdAt, + negotiation.expiresAt, + now(), + negotiation.reason || '', + JSON.stringify({ relation: negotiation.relation, policyVersion: negotiation.policyVersion }).slice(0, 1200) + ] + ], 'bot-negotiation:upsert').then(() => true).catch(() => false); +} + +function audit(negotiation, outcome, reason, meta = {}) { + BotToolAudit.record({ + playerId: actorId(negotiation.playerSession), + botId: actorId(negotiation.botSession), + turnId: `negotiation:${negotiation.id}`, + toolName: 'negotiation', + outcome, + reason, + meta: { negotiationId: negotiation.id, ...meta } + }).catch(() => {}); +} + +function journal(negotiation, event, detail) { + const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); + Promise.resolve(BotEventJournal.record({ + playerId: actorId(negotiation.playerSession), + botId: actorId(negotiation.botSession), + eventType: `negotiation_${event}`, + summary: detail, + weight: event === 'completed' ? 5 : 2, + dedupeKey: `${negotiation.id}:${event}`, + meta: { itemSelfId: negotiation.itemSelfId, quantity: negotiation.quantity, totalPrice: negotiation.agreedTotalPrice || negotiation.currentUnitPrice * negotiation.quantity } + })).catch(() => {}); +} + +function releaseReservation(negotiation) { + const reservations = negotiation.botSession?.botNegotiationReservations; + if (reservations?.get(negotiation.itemObjectId)?.negotiationId === negotiation.id) { + reservations.delete(negotiation.itemObjectId); + } +} + +function clear(negotiation) { + releaseReservation(negotiation); + if (negotiation.botSession?.activeNegotiation === negotiation) negotiation.botSession.activeNegotiation = null; + if (negotiation.playerSession?.activeNegotiation === negotiation) negotiation.playerSession.activeNegotiation = null; + negotiations.delete(negotiation.id); +} + +function setTerminal(negotiation, state, reason) { + negotiation.state = state; + negotiation.reason = reason || ''; + persist(negotiation); + audit(negotiation, state, reason || state); + journal(negotiation, state, `${actorName(negotiation.botSession)} negotiation ${state}: ${negotiation.itemName} x${negotiation.quantity}.`); + clear(negotiation); +} + +function stockValid(negotiation) { + const item = negotiation.botSession?.actor?.backpack?.fetchItemRaw?.(negotiation.itemObjectId); + return safeItem(item) && Number(item.fetchSelfId()) === Number(negotiation.itemSelfId) && Number(item.fetchAmount()) >= negotiation.quantity; +} + +function activeFor(session) { + const negotiation = session?.activeNegotiation; + if (!negotiation) return null; + if (negotiation.state === 'open' || negotiation.state === 'countered' || negotiation.state === 'accepted') { + if (negotiation.expiresAt <= now()) { + setTerminal(negotiation, 'expired', 'ttl_expired'); + return null; + } + if (!stockValid(negotiation)) { + setTerminal(negotiation, 'expired', 'stock_changed'); + return null; + } + } + return negotiation; +} + +function canAccess(bot, player) { + if (!isBot(bot) || !isPlayer(player) || !bot.actor) return 'invalid_pair'; + if (!NEGOTIABLE_BOT_PLANS.has(bot.plan) && bot.partyCompanion !== true) return 'bot_not_trading'; + if (bot.partyCompanion === true && bot.followPlayerSession !== player) return 'not_authorized_relationship'; + if (distance(bot.actor, player.actor) > 1500) return 'too_far'; + return null; +} + +function quoteItem(bot, player, itemObjectId, amount = 1) { + if (!enabled()) return { ok: false, reason: 'negotiation_disabled' }; + const access = canAccess(bot, player); + if (access) return { ok: false, reason: access }; + if (activeFor(bot)) return { ok: false, reason: 'negotiation_active' }; + const item = bot.actor.backpack.fetchItemRaw(Number(itemObjectId)); + if (!safeItem(item)) return { ok: false, reason: 'item_not_negotiable' }; + const quantity = Math.max(1, Math.min(MAX_QUANTITY, Math.floor(Number(amount) || 1))); + const reservations = bot.botNegotiationReservations || (bot.botNegotiationReservations = new Map()); + const reservation = reservations.get(Number(itemObjectId)); + if (reservation) return { ok: false, reason: 'stock_reserved' }; + if (Number(item.fetchAmount()) < quantity) return { ok: false, reason: 'insufficient_stock' }; + + const policy = pricePolicy(bot, player, item, quantity); + const negotiation = { + id: `negotiation-${++sequence}`, + botSession: bot, + playerSession: player, + itemObjectId: Number(itemObjectId), + itemSelfId: Number(item.fetchSelfId()), + itemName: item.fetchName(), + quantity, + ...policy, + currentUnitPrice: policy.desiredUnitPrice, + agreedTotalPrice: null, + round: 0, + state: 'open', + reason: 'quoted', + createdAt: now(), + expiresAt: now() + NEGOTIATION_TTL_MS + }; + reservations.set(negotiation.itemObjectId, { negotiationId: negotiation.id, count: quantity }); + negotiations.set(negotiation.id, negotiation); + bot.activeNegotiation = negotiation; + player.activeNegotiation = negotiation; + persist(negotiation); + audit(negotiation, 'proposed', 'quote_created', { currentTotalPrice: negotiation.currentUnitPrice * quantity }); + journal(negotiation, 'proposed', `${actorName(bot)} quoted ${negotiation.itemName} x${quantity}.`); + return { ok: true, negotiation: summary(negotiation) }; +} + +function counterOffer(bot, player, totalPrice) { + const negotiation = activeFor(bot); + if (!negotiation || negotiation.playerSession !== player) return { ok: false, reason: 'no_active_negotiation' }; + if (negotiation.state === 'accepted' || negotiation.state === 'trade_open') return { ok: false, reason: 'price_already_accepted' }; + if (negotiation.round >= MAX_ROUNDS) return { ok: false, reason: 'round_limit' }; + const total = Math.floor(Number(totalPrice)); + const minTotal = negotiation.minimumUnitPrice * negotiation.quantity; + const maxTotal = negotiation.maximumUnitPrice * negotiation.quantity; + if (!Number.isSafeInteger(total) || total < minTotal || total > maxTotal) { + audit(negotiation, 'rejected', 'price_out_of_bounds', { requestedTotalPrice: totalPrice, minTotal, maxTotal }); + return { ok: false, reason: 'price_out_of_bounds', negotiation: summary(negotiation) }; + } + if (total % negotiation.quantity !== 0) return { ok: false, reason: 'price_must_be_whole_unit' }; + negotiation.currentUnitPrice = total / negotiation.quantity; + negotiation.round += 1; + negotiation.state = 'countered'; + negotiation.reason = 'counter_offer'; + persist(negotiation); + audit(negotiation, 'countered', 'price_countered', { currentTotalPrice: total }); + return { ok: true, negotiation: summary(negotiation) }; +} + +function acceptPrice(bot, player, totalPrice = null) { + const negotiation = activeFor(bot); + if (!negotiation || negotiation.playerSession !== player) return { ok: false, reason: 'no_active_negotiation' }; + if (totalPrice !== null && Math.floor(Number(totalPrice)) !== negotiation.currentUnitPrice * negotiation.quantity) { + return { ok: false, reason: 'price_mismatch', negotiation: summary(negotiation) }; + } + negotiation.agreedTotalPrice = negotiation.currentUnitPrice * negotiation.quantity; + negotiation.state = 'accepted'; + negotiation.reason = 'price_accepted'; + persist(negotiation); + audit(negotiation, 'accepted', 'price_accepted', { agreedTotalPrice: negotiation.agreedTotalPrice }); + journal(negotiation, 'accepted', `${actorName(bot)} and ${actorName(player)} accepted ${negotiation.agreedTotalPrice} Adena.`); + return { ok: true, negotiation: summary(negotiation) }; +} + +function declinePrice(bot, player, reason = 'declined') { + const negotiation = activeFor(bot); + if (!negotiation || negotiation.playerSession !== player) return { ok: false, reason: 'no_active_negotiation' }; + const result = summary(negotiation); + setTerminal(negotiation, 'declined', reason); + return { ok: true, reason: 'price_declined', negotiation: result }; +} + +function openNegotiatedTrade(bot, player) { + const negotiation = activeFor(bot); + if (!negotiation || negotiation.playerSession !== player) return { ok: false, reason: 'no_active_negotiation' }; + if (negotiation.state !== 'accepted' || !negotiation.agreedTotalPrice) return { ok: false, reason: 'price_not_accepted' }; + const adena = player.actor.backpack.fetchItemFromSelfId?.(57); + if (!adena || Number(adena.fetchAmount?.() || 0) < negotiation.agreedTotalPrice + 1000) { + return { ok: false, reason: 'insufficient_funds', negotiation: summary(negotiation) }; + } + const BotTradeService = invoke('GameServer/Bot/BotTradeService'); + const result = BotTradeService.startNegotiatedTrade(bot, player, negotiation); + if (!result.ok) return result; + negotiation.state = 'trade_open'; + negotiation.tradeId = result.trade.id; + negotiation.reason = 'native_trade_open'; + persist(negotiation); + audit(negotiation, 'trade_open', 'native_trade_open', { tradeId: negotiation.tradeId }); + return { + ok: true, + reason: 'native_trade_open', + trade: BotTradeService.activeTradeSummary(bot), + negotiation: summary(negotiation) + }; +} + +function validateTrade(trade) { + const negotiation = negotiations.get(trade?.negotiationId); + if (!negotiation || negotiation.state !== 'trade_open' || negotiation.tradeId !== trade.id) return { ok: false, reason: 'negotiation_missing' }; + if (!stockValid(negotiation)) return { ok: false, reason: 'stock_changed' }; + const itemLines = [...trade.botItems.values()]; + const adenaLines = [...trade.playerItems.values()].filter((line) => Number(line.selfId) === 57); + if (itemLines.length !== 1 || itemLines[0].objectId !== negotiation.itemObjectId || itemLines[0].count !== negotiation.quantity) { + return { ok: false, reason: 'negotiated_item_mismatch' }; + } + if (trade.playerItems.size !== 1 || adenaLines.length !== 1 || adenaLines[0].count !== negotiation.agreedTotalPrice) { + return { ok: false, reason: 'negotiated_price_mismatch' }; + } + return { ok: true, negotiation }; +} + +function completeTrade(trade) { + const negotiation = negotiations.get(trade?.negotiationId); + if (!negotiation) return false; + negotiation.state = 'completed'; + negotiation.reason = 'trade_completed'; + persist(negotiation); + audit(negotiation, 'completed', 'trade_completed', { tradeId: trade.id, agreedTotalPrice: negotiation.agreedTotalPrice }); + journal(negotiation, 'completed', `${actorName(negotiation.botSession)} sold ${negotiation.itemName} for ${negotiation.agreedTotalPrice} Adena.`); + clear(negotiation); + return true; +} + +function cancelForTrade(trade, reason = 'trade_cancelled') { + const negotiation = negotiations.get(trade?.negotiationId); + if (!negotiation) return false; + setTerminal(negotiation, reason === 'expired' ? 'expired' : 'cancelled', reason); + return true; +} + +function cleanup(session, reason = 'lifecycle') { + const negotiation = activeFor(session); + if (!negotiation) return false; + setTerminal(negotiation, reason === 'expired' ? 'expired' : 'cancelled', reason); + return true; +} + +function activeSummary(session) { + return summary(activeFor(session)); +} + +module.exports = { + MAX_QUANTITY, + MAX_ROUNDS, + MAX_UNIT_PRICE, + NEGOTIATION_TTL_MS, + acceptPrice, + activeSummary, + cancelForTrade, + cleanup, + counterOffer, + declinePrice, + enabled, + completeTrade, + openNegotiatedTrade, + quoteItem, + reset() { + negotiations.clear(); + sequence = 0; + }, + summary, + validateTrade +}; diff --git a/src/GameServer/Bot/Population/Cooldown.js b/src/GameServer/Bot/Population/Cooldown.js index a0f0ecee..03c164d5 100644 --- a/src/GameServer/Bot/Population/Cooldown.js +++ b/src/GameServer/Bot/Population/Cooldown.js @@ -33,6 +33,7 @@ const Cooldown = { transitionToColdState(session, state, reason = 'transition') { if (!session || !session.actor || !state) return Promise.resolve({ ok: false, reason: 'missing_state' }); const BotManager = invoke('GameServer/Bot/BotManager'); + try { invoke('GameServer/Bot/BotTradeService').cleanup(session, 'cold_transition'); } catch (_) { /* optional hot trade modules */ } return LifeState.upsertState(state, reason).then((saved) => { if (!saved) return { ok: false, reason: 'state_save_failed' }; @@ -58,6 +59,7 @@ const Cooldown = { if (session.actor.fetchKarma?.() > 0 && !options.allowPk) return { ok: false, reason: 'pk_active' }; if (session.partyCompanion === true || session.followPlayerSession) return { ok: false, reason: 'player_party' }; if (session.trade || session.activeTrade) return { ok: false, reason: 'trade_active' }; + if (session.activeNegotiation) return { ok: false, reason: 'negotiation_active' }; if (session.actor.state.fetchDead && session.actor.state.fetchDead()) return { ok: false, reason: 'dead_visible_state' }; if (!options.ignoreVisibility && isVisibleToRealPlayer(session)) return { ok: false, reason: 'visible_to_player' }; return { ok: true, reason: 'eligible' }; diff --git a/src/GameServer/Network/Request/TradeDone.js b/src/GameServer/Network/Request/TradeDone.js index 04f90351..86e0b6fd 100644 --- a/src/GameServer/Network/Request/TradeDone.js +++ b/src/GameServer/Network/Request/TradeDone.js @@ -36,6 +36,8 @@ async function tradeDone(session, buffer) { } const detail = describeMovedItems(result.moved); + const receivedByBot = describeMovedItems((result.moved || []).filter((item) => item.direction === 'player_to_bot')); + const receivedByPlayer = describeMovedItems((result.moved || []).filter((item) => item.direction === 'bot_to_player')); const lootRequest = result.direction === 'bot_outbound' ? null : BotLootEtiquette.resolveTrade(session, result.partnerSession, result.moved); @@ -58,7 +60,13 @@ async function tradeDone(session, buffer) { BotManager.botTell( result.partnerSession, session, - lootRequest ? `Thanks, that's exactly what I needed: ${detail}.` : `Thanks for the trade. I got ${detail}.` + result.negotiationId + ? `The agreed price is settled. I received ${receivedByBot || 'your payment'} for ${receivedByPlayer || 'the item'}.` + : lootRequest + ? `Thanks, that's exactly what I needed: ${detail}.` + : result.direction === 'bot_outbound' + ? `Trade complete. I sent ${receivedByPlayer || 'the agreed resources'}.` + : `Thanks for the trade. I got ${detail}.` ); BotEquipmentUpgrade.applyBestUpgrades(result.partnerSession, { force: true }); diff --git a/tests/test_bot_negotiation_database.js b/tests/test_bot_negotiation_database.js new file mode 100644 index 00000000..fa6f0e9a --- /dev/null +++ b/tests/test_bot_negotiation_database.js @@ -0,0 +1,58 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +require('../src/Global'); + +const Database = invoke('Database'); +const BotNegotiationService = invoke('GameServer/Bot/Economy/BotNegotiationService'); +const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); +const Item = invoke('GameServer/Item/Item'); + +options.default.OpenRouter.negotiationEnabled = true; + +function actor(id, name, backpack) { + return { fetchId: () => id, fetchName: () => name, fetchLocX: () => 0, fetchLocY: () => 0, fetchLocZ: () => 0, fetchIsOnline: () => true, isDead: () => false, backpack }; +} +function backpack(items) { + return { items, fetchItemRaw(id) { return this.items.find((item) => Number(item.fetchId()) === Number(id)); }, fetchItems() { return this.items; }, fetchItemFromSelfId(id) { return this.items.find((item) => Number(item.fetchSelfId()) === Number(id)); } }; +} + +async function main() { + const databasePath = path.join(process.cwd(), 'tmp', 'test-bot-negotiation.sqlite'); + fs.rmSync(databasePath, { force: true }); + options.default.Database.path = path.relative(process.cwd(), databasePath); + Database.init(); + await Database.createAccount('neg_db_player', 'secret'); + await Database.createAccount('neg_db_bot', 'secret'); + await Database.createCharacter('neg_db_player', { name: 'NegDbPlayer', race: 0, classId: 0, maxHp: 50, maxMp: 25, sex: 0, face: 0, hair: 0, hairColor: 0, locX: 0, locY: 0, locZ: 0 }); + await Database.createCharacter('neg_db_bot', { name: 'NegDbBot', race: 0, classId: 0, maxHp: 50, maxMp: 25, sex: 0, face: 0, hair: 0, hairColor: 0, locX: 0, locY: 0, locZ: 0 }); + const playerId = Number((await Database.fetchCharacterName('NegDbPlayer'))[0].id); + const botId = Number((await Database.fetchCharacterName('NegDbBot'))[0].id); + const player = { accountId: 'neg_db_player', actor: actor(playerId, 'NegDbPlayer', backpack([new Item(960, { selfId: 57, name: 'Adena', kind: 'Other.Currency', amount: 5000, stackable: true, equipped: false, slot: 0 })])) }; + const bot = { accountId: 'bot_neg_db', plan: 'merchant', actor: actor(botId, 'NegDbBot', backpack([new Item(961, { selfId: 9061, name: 'Database Cloth', kind: 'Other.Material', price: 1000, amount: 2, stackable: true, equipped: false, slot: 0 })])) }; + const originalSnapshot = BotSocialMemory.getSnapshot; + BotSocialMemory.getSnapshot = () => ({ trust: 0, familiarity: 0 }); + try { + const quote = BotNegotiationService.quoteItem(bot, player, 961, 1); + assert.strictEqual(quote.ok, true); + await new Promise((resolve) => setImmediate(resolve)); + let row = (await Database.execute(['SELECT state, currentUnitPrice FROM bot_negotiations WHERE id = ?', [quote.negotiation.id]]))[0]; + assert.strictEqual(row.state, 'open'); + const accepted = BotNegotiationService.acceptPrice(bot, player, quote.negotiation.currentTotalPrice); + assert.strictEqual(accepted.ok, true); + await new Promise((resolve) => setImmediate(resolve)); + row = (await Database.execute(['SELECT state, agreedTotalPrice FROM bot_negotiations WHERE id = ?', [quote.negotiation.id]]))[0]; + assert.strictEqual(row.state, 'accepted'); + assert.strictEqual(Number(row.agreedTotalPrice), quote.negotiation.currentTotalPrice); + console.log('Bot negotiation database checks passed'); + } finally { + BotSocialMemory.getSnapshot = originalSnapshot; + BotNegotiationService.reset(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_bot_negotiation_flow.js b/tests/test_bot_negotiation_flow.js new file mode 100644 index 00000000..31fc648f --- /dev/null +++ b/tests/test_bot_negotiation_flow.js @@ -0,0 +1,108 @@ +const assert = require('assert'); +require('../src/Global'); + +const Database = invoke('Database'); +const BotNegotiationService = invoke('GameServer/Bot/Economy/BotNegotiationService'); +const BotTradeService = invoke('GameServer/Bot/BotTradeService'); +const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); +const Item = invoke('GameServer/Item/Item'); + +options.default.OpenRouter.negotiationEnabled = true; + +function actor(id, name, backpack) { + return { + fetchId: () => id, + fetchName: () => name, + fetchLocX: () => 0, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchIsOnline: () => true, + isDead: () => false, + backpack + }; +} + +function backpack(items) { + return { + items, + fetchItems() { return this.items; }, + fetchItemRaw(id) { return this.items.find((item) => Number(item.fetchId()) === Number(id)); }, + fetchItemFromSelfId(selfId) { return this.items.find((item) => Number(item.fetchSelfId()) === Number(selfId)); }, + insertItem(id, selfId, data) { + this.items.push(new Item(id, { selfId, ...data, kind: Number(selfId) === 57 ? 'Other.Currency' : 'Other.Material', stackable: true })); + } + }; +} + +const playerItem = new Item(921, { selfId: 57, name: 'Adena', kind: 'Other.Currency', amount: 5000, stackable: true, equipped: false, slot: 0 }); +const botItem = new Item(922, { selfId: 9022, name: 'Negotiated Ore', kind: 'Other.Material', price: 1000, amount: 2, stackable: true, equipped: false, slot: 0 }); +const playerBackpack = backpack([playerItem]); +const botBackpack = backpack([botItem]); +const packets = []; +const player = { accountId: 'player_neg_flow', dataSendToMe: (packet) => packets.push(packet), actor: actor(920, 'FlowPlayer', playerBackpack) }; +const bot = { accountId: 'bot_neg_flow', plan: 'merchant', actor: actor(921, 'FlowMerchant', botBackpack) }; +const originalSnapshot = BotSocialMemory.getSnapshot; +const originalTransfer = Database.transferInventoryBetweenCharacters; + +(async () => { +try { + BotSocialMemory.getSnapshot = () => ({ trust: 3, familiarity: 5 }); + Database.transferInventoryBetweenCharacters = async (entries) => entries.map((entry, index) => ({ + ...entry, + targetItemId: 9300 + index, + remaining: entry.sourceItemId === 922 ? 1 : Number(entry.sourceItemId === 921 ? 5000 - entry.amount : 0) + })); + + const quoted = BotNegotiationService.quoteItem(bot, player, 922, 1); + assert.strictEqual(quoted.ok, true); + assert(quoted.negotiation.currentTotalPrice > 0); + const countered = BotNegotiationService.counterOffer(bot, player, quoted.negotiation.minimumUnitPrice); + assert.strictEqual(countered.ok, true); + const total = countered.negotiation.currentTotalPrice; + const accepted = BotNegotiationService.acceptPrice(bot, player, total); + assert.strictEqual(accepted.ok, true); + const opened = BotNegotiationService.openNegotiatedTrade(bot, player); + assert.strictEqual(opened.ok, true); + assert.deepStrictEqual(packets.slice(0, 2).map((packet) => packet[0]), [0x1e, 0x21]); + assert.strictEqual(BotTradeService.addItem(player, 921, total).ok, true, 'player must add the exact accepted Adena price'); + const committed = await BotTradeService.commit(player); + assert.strictEqual(committed.ok, true); + assert.strictEqual(committed.negotiationId, accepted.negotiation.id); + assert.strictEqual(BotNegotiationService.activeSummary(bot), null, 'completed negotiation must be cleared'); + assert.strictEqual(playerItem.fetchAmount(), 5000 - total); + assert.strictEqual(botItem.fetchAmount(), 1); + assert(playerBackpack.fetchItemFromSelfId(9022), 'native commit must deliver exactly the negotiated item'); + assert.strictEqual(botBackpack.fetchItemFromSelfId(57).fetchAmount(), total, 'native commit must deliver exact payment'); + assert.strictEqual(JSON.stringify(await BotTradeService.commit(player)).includes('idempotent'), true, 'replay remains safe'); + + const stale = BotNegotiationService.quoteItem(bot, player, 922, 1); + assert.strictEqual(stale.ok, true); + botItem.setAmount(0); + assert.strictEqual(BotNegotiationService.activeSummary(bot), null, 'stock changes expire the quote'); + assert.strictEqual(bot.botNegotiationReservations.size, 0); + assert.strictEqual(BotNegotiationService.activeSummary(bot), null, 'summary cannot revive an expired quote'); + + botItem.setAmount(1); + const rounds = BotNegotiationService.quoteItem(bot, player, 922, 1); + assert.strictEqual(rounds.ok, true); + assert.strictEqual(BotNegotiationService.counterOffer(bot, player, rounds.negotiation.minimumUnitPrice).ok, true); + assert.strictEqual(BotNegotiationService.counterOffer(bot, player, rounds.negotiation.maximumUnitPrice).ok, true); + assert.strictEqual(BotNegotiationService.counterOffer(bot, player, rounds.negotiation.currentTotalPrice).ok, true); + assert.strictEqual(BotNegotiationService.counterOffer(bot, player, rounds.negotiation.currentTotalPrice).reason, 'round_limit'); + BotNegotiationService.declinePrice(bot, player); + + const ttl = BotNegotiationService.quoteItem(bot, player, 922, 1); + assert.strictEqual(ttl.ok, true); + bot.activeNegotiation.expiresAt = Date.now() - 1; + assert.strictEqual(BotNegotiationService.activeSummary(bot), null, 'TTL expires an unanswered quote'); + assert.strictEqual(bot.botNegotiationReservations.size, 0); + console.log('Bot negotiation flow checks passed'); +} finally { + BotSocialMemory.getSnapshot = originalSnapshot; + Database.transferInventoryBetweenCharacters = originalTransfer; + BotNegotiationService.reset(); +} +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_bot_negotiation_policy.js b/tests/test_bot_negotiation_policy.js new file mode 100644 index 00000000..7b0fa011 --- /dev/null +++ b/tests/test_bot_negotiation_policy.js @@ -0,0 +1,87 @@ +const assert = require('assert'); +require('../src/Global'); + +const BotNegotiationService = invoke('GameServer/Bot/Economy/BotNegotiationService'); +const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); +const Item = invoke('GameServer/Item/Item'); + +options.default.OpenRouter.negotiationEnabled = true; + +function actor(id, name, backpack) { + return { + fetchId: () => id, + fetchName: () => name, + fetchLocX: () => 0, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchIsOnline: () => true, + isDead: () => false, + backpack + }; +} + +function backpack(items) { + return { + items, + fetchItems() { return this.items; }, + fetchItemRaw(id) { return this.items.find((item) => Number(item.fetchId()) === Number(id)); } + }; +} + +const player = { accountId: 'player_neg_policy', actor: actor(910, 'NegotiationPlayer', backpack([])) }; +const item = new Item(911, { + selfId: 9011, + name: 'Policy Herb', + kind: 'Other.Material', + price: 1000, + amount: 4, + stackable: true, + equipped: false, + slot: 0 +}); +const bot = { + accountId: 'bot_neg_policy', + plan: 'merchant', + persona: { + primaryDrive: 'wealth', + traits: { caution: 0.8, ambition: 0.7, assertiveness: 0.7 } + }, + actor: actor(912, 'PolicyMerchant', backpack([item])) +}; + +const originalSnapshot = BotSocialMemory.getSnapshot; +try { + BotSocialMemory.getSnapshot = (_player, session) => session.accountId === 'bot_neg_policy' && _player.accountId === 'player_neg_policy' + ? { trust: 0, familiarity: 0 } + : { trust: 0, familiarity: 0 }; + + const stranger = BotNegotiationService.quoteItem(bot, player, 911, 1); + assert.strictEqual(stranger.ok, true); + assert.strictEqual(stranger.negotiation.relation, 'stranger'); + assert(stranger.negotiation.minimumUnitPrice <= stranger.negotiation.currentUnitPrice); + assert(stranger.negotiation.currentUnitPrice <= stranger.negotiation.maximumUnitPrice); + assert.match(stranger.negotiation.rationale, /value|reference|margin/i); + const firstRange = [stranger.negotiation.minimumUnitPrice, stranger.negotiation.maximumUnitPrice]; + BotNegotiationService.declinePrice(bot, player); + + BotSocialMemory.getSnapshot = () => ({ trust: 9, familiarity: 9 }); + const trusted = BotNegotiationService.quoteItem(bot, player, 911, 1); + assert.strictEqual(trusted.ok, true); + assert.strictEqual(trusted.negotiation.relation, 'trusted'); + assert(trusted.negotiation.currentUnitPrice < stranger.negotiation.currentUnitPrice, 'trusted relationship should receive a deterministic discount'); + assert(trusted.negotiation.minimumUnitPrice <= firstRange[0]); + assert(trusted.negotiation.maximumUnitPrice <= firstRange[1], 'relationship remains within the same deterministic market ceiling'); + assert.strictEqual(BotNegotiationService.counterOffer(bot, player, trusted.negotiation.minimumUnitPrice - 1).reason, 'price_out_of_bounds'); + assert.strictEqual(bot.botNegotiationReservations.get(911).count, 1); + BotNegotiationService.declinePrice(bot, player); + assert.strictEqual(bot.botNegotiationReservations.size, 0, 'decline releases stock reservation'); + const lifecycle = BotNegotiationService.quoteItem(bot, player, 911, 1); + assert.strictEqual(lifecycle.ok, true); + assert.strictEqual(BotNegotiationService.cleanup(bot, 'death'), true); + assert.strictEqual(BotNegotiationService.activeSummary(player), null, 'death cleanup clears both negotiation participants'); + assert.strictEqual(bot.botNegotiationReservations.size, 0, 'death cleanup releases reserved stock'); + console.log('Bot negotiation policy checks passed'); +} finally { + BotSocialMemory.getSnapshot = originalSnapshot; + BotNegotiationService.reset(); +} diff --git a/tests/test_llm_negotiation_tools.js b/tests/test_llm_negotiation_tools.js new file mode 100644 index 00000000..8435e66f --- /dev/null +++ b/tests/test_llm_negotiation_tools.js @@ -0,0 +1,77 @@ +const assert = require('assert'); +require('../src/Global'); + +const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); +const BotNegotiationService = invoke('GameServer/Bot/Economy/BotNegotiationService'); +const BotTradeService = invoke('GameServer/Bot/BotTradeService'); +const Item = invoke('GameServer/Item/Item'); + +options.default.OpenRouter.negotiationEnabled = true; + +function backpack(items) { + return { + items, + fetchItems() { return this.items; }, + fetchItemRaw(id) { return this.items.find((item) => Number(item.fetchId()) === Number(id)); }, + fetchItemFromSelfId(selfId) { return this.items.find((item) => Number(item.fetchSelfId()) === Number(selfId)); }, + insertItem(id, selfId, data) { this.items.push(new Item(id, { selfId, ...data, kind: 'Other.Material', stackable: true })); } + }; +} + +function actor(id, name, bag) { + return { + fetchId: () => id, + fetchName: () => name, + fetchLocX: () => 0, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchIsOnline: () => true, + isDead: () => false, + state: { fetchSeated: () => false, setSeated() {} }, + backpack: bag + }; +} + +const player = { + accountId: 'player_llm_neg', + actor: actor(940, 'NegotiationLeader', backpack([ + new Item(943, { selfId: 57, name: 'Adena', kind: 'Other.Currency', amount: 5000, stackable: true, equipped: false, slot: 0 }) + ])) +}; +const botItem = new Item(941, { selfId: 9041, name: 'Quoted Cloth', kind: 'Other.Material', price: 1000, amount: 2, stackable: true, equipped: false, slot: 0 }); +const bot = { accountId: 'bot_llm_neg', plan: 'merchant', actor: actor(942, 'NegotiationMerchant', backpack([botItem])) }; + +function decision(action, turnId, extra = {}) { + return { action, confidence: 0.99, reason: 'negotiation tool test', turnId, ...extra }; +} + +function context(turnId, session = player) { + return { playerSession: session, conversationTurn: { turnId } }; +} + +try { + assert(BotAgentTools.toolDescriptions(bot).some((tool) => tool.action === 'quote_item')); + const quoted = BotAgentTools.execute(bot, decision('quote_item', 'neg-tool-1', { negotiationItemId: 941, negotiationAmount: 1 }), [], context('neg-tool-1')); + assert.strictEqual(quoted.applied, true); + assert.strictEqual(quoted.reason, 'price_quoted'); + + const stranger = { accountId: 'player_llm_stranger', actor: actor(949, 'Stranger', backpack([])) }; + const rejected = BotAgentTools.execute(bot, decision('decline_price', 'neg-tool-2'), [], context('neg-tool-2', stranger)); + assert.deepStrictEqual(rejected, { applied: false, reason: 'not_authorized' }); + + const counter = BotAgentTools.execute(bot, decision('counter_offer', 'neg-tool-3', { negotiationPrice: quoted.negotiation.currentTotalPrice }), [], context('neg-tool-3')); + assert.strictEqual(counter.applied, true); + const accepted = BotAgentTools.execute(bot, decision('accept_price', 'neg-tool-4', { negotiationPrice: counter.negotiation.currentTotalPrice }), [], context('neg-tool-4')); + assert.strictEqual(accepted.applied, true); + const opened = BotAgentTools.execute(bot, decision('open_negotiated_trade', 'neg-tool-5'), [], context('neg-tool-5')); + assert.strictEqual(opened.applied, true); + assert.strictEqual(opened.trade.negotiationId, accepted.negotiation.id); + BotTradeService.cancel(bot, 'test_cleanup', false); + assert.strictEqual(BotNegotiationService.activeSummary(bot), null); + console.log('LLM negotiation tool checks passed'); +} catch (error) { + console.error(error); + process.exitCode = 1; +} finally { + BotNegotiationService.reset(); +} From fff3bf4c627d545840d3ed8ff7a8fc5578a46b18 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:13:22 -0400 Subject: [PATCH 08/59] Add ambient hot bot scene director --- config/default.ini | 4 + scripts/run-tests.js | 1 + src/GameServer/Bot/AI/BotAmbientDirector.js | 310 ++++++++++++++++++++ src/GameServer/Bot/AI/BotBrain.js | 1 + src/GameServer/Bot/AI/BotBrainContext.js | 1 + src/GameServer/Bot/AI/BotStatus.js | 6 +- src/GameServer/Bot/BotAI.js | 2 + src/GameServer/Bot/BotManager.js | 21 +- src/GameServer/Bot/Population/Cooldown.js | 1 + tests/test_bot_ambient_director.js | 116 ++++++++ 10 files changed, 457 insertions(+), 6 deletions(-) create mode 100644 src/GameServer/Bot/AI/BotAmbientDirector.js create mode 100644 tests/test_bot_ambient_director.js diff --git a/config/default.ini b/config/default.ini index bd9a3896..f5c30ef0 100644 --- a/config/default.ini +++ b/config/default.ini @@ -45,6 +45,10 @@ negotiationEnabled = false [BotPopulation] enabled = true +ambientScenesEnabled = true +ambientSceneCooldownMs = 180000 +ambientPairCooldownMs = 90000 +ambientSceneTtlMs = 8000 backgroundResolverEnabled = true backgroundPartyEnabled = true phasePolicyEnabled = true diff --git a/scripts/run-tests.js b/scripts/run-tests.js index afce69eb..20415493 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -35,6 +35,7 @@ const tests = [ 'tests/test_bot_agent_support_confirmation.js', 'tests/test_bot_combat_skill_selection.js', 'tests/test_bot_conversation.js', + 'tests/test_bot_ambient_director.js', 'tests/test_bot_death_respawn.js', 'tests/test_bot_gear.js', 'tests/test_bot_economy_pricing.js', diff --git a/src/GameServer/Bot/AI/BotAmbientDirector.js b/src/GameServer/Bot/AI/BotAmbientDirector.js new file mode 100644 index 00000000..bf75e3cf --- /dev/null +++ b/src/GameServer/Bot/AI/BotAmbientDirector.js @@ -0,0 +1,310 @@ +const BotConversation = invoke('GameServer/Bot/AI/BotConversation'); +const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); +const BotPersona = invoke('GameServer/Bot/AI/BotPersona'); + +const DEFAULT_SCENE_COOLDOWN_MS = 3 * 60 * 1000; +const DEFAULT_PAIR_COOLDOWN_MS = 90 * 1000; +const DEFAULT_SCENE_TTL_MS = 8 * 1000; +const MAX_REASON_CHARS = 120; + +const MOODS = Object.freeze(['calm', 'focused', 'sociable', 'restless', 'tired', 'guarded']); +const activeScenes = new Map(); + +function bool(value, fallback = true) { + if (value === undefined || value === null || value === '') return fallback; + if (typeof value === 'boolean') return value; + return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase()); +} + +function config() { + return options.default?.BotPopulation || {}; +} + +function enabled() { + return bool(config().ambientScenesEnabled, true); +} + +function number(value, fallback = 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function actorId(session) { + return number(session?.actor?.fetchId?.(), 0); +} + +function actorName(session) { + return session?.actor?.fetchName?.() || session?.name || `bot-${actorId(session) || 'unknown'}`; +} + +function clean(value, max = MAX_REASON_CHARS) { + return String(value || '').replace(/\s+/g, ' ').trim().slice(0, max); +} + +function isOnline(session) { + if (!session?.actor) return false; + return typeof session.actor.fetchIsOnline !== 'function' || session.actor.fetchIsOnline() !== false; +} + +function isBotSession(session) { + if (!session?.actor) return false; + if (session.accountId) return String(session.accountId).startsWith('bot_'); + return session.constructor?.name === 'BotSession' || session.botSession === true; +} + +function trait(session, name, fallback = 0.5) { + const value = Number(session?.persona?.traits?.[name]); + return Number.isFinite(value) ? value : fallback; +} + +function personaFor(session) { + if (session?.persona?.primaryDrive) return session.persona; + const id = actorId(session); + if (!id) return null; + try { return BotPersona.generate({ characterId: id }); } catch (_) { return null; } +} + +function ratio(actor, current, maximum) { + const max = number(actor?.[maximum]?.(), 0); + if (max <= 0) return 1; + return Math.max(0, Math.min(1, number(actor?.[current]?.(), max) / max)); +} + +function deriveMood(session) { + const actor = session?.actor; + if (!actor) return { mood: 'calm', intent: 'keep_ready', reason: 'missing_actor' }; + if (actor.state?.fetchDead?.() || session.plan === 'dead') { + return { mood: 'tired', intent: 'recover', reason: 'dead_or_recovering' }; + } + + const hp = ratio(actor, 'fetchHp', 'fetchMaxHp'); + const mp = ratio(actor, 'fetchMp', 'fetchMaxMp'); + if (hp < 0.35 || mp < 0.20 || session.plan === 'resting' && (hp < 0.65 || mp < 0.45)) { + return { mood: 'tired', intent: 'recover', reason: 'low_vitals' }; + } + if (session.activeTrade || session.activeNegotiation || session.plan === 'shopping' || session.plan === 'merchant') { + return { mood: 'focused', intent: 'complete_errand', reason: 'commerce_or_errand' }; + } + if (session.partyCompanion === true || session.plan === 'following') { + return { mood: 'focused', intent: 'support_party', reason: 'party_duty' }; + } + + const socialEvent = session.lastSocialEvent; + if (socialEvent?.event === 'insulted' || socialEvent?.event === 'party_kicked' || socialEvent?.event === 'party_dismissed') { + return { mood: 'guarded', intent: 'keep_distance', reason: 'recent_social_harm' }; + } + + const persona = personaFor(session); + if (persona?.primaryDrive === 'social' || trait(session, 'sociability') >= 0.76) { + return { mood: 'sociable', intent: 'seek_company', reason: 'social_persona' }; + } + if (Number(session.noTargetTicks || 0) >= 3 || session.plan === 'resting' && trait(session, 'restlessness') >= 0.68) { + return { mood: 'restless', intent: 'look_for_activity', reason: 'idle_or_restless' }; + } + if (persona?.primaryDrive === 'progression' || persona?.primaryDrive === 'wealth') { + return { mood: 'focused', intent: 'complete_run', reason: 'goal_persona' }; + } + return { mood: 'calm', intent: 'keep_ready', reason: 'stable_state' }; +} + +function snapshot(session, now = Date.now()) { + const current = session?.ambientState || refresh(session, now); + const scene = session?.ambientScene; + return { + mood: MOODS.includes(current.mood) ? current.mood : 'calm', + intent: current.intent || 'keep_ready', + reason: current.reason || 'stable_state', + updatedAt: Number(current.updatedAt || now), + scene: scene ? { + id: scene.id, + topic: scene.topic, + participants: [...scene.participants], + startedAt: scene.startedAt, + expiresAt: scene.expiresAt + } : null, + cooldownRemainingMs: Math.max(0, Number(session?.ambientLastSceneAt || 0) + sceneCooldownMs() - now) + }; +} + +function refresh(session, now = Date.now()) { + if (!session) return null; + const mood = deriveMood(session); + const previous = session.ambientState; + session.ambientState = { + ...mood, + updatedAt: now + }; + if (previous?.mood && previous.mood !== mood.mood && actorId(session)) { + BotEventJournal.record({ + botId: actorId(session), + eventType: 'ambient_mood', + summary: `${actorName(session)} mood=${mood.mood} intent=${mood.intent}`, + dedupeKey: `mood:${mood.mood}`, + meta: { mood: mood.mood, intent: mood.intent, reason: mood.reason } + }).catch(() => {}); + } + return session.ambientState; +} + +function sceneCooldownMs() { + return Math.max(30 * 1000, number(config().ambientSceneCooldownMs, DEFAULT_SCENE_COOLDOWN_MS)); +} + +function pairCooldownMs() { + return Math.max(30 * 1000, number(config().ambientPairCooldownMs, DEFAULT_PAIR_COOLDOWN_MS)); +} + +function sceneTtlMs() { + return Math.max(2500, number(config().ambientSceneTtlMs, DEFAULT_SCENE_TTL_MS)); +} + +function pairKey(first, second) { + return [actorId(first), actorId(second)].sort((a, b) => a - b).join(':'); +} + +function distance2d(first, second) { + const a = first?.actor; + const b = second?.actor; + if (!a || !b || typeof a.fetchLocX !== 'function' || typeof b.fetchLocX !== 'function') return null; + const dx = number(a.fetchLocX()) - number(b.fetchLocX()); + const dy = number(a.fetchLocY()) - number(b.fetchLocY()); + return Math.sqrt(dx * dx + dy * dy); +} + +function expireSceneIfNeeded(session, now) { + const scene = session?.ambientScene; + if (scene && Number(scene.expiresAt || 0) <= now) finish(scene, 'ttl_expired', now); +} + +function eligible(initiator, responder, now = Date.now()) { + if (!enabled()) return { ok: false, reason: 'ambient_disabled' }; + expireSceneIfNeeded(initiator, now); + expireSceneIfNeeded(responder, now); + if (!isBotSession(initiator) || !isBotSession(responder)) return { ok: false, reason: 'bot_only_scene' }; + if (!isOnline(initiator) || !isOnline(responder)) return { ok: false, reason: 'offline' }; + if (initiator === responder) return { ok: false, reason: 'same_session' }; + if (initiator.partyCompanion || responder.partyCompanion) return { ok: false, reason: 'player_companion' }; + if (initiator.plan !== 'resting' || responder.plan !== 'resting') return { ok: false, reason: 'not_resting' }; + if (initiator.inConversation || responder.inConversation || initiator.ambientScene || responder.ambientScene) { + return { ok: false, reason: 'scene_active' }; + } + if (initiator.activeTrade || responder.activeTrade || initiator.activeNegotiation || responder.activeNegotiation) { + return { ok: false, reason: 'commerce_active' }; + } + + const distance = distance2d(initiator, responder); + if (distance !== null && distance > BotConversation.CONVERSATION_RANGE) { + return { ok: false, reason: 'too_far', distance }; + } + + const lastScene = Math.max(Number(initiator.ambientLastSceneAt || 0), Number(responder.ambientLastSceneAt || 0)); + if (lastScene && now - lastScene < pairCooldownMs()) { + return { ok: false, reason: 'pair_cooldown', retryAfterMs: pairCooldownMs() - (now - lastScene) }; + } + if ([initiator, responder].some((session) => session.lastAmbientSceneAt && now - session.lastAmbientSceneAt < sceneCooldownMs())) { + return { ok: false, reason: 'bot_cooldown' }; + } + return { ok: true, distance }; +} + +function recordSceneEvent(session, scene, eventType, reason = '') { + const id = actorId(session); + if (!id) return; + BotEventJournal.record({ + botId: id, + eventType, + summary: `${actorName(session)} ambient ${scene.topic}${reason ? ` (${reason})` : ''}`, + dedupeKey: `${eventType}:${scene.id}`, + meta: { + sceneId: scene.id, + topic: scene.topic, + participants: scene.participants, + reason: clean(reason, 80) + } + }).catch(() => {}); +} + +function start(initiator, responder, now = Date.now()) { + const check = eligible(initiator, responder, now); + if (!check.ok) return check; + + const conversation = BotConversation.start(initiator, responder, now); + if (!conversation) return { ok: false, reason: 'conversation_unavailable' }; + + const scene = { + id: `ambient-${actorId(initiator)}-${actorId(responder)}-${now}`, + topic: conversation.topic, + participants: [actorName(initiator), actorName(responder)], + startedAt: now, + expiresAt: now + sceneTtlMs(), + conversation, + finished: false + }; + initiator.ambientScene = scene; + responder.ambientScene = scene; + initiator.ambientLastSceneAt = now; + responder.ambientLastSceneAt = now; + activeScenes.set(scene.id, scene); + refresh(initiator, now); + refresh(responder, now); + recordSceneEvent(initiator, scene, 'ambient_scene_started'); + recordSceneEvent(responder, scene, 'ambient_scene_started'); + return { ok: true, scene, conversation }; +} + +function finish(sceneOrSession, reason = 'completed', now = Date.now()) { + const scene = sceneOrSession?.conversation ? sceneOrSession : sceneOrSession?.ambientScene; + if (!scene || scene.finished) return false; + scene.finished = true; + BotConversation.finish(scene.conversation || scene); + activeScenes.delete(scene.id); + const participants = [scene.conversation?.lines?.[0]?.speaker, scene.conversation?.lines?.[1]?.speaker] + .filter(Boolean); + participants.forEach((session) => { + if (session.ambientScene === scene) session.ambientScene = null; + session.ambientLastSceneAt = Number(session.ambientLastSceneAt || scene.startedAt || now); + session.lastAmbientScene = { + id: scene.id, + topic: scene.topic, + at: now, + reason: clean(reason, 80) + }; + recordSceneEvent(session, scene, 'ambient_scene_finished', reason); + refresh(session, now); + }); + return true; +} + +function cleanup(session, reason = 'lifecycle') { + if (!session) return false; + const scene = session.ambientScene; + if (!scene) return false; + scene.cancelled = true; + return finish(scene, reason); +} + +function sceneFor(session) { + return session?.ambientScene || null; +} + +const BotAmbientDirector = { + MOODS, + DEFAULT_SCENE_COOLDOWN_MS, + DEFAULT_PAIR_COOLDOWN_MS, + DEFAULT_SCENE_TTL_MS, + enabled, + deriveMood, + refresh, + snapshot, + eligible, + start, + finish, + cleanup, + sceneFor, + activeScenes() { return [...activeScenes.values()].map((scene) => ({ ...scene, conversation: undefined })); }, + reset() { + activeScenes.clear(); + } +}; + +module.exports = BotAmbientDirector; diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 8630e928..00c50e83 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -220,6 +220,7 @@ function systemPrompt() { 'Skill priorities are bounded hints to the deterministic offensive scorer. Emergency healing, defense, resurrection, cooldowns, MP, range, and C4 compatibility always win.', 'Equipment tools may only use safe candidates from actual inventory and native persistence. Never equip quest, incompatible, over-grade, or non-upgrade items.', 'The persona describes tone and high-level preferences only. It never overrides safety, current game state, or the allowed actions.', + 'Ambient mood and intent are server-owned soft context. Treat an active ambient scene as factual only when bot.ambient.scene is present; never start or claim a scene from mood alone.', 'The contextFragments field is bounded and includes recent authoritative events; treat summaries as memory, never as permission to perform an action.', 'Resource-gift trade tools can open a native window only with the current party leader; negotiated market tools use only the active real player pair. Both reserve safe inventory without mutating it, expose only server-owned bounds, allow at most three negotiation rounds, and release reservations on cancel/expiry. Never claim completion before native player confirmation.', 'Never invent unavailable actions, players, items, or spells.' diff --git a/src/GameServer/Bot/AI/BotBrainContext.js b/src/GameServer/Bot/AI/BotBrainContext.js index 926b1f96..745cce9b 100644 --- a/src/GameServer/Bot/AI/BotBrainContext.js +++ b/src/GameServer/Bot/AI/BotBrainContext.js @@ -275,6 +275,7 @@ function compactStatus(session, status, text = '', options = {}) { skills: includeSkills ? skillsSnapshot(actor, text) : null, roleDecision: status.roleDecision || null, trade: status.trade || null, + ambient: status.ambient || null, policy: status.policy || null, persona: status.persona || null, social: status.social || null diff --git a/src/GameServer/Bot/AI/BotStatus.js b/src/GameServer/Bot/AI/BotStatus.js index a747f68e..6abb9e68 100644 --- a/src/GameServer/Bot/AI/BotStatus.js +++ b/src/GameServer/Bot/AI/BotStatus.js @@ -8,6 +8,7 @@ const PartyCombatState = invoke('GameServer/Bot/AI/PartyCombatState'); const EffectStore = invoke('GameServer/Effects/EffectStore'); const GearSkillHints = invoke('GameServer/Bot/AI/GearSkillHints'); const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); +const BotAmbientDirector = invoke('GameServer/Bot/AI/BotAmbientDirector'); function ratio(value, max) { if (!max) return 0; @@ -247,6 +248,7 @@ const BotStatus = { const role = BotRoles.inferRole(bot); const dead = bot.state.fetchDead(); + const ambient = BotAmbientDirector.snapshot(session); const target = findTarget(session, bot); const leaderSession = session.followPlayerSession && session.partyCompanion === true ? session.followPlayerSession : null; const partySettings = leaderSession ? PartyCompanionService.getSettings(leaderSession) : null; @@ -344,6 +346,7 @@ const BotStatus = { }, nearby: nearbySnapshot(bot), trade: tradeSnapshot(session, bot), + ambient, persona: personaSnapshot(session), policy: HotBotPolicyOverlay.status(session), social: session.socialSummary || null, @@ -365,6 +368,7 @@ const BotStatus = { const spot = status.spot && status.spot.name ? ` spot=${status.spot.name}` : ''; const home = status.home && status.home.region ? ` home=${status.home.region}${status.home.visitor ? ':visitor' : ''}` : ''; const social = status.social ? ` social=${status.social.playerName}:${status.social.relationship}/${status.social.trust}` : ''; + const ambient = status.ambient ? ` mood=${status.ambient.mood}/${status.ambient.intent}` : ''; const roleDecision = status.roleDecision ? ` decision=${status.roleDecision.action}/${status.roleDecision.reason}` : ''; const targetDecision = status.decisions?.target ? ` targetScore=${status.decisions.target.score}` : ''; const combatDecision = status.decisions?.combat @@ -378,7 +382,7 @@ const BotStatus = { const buffs = status.buffs?.needsRefresh ? ' buffs=refresh' : ''; const blockers = status.blockers.length > 0 ? ` blockers=${status.blockers.join(',')}` : ''; - return `${status.name}: mode=${status.mode} intent=${status.intent} role=${status.role}${home} hp=${hp}% mp=${mp}%${target}${spot}${social}${roleDecision}${targetDecision}${combatDecision}${pvpDecision}${build}${path}${buffs}${blockers}`; + return `${status.name}: mode=${status.mode} intent=${status.intent} role=${status.role}${home} hp=${hp}% mp=${mp}%${target}${spot}${ambient}${social}${roleDecision}${targetDecision}${combatDecision}${pvpDecision}${build}${path}${buffs}${blockers}`; } }; diff --git a/src/GameServer/Bot/BotAI.js b/src/GameServer/Bot/BotAI.js index 34de7b2e..b0cdfca4 100644 --- a/src/GameServer/Bot/BotAI.js +++ b/src/GameServer/Bot/BotAI.js @@ -138,6 +138,7 @@ const BotAI = { stop(session) { session.aiActive = false; HotBotPolicyOverlay.clearForCold(session); + try { invoke('GameServer/Bot/AI/BotAmbientDirector').cleanup(session, 'ai_stop'); } catch (_) { /* optional ambient module */ } if (session.aiTimeout) { clearTimeout(session.aiTimeout); session.aiTimeout = null; @@ -332,6 +333,7 @@ const BotAI = { clearTacticalState(session); HotBotPolicyOverlay.clearForDeath(session); BotTradeService.cleanup(session, 'death'); + try { invoke('GameServer/Bot/AI/BotAmbientDirector').cleanup(session, 'death'); } catch (_) { /* optional ambient module */ } } else { // TTL expiry is intentionally lazy and bounded to hot ticks; no // background timer is needed for a session-local preference. diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js index 7abb549d..64df3e8b 100644 --- a/src/GameServer/Bot/BotManager.js +++ b/src/GameServer/Bot/BotManager.js @@ -20,6 +20,7 @@ const PopulationService = invoke('GameServer/Bot/Population/PopulationService'); const SimulationKernel = invoke('GameServer/Bot/Simulation/SimulationKernel'); const GoalService = invoke('GameServer/Bot/Goals/GoalService'); const BotConversation = invoke('GameServer/Bot/AI/BotConversation'); +const BotAmbientDirector = invoke('GameServer/Bot/AI/BotAmbientDirector'); const BotDialogueArbiter = invoke('GameServer/Bot/AI/BotDialogueArbiter'); const BotSupportPlanner = invoke('GameServer/Bot/AI/BotSupportPlanner'); const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); @@ -1062,16 +1063,24 @@ const BotManager = { return dist < BotConversation.CONVERSATION_RANGE; }); - if (targetSession && BotConversation.canStart(botSession, targetSession)) { - this.triggerConversation(botSession, targetSession); + if (!targetSession) return false; + if (BotAmbientDirector.enabled()) { + const started = BotAmbientDirector.start(botSession, targetSession); + if (!started.ok) return false; + return this.triggerConversation(botSession, targetSession, started.conversation, started.scene); } + if (BotConversation.canStart(botSession, targetSession)) { + return this.triggerConversation(botSession, targetSession); + } + return false; }, - triggerConversation(botSession, targetSession) { - const conversation = BotConversation.start(botSession, targetSession); + triggerConversation(botSession, targetSession, existingConversation = null, ambientScene = null) { + const conversation = existingConversation || BotConversation.start(botSession, targetSession); if (!conversation) return false; const deliver = (index) => { + if (ambientScene?.cancelled || ambientScene?.finished) return; const line = conversation.lines[index]; if (!line?.speaker?.actor) return; this.botSay(line.speaker, line.text); @@ -1080,7 +1089,9 @@ const BotManager = { deliver(0); setTimeout(() => deliver(1), 2200); setTimeout(() => deliver(2), 4300); - setTimeout(() => BotConversation.finish(conversation), 6500); + setTimeout(() => ambientScene + ? BotAmbientDirector.finish(ambientScene, 'completed') + : BotConversation.finish(conversation), 6500); return true; }, diff --git a/src/GameServer/Bot/Population/Cooldown.js b/src/GameServer/Bot/Population/Cooldown.js index 03c164d5..d60c0d9d 100644 --- a/src/GameServer/Bot/Population/Cooldown.js +++ b/src/GameServer/Bot/Population/Cooldown.js @@ -34,6 +34,7 @@ const Cooldown = { if (!session || !session.actor || !state) return Promise.resolve({ ok: false, reason: 'missing_state' }); const BotManager = invoke('GameServer/Bot/BotManager'); try { invoke('GameServer/Bot/BotTradeService').cleanup(session, 'cold_transition'); } catch (_) { /* optional hot trade modules */ } + try { invoke('GameServer/Bot/AI/BotAmbientDirector').cleanup(session, 'cold_transition'); } catch (_) { /* optional ambient module */ } return LifeState.upsertState(state, reason).then((saved) => { if (!saved) return { ok: false, reason: 'state_save_failed' }; diff --git a/tests/test_bot_ambient_director.js b/tests/test_bot_ambient_director.js new file mode 100644 index 00000000..2be93282 --- /dev/null +++ b/tests/test_bot_ambient_director.js @@ -0,0 +1,116 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotAmbientDirector = invoke('GameServer/Bot/AI/BotAmbientDirector'); +const BotConversation = invoke('GameServer/Bot/AI/BotConversation'); +const BotBrainContext = invoke('GameServer/Bot/AI/BotBrainContext'); + +function actor(id, name, overrides = {}) { + return { + fetchId: () => id, + fetchName: () => name, + fetchIsOnline: () => true, + fetchHp: () => overrides.hp ?? 100, + fetchMaxHp: () => 100, + fetchMp: () => overrides.mp ?? 100, + fetchMaxMp: () => 100, + fetchLocX: () => overrides.x ?? -84318, + fetchLocY: () => overrides.y ?? 244579, + fetchLocZ: () => -3730, + state: { + fetchDead: () => !!overrides.dead + } + }; +} + +function bot(id, name, overrides = {}) { + return { + accountId: `bot_${id}`, + actor: actor(id, name, overrides), + plan: 'resting', + persona: overrides.persona || { + primaryDrive: 'social', + traits: { sociability: 0.9, restlessness: 0.2 } + }, + botStatus: { home: { region: 'Talking Island' }, role: overrides.role || 'fighter' }, + ...overrides + }; +} + +const now = 1_000_000; +const aria = bot(2000101, 'Aria', { role: 'tank' }); +const belen = bot(2000102, 'Belen', { role: 'healer' }); + +BotAmbientDirector.reset(); +assert.strictEqual(BotAmbientDirector.enabled(), true, 'ambient director should be enabled by default'); +assert.strictEqual(BotAmbientDirector.deriveMood(aria).mood, 'sociable', 'social persona should become sociable mood'); + +const started = BotAmbientDirector.start(aria, belen, now); +assert.strictEqual(started.ok, true, 'two resting hot bots should start one bounded ambient scene'); +assert.ok(['rest', 'party'].includes(started.conversation.topic), 'ambient scene should use a native bounded conversation topic'); +assert.strictEqual(aria.inConversation, true); +assert.strictEqual(BotAmbientDirector.snapshot(aria).scene.id, started.scene.id); +assert.strictEqual(BotAmbientDirector.eligible(aria, belen, now + 1).reason, 'scene_active'); + +assert.strictEqual(BotAmbientDirector.finish(started.scene, 'test_complete'), true); +assert.strictEqual(aria.inConversation, false); +assert.strictEqual(belen.inConversation, false); +assert.strictEqual( + BotAmbientDirector.eligible(aria, belen, now + 1000).reason, + 'pair_cooldown', + 'a finished scene must protect both bots from immediate ambient spam' +); +assert.strictEqual( + BotAmbientDirector.eligible(aria, belen, now + 180000).ok, + true, + 'the per-bot cooldown should eventually expire' +); + +const player = bot(2000103, 'VisiblePlayer', { accountId: 'player_1' }); +assert.strictEqual(BotAmbientDirector.eligible(aria, player, now + 180000).reason, 'bot_only_scene'); + +const companion = bot(2000104, 'Companion', { partyCompanion: true }); +assert.strictEqual(BotAmbientDirector.eligible(aria, companion, now + 180000).reason, 'player_companion'); + +const lowVitals = bot(2000105, 'Tired', { persona: { primaryDrive: 'progression', traits: {} }, hp: 20 }); +assert.strictEqual(BotAmbientDirector.deriveMood(lowVitals).mood, 'tired', 'low HP should dominate personality mood'); + +const commerce = bot(2000106, 'Merchant', { plan: 'merchant', activeTrade: { id: 'trade-1' } }); +assert.strictEqual(BotAmbientDirector.deriveMood(commerce).mood, 'focused', 'commerce should keep mood focused'); + +const staleA = bot(2000107, 'StaleA'); +const staleB = bot(2000108, 'StaleB'); +const stale = BotAmbientDirector.start(staleA, staleB, now + 400000); +assert.strictEqual(stale.ok, true); +assert.strictEqual( + BotAmbientDirector.eligible(staleA, staleB, stale.scene.expiresAt + 1).reason, + 'pair_cooldown', + 'expired scenes must be finished and then remain cooldown-protected' +); +assert.strictEqual(staleA.inConversation, false, 'TTL expiry must release the native conversation lock'); + +const compact = BotBrainContext.compactStatus({ actor: null }, { + available: true, + name: 'ContextBot', + level: 20, + classId: 31, + mode: 'resting', + intent: 'recover', + role: 'dps', + vitals: { hpPct: 0.8, mpPct: 0.8 }, + target: null, + party: null, + nearby: {}, + blockers: [], + spot: null, + ambient: { mood: 'sociable', intent: 'seek_company', scene: null }, + trade: null, + persona: null, + social: null +}); +assert.strictEqual(compact.ambient.mood, 'sociable', 'compact LLM context must expose the bounded mood snapshot'); + +BotConversation.finish({ lines: [] }); +BotAmbientDirector.reset(); +console.log('Bot ambient director checks passed'); From fc55792ddae1150f121b754a11839b2697a8d8ab Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:21:15 -0400 Subject: [PATCH 09/59] Add per-bot hot inference budgets --- config/default.ini | 4 + config/local.example.ini | 4 + scripts/run-tests.js | 1 + src/GameServer/Bot/AI/BotBrain.js | 38 +++- src/GameServer/Bot/AI/BotBrainContext.js | 1 + src/GameServer/Bot/AI/BotInferenceBudget.js | 185 ++++++++++++++++++++ src/GameServer/Bot/AI/BotStatus.js | 5 +- src/GameServer/Bot/AI/OpenRouterGateway.js | 12 ++ src/GameServer/Bot/BotAI.js | 1 + tests/test_bot_inference_budget.js | 84 +++++++++ 10 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 src/GameServer/Bot/AI/BotInferenceBudget.js create mode 100644 tests/test_bot_inference_budget.js diff --git a/config/default.ini b/config/default.ini index f5c30ef0..f8adafb6 100644 --- a/config/default.ini +++ b/config/default.ini @@ -40,6 +40,10 @@ usageInclude = true requireProviderParameters = true circuitBreakerFailureThreshold = 3 circuitBreakerOpenMs = 30000 +hotBotBudgetEnabled = true +hotBotMaxRequestsPerMinute = 6 +hotBotPromptTokenBudgetPerMinute = 12000 +hotBotCompletionTokenBudgetPerMinute = 2400 debug = false negotiationEnabled = false diff --git a/config/local.example.ini b/config/local.example.ini index 3cdab0c8..dbc36400 100644 --- a/config/local.example.ini +++ b/config/local.example.ini @@ -4,3 +4,7 @@ apiKey = sk-or-v1-your-key-here model = google/gemini-2.5-flash-lite debug = true negotiationEnabled = false +hotBotBudgetEnabled = true +hotBotMaxRequestsPerMinute = 6 +hotBotPromptTokenBudgetPerMinute = 12000 +hotBotCompletionTokenBudgetPerMinute = 2400 diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 20415493..bea6a6a6 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -7,6 +7,7 @@ const tests = [ 'tests/test_armor_stats.js', 'tests/test_bot_ai_visibility.js', 'tests/test_openrouter_gateway.js', + 'tests/test_bot_inference_budget.js', 'tests/test_bot_conversation_store.js', 'tests/test_bot_dialogue_arbiter.js', 'tests/test_hot_bot_conversation_flow.js', diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 00c50e83..808a339e 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -2,6 +2,8 @@ const SpotService = invoke('GameServer/Bot/AI/SpotService'); const BotBrainContext = invoke('GameServer/Bot/AI/BotBrainContext'); const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); +const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); +const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); const ALLOWED_PLANS = ['hunting', 'following', 'resting', 'shopping', 'pk_hunting', 'merchant']; @@ -253,6 +255,10 @@ function userPayload(event, session, status, visiblePlayers, text, requestContex }; } +function estimatePromptTokens(payload) { + try { return Math.max(1, Math.ceil(JSON.stringify(payload).length / 4)); } catch (_) { return 1; } +} + async function requestDecision(payload, cfg, session, requestContext, visiblePlayers) { const botId = session?.actor?.fetchId?.() || session?.accountId || 'unknown'; const playerId = requestContext?.playerSession?.actor?.fetchId?.() || @@ -342,6 +348,8 @@ function fallbackReply(session, requestContext, outcome) { const plan = session.plan || 'hunting'; const reply = outcome === 'timeout' ? 'Give me a moment. I am still sorting things out.' + : String(outcome || '').startsWith('inference_budget_') + ? 'Give me a moment. I have a lot to sort through right now.' : `I am ${plan} right now.`; BotManager.botTell(session, playerSession, reply); @@ -437,8 +445,32 @@ const BotBrain = { session.lastBrainThinkAt = Date.now(); } - session.brainInFlight = true; const payload = userPayload(event, session, status, visiblePlayers, text, requestContext); + const admission = BotInferenceBudget.reserve(session, { + event, + estimatedPromptTokens: requestContext?.assembledContext?.estimatedTokens || estimatePromptTokens(payload), + maxCompletionTokens: cfg.maxTokens + }); + if (!admission.ok) { + session.lastBrainBudget = { + ...BotInferenceBudget.status(session), + deniedReason: admission.reason, + retryAfterMs: admission.retryAfterMs, + at: Date.now() + }; + BotEventJournal.record({ + botId: bot.fetchId?.(), + eventType: 'llm_budget', + summary: `${bot.fetchName?.() || 'bot'} inference denied: ${admission.reason}`, + dedupeKey: `deny:${admission.reason}`, + meta: { reason: admission.reason, retryAfterMs: admission.retryAfterMs } + }).catch(() => {}); + debugSkip(session, cfg, admission.reason); + fallbackReply(session, requestContext, admission.reason); + return true; + } + + session.brainInFlight = true; if (requestContext?.assembledContext?.telemetry) { session.lastBrainContextTelemetry = { ...requestContext.assembledContext.telemetry, @@ -452,7 +484,9 @@ const BotBrain = { utils.infoSuccess('BotBrain', '%s requesting %s decision via %s', bot.fetchName(), event, cfg.model); } + let providerResult = null; requestDecision(payload, cfg, session, requestContext, visiblePlayers).then((result) => { + providerResult = result; rememberTelemetry(session, result); if (result?.ok === false) { fallbackReply(session, requestContext, result.reason); @@ -463,6 +497,8 @@ const BotBrain = { utils.infoWarn('BotBrain', 'decision request failed for %s: %s', bot.fetchName(), err.message); fallbackReply(session, requestContext, 'provider_error'); }).finally(() => { + BotInferenceBudget.settle(admission.reservation, providerResult?.usage); + session.lastBrainBudget = BotInferenceBudget.status(session); session.brainInFlight = false; const pending = session.pendingBrainTurn; session.pendingBrainTurn = null; diff --git a/src/GameServer/Bot/AI/BotBrainContext.js b/src/GameServer/Bot/AI/BotBrainContext.js index 745cce9b..a4374c59 100644 --- a/src/GameServer/Bot/AI/BotBrainContext.js +++ b/src/GameServer/Bot/AI/BotBrainContext.js @@ -276,6 +276,7 @@ function compactStatus(session, status, text = '', options = {}) { roleDecision: status.roleDecision || null, trade: status.trade || null, ambient: status.ambient || null, + inference: status.inference || null, policy: status.policy || null, persona: status.persona || null, social: status.social || null diff --git a/src/GameServer/Bot/AI/BotInferenceBudget.js b/src/GameServer/Bot/AI/BotInferenceBudget.js new file mode 100644 index 00000000..e31ba89a --- /dev/null +++ b/src/GameServer/Bot/AI/BotInferenceBudget.js @@ -0,0 +1,185 @@ +const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); + +const WINDOW_MS = 60 * 1000; +const buckets = new Map(); +let reservationSequence = 0; + +function actorId(session) { + const id = Number(session?.actor?.fetchId?.() || session?.characterId || 0); + return Number.isInteger(id) && id > 0 ? id : null; +} + +function number(value, fallback = 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function nonNegative(value, fallback = 0) { + return Math.max(0, number(value, fallback)); +} + +function config() { + return OpenRouterGateway.config(); +} + +function bucketFor(id) { + if (!buckets.has(id)) buckets.set(id, { entries: [], lastDeniedAt: 0, lastDeniedReason: null }); + return buckets.get(id); +} + +function prune(bucket, now) { + bucket.entries = bucket.entries.filter((entry) => now - entry.startedAt < WINDOW_MS); +} + +function usageValue(usage, key) { + if (!usage || typeof usage !== 'object') return null; + const value = Number(usage[key]); + return Number.isFinite(value) && value >= 0 ? value : null; +} + +function sum(bucket, field) { + return bucket.entries.reduce((total, entry) => total + nonNegative(entry[field]), 0); +} + +function knownCost(bucket) { + return bucket.entries.reduce((total, entry) => total + (Number.isFinite(Number(entry.cost)) ? Number(entry.cost) : 0), 0); +} + +function rejection(bucket, reason, now, retryAfterMs = 0) { + bucket.lastDeniedAt = now; + bucket.lastDeniedReason = reason; + return { + ok: false, + reason, + retryAfterMs: Math.max(0, Math.ceil(retryAfterMs)), + status: null + }; +} + +function reserve(session, input = {}) { + const id = actorId(session); + if (!id) return { ok: false, reason: 'missing_bot' }; + + const cfg = config(); + if (cfg.hotBotBudgetEnabled === false || input.bypass === true) { + return { ok: true, bypassed: true, reservation: null, status: status(session) }; + } + + const now = Number(input.now || Date.now()); + const bucket = bucketFor(id); + prune(bucket, now); + const maxRequests = Math.max(1, Math.floor(number(input.maxRequests, cfg.hotBotMaxRequestsPerMinute))); + const promptBudget = Math.max(240, number(input.promptBudget, cfg.hotBotPromptTokenBudgetPerMinute)); + const completionBudget = Math.max(64, number(input.completionBudget, cfg.hotBotCompletionTokenBudgetPerMinute)); + const promptTokens = nonNegative(input.estimatedPromptTokens); + const completionTokens = nonNegative(input.maxCompletionTokens ?? cfg.maxTokens); + + if (bucket.entries.length >= maxRequests) { + const oldest = bucket.entries[0]; + return rejection(bucket, 'inference_budget_requests', now, oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS); + } + if (sum(bucket, 'promptTokens') + promptTokens > promptBudget) { + const oldest = bucket.entries[0]; + return rejection(bucket, 'inference_budget_prompt_tokens', now, oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS); + } + if (sum(bucket, 'completionTokens') + completionTokens > completionBudget) { + const oldest = bucket.entries[0]; + return rejection(bucket, 'inference_budget_completion_tokens', now, oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS); + } + + const reservation = { + id: `inference-${id}-${++reservationSequence}`, + botId: id, + startedAt: now, + promptTokens, + completionTokens, + reservedPromptTokens: promptTokens, + reservedCompletionTokens: completionTokens, + settled: false, + event: String(input.event || 'hot_decision').slice(0, 48), + priority: String(input.priority || 'normal').slice(0, 24) + }; + bucket.entries.push(reservation); + return { ok: true, reservation, status: status(session, now) }; +} + +function settle(reservation, usage = null) { + if (!reservation || reservation.settled) return false; + const prompt = usageValue(usage, 'promptTokens'); + const completion = usageValue(usage, 'completionTokens'); + reservation.promptTokens = prompt === null ? reservation.reservedPromptTokens : prompt; + reservation.completionTokens = completion === null ? reservation.reservedCompletionTokens : completion; + reservation.totalTokens = reservation.promptTokens + reservation.completionTokens; + reservation.cost = Number.isFinite(Number(usage?.cost)) ? Number(usage.cost) : null; + reservation.settled = true; + return true; +} + +function status(session, now = Date.now()) { + const id = actorId(session); + const cfg = config(); + if (!id) { + return { + enabled: cfg.hotBotBudgetEnabled !== false, + windowMs: WINDOW_MS, + requests: 0, + maxRequests: Math.max(1, Math.floor(number(cfg.hotBotMaxRequestsPerMinute, 6))), + promptTokens: 0, + promptBudget: Math.max(240, number(cfg.hotBotPromptTokenBudgetPerMinute, 12000)), + completionTokens: 0, + completionBudget: Math.max(64, number(cfg.hotBotCompletionTokenBudgetPerMinute, 2400)), + cost: 0, + remainingRequests: 0, + remainingPromptTokens: 0, + remainingCompletionTokens: 0, + nextResetAt: null, + lastDeniedReason: null + }; + } + + const bucket = bucketFor(id); + prune(bucket, now); + const maxRequests = Math.max(1, Math.floor(number(cfg.hotBotMaxRequestsPerMinute, 6))); + const promptBudget = Math.max(240, number(cfg.hotBotPromptTokenBudgetPerMinute, 12000)); + const completionBudget = Math.max(64, number(cfg.hotBotCompletionTokenBudgetPerMinute, 2400)); + const promptTokens = sum(bucket, 'promptTokens'); + const completionTokens = sum(bucket, 'completionTokens'); + const oldest = bucket.entries[0]; + return { + enabled: cfg.hotBotBudgetEnabled !== false, + windowMs: WINDOW_MS, + requests: bucket.entries.length, + maxRequests, + promptTokens, + promptBudget, + completionTokens, + completionBudget, + cost: knownCost(bucket), + remainingRequests: Math.max(0, maxRequests - bucket.entries.length), + remainingPromptTokens: Math.max(0, promptBudget - promptTokens), + remainingCompletionTokens: Math.max(0, completionBudget - completionTokens), + nextResetAt: oldest ? oldest.startedAt + WINDOW_MS : null, + lastDeniedReason: bucket.lastDeniedReason || null, + lastDeniedAt: bucket.lastDeniedAt || null + }; +} + +const BotInferenceBudget = { + WINDOW_MS, + reserve, + settle, + status, + snapshot: status, + reset(session = null) { + if (session) { + const id = actorId(session); + if (id) buckets.delete(id); + return; + } + buckets.clear(); + reservationSequence = 0; + }, + bucketCount() { return buckets.size; } +}; + +module.exports = BotInferenceBudget; diff --git a/src/GameServer/Bot/AI/BotStatus.js b/src/GameServer/Bot/AI/BotStatus.js index 6abb9e68..c282e905 100644 --- a/src/GameServer/Bot/AI/BotStatus.js +++ b/src/GameServer/Bot/AI/BotStatus.js @@ -9,6 +9,7 @@ const EffectStore = invoke('GameServer/Effects/EffectStore'); const GearSkillHints = invoke('GameServer/Bot/AI/GearSkillHints'); const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); const BotAmbientDirector = invoke('GameServer/Bot/AI/BotAmbientDirector'); +const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); function ratio(value, max) { if (!max) return 0; @@ -347,6 +348,7 @@ const BotStatus = { nearby: nearbySnapshot(bot), trade: tradeSnapshot(session, bot), ambient, + inference: BotInferenceBudget.snapshot(session), persona: personaSnapshot(session), policy: HotBotPolicyOverlay.status(session), social: session.socialSummary || null, @@ -369,6 +371,7 @@ const BotStatus = { const home = status.home && status.home.region ? ` home=${status.home.region}${status.home.visitor ? ':visitor' : ''}` : ''; const social = status.social ? ` social=${status.social.playerName}:${status.social.relationship}/${status.social.trust}` : ''; const ambient = status.ambient ? ` mood=${status.ambient.mood}/${status.ambient.intent}` : ''; + const inference = status.inference ? ` llm=${status.inference.requests}/${status.inference.maxRequests}` : ''; const roleDecision = status.roleDecision ? ` decision=${status.roleDecision.action}/${status.roleDecision.reason}` : ''; const targetDecision = status.decisions?.target ? ` targetScore=${status.decisions.target.score}` : ''; const combatDecision = status.decisions?.combat @@ -382,7 +385,7 @@ const BotStatus = { const buffs = status.buffs?.needsRefresh ? ' buffs=refresh' : ''; const blockers = status.blockers.length > 0 ? ` blockers=${status.blockers.join(',')}` : ''; - return `${status.name}: mode=${status.mode} intent=${status.intent} role=${status.role}${home} hp=${hp}% mp=${mp}%${target}${spot}${ambient}${social}${roleDecision}${targetDecision}${combatDecision}${pvpDecision}${build}${path}${buffs}${blockers}`; + return `${status.name}: mode=${status.mode} intent=${status.intent} role=${status.role}${home} hp=${hp}% mp=${mp}%${target}${spot}${ambient}${inference}${social}${roleDecision}${targetDecision}${combatDecision}${pvpDecision}${build}${path}${buffs}${blockers}`; } }; diff --git a/src/GameServer/Bot/AI/OpenRouterGateway.js b/src/GameServer/Bot/AI/OpenRouterGateway.js index dc1abfbe..760713a7 100644 --- a/src/GameServer/Bot/AI/OpenRouterGateway.js +++ b/src/GameServer/Bot/AI/OpenRouterGateway.js @@ -17,6 +17,10 @@ const DEFAULTS = Object.freeze({ requireProviderParameters: true, circuitBreakerFailureThreshold: 3, circuitBreakerOpenMs: 30000, + hotBotBudgetEnabled: true, + hotBotMaxRequestsPerMinute: 6, + hotBotPromptTokenBudgetPerMinute: 12000, + hotBotCompletionTokenBudgetPerMinute: 2400, debug: false }); @@ -72,6 +76,10 @@ function config(overrides = {}) { DEFAULTS.circuitBreakerFailureThreshold ), circuitBreakerOpenMs: num(optn.circuitBreakerOpenMs, DEFAULTS.circuitBreakerOpenMs), + hotBotBudgetEnabled: bool(optn.hotBotBudgetEnabled, DEFAULTS.hotBotBudgetEnabled), + hotBotMaxRequestsPerMinute: num(optn.hotBotMaxRequestsPerMinute, DEFAULTS.hotBotMaxRequestsPerMinute), + hotBotPromptTokenBudgetPerMinute: num(optn.hotBotPromptTokenBudgetPerMinute, DEFAULTS.hotBotPromptTokenBudgetPerMinute), + hotBotCompletionTokenBudgetPerMinute: num(optn.hotBotCompletionTokenBudgetPerMinute, DEFAULTS.hotBotCompletionTokenBudgetPerMinute), debug: bool(optn.debug, DEFAULTS.debug) }; @@ -96,6 +104,10 @@ function config(overrides = {}) { num(overrides.circuitBreakerFailureThreshold, source.circuitBreakerFailureThreshold) ), circuitBreakerOpenMs: Math.max(0, num(overrides.circuitBreakerOpenMs, source.circuitBreakerOpenMs)), + hotBotBudgetEnabled: bool(overrides.hotBotBudgetEnabled, source.hotBotBudgetEnabled), + hotBotMaxRequestsPerMinute: Math.max(1, num(overrides.hotBotMaxRequestsPerMinute, source.hotBotMaxRequestsPerMinute)), + hotBotPromptTokenBudgetPerMinute: Math.max(240, num(overrides.hotBotPromptTokenBudgetPerMinute, source.hotBotPromptTokenBudgetPerMinute)), + hotBotCompletionTokenBudgetPerMinute: Math.max(64, num(overrides.hotBotCompletionTokenBudgetPerMinute, source.hotBotCompletionTokenBudgetPerMinute)), debug: bool(overrides.debug, source.debug) }; } diff --git a/src/GameServer/Bot/BotAI.js b/src/GameServer/Bot/BotAI.js index b0cdfca4..acbad7fd 100644 --- a/src/GameServer/Bot/BotAI.js +++ b/src/GameServer/Bot/BotAI.js @@ -139,6 +139,7 @@ const BotAI = { session.aiActive = false; HotBotPolicyOverlay.clearForCold(session); try { invoke('GameServer/Bot/AI/BotAmbientDirector').cleanup(session, 'ai_stop'); } catch (_) { /* optional ambient module */ } + try { invoke('GameServer/Bot/AI/BotInferenceBudget').reset(session); } catch (_) { /* optional budget module */ } if (session.aiTimeout) { clearTimeout(session.aiTimeout); session.aiTimeout = null; diff --git a/tests/test_bot_inference_budget.js b/tests/test_bot_inference_budget.js new file mode 100644 index 00000000..3d384102 --- /dev/null +++ b/tests/test_bot_inference_budget.js @@ -0,0 +1,84 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); + +function session(id) { + return { actor: { fetchId: () => id } }; +} + +const originalConfig = options.default.OpenRouter; +const bot = session(2000201); + +try { + options.default.OpenRouter = { + ...originalConfig, + hotBotBudgetEnabled: true, + hotBotMaxRequestsPerMinute: 2, + hotBotPromptTokenBudgetPerMinute: 300, + hotBotCompletionTokenBudgetPerMinute: 100, + maxTokens: 40 + }; + BotInferenceBudget.reset(); + + const first = BotInferenceBudget.reserve(bot, { + event: 'player_chat', + estimatedPromptTokens: 40, + maxCompletionTokens: 40, + now: 1000 + }); + assert.strictEqual(first.ok, true, 'the first hot decision should reserve within budget'); + BotInferenceBudget.settle(first.reservation, { promptTokens: 20, completionTokens: 10, cost: 0.001 }); + + const second = BotInferenceBudget.reserve(bot, { + event: 'player_chat', + estimatedPromptTokens: 260, + maxCompletionTokens: 40, + now: 2000 + }); + assert.strictEqual(second.ok, true, 'the remaining prompt budget should admit a second decision'); + const mid = BotInferenceBudget.status(bot, 2000); + assert.strictEqual(mid.requests, 2); + assert.strictEqual(mid.promptTokens, 280, 'settlement must replace reservation with actual prompt usage'); + assert.strictEqual(mid.completionTokens, 50); + assert.strictEqual(mid.remainingRequests, 0); + + const requestDenied = BotInferenceBudget.reserve(bot, { + estimatedPromptTokens: 1, + maxCompletionTokens: 1, + now: 3000 + }); + assert.strictEqual(requestDenied.ok, false); + assert.strictEqual(requestDenied.reason, 'inference_budget_requests'); + assert(requestDenied.retryAfterMs > 0, 'a budget rejection should tell the caller when to retry'); + + BotInferenceBudget.settle(second.reservation, { promptTokens: 10, completionTokens: 5 }); + options.default.OpenRouter.hotBotMaxRequestsPerMinute = 10; + const promptDenied = BotInferenceBudget.reserve(bot, { + estimatedPromptTokens: 295, + maxCompletionTokens: 1, + now: 4000 + }); + assert.strictEqual(promptDenied.ok, false, 'prompt reservation should be bounded independently'); + assert.strictEqual(promptDenied.reason, 'inference_budget_prompt_tokens'); + + const afterWindow = BotInferenceBudget.reserve(bot, { + estimatedPromptTokens: 100, + maxCompletionTokens: 20, + now: 62001 + }); + assert.strictEqual(afterWindow.ok, true, 'entries should expire from the sliding window'); + + options.default.OpenRouter.hotBotBudgetEnabled = false; + BotInferenceBudget.reset(); + const bypassed = BotInferenceBudget.reserve(bot, { estimatedPromptTokens: 999999, maxCompletionTokens: 999999 }); + assert.strictEqual(bypassed.ok, true); + assert.strictEqual(bypassed.bypassed, true, 'the explicit server disable should bypass admission without fake usage'); + + assert.strictEqual(BotInferenceBudget.reserve({ actor: null }).reason, 'missing_bot'); + console.log('Bot inference budget checks passed'); +} finally { + options.default.OpenRouter = originalConfig; + BotInferenceBudget.reset(); +} From 2fb396666de7aa43c03c4104cdc616c5e6fbd7f9 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:26:42 -0400 Subject: [PATCH 10/59] Trigger bounded hot decisions on state changes --- scripts/run-tests.js | 1 + src/GameServer/Bot/AI/BotBrain.js | 8 +- src/GameServer/Bot/BotAI.js | 40 +++++++++ tests/test_bot_brain_state_change.js | 120 +++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 tests/test_bot_brain_state_change.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index bea6a6a6..8c80d222 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -8,6 +8,7 @@ const tests = [ 'tests/test_bot_ai_visibility.js', 'tests/test_openrouter_gateway.js', 'tests/test_bot_inference_budget.js', + 'tests/test_bot_brain_state_change.js', 'tests/test_bot_conversation_store.js', 'tests/test_bot_dialogue_arbiter.js', 'tests/test_hot_bot_conversation_flow.js', diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 808a339e..2ab34c98 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -6,6 +6,7 @@ const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); const ALLOWED_PLANS = ['hunting', 'following', 'resting', 'shopping', 'pk_hunting', 'merchant']; +const ALLOWED_EVENTS = new Set(['player_chat', 'state_change']); function config() { return OpenRouterGateway.config({ maxTokens: 160 }); @@ -212,8 +213,9 @@ function systemPrompt() { 'You are the slow high-level brain for one Lineage 2 bot.', 'The deterministic server code handles combat, pathfinding, HP/MP, loot, and safety.', 'Only choose small, high-level social or intent changes.', - 'React only when a real visible player writes to this bot or nearby bots.', + 'For player-facing events, react only when a real visible player writes to this bot or nearby bots; state_change is a bounded server signal.', 'For player_chat, react only if the message is addressed to this bot, nearby bots, or clearly asks for help.', + 'For state_change, treat the player-visible state transition as a prompt to make at most one small high-level choice; do not narrate private internal events as facts.', 'follow_player only means approach a visible player unless the bot is already an invited party companion.', 'For buff_target and heal_target, choose a visible player and let the server validate class, learned skill, MP, range, and safety.', 'Do not claim that buffs or heals are ready in a plain chat reply. Use buff_target or heal_target; only the validated server action may confirm a cast.', @@ -378,7 +380,7 @@ const BotBrain = { const cfg = config(); const bot = session.actor; if (!bot) return false; - if (event !== 'player_chat') { + if (!ALLOWED_EVENTS.has(event)) { debugSkip(session, cfg, `event_not_chat:${event}`); return false; } @@ -434,7 +436,7 @@ const BotBrain = { return false; } - if (event !== 'player_chat' && Math.random() > 0.12) { + if (event === 'state_change' && Math.random() > 0.12) { debugSkip(session, cfg, 'ambient_sample_skip'); return false; } diff --git a/src/GameServer/Bot/BotAI.js b/src/GameServer/Bot/BotAI.js index acbad7fd..ed73e688 100644 --- a/src/GameServer/Bot/BotAI.js +++ b/src/GameServer/Bot/BotAI.js @@ -10,6 +10,7 @@ const PartyRevivalService = invoke('GameServer/Bot/AI/PartyRevivalService'); const TownRespawn = invoke('GameServer/World/TownRespawn'); const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); const BotTradeService = invoke('GameServer/Bot/BotTradeService'); +const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); const CHAT_PHRASES = { foundTarget: [ @@ -114,6 +115,44 @@ function clearTacticalState(session) { session.lastPvpDecision = undefined; } +function highLevelStateFingerprint(status) { + if (!status?.available) return 'unavailable'; + const party = status.party; + const trade = status.trade; + return [ + status.mode, + party?.leader?.id || 0, + party?.stance || '', + status.ambient?.mood || '', + (status.blockers || []).filter((blocker) => ['dead', 'low_hp', 'low_mp', 'too_far_from_leader', 'no_targets_nearby'].includes(blocker)).join(','), + trade?.active?.id || '', + trade?.negotiation?.id || '', + trade?.negotiation?.state || '' + ].join('|'); +} + +function maybeTriggerStateChange(session, status) { + const fingerprint = highLevelStateFingerprint(status); + const previous = session.lastBrainStateFingerprint; + session.lastBrainStateFingerprint = fingerprint; + if (!previous || previous === fingerprint) return false; + + const mode = status?.mode || session.plan || 'hunting'; + const mood = status?.ambient?.mood || 'unknown'; + try { + return BotBrain.maybeThink( + session, + 'state_change', + status, + `High-level state changed: mode=${mode}, mood=${mood}. Choose at most one safe high-level response.`, + null + ); + } catch (error) { + utils.infoWarn('BotBrain', 'state-change trigger failed for %s: %s', session.actor?.fetchName?.() || 'bot', error.message); + return false; + } +} + const BotAI = { clearTacticalState, @@ -341,6 +380,7 @@ const BotAI = { HotBotPolicyOverlay.get(session); } session.botStatus = BotStatus.getStatus(session); + if (!botDead) maybeTriggerStateChange(session, session.botStatus); const isCompanion = !!session.followPlayerSession && session.partyCompanion === true; const World = invoke('GameServer/World/World'); diff --git a/tests/test_bot_brain_state_change.js b/tests/test_bot_brain_state_change.js new file mode 100644 index 00000000..937d15ae --- /dev/null +++ b/tests/test_bot_brain_state_change.js @@ -0,0 +1,120 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); +const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); +const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); +const World = invoke('GameServer/World/World'); + +function actor(id, name, x = 0) { + return { + fetchId: () => id, + fetchName: () => name, + fetchLevel: () => 20, + fetchHp: () => 100, + fetchMaxHp: () => 100, + fetchMp: () => 100, + fetchMaxMp: () => 100, + fetchLocX: () => x, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchKarma: () => 0, + fetchDestId: () => 0, + fetchIsOnline: () => true, + isDead: () => false + }; +} + +function response(body) { + return { ok: true, status: 200, json: async () => body }; +} + +async function main() { + const originalConfig = options.default.OpenRouter; + const originalWorldUser = World.user; + const originalFetchVisibleUsers = World.fetchVisibleUsers; + const originalRandom = Math.random; + const requests = []; + const botSession = { accountId: 'bot_state_change', actor: actor(2000301, 'StateBot'), plan: 'resting' }; + const playerSession = { accountId: 'player_state_change', actor: actor(2000302, 'NearbyPlayer', 100) }; + + try { + options.default.OpenRouter = { + ...originalConfig, + enabled: true, + apiKey: 'state-change-test-key', + model: 'test/state-change', + cooldownMs: 0, + chatCooldownMs: 0, + hotBotMaxRequestsPerMinute: 5, + hotBotPromptTokenBudgetPerMinute: 12000, + hotBotCompletionTokenBudgetPerMinute: 2400 + }; + World.user = { sessions: [playerSession, botSession] }; + World.fetchVisibleUsers = () => [playerSession]; + BotInferenceBudget.reset(); + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.setTransport(async (_url, init) => { + requests.push(JSON.parse(init.body)); + return response({ + choices: [{ message: { content: JSON.stringify({ + action: 'none', + reply: '', + targetPlayerName: '', + spotId: '', + buffType: '', + reason: 'state_change_probe', + confidence: 0.9 + }) } }] + }); + }); + + Math.random = () => 0.01; + const started = BotBrain.maybeThink(botSession, 'state_change', { + available: true, + name: 'StateBot', + mode: 'resting', + intent: 'recover', + level: 20, + vitals: { hpPct: 1, mpPct: 1 }, + blockers: [], + nearby: {}, + target: null, + party: null, + spot: null, + ambient: { mood: 'sociable', intent: 'seek_company', scene: null }, + trade: null, + persona: null, + social: null + }, 'High-level state changed: test'); + assert.strictEqual(started, true, 'a meaningful state-change event should enter the bounded brain path'); + + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.strictEqual(requests.length, 1, 'state-change routing should produce one provider request'); + const payload = JSON.parse(requests[0].messages[1].content); + assert.strictEqual(payload.event, 'state_change'); + assert.strictEqual(BotInferenceBudget.status(botSession).requests, 1); + + options.default.OpenRouter.enabled = false; + assert.strictEqual( + BotBrain.maybeThink(botSession, 'state_change', { available: true }, 'disabled probe'), + false, + 'disabled OpenRouter must keep state-change routing inert' + ); + console.log('Bot brain state-change checks passed'); + } finally { + Math.random = originalRandom; + options.default.OpenRouter = originalConfig; + World.user = originalWorldUser; + World.fetchVisibleUsers = originalFetchVisibleUsers; + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.resetTransport(); + BotInferenceBudget.reset(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From 773c869e181c46507d490c3cf2b61c57e504c4f1 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:31:07 -0400 Subject: [PATCH 11/59] Journal hot bot inference outcomes --- src/GameServer/Bot/AI/BotBrain.js | 46 ++++++++++++++++++++++++++++ tests/test_bot_brain_state_change.js | 8 +++++ 2 files changed, 54 insertions(+) diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 2ab34c98..6d221b30 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -342,6 +342,46 @@ function rememberTelemetry(session, result) { }; } +function recordInferenceEvent(session, event, result, requestContext = null, extra = {}) { + const botId = session?.actor?.fetchId?.(); + if (!botId) return; + const telemetry = result?.llmTelemetry || result?.telemetry || {}; + const usage = result?.usage || telemetry.usage || {}; + const outcome = result?.ok === false + ? result.reason || telemetry.outcome || 'provider_failure' + : telemetry.outcome || 'success'; + const action = result?.action || null; + const decisionReason = result?.reason || null; + const requestId = telemetry.requestId || requestContext?.requestId || `${event}-${Date.now()}`; + const playerId = requestContext?.playerSession?.actor?.fetchId?.() || requestContext?.playerId || null; + const name = session.actor.fetchName?.() || 'bot'; + + BotEventJournal.record({ + playerId, + botId, + eventType: 'llm_decision', + summary: `${name} ${event} outcome=${outcome}${action ? ` action=${action}` : ''}`, + dedupeKey: `request:${requestId}`, + meta: { + event, + outcome, + model: telemetry.model || config().model, + action, + reason: decisionReason, + confidence: Number.isFinite(Number(result?.confidence)) ? Number(result.confidence) : null, + latencyMs: Number(telemetry.latencyMs || 0), + providerStatus: telemetry.status || null, + usage: { + promptTokens: Number(usage.promptTokens || 0), + completionTokens: Number(usage.completionTokens || 0), + totalTokens: Number(usage.totalTokens || 0), + cost: Number.isFinite(Number(usage.cost)) ? Number(usage.cost) : null + }, + ...extra + } + }).catch(() => {}); +} + function fallbackReply(session, requestContext, outcome) { const playerSession = requestContext?.playerSession; if (!playerSession?.actor || !session?.actor) return false; @@ -489,6 +529,7 @@ const BotBrain = { let providerResult = null; requestDecision(payload, cfg, session, requestContext, visiblePlayers).then((result) => { providerResult = result; + recordInferenceEvent(session, event, result, requestContext); rememberTelemetry(session, result); if (result?.ok === false) { fallbackReply(session, requestContext, result.reason); @@ -496,6 +537,11 @@ const BotBrain = { } applyDecision(session, result, visiblePlayers, requestContext); }).catch((err) => { + recordInferenceEvent(session, event, { + ok: false, + reason: 'provider_error', + telemetry: { outcome: 'provider_error' } + }, requestContext); utils.infoWarn('BotBrain', 'decision request failed for %s: %s', bot.fetchName(), err.message); fallbackReply(session, requestContext, 'provider_error'); }).finally(() => { diff --git a/tests/test_bot_brain_state_change.js b/tests/test_bot_brain_state_change.js index 937d15ae..92dd3f37 100644 --- a/tests/test_bot_brain_state_change.js +++ b/tests/test_bot_brain_state_change.js @@ -4,6 +4,7 @@ require('../src/Global'); const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); +const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); const World = invoke('GameServer/World/World'); @@ -54,6 +55,7 @@ async function main() { World.user = { sessions: [playerSession, botSession] }; World.fetchVisibleUsers = () => [playerSession]; BotInferenceBudget.reset(); + BotEventJournal.resetMemory(); OpenRouterGateway.resetCircuit(); OpenRouterGateway.setTransport(async (_url, init) => { requests.push(JSON.parse(init.body)); @@ -95,6 +97,12 @@ async function main() { const payload = JSON.parse(requests[0].messages[1].content); assert.strictEqual(payload.event, 'state_change'); assert.strictEqual(BotInferenceBudget.status(botSession).requests, 1); + const recent = await BotEventJournal.recent({ botId: botSession.actor.fetchId(), limit: 10 }); + const decisionEvent = recent.find((event) => event.eventType === 'llm_decision'); + assert(decisionEvent, 'provider decisions should be available in the bounded activity journal'); + assert.strictEqual(decisionEvent.meta.event, 'state_change'); + assert.strictEqual(decisionEvent.meta.action, 'none'); + assert(!JSON.stringify(decisionEvent).includes('High-level state changed: test'), 'raw prompt text must not enter the journal'); options.default.OpenRouter.enabled = false; assert.strictEqual( From 41261af321b93562701ca485ea31b4381ba938f7 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:44:01 -0400 Subject: [PATCH 12/59] Fix hot bot AI review findings --- src/GameServer/Bot/AI/BotAgentTools.js | 46 ++++++++++++++++--- src/GameServer/Bot/AI/BotAmbientDirector.js | 19 ++++++-- src/GameServer/Bot/AI/BotBrain.js | 32 +++++++++++-- .../Bot/AI/BotConversationSummarizer.js | 46 +++++++++++++------ src/GameServer/Bot/AI/BotInferenceBudget.js | 5 ++ src/GameServer/Bot/AI/HotBotPolicyOverlay.js | 43 +++++++++++++++++ src/GameServer/Bot/BotAI.js | 2 + .../Bot/Economy/BotNegotiationService.js | 11 ++++- tests/test_bot_ambient_director.js | 6 +++ tests/test_bot_conversation_summary.js | 4 ++ tests/test_bot_negotiation_flow.js | 5 ++ tests/test_hot_bot_conversation_flow.js | 21 +++++++-- tests/test_llm_pull_policy_tools.js | 12 +++++ 13 files changed, 219 insertions(+), 33 deletions(-) diff --git a/src/GameServer/Bot/AI/BotAgentTools.js b/src/GameServer/Bot/AI/BotAgentTools.js index 0748534c..8985776b 100644 --- a/src/GameServer/Bot/AI/BotAgentTools.js +++ b/src/GameServer/Bot/AI/BotAgentTools.js @@ -342,27 +342,41 @@ function isAuthorizedPartyLeader(context) { return Number(player.actor.fetchId?.()) === Number(leader.actor.fetchId?.()); } -function controllerContext(context) { +function controllerContext(context, policyContext = {}) { const player = context?.requestContext?.playerSession; return { ownerId: player?.actor?.fetchId?.() || null, ownerName: player?.actor?.fetchName?.() || null, + ownerSession: player || null, reason: context?.decision?.reason || 'player_policy_request', - ttlMs: context?.decision?.policyTtlMs + ttlMs: context?.decision?.policyTtlMs, + ...policyContext }; } -function policyActionResult(session, patch, context, reason) { - const policy = HotBotPolicyOverlay.set(session, patch, controllerContext(context)); +function policyActionResult(session, patch, context, reason, policyContext = {}) { + const policy = HotBotPolicyOverlay.set(session, patch, controllerContext(context, policyContext)); return { applied: true, reason, policy: HotBotPolicyOverlay.status(session) || policy }; } +function inheritedPullRestore(session, current) { + const existing = HotBotPolicyOverlay.get(session); + const applied = existing?.pullApplied; + if (!existing || !applied) return null; + if (String(current?.pullMode || 'auto') !== String(applied.mode || 'auto') || + Number(current?.pullerId || 0) !== Number(applied.pullerId || 0)) { + return null; + } + return existing.pullRestore || null; +} + function applyPullPolicy(context) { const session = context.session; const leader = partyLeaderFor(session); if (!leader) return { applied: false, reason: 'not_a_party_companion' }; const current = PartyCompanionService.getSettings(leader); + const inheritedRestore = inheritedPullRestore(session, current); const requestedPermission = String(context.decision.pullPermission || '').toLowerCase(); const requestedMode = String(context.decision.pullMode || '').toLowerCase(); const permission = ['allow', 'deny'].includes(requestedPermission) @@ -378,7 +392,12 @@ function applyPullPolicy(context) { PartyCompanionService.refreshPanel(leader); return policyActionResult(session, { pull: { permission, mode, pullerId } - }, context, `pull_policy:${permission}:${mode}`); + }, context, `pull_policy:${permission}:${mode}`, { + pullRestore: inheritedRestore || { + pullMode: current.pullMode || 'auto', + pullerId: current.pullerId + } + }); } function assignPuller(context) { @@ -388,6 +407,8 @@ function assignPuller(context) { const requestedId = Number(context.decision.pullerId || 0); if (requestedId && requestedId !== Number(session.actor.fetchId())) return { applied: false, reason: 'puller_must_be_target' }; + const current = PartyCompanionService.getSettings(leader); + const inheritedRestore = inheritedPullRestore(session, current); PartyCompanionService.updateSettings(leader, { pullMode: 'bot', pullerId: session.actor.fetchId() @@ -395,7 +416,12 @@ function assignPuller(context) { PartyCompanionService.refreshPanel(leader); return policyActionResult(session, { pull: { permission: 'allow', mode: 'bot', pullerId: session.actor.fetchId() } - }, context, 'puller_assigned'); + }, context, 'puller_assigned', { + pullRestore: inheritedRestore || { + pullMode: current.pullMode || 'auto', + pullerId: current.pullerId + } + }); } function unassignPuller(context) { @@ -409,11 +435,17 @@ function unassignPuller(context) { // Unassigning one member returns to the existing automatic policy. It // never turns pull off globally and never silently assigns another bot. + const inheritedRestore = inheritedPullRestore(session, settings); PartyCompanionService.updateSettings(leader, { pullMode: 'auto', pullerId: null }); PartyCompanionService.refreshPanel(leader); return policyActionResult(session, { pull: { permission: 'allow', mode: 'auto', pullerId: null } - }, context, 'puller_unassigned'); + }, context, 'puller_unassigned', { + pullRestore: inheritedRestore || { + pullMode: settings.pullMode || 'auto', + pullerId: settings.pullerId + } + }); } function learnedSkill(session, skillId) { diff --git a/src/GameServer/Bot/AI/BotAmbientDirector.js b/src/GameServer/Bot/AI/BotAmbientDirector.js index bf75e3cf..66e35d2c 100644 --- a/src/GameServer/Bot/AI/BotAmbientDirector.js +++ b/src/GameServer/Bot/AI/BotAmbientDirector.js @@ -9,6 +9,7 @@ const MAX_REASON_CHARS = 120; const MOODS = Object.freeze(['calm', 'focused', 'sociable', 'restless', 'tired', 'guarded']); const activeScenes = new Map(); +const pairLastSceneAt = new Map(); function bool(value, fallback = true) { if (value === undefined || value === null || value === '') return fallback; @@ -162,6 +163,13 @@ function pairKey(first, second) { return [actorId(first), actorId(second)].sort((a, b) => a - b).join(':'); } +function prunePairHistory(now) { + const cooldown = pairCooldownMs(); + for (const [key, at] of pairLastSceneAt.entries()) { + if (now - at >= cooldown) pairLastSceneAt.delete(key); + } +} + function distance2d(first, second) { const a = first?.actor; const b = second?.actor; @@ -178,6 +186,7 @@ function expireSceneIfNeeded(session, now) { function eligible(initiator, responder, now = Date.now()) { if (!enabled()) return { ok: false, reason: 'ambient_disabled' }; + prunePairHistory(now); expireSceneIfNeeded(initiator, now); expireSceneIfNeeded(responder, now); if (!isBotSession(initiator) || !isBotSession(responder)) return { ok: false, reason: 'bot_only_scene' }; @@ -197,11 +206,11 @@ function eligible(initiator, responder, now = Date.now()) { return { ok: false, reason: 'too_far', distance }; } - const lastScene = Math.max(Number(initiator.ambientLastSceneAt || 0), Number(responder.ambientLastSceneAt || 0)); - if (lastScene && now - lastScene < pairCooldownMs()) { - return { ok: false, reason: 'pair_cooldown', retryAfterMs: pairCooldownMs() - (now - lastScene) }; + const pairAt = pairLastSceneAt.get(pairKey(initiator, responder)) || 0; + if (pairAt && now - pairAt < pairCooldownMs()) { + return { ok: false, reason: 'pair_cooldown', retryAfterMs: pairCooldownMs() - (now - pairAt) }; } - if ([initiator, responder].some((session) => session.lastAmbientSceneAt && now - session.lastAmbientSceneAt < sceneCooldownMs())) { + if ([initiator, responder].some((session) => session.ambientLastSceneAt && now - session.ambientLastSceneAt < sceneCooldownMs())) { return { ok: false, reason: 'bot_cooldown' }; } return { ok: true, distance }; @@ -244,6 +253,7 @@ function start(initiator, responder, now = Date.now()) { responder.ambientScene = scene; initiator.ambientLastSceneAt = now; responder.ambientLastSceneAt = now; + pairLastSceneAt.set(pairKey(initiator, responder), now); activeScenes.set(scene.id, scene); refresh(initiator, now); refresh(responder, now); @@ -304,6 +314,7 @@ const BotAmbientDirector = { activeScenes() { return [...activeScenes.values()].map((scene) => ({ ...scene, conversation: undefined })); }, reset() { activeScenes.clear(); + pairLastSceneAt.clear(); } }; diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 6d221b30..7c8754dc 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -7,6 +7,7 @@ const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); const ALLOWED_PLANS = ['hunting', 'following', 'resting', 'shopping', 'pk_hunting', 'merchant']; const ALLOWED_EVENTS = new Set(['player_chat', 'state_change']); +const MAX_PENDING_BRAIN_TURNS = 4; function config() { return OpenRouterGateway.config({ maxTokens: 160 }); @@ -261,6 +262,19 @@ function estimatePromptTokens(payload) { try { return Math.max(1, Math.ceil(JSON.stringify(payload).length / 4)); } catch (_) { return 1; } } +function estimateRequestPromptTokens(payload, session) { + return estimatePromptTokens({ + messages: [ + { role: 'system', content: systemPrompt() }, + { role: 'user', content: JSON.stringify(payload) } + ], + responseSchema: { + name: 'bot_brain_decision', + schema: schema(BotAgentTools.availableActions(session)) + } + }); +} + async function requestDecision(payload, cfg, session, requestContext, visiblePlayers) { const botId = session?.actor?.fetchId?.() || session?.accountId || 'unknown'; const playerId = requestContext?.playerSession?.actor?.fetchId?.() || @@ -434,12 +448,19 @@ const BotBrain = { } if (session.brainInFlight) { if (requestContext?.conversationTurn) { - session.pendingBrainTurn = { + const pending = { event, status, text, requestContext }; + const queue = session.pendingBrainTurns || (session.pendingBrainTurns = []); + if (queue.length >= MAX_PENDING_BRAIN_TURNS) { + const dropped = queue.shift(); + if (dropped?.requestContext) fallbackReply(session, dropped.requestContext, 'queued_overflow'); + } + queue.push(pending); + session.pendingBrainTurn = queue[0]; debugSkip(session, cfg, 'request_queued'); return true; } @@ -450,7 +471,7 @@ const BotBrain = { debugSkip(session, cfg, 'dead'); return false; } - if (session.plan === 'merchant') { + if (session.plan === 'merchant' && event !== 'player_chat') { debugSkip(session, cfg, 'merchant_plan'); return false; } @@ -490,7 +511,7 @@ const BotBrain = { const payload = userPayload(event, session, status, visiblePlayers, text, requestContext); const admission = BotInferenceBudget.reserve(session, { event, - estimatedPromptTokens: requestContext?.assembledContext?.estimatedTokens || estimatePromptTokens(payload), + estimatedPromptTokens: estimateRequestPromptTokens(payload, session), maxCompletionTokens: cfg.maxTokens }); if (!admission.ok) { @@ -548,8 +569,9 @@ const BotBrain = { BotInferenceBudget.settle(admission.reservation, providerResult?.usage); session.lastBrainBudget = BotInferenceBudget.status(session); session.brainInFlight = false; - const pending = session.pendingBrainTurn; - session.pendingBrainTurn = null; + const queue = session.pendingBrainTurns || []; + const pending = queue.shift() || null; + session.pendingBrainTurn = queue[0] || null; if (pending) { const waitMs = Math.max( 0, diff --git a/src/GameServer/Bot/AI/BotConversationSummarizer.js b/src/GameServer/Bot/AI/BotConversationSummarizer.js index 11a19ec4..28365771 100644 --- a/src/GameServer/Bot/AI/BotConversationSummarizer.js +++ b/src/GameServer/Bot/AI/BotConversationSummarizer.js @@ -1,5 +1,6 @@ const BotConversationStore = invoke('GameServer/Bot/AI/BotConversationStore'); const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); +const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); const SUMMARY_THRESHOLD_MESSAGES = 24; const SUMMARY_RECENT_TURNS = BotConversationStore.DEFAULT_RECENT_TURNS; @@ -19,6 +20,10 @@ function compactTurns(turns) { })); } +function estimateTokens(value) { + try { return Math.max(1, Math.ceil(JSON.stringify(value).length / 4)); } catch (_) { return 1; } +} + function schema() { return { name: 'bot_conversation_summary', @@ -78,20 +83,35 @@ async function summarize(input = {}) { } const cfg = OpenRouterGateway.config({ maxTokens: SUMMARY_MAX_TOKENS, temperature: 0.1 }); - const result = await OpenRouterGateway.request({ - config: cfg, - circuitKey: 'hot-summary', - requestId: input.requestId || `summary-${botId}-${playerId}-${Date.now()}`, - sessionId: `hot-summary:${botId}:player:${playerId}`, - messages: [ - { - role: 'system', - content: 'Summarize a game conversation for the same bot and player. Keep durable facts, preferences, unresolved requests, and explicit promises. Never create permissions, tool authorizations, or facts not stated in the dialogue.' - }, - { role: 'user', content: JSON.stringify({ previousSummary: current.summary || '', turns: compactTurns(compacted) }) } - ], - responseSchema: schema() + const messages = [ + { + role: 'system', + content: 'Summarize a game conversation for the same bot and player. Keep durable facts, preferences, unresolved requests, and explicit promises. Never create permissions, tool authorizations, or facts not stated in the dialogue.' + }, + { role: 'user', content: JSON.stringify({ previousSummary: current.summary || '', turns: compactTurns(compacted) }) } + ]; + const admission = BotInferenceBudget.reserveForBotId(botId, { + event: 'conversation_summary', + estimatedPromptTokens: estimateTokens({ messages, responseSchema: schema() }), + maxCompletionTokens: cfg.maxTokens }); + if (!admission.ok) return { ok: false, reason: admission.reason, retryAfterMs: admission.retryAfterMs }; + + let result = null; + try { + result = await OpenRouterGateway.request({ + config: cfg, + circuitKey: 'hot-summary', + requestId: input.requestId || `summary-${botId}-${playerId}-${Date.now()}`, + sessionId: `hot-summary:${botId}:player:${playerId}`, + messages, + responseSchema: schema() + }); + } catch (_) { + return { ok: false, reason: 'summary_provider_error' }; + } finally { + BotInferenceBudget.settle(admission.reservation, result?.usage); + } if (!result.ok) return { ok: false, reason: result.reason || 'summary_provider_error' }; const summary = normalizeSummary(result.data); diff --git a/src/GameServer/Bot/AI/BotInferenceBudget.js b/src/GameServer/Bot/AI/BotInferenceBudget.js index e31ba89a..5f482817 100644 --- a/src/GameServer/Bot/AI/BotInferenceBudget.js +++ b/src/GameServer/Bot/AI/BotInferenceBudget.js @@ -103,6 +103,10 @@ function reserve(session, input = {}) { return { ok: true, reservation, status: status(session, now) }; } +function reserveForBotId(botId, input = {}) { + return reserve({ characterId: Number(botId) }, input); +} + function settle(reservation, usage = null) { if (!reservation || reservation.settled) return false; const prompt = usageValue(usage, 'promptTokens'); @@ -167,6 +171,7 @@ function status(session, now = Date.now()) { const BotInferenceBudget = { WINDOW_MS, reserve, + reserveForBotId, settle, status, snapshot: status, diff --git a/src/GameServer/Bot/AI/HotBotPolicyOverlay.js b/src/GameServer/Bot/AI/HotBotPolicyOverlay.js index 3ce1e9de..db13d74c 100644 --- a/src/GameServer/Bot/AI/HotBotPolicyOverlay.js +++ b/src/GameServer/Bot/AI/HotBotPolicyOverlay.js @@ -32,6 +32,36 @@ function ttl(value) { return clamp(Math.floor(requested), MIN_TTL_MS, MAX_TTL_MS); } +function partyCompanionService() { + try { return invoke('GameServer/Bot/AI/PartyCompanionService'); } catch (_) { return null; } +} + +function pullValue(value) { + return Number(value || 0) || null; +} + +function restorePullPolicy(session, overlay) { + const leader = overlay?.ownerSession; + const restore = overlay?.pullRestore; + const applied = overlay?.pullApplied; + const service = partyCompanionService(); + if (!leader || !restore || !applied || !service?.getSettings || !service?.updateSettings) return; + + const current = service.getSettings(leader); + if (String(current?.pullMode || 'auto') !== String(applied.mode || 'auto') || + pullValue(current?.pullerId) !== pullValue(applied.pullerId)) { + // Another authoritative party action changed the policy. Do not + // overwrite that newer decision while cleaning up this overlay. + return; + } + + service.updateSettings(leader, { + pullMode: restore.pullMode || 'auto', + pullerId: pullValue(restore.pullerId) + }); + service.refreshPanel?.(leader); +} + function normalizePriorities(value) { if (!value || typeof value !== 'object') return {}; return Object.entries(value) @@ -63,7 +93,9 @@ function prune(session) { const overlay = session?.hotPolicyOverlay; if (!overlay) return null; if (Number(overlay.expiresAt || 0) > now()) return overlay; + restorePullPolicy(session, overlay); delete session.hotPolicyOverlay; + session.lastHotPolicyReset = { reason: 'expired', at: now() }; return null; } @@ -79,6 +111,8 @@ function set(session, patch = {}, context = {}) { ...previous, ownerId: Number(context.ownerId || previous.ownerId || 0) || null, ownerName: context.ownerName || previous.ownerName || null, + ownerSession: context.ownerSession || previous.ownerSession || null, + pullRestore: context.pullRestore || previous.pullRestore || null, reason: String(context.reason || patch.reason || previous.reason || 'player_request').slice(0, 160), createdAt: Number(previous.createdAt || now()), updatedAt: now(), @@ -88,6 +122,14 @@ function set(session, patch = {}, context = {}) { pull: normalizePull(patch.pull ?? previous.pull) }; + if (patch.pull !== undefined) { + updated.pullApplied = updated.pull + ? { mode: updated.pull.mode || 'auto', pullerId: pullValue(updated.pull.pullerId) } + : null; + } else if (previous.pullApplied) { + updated.pullApplied = { ...previous.pullApplied }; + } + // An explicitly cleared field must not be resurrected by the old object. if (patch.skillPriorities === null) updated.skillPriorities = {}; if (patch.combatStance === null) updated.combatStance = null; @@ -99,6 +141,7 @@ function set(session, patch = {}, context = {}) { function clear(session, reason = 'lifecycle') { if (!session?.hotPolicyOverlay) return false; + restorePullPolicy(session, session.hotPolicyOverlay); delete session.hotPolicyOverlay; session.lastHotPolicyReset = { reason, at: now() }; return true; diff --git a/src/GameServer/Bot/BotAI.js b/src/GameServer/Bot/BotAI.js index ed73e688..6c05b8f4 100644 --- a/src/GameServer/Bot/BotAI.js +++ b/src/GameServer/Bot/BotAI.js @@ -176,6 +176,8 @@ const BotAI = { stop(session) { session.aiActive = false; + session.pendingBrainTurns = []; + session.pendingBrainTurn = null; HotBotPolicyOverlay.clearForCold(session); try { invoke('GameServer/Bot/AI/BotAmbientDirector').cleanup(session, 'ai_stop'); } catch (_) { /* optional ambient module */ } try { invoke('GameServer/Bot/AI/BotInferenceBudget').reset(session); } catch (_) { /* optional budget module */ } diff --git a/src/GameServer/Bot/Economy/BotNegotiationService.js b/src/GameServer/Bot/Economy/BotNegotiationService.js index da0c4250..05b3263a 100644 --- a/src/GameServer/Bot/Economy/BotNegotiationService.js +++ b/src/GameServer/Bot/Economy/BotNegotiationService.js @@ -1,3 +1,4 @@ +const crypto = require('crypto'); const DataCache = invoke('GameServer/DataCache'); const Database = invoke('Database'); const BotEconomyPricing = invoke('GameServer/Bot/Economy/BotEconomyPricing'); @@ -22,6 +23,14 @@ function enabled() { function actorId(session) { return Number(session?.actor?.fetchId?.() || 0); } function actorName(session) { return session?.actor?.fetchName?.() || session?.accountId || 'unknown'; } + +function negotiationId(bot, player) { + sequence += 1; + const suffix = typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; + return `negotiation-${actorId(bot)}-${actorId(player)}-${suffix}-${sequence}`; +} function isBot(session) { return !!session?.accountId && String(session.accountId).startsWith('bot_'); } function isPlayer(session) { return !!session?.actor && !isBot(session) && session.actor.fetchIsOnline?.() !== false; } @@ -268,7 +277,7 @@ function quoteItem(bot, player, itemObjectId, amount = 1) { const policy = pricePolicy(bot, player, item, quantity); const negotiation = { - id: `negotiation-${++sequence}`, + id: negotiationId(bot, player), botSession: bot, playerSession: player, itemObjectId: Number(itemObjectId), diff --git a/tests/test_bot_ambient_director.js b/tests/test_bot_ambient_director.js index 2be93282..d442fb73 100644 --- a/tests/test_bot_ambient_director.js +++ b/tests/test_bot_ambient_director.js @@ -41,6 +41,7 @@ function bot(id, name, overrides = {}) { const now = 1_000_000; const aria = bot(2000101, 'Aria', { role: 'tank' }); const belen = bot(2000102, 'Belen', { role: 'healer' }); +const clara = bot(2000109, 'Clara', { role: 'dps' }); BotAmbientDirector.reset(); assert.strictEqual(BotAmbientDirector.enabled(), true, 'ambient director should be enabled by default'); @@ -66,6 +67,11 @@ assert.strictEqual( true, 'the per-bot cooldown should eventually expire' ); +assert.strictEqual( + BotAmbientDirector.eligible(aria, clara, now + 100000).reason, + 'bot_cooldown', + 'a bot cooldown must apply even when the next scene uses a different pair' +); const player = bot(2000103, 'VisiblePlayer', { accountId: 'player_1' }); assert.strictEqual(BotAmbientDirector.eligible(aria, player, now + 180000).reason, 'bot_only_scene'); diff --git a/tests/test_bot_conversation_summary.js b/tests/test_bot_conversation_summary.js index 36c61ac4..8a62e421 100644 --- a/tests/test_bot_conversation_summary.js +++ b/tests/test_bot_conversation_summary.js @@ -4,10 +4,12 @@ require('../src/Global'); const BotConversationStore = invoke('GameServer/Bot/AI/BotConversationStore'); const BotConversationSummarizer = invoke('GameServer/Bot/AI/BotConversationSummarizer'); const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); +const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); async function main() { BotConversationStore.resetMemory(); BotConversationSummarizer.reset(); + BotInferenceBudget.reset(); options.default.OpenRouter = { enabled: true, apiKey: 'test-key', @@ -44,6 +46,7 @@ async function main() { } const first = await BotConversationSummarizer.summarize({ playerId: 10, botId: 20, threshold: 24 }); assert.strictEqual(first.ok, true); + assert.strictEqual(BotInferenceBudget.status({ actor: { fetchId: () => 20 } }).requests, 1, 'summary inference must consume the bot budget'); assert.match(first.summary, /Open topics/); const compacted = await BotConversationStore.context(10, 20, { limit: 20 }); assert.match(compacted.summary, /concise answers/); @@ -82,6 +85,7 @@ async function main() { } finally { OpenRouterGateway.resetTransport(); OpenRouterGateway.resetCircuit(); + BotInferenceBudget.reset(); options.default.OpenRouter = {}; } } diff --git a/tests/test_bot_negotiation_flow.js b/tests/test_bot_negotiation_flow.js index 31fc648f..105cfa26 100644 --- a/tests/test_bot_negotiation_flow.js +++ b/tests/test_bot_negotiation_flow.js @@ -96,6 +96,11 @@ try { bot.activeNegotiation.expiresAt = Date.now() - 1; assert.strictEqual(BotNegotiationService.activeSummary(bot), null, 'TTL expires an unanswered quote'); assert.strictEqual(bot.botNegotiationReservations.size, 0); + const previousId = ttl.negotiation.id; + BotNegotiationService.reset(); + const afterReset = BotNegotiationService.quoteItem(bot, player, 922, 1); + assert.strictEqual(afterReset.ok, true); + assert.notStrictEqual(afterReset.negotiation.id, previousId, 'negotiation IDs must remain unique across service resets'); console.log('Bot negotiation flow checks passed'); } finally { BotSocialMemory.getSnapshot = originalSnapshot; diff --git a/tests/test_hot_bot_conversation_flow.js b/tests/test_hot_bot_conversation_flow.js index 2a5160f9..08552a97 100644 --- a/tests/test_hot_bot_conversation_flow.js +++ b/tests/test_hot_bot_conversation_flow.js @@ -41,6 +41,7 @@ async function main() { enabled: true, apiKey: 'conversation-test-key', model: 'test/conversation', + negotiationEnabled: true, chatCooldownMs: 0, timeoutMs: 1000, circuitBreakerFailureThreshold: 5 @@ -49,7 +50,7 @@ async function main() { const botSession = { accountId: 'bot_flow', actor: actor(302, 'FlowBot', 100), - plan: 'resting' + plan: 'merchant' }; World.user = { sessions: [playerSession, botSession] }; BotBrainContext.compactStatus = (_session, status) => status; @@ -87,6 +88,11 @@ async function main() { }; const firstTurn = { turnId: 'flow-1', channel: 'local_chat' }; const secondTurn = { turnId: 'flow-2', channel: 'local_chat' }; + const thirdContext = { + ...secondContext, + recentTurns: [...secondContext.recentTurns, { turnId: 'flow-3', role: 'player', channel: 'local_chat', text: 'third', createdAt: 3 }] + }; + const thirdTurn = { turnId: 'flow-3', channel: 'local_chat' }; const firstStarted = BotBrain.maybeThink( botSession, 'player_chat', @@ -105,10 +111,19 @@ async function main() { ); assert.strictEqual(secondStarted, true, 'one additional dialogue turn should queue while the first is in flight'); assert.strictEqual(botSession.pendingBrainTurn.text, 'second'); + const thirdStarted = BotBrain.maybeThink( + botSession, + 'player_chat', + { available: true, mode: 'merchant', level: 10, name: 'FlowBot' }, + 'third', + { playerSession, conversation: thirdContext, conversationTurn: thirdTurn, requestId: 'flow-3' } + ); + assert.strictEqual(thirdStarted, true); + assert.strictEqual(botSession.pendingBrainTurns.length, 2, 'queued turns should be retained in FIFO order'); firstRequestRelease(); - await new Promise((resolve) => setTimeout(resolve, 30)); - assert.strictEqual(requests.length, 2, 'the queued turn should be submitted after the first turn completes'); + await new Promise((resolve) => setTimeout(resolve, 80)); + assert.strictEqual(requests.length, 3, 'all queued turns should be submitted after the first turn completes'); const secondPayload = JSON.parse(requests[1].messages[1].content); assert.deepStrictEqual( secondPayload.conversation.recentTurns.map((turn) => turn.text), diff --git a/tests/test_llm_pull_policy_tools.js b/tests/test_llm_pull_policy_tools.js index 27c6f3d2..5eb99512 100644 --- a/tests/test_llm_pull_policy_tools.js +++ b/tests/test_llm_pull_policy_tools.js @@ -4,6 +4,7 @@ require('../src/Global'); const BotManager = invoke('GameServer/Bot/BotManager'); const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); +const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); function actor(id, name) { return { @@ -45,6 +46,7 @@ const bot = { BotManager.sessions = [bot]; const originalRefreshPanel = PartyCompanionService.refreshPanel; +const originalNow = Date.now; PartyCompanionService.refreshPanel = () => {}; try { @@ -70,7 +72,17 @@ try { const repeatUnassign = BotAgentTools.execute(bot, decision('unassign_puller', 'pull-4'), [], context('pull-4')); assert.strictEqual(repeatUnassign.applied, true); assert.strictEqual(PartyCompanionService.getSettings(leader).pullMode, 'auto', 'unassign must not disable autonomous pulls'); + + leader.partyCompanionSettings.pullMode = 'off'; + leader.partyCompanionSettings.pullerId = null; + Date.now = () => 100000; + const temporary = BotAgentTools.execute(bot, decision('assign_puller', 'pull-5', { policyTtlMs: 5000 }), [], context('pull-5')); + assert.strictEqual(temporary.applied, true); + Date.now = () => 106000; + assert.strictEqual(HotBotPolicyOverlay.status(bot), null, 'expired policy overlay should be removed'); + assert.strictEqual(PartyCompanionService.getSettings(leader).pullMode, 'off', 'expired pull policy must restore the previous party setting'); console.log('LLM pull policy tool checks passed'); } finally { + Date.now = originalNow; PartyCompanionService.refreshPanel = originalRefreshPanel; } From 741aa6cf0905bc877c719f117d1a76f3592a4b45 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:10:02 -0400 Subject: [PATCH 13/59] Make hot bot chat mandatory and add Langfuse telemetry --- config/default.ini | 19 +- database/sql/sqlite.sql | 25 +++ package.json | 3 + src/Database.js | 26 +++ src/GameServer/Bot/AI/BotBrain.js | 148 +++++++++---- src/GameServer/Bot/AI/BotLLMTurnStore.js | 83 +++++++ src/GameServer/Bot/AI/BotStatus.js | 6 + src/GameServer/Bot/AI/BotToolRegistry.js | 18 ++ src/GameServer/Bot/AI/LangfuseTracing.js | 244 +++++++++++++++++++++ src/GameServer/Bot/AI/OpenRouterGateway.js | 131 +++++++++-- src/GameServer/Bot/BotManager.js | 22 ++ src/NodeL2.js | 4 + tests/test_bot_chat_commands.js | 7 + tests/test_openrouter_gateway.js | 30 +++ 14 files changed, 709 insertions(+), 57 deletions(-) create mode 100644 src/GameServer/Bot/AI/BotLLMTurnStore.js create mode 100644 src/GameServer/Bot/AI/LangfuseTracing.js diff --git a/config/default.ini b/config/default.ini index f8adafb6..3280f102 100644 --- a/config/default.ini +++ b/config/default.ini @@ -29,10 +29,10 @@ enabled = false apiKey = model = google/gemini-2.5-flash-lite temperature = 0.35 -maxTokens = 160 +maxTokens = 320 timeoutMs = 3500 cooldownMs = 45000 -chatCooldownMs = 12000 +chatCooldownMs = 0 visibilityRadius = 6000 maxPromptPrice = 0.15 maxCompletionPrice = 0.60 @@ -47,6 +47,21 @@ hotBotCompletionTokenBudgetPerMinute = 2400 debug = false negotiationEnabled = false +[Langfuse] +enabled = false +envFile = +baseUrl = http://localhost:3000 +publicKey = +secretKey = +environment = development +release = +serviceName = nodel2 +flushAt = 10 +flushInterval = 1 +captureInput = true +captureOutput = true +debug = false + [BotPopulation] enabled = true ambientScenesEnabled = true diff --git a/database/sql/sqlite.sql b/database/sql/sqlite.sql index 6e0ac184..2c667b99 100644 --- a/database/sql/sqlite.sql +++ b/database/sql/sqlite.sql @@ -300,6 +300,31 @@ CREATE TABLE IF NOT EXISTS bot_tool_outcomes ( CREATE INDEX IF NOT EXISTS bot_tool_outcomes_bot_recent ON bot_tool_outcomes(botId, createdAt DESC); CREATE INDEX IF NOT EXISTS bot_tool_outcomes_turn ON bot_tool_outcomes(botId, turnId, toolName, createdAt DESC); +CREATE TABLE IF NOT EXISTS bot_llm_turns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + turnId TEXT NOT NULL UNIQUE, + playerId INTEGER REFERENCES characters(id) ON DELETE SET NULL, + botId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + eventType TEXT NOT NULL, + channel TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'queued', + requestId TEXT, + traceId TEXT, + startedAt INTEGER, + finishedAt INTEGER, + outcome TEXT, + model TEXT, + promptTokens INTEGER NOT NULL DEFAULT 0, + completionTokens INTEGER NOT NULL DEFAULT 0, + totalTokens INTEGER NOT NULL DEFAULT 0, + cost REAL, + error TEXT NOT NULL DEFAULT '', + metaJson TEXT +); +CREATE INDEX IF NOT EXISTS bot_llm_turns_bot_recent ON bot_llm_turns(botId, id DESC); +CREATE INDEX IF NOT EXISTS bot_llm_turns_player_recent ON bot_llm_turns(playerId, id DESC); +CREATE INDEX IF NOT EXISTS bot_llm_turns_state_recent ON bot_llm_turns(state, id DESC); + CREATE TABLE IF NOT EXISTS bot_negotiations ( id TEXT PRIMARY KEY, playerId INTEGER REFERENCES characters(id) ON DELETE SET NULL, diff --git a/package.json b/package.json index 905a68f3..7b5f8980 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "wipe:bots": "node scripts/wipe-bots.js" }, "dependencies": { + "@langfuse/otel": "^5.10.0", + "@langfuse/tracing": "^5.10.0", + "@opentelemetry/sdk-node": "^0.221.0", "blowfish-ecb": "^1.0.2", "eslint": "^8.36.0", "explicit-json": "^1.0.5", diff --git a/src/Database.js b/src/Database.js index aaa1e09e..82b7f95b 100644 --- a/src/Database.js +++ b/src/Database.js @@ -263,6 +263,32 @@ function applySchemaMigrations() { ); CREATE INDEX IF NOT EXISTS bot_negotiations_pair_recent ON bot_negotiations(playerId, botId, updatedAt DESC); CREATE INDEX IF NOT EXISTS bot_negotiations_bot_recent ON bot_negotiations(botId, updatedAt DESC); + `)], + [7, () => connection.exec(` + CREATE TABLE IF NOT EXISTS bot_llm_turns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + turnId TEXT NOT NULL UNIQUE, + playerId INTEGER REFERENCES characters(id) ON DELETE SET NULL, + botId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + eventType TEXT NOT NULL, + channel TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'queued', + requestId TEXT, + traceId TEXT, + startedAt INTEGER, + finishedAt INTEGER, + outcome TEXT, + model TEXT, + promptTokens INTEGER NOT NULL DEFAULT 0, + completionTokens INTEGER NOT NULL DEFAULT 0, + totalTokens INTEGER NOT NULL DEFAULT 0, + cost REAL, + error TEXT NOT NULL DEFAULT '', + metaJson TEXT + ); + CREATE INDEX IF NOT EXISTS bot_llm_turns_bot_recent ON bot_llm_turns(botId, id DESC); + CREATE INDEX IF NOT EXISTS bot_llm_turns_player_recent ON bot_llm_turns(playerId, id DESC); + CREATE INDEX IF NOT EXISTS bot_llm_turns_state_recent ON bot_llm_turns(state, id DESC); `)] ]; const applied = new Set(connection.prepare('SELECT version FROM schema_migrations').all().map((row) => Number(row.version))); diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 7c8754dc..b1d3bb44 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -4,13 +4,13 @@ const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); +const BotLLMTurnStore = invoke('GameServer/Bot/AI/BotLLMTurnStore'); +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); const ALLOWED_PLANS = ['hunting', 'following', 'resting', 'shopping', 'pk_hunting', 'merchant']; const ALLOWED_EVENTS = new Set(['player_chat', 'state_change']); -const MAX_PENDING_BRAIN_TURNS = 4; - function config() { - return OpenRouterGateway.config({ maxTokens: 160 }); + return OpenRouterGateway.config({ maxTokens: 320 }); } function debugSkip(session, cfg, reason) { @@ -60,7 +60,7 @@ function compactPlayer(session, botLoc) { }; } -function visibleRealPlayers(session, bot, cfg = config()) { +function visibleRealPlayers(session, bot, cfg = config(), requestContext = null) { if (!bot) return []; const World = invoke('GameServer/World/World'); @@ -71,7 +71,13 @@ function visibleRealPlayers(session, bot, cfg = config()) { .filter((player) => player.distance <= cfg.visibilityRadius) .sort((a, b) => a.distance - b.distance); - return visible; + const directSession = requestContext?.playerSession; + if (isRealPlayer(directSession)) { + const direct = compactPlayer(directSession, botLoc); + if (!visible.some((player) => Number(player.id) === Number(direct.id))) visible.push(direct); + } + + return visible.sort((a, b) => a.distance - b.distance); } function candidateSpots(status) { @@ -283,9 +289,17 @@ async function requestDecision(payload, cfg, session, requestContext, visiblePla 'nearby'; const result = await OpenRouterGateway.request({ config: cfg, - circuitKey: 'hot', + circuitKey: requestContext?.conversationTurn + ? `hot-chat:${botId}:${playerId}` + : 'hot-background', + circuitBreaker: requestContext?.conversationTurn ? false : true, + timeoutMs: requestContext?.conversationTurn ? 0 : cfg.timeoutMs, requestId: requestContext?.requestId || `hot-${botId}-${Date.now()}`, sessionId: `hot-bot:${botId}:player:${playerId}`, + source: requestContext?.source || requestContext?.channel || 'hot_brain', + botId, + playerId, + turnId: requestContext?.conversationTurn?.turnId || requestContext?.requestId || null, messages: [ { role: 'system', content: systemPrompt() }, { role: 'user', content: JSON.stringify(payload) } @@ -307,7 +321,7 @@ async function requestDecision(payload, cfg, session, requestContext, visiblePla function recordConversationReply(session, decision, result, requestContext) { const turn = requestContext?.conversationTurn; const action = decision?.action; - if (!turn || !result?.applied || !decision?.reply || ['none', 'buff_target', 'heal_target'].includes(action)) return; + if (!turn || !result?.applied || !decision?.reply || ['buff_target', 'heal_target'].includes(action)) return; const BotChatText = invoke('GameServer/Bot/AI/BotChatText'); const reply = BotChatText.normalize(decision.reply) .slice(0, BotChatText.DEFAULT_LINE_LIMIT * BotChatText.DEFAULT_MAX_LINES); @@ -327,6 +341,12 @@ function recordConversationReply(session, decision, result, requestContext) { function applyDecision(session, decision, visiblePlayers, requestContext) { const result = BotAgentTools.execute(session, decision, visiblePlayers, requestContext); BotAgentTools.remember(session, decision, result, config().model); + if (result.applied && decision.action === 'none' && decision.reply && requestContext?.playerSession?.actor) { + // `none` means “no world mutation”, not “drop the player's message”. + // A direct hot dialogue still needs a visible reply when the model + // chooses to speak without selecting a tool. + invoke('GameServer/Bot/BotManager').botTell(session, requestContext.playerSession, decision.reply); + } recordConversationReply(session, decision, result, requestContext); if (!result.applied && requestContext?.playerSession?.actor) { const BotManager = invoke('GameServer/Bot/BotManager'); @@ -455,10 +475,6 @@ const BotBrain = { requestContext }; const queue = session.pendingBrainTurns || (session.pendingBrainTurns = []); - if (queue.length >= MAX_PENDING_BRAIN_TURNS) { - const dropped = queue.shift(); - if (dropped?.requestContext) fallbackReply(session, dropped.requestContext, 'queued_overflow'); - } queue.push(pending); session.pendingBrainTurn = queue[0]; debugSkip(session, cfg, 'request_queued'); @@ -484,15 +500,15 @@ const BotBrain = { return false; } - const visiblePlayers = visibleRealPlayers(session, bot, cfg); + const visiblePlayers = visibleRealPlayers(session, bot, cfg, requestContext); if (visiblePlayers.length === 0) { debugSkip(session, cfg, 'no_visible_real_players'); return false; } - const cooldown = event === 'player_chat' ? cfg.chatCooldownMs : cfg.cooldownMs; - const lastAt = event === 'player_chat' ? session.lastBrainChatAt : session.lastBrainThinkAt; - if (lastAt && requestContext?.queued !== true && Date.now() - lastAt < cooldown) { + const cooldown = cfg.cooldownMs; + const lastAt = session.lastBrainThinkAt; + if (event !== 'player_chat' && lastAt && requestContext?.queued !== true && Date.now() - lastAt < cooldown) { debugSkip(session, cfg, `cooldown:${event}`); return false; } @@ -502,15 +518,15 @@ const BotBrain = { return false; } - if (event === 'player_chat') { - session.lastBrainChatAt = Date.now(); - } else { + if (event !== 'player_chat') { session.lastBrainThinkAt = Date.now(); } const payload = userPayload(event, session, status, visiblePlayers, text, requestContext); const admission = BotInferenceBudget.reserve(session, { event, + bypass: event === 'player_chat', + priority: event === 'player_chat' ? 'interactive' : 'normal', estimatedPromptTokens: estimateRequestPromptTokens(payload, session), maxCompletionTokens: cfg.maxTokens }); @@ -534,6 +550,18 @@ const BotBrain = { } session.brainInFlight = true; + const turnId = requestContext?.conversationTurn?.turnId || requestContext?.requestId || `${event}:${bot.fetchId?.()}:${Date.now()}`; + if (requestContext && !requestContext.requestId) requestContext.requestId = turnId; + const turnPersistence = BotLLMTurnStore.begin({ + turnId, + requestId: requestContext?.requestId || turnId, + playerId: requestContext?.playerSession?.actor?.fetchId?.() || requestContext?.playerId, + botId: bot.fetchId?.(), + eventType: event, + channel: requestContext?.channel, + model: cfg.model, + meta: { source: requestContext?.source || event } + }).then(() => BotLLMTurnStore.markStarted({ turnId })).catch(() => false); if (requestContext?.assembledContext?.telemetry) { session.lastBrainContextTelemetry = { ...requestContext.assembledContext.telemetry, @@ -548,36 +576,33 @@ const BotBrain = { } let providerResult = null; - requestDecision(payload, cfg, session, requestContext, visiblePlayers).then((result) => { - providerResult = result; - recordInferenceEvent(session, event, result, requestContext); - rememberTelemetry(session, result); - if (result?.ok === false) { - fallbackReply(session, requestContext, result.reason); - return; - } - applyDecision(session, result, visiblePlayers, requestContext); - }).catch((err) => { - recordInferenceEvent(session, event, { - ok: false, - reason: 'provider_error', - telemetry: { outcome: 'provider_error' } - }, requestContext); - utils.infoWarn('BotBrain', 'decision request failed for %s: %s', bot.fetchName(), err.message); - fallbackReply(session, requestContext, 'provider_error'); - }).finally(() => { + const finishTurn = () => { BotInferenceBudget.settle(admission.reservation, providerResult?.usage); + const finalTelemetry = providerResult?.llmTelemetry || providerResult?.telemetry || {}; + turnPersistence.then(() => BotLLMTurnStore.finish({ + turnId, + ok: providerResult?.ok !== false, + outcome: providerResult?.ok === false ? providerResult.reason : finalTelemetry.outcome, + model: finalTelemetry.model || cfg.model, + traceId: finalTelemetry.traceId || null, + usage: providerResult?.usage || finalTelemetry.usage, + error: providerResult?.ok === false ? providerResult.reason : '', + meta: { + event, + action: providerResult?.action || null, + traceId: finalTelemetry.traceId || null, + observationId: finalTelemetry.observationId || null, + finishReason: finalTelemetry.finishReason || null, + status: finalTelemetry.status || null + } + })).catch(() => {}); session.lastBrainBudget = BotInferenceBudget.status(session); session.brainInFlight = false; const queue = session.pendingBrainTurns || []; const pending = queue.shift() || null; session.pendingBrainTurn = queue[0] || null; if (pending) { - const waitMs = Math.max( - 0, - cfg.chatCooldownMs - (Date.now() - Number(session.lastBrainChatAt || 0)) - ); - setTimeout(() => { + Promise.resolve().then(() => { const started = BotBrain.maybeThink( session, pending.event, @@ -586,8 +611,47 @@ const BotBrain = { { ...pending.requestContext, queued: true } ); if (!started) fallbackReply(session, pending.requestContext, 'queued_not_started'); - }, waitMs); + }); } + }; + const runTurn = async () => { + try { + providerResult = await requestDecision(payload, cfg, session, requestContext, visiblePlayers); + recordInferenceEvent(session, event, providerResult, requestContext); + rememberTelemetry(session, providerResult); + if (providerResult?.ok === false) { + fallbackReply(session, requestContext, providerResult.reason); + return; + } + applyDecision(session, providerResult, visiblePlayers, requestContext); + } catch (err) { + providerResult = { + ok: false, + reason: 'provider_error', + telemetry: { outcome: 'provider_error' } + }; + recordInferenceEvent(session, event, providerResult, requestContext); + utils.infoWarn('BotBrain', 'decision request failed for %s: %s', bot.fetchName(), err.message); + fallbackReply(session, requestContext, 'provider_error'); + } finally { + finishTurn(); + } + }; + LangfuseTracing.withObservation( + 'hot-bot.dialogue', + payload, + { + event, + botId: bot.fetchId?.(), + playerId: requestContext?.playerSession?.actor?.fetchId?.() || requestContext?.playerId || null, + turnId, + requestId: requestContext?.requestId || turnId, + source: requestContext?.source || event + }, + runTurn, + 'agent' + ).catch((error) => { + utils.infoWarn('Langfuse', 'hot-bot observation failed: %s', error.message); }); return true; diff --git a/src/GameServer/Bot/AI/BotLLMTurnStore.js b/src/GameServer/Bot/AI/BotLLMTurnStore.js new file mode 100644 index 00000000..30952c9d --- /dev/null +++ b/src/GameServer/Bot/AI/BotLLMTurnStore.js @@ -0,0 +1,83 @@ +const Database = invoke('Database'); + +function id(value) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +function text(value, max = 160) { + return String(value || '').replace(/\s+/g, ' ').trim().slice(0, max); +} + +function json(value, max = 12000) { + try { return JSON.stringify(value ?? null).slice(0, max); } catch (_) { return '{}'; } +} + +function turnId(input) { + return text(input?.turnId || input?.requestId, 128) || null; +} + +function begin(input = {}) { + const turn = turnId(input); + const botId = id(input.botId); + if (!turn || !botId || !Database.isReady?.()) return Promise.resolve(false); + const playerId = id(input.playerId); + return Database.execute([` + INSERT INTO bot_llm_turns + (turnId, playerId, botId, eventType, channel, state, requestId, traceId, startedAt, metaJson) + VALUES (?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?) + ON CONFLICT(turnId) DO UPDATE SET + requestId = excluded.requestId, + traceId = COALESCE(excluded.traceId, bot_llm_turns.traceId), + metaJson = excluded.metaJson + `, [ + turn, + playerId, + botId, + text(input.eventType || 'player_chat', 48), + text(input.channel || '', 32), + text(input.requestId, 128) || null, + text(input.traceId, 128) || null, + Number(input.startedAt || Date.now()), + json(input.meta) + ]], 'bot-llm-turn:begin').then(() => true).catch(() => false); +} + +function markStarted(input = {}) { + const turn = turnId(input); + if (!turn || !Database.isReady?.()) return Promise.resolve(false); + return Database.execute([` + UPDATE bot_llm_turns + SET state = 'running', startedAt = COALESCE(startedAt, ?), traceId = COALESCE(?, traceId) + WHERE turnId = ? + `, [Number(input.startedAt || Date.now()), text(input.traceId, 128) || null, turn]], 'bot-llm-turn:started') + .then(() => true).catch(() => false); +} + +function finish(input = {}) { + const turn = turnId(input); + if (!turn || !Database.isReady?.()) return Promise.resolve(false); + const usage = input.usage || {}; + const state = input.ok === false ? 'failed' : 'completed'; + return Database.execute([` + UPDATE bot_llm_turns + SET state = ?, finishedAt = ?, outcome = ?, model = ?, + traceId = COALESCE(?, traceId), promptTokens = ?, completionTokens = ?, totalTokens = ?, cost = ?, error = ?, metaJson = ? + WHERE turnId = ? + `, [ + state, + Number(input.finishedAt || Date.now()), + text(input.outcome || (input.ok === false ? 'failed' : 'success'), 64), + text(input.model, 160) || null, + text(input.traceId, 128) || null, + Number(usage.promptTokens || 0), + Number(usage.completionTokens || 0), + Number(usage.totalTokens || 0), + Number.isFinite(Number(usage.cost)) ? Number(usage.cost) : null, + text(input.error, 240), + json(input.meta), + turn + ]], 'bot-llm-turn:finish').then(() => true).catch(() => false); +} + +module.exports = { begin, markStarted, finish }; diff --git a/src/GameServer/Bot/AI/BotStatus.js b/src/GameServer/Bot/AI/BotStatus.js index c282e905..477564f0 100644 --- a/src/GameServer/Bot/AI/BotStatus.js +++ b/src/GameServer/Bot/AI/BotStatus.js @@ -10,6 +10,7 @@ const GearSkillHints = invoke('GameServer/Bot/AI/GearSkillHints'); const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); const BotAmbientDirector = invoke('GameServer/Bot/AI/BotAmbientDirector'); const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); function ratio(value, max) { if (!max) return 0; @@ -349,6 +350,11 @@ const BotStatus = { trade: tradeSnapshot(session, bot), ambient, inference: BotInferenceBudget.snapshot(session), + llm: { + last: session.lastBrainTelemetry || null, + context: session.lastBrainContextTelemetry || null, + langfuse: LangfuseTracing.status() + }, persona: personaSnapshot(session), policy: HotBotPolicyOverlay.status(session), social: session.socialSummary || null, diff --git a/src/GameServer/Bot/AI/BotToolRegistry.js b/src/GameServer/Bot/AI/BotToolRegistry.js index 593cd747..7939bcc4 100644 --- a/src/GameServer/Bot/AI/BotToolRegistry.js +++ b/src/GameServer/Bot/AI/BotToolRegistry.js @@ -1,5 +1,6 @@ const BotToolAudit = invoke('GameServer/Bot/AI/BotToolAudit'); const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); const definitions = new Map(); @@ -106,6 +107,23 @@ function availableNames(session = null) { } function audit(context, outcome, reason, meta = {}) { + const observation = LangfuseTracing.startObservation( + `bot.tool.${text(context.decision?.action || 'unknown', 64)}`, + { + action: context.decision?.action || null, + arguments: context.decision || null, + expectedWorldRevision: context.expectedWorldRevision || null + }, + { + botId: actorId(context.session), + playerId: playerId(context), + turnId: turnId(context), + outcome, + reason + }, + 'tool' + ); + observation?.end({ outcome, reason, ...meta }); BotToolAudit.record({ playerId: playerId(context), botId: actorId(context.session), diff --git a/src/GameServer/Bot/AI/LangfuseTracing.js b/src/GameServer/Bot/AI/LangfuseTracing.js new file mode 100644 index 00000000..442ce8df --- /dev/null +++ b/src/GameServer/Bot/AI/LangfuseTracing.js @@ -0,0 +1,244 @@ +const fs = require('fs'); +const path = require('path'); + +let sdk = null; +let processor = null; +let tracing = null; +let initialized = false; +let initError = null; +let envCache = { filename: null, values: {} }; + +function bool(value, fallback = false) { + if (value === undefined || value === null || value === '') return fallback; + if (typeof value === 'boolean') return value; + return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase()); +} + +function number(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function text(value, max = 240) { + return String(value || '').replace(/\s+/g, ' ').trim().slice(0, max); +} + +function parseEnvFile(filename) { + if (!filename) return {}; + if (envCache.filename === String(filename)) return envCache.values; + try { + const absolute = path.resolve(String(filename)); + if (!fs.existsSync(absolute)) { + envCache = { filename: String(filename), values: {} }; + return envCache.values; + } + const values = Object.fromEntries(fs.readFileSync(absolute, 'utf8') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) + .map((line) => { + const index = line.indexOf('='); + if (index < 1) return null; + const key = line.slice(0, index).trim(); + let value = line.slice(index + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + return [key, value]; + }) + .filter(Boolean)); + envCache = { filename: String(filename), values }; + return values; + } catch (_) { + return {}; + } +} + +function config(overrides = {}) { + const option = options.default.Langfuse || {}; + const fileEnv = parseEnvFile(option.envFile); + const value = (key, fallback = '') => process.env[key] || fileEnv[key] || option[key] || fallback; + const source = { + enabled: bool(process.env.LANGFUSE_ENABLED || option.enabled, false), + baseUrl: value('LANGFUSE_BASE_URL', 'http://localhost:3000'), + publicKey: value('LANGFUSE_PUBLIC_KEY') || value('LANGFUSE_INIT_PROJECT_PUBLIC_KEY'), + secretKey: value('LANGFUSE_SECRET_KEY') || value('LANGFUSE_INIT_PROJECT_SECRET_KEY'), + environment: value('LANGFUSE_TRACING_ENVIRONMENT', 'development'), + release: value('LANGFUSE_RELEASE', utils.buildNumber?.() || 'nodel2'), + serviceName: text(option.serviceName || process.env.OTEL_SERVICE_NAME || 'nodel2', 80), + flushAt: Math.max(1, number(option.flushAt, 10)), + flushInterval: Math.max(1, number(option.flushInterval, 1)), + captureInput: bool(option.captureInput, true), + captureOutput: bool(option.captureOutput, true), + debug: bool(option.debug, false) + }; + return { + ...source, + ...overrides, + enabled: bool(overrides.enabled, source.enabled), + baseUrl: overrides.baseUrl || source.baseUrl, + publicKey: overrides.publicKey || source.publicKey, + secretKey: overrides.secretKey || source.secretKey, + captureInput: bool(overrides.captureInput, source.captureInput), + captureOutput: bool(overrides.captureOutput, source.captureOutput), + flushAt: Math.max(1, number(overrides.flushAt, source.flushAt)), + flushInterval: Math.max(1, number(overrides.flushInterval, source.flushInterval)) + }; +} + +function serializable(value, limit = 24000) { + try { + const raw = JSON.stringify(value ?? null); + return raw.length > limit ? `${raw.slice(0, limit)}…` : value; + } catch (_) { + return text(value, limit); + } +} + +function observationInput(value, cfg) { + if (cfg.captureInput) return serializable(value); + return { captured: false, type: typeof value }; +} + +function observationOutput(value, cfg) { + if (cfg.captureOutput) return serializable(value); + return { captured: false, type: typeof value }; +} + +function updateObservation(observation, attributes) { + if (typeof observation?.update === 'function') observation.update(attributes); +} + +function init(overrides = {}) { + if (initialized) return status(); + const cfg = config(overrides); + if (!cfg.enabled) return status(); + if (!cfg.publicKey || !cfg.secretKey) { + initError = 'missing_credentials'; + if (cfg.debug) utils.infoWarn('Langfuse', 'enabled but credentials are missing'); + return status(); + } + + try { + const { NodeSDK } = require('@opentelemetry/sdk-node'); + const { LangfuseSpanProcessor } = require('@langfuse/otel'); + tracing = require('@langfuse/tracing'); + processor = new LangfuseSpanProcessor({ + publicKey: cfg.publicKey, + secretKey: cfg.secretKey, + baseUrl: cfg.baseUrl, + flushAt: cfg.flushAt, + flushInterval: cfg.flushInterval, + environment: cfg.environment, + release: cfg.release, + exportMode: 'batched' + }); + sdk = new NodeSDK({ + serviceName: cfg.serviceName, + spanProcessors: [processor] + }); + sdk.start(); + initialized = true; + if (cfg.debug) utils.infoSuccess('Langfuse', 'tracing enabled at %s', cfg.baseUrl); + } catch (error) { + initError = text(error.message, 240); + initialized = false; + if (cfg.debug) utils.infoWarn('Langfuse', 'initialization failed: %s', initError); + } + return status(); +} + +function status() { + const cfg = config(); + return { + configured: !!(cfg.publicKey && cfg.secretKey), + enabled: cfg.enabled, + initialized, + baseUrl: cfg.baseUrl, + error: initError + }; +} + +function withObservation(name, input, metadata, work, asType = 'span') { + const cfg = config(); + if (!initialized || !tracing?.startActiveObservation) return work(null); + return tracing.startActiveObservation(String(name), async (observation) => { + try { + tracing.propagateAttributes?.({ + userId: text(metadata?.playerId, 200) || undefined, + sessionId: text(metadata?.turnId || metadata?.sessionId, 200) || undefined, + traceName: text(name, 200), + metadata: Object.fromEntries(Object.entries(metadata || {}) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key, value]) => [key, text(value, 200)])) + }); + } catch (_) { + // Propagation is auxiliary; an invalid dimension must never block a turn. + } + updateObservation(observation, { input: observationInput(input, cfg), metadata: serializable(metadata || {}) }); + try { + const result = await work(observation); + updateObservation(observation, { output: observationOutput(result, cfg) }); + return result; + } catch (error) { + updateObservation(observation, { + level: 'ERROR', + statusMessage: text(error.message, 240), + output: observationOutput({ error: error.message }, cfg) + }); + throw error; + } + }, { asType, endOnExit: true }); +} + +function startObservation(name, input, metadata, asType = 'span') { + if (!initialized || !tracing?.startObservation) return null; + const cfg = config(); + try { + const observation = tracing.startObservation(String(name), { + input: observationInput(input, cfg), + metadata: serializable(metadata || {}) + }, { asType }); + return { + update(value = {}) { + updateObservation(observation, { + ...value, + output: value.output === undefined ? undefined : observationOutput(value.output, cfg) + }); + }, + end(value) { + if (value !== undefined) observation.update({ output: observationOutput(value, cfg) }); + observation.end(); + }, + traceId: observation.traceId, + id: observation.id + }; + } catch (_) { + return null; + } +} + +function activeTraceId() { + try { return tracing?.getActiveTraceId?.() || null; } catch (_) { return null; } +} + +async function shutdown() { + if (!sdk) return; + try { await sdk.shutdown(); } catch (error) { + if (config().debug) utils.infoWarn('Langfuse', 'shutdown failed: %s', error.message); + } finally { + sdk = null; + processor = null; + initialized = false; + } +} + +module.exports = { + config, + init, + status, + withObservation, + startObservation, + activeTraceId, + shutdown +}; diff --git a/src/GameServer/Bot/AI/OpenRouterGateway.js b/src/GameServer/Bot/AI/OpenRouterGateway.js index 760713a7..16cb7efd 100644 --- a/src/GameServer/Bot/AI/OpenRouterGateway.js +++ b/src/GameServer/Bot/AI/OpenRouterGateway.js @@ -1,14 +1,15 @@ const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); const DEFAULTS = Object.freeze({ enabled: false, apiKey: '', model: 'google/gemini-2.5-flash-lite', temperature: 0.35, - maxTokens: 160, + maxTokens: 320, timeoutMs: 3500, cooldownMs: 45000, - chatCooldownMs: 12000, + chatCooldownMs: 0, remoteChatCooldownMs: 10000, visibilityRadius: 6000, maxPromptPrice: 0, @@ -183,7 +184,11 @@ function telemetry(request, cfg, outcome, startedAt, extra = {}) { outcome, latencyMs: Date.now() - startedAt, status: extra.status || null, - usage: extra.usage || null + usage: extra.usage || null, + finishReason: extra.finishReason || null, + providerRequestId: extra.providerRequestId || null, + rawContent: extra.rawContent || null, + responsePreview: extra.responsePreview || null }; } @@ -240,8 +245,27 @@ function parseContent(content) { return JSON.parse(content); } -async function request(spec = {}) { - const cfg = config(spec.config || {}); +function validateContent(data, responseSchema) { + if (!responseSchema?.schema) return null; + try { + const Validator = require('jsonschema').Validator; + const validation = new Validator().validate(data, responseSchema.schema); + if (validation.valid) return null; + return validation.errors + .slice(0, 4) + .map((error) => `${error.property || 'response'}:${error.message}`) + .join('; ') + .slice(0, 480); + } catch (error) { + return `validator_error:${error.message}`.slice(0, 480); + } +} + +async function requestUntraced(spec = {}) { + const cfg = config({ + ...(spec.config || {}), + ...(spec.timeoutMs !== undefined ? { timeoutMs: spec.timeoutMs } : {}) + }); const requestData = { ...spec, requestId: requestId(spec.requestId), @@ -252,13 +276,17 @@ async function request(spec = {}) { if (!cfg.enabled) return complete(requestData, cfg, 'disabled', startedAt); if (!cfg.apiKey) return complete(requestData, cfg, 'missing_api_key', startedAt); - if (circuitIsOpen(cfg, requestData.circuitKey)) return complete(requestData, cfg, 'circuit_open', startedAt); + if (requestData.circuitBreaker !== false && circuitIsOpen(cfg, requestData.circuitKey)) { + return complete(requestData, cfg, 'circuit_open', startedAt); + } const fetcher = transport || global.fetch; if (typeof fetcher !== 'function') return complete(requestData, cfg, 'provider_error', startedAt); const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), Math.max(1, cfg.timeoutMs)); + const timeout = cfg.timeoutMs > 0 + ? setTimeout(() => controller.abort(), Math.max(1, cfg.timeoutMs)) + : null; const body = { model: cfg.model, messages: Array.isArray(requestData.messages) ? requestData.messages : [], @@ -299,43 +327,120 @@ async function request(spec = {}) { if (!response?.ok) { markFailure(cfg, requestData.circuitKey); return complete(requestData, cfg, 'provider_error', startedAt, { - status: Number(response?.status || 0) || null + status: Number(response?.status || 0) || null, + responsePreview: String(response?.statusText || '').slice(0, 240) }); } const json = await response.json(); - const content = json.choices?.[0]?.message?.content; + const choice = json.choices?.[0] || {}; + const content = choice.message?.content; + const finishReason = choice.finish_reason || null; + const rawContent = typeof content === 'string' ? content.slice(0, 12000) : null; let data; try { data = parseContent(content); } catch (_) { markFailure(cfg, requestData.circuitKey); return complete(requestData, cfg, 'schema_error', startedAt, { - usage: normalizeUsage(json.usage) + usage: normalizeUsage(json.usage), + finishReason, + providerRequestId: json.id || null, + rawContent, + responsePreview: finishReason === 'length' ? 'provider_output_truncated' : null }); } if (!data) { markFailure(cfg, requestData.circuitKey); return complete(requestData, cfg, 'schema_error', startedAt, { - usage: normalizeUsage(json.usage) + usage: normalizeUsage(json.usage), + finishReason, + providerRequestId: json.id || null, + rawContent + }); + } + + const validationError = validateContent(data, requestData.responseSchema); + if (validationError) { + markFailure(cfg, requestData.circuitKey); + return complete(requestData, cfg, 'schema_error', startedAt, { + usage: normalizeUsage(json.usage), + finishReason, + providerRequestId: json.id || null, + rawContent, + responsePreview: validationError }); } markSuccess(requestData.circuitKey); return complete(requestData, cfg, 'success', startedAt, { data, - usage: normalizeUsage(json.usage) + usage: normalizeUsage(json.usage), + finishReason, + providerRequestId: json.id || null, + rawContent }); } catch (err) { const outcome = err?.name === 'AbortError' ? 'timeout' : 'provider_error'; markFailure(cfg, requestData.circuitKey); return complete(requestData, cfg, outcome, startedAt); } finally { - clearTimeout(timeout); + if (timeout) clearTimeout(timeout); } } +async function request(spec = {}) { + const input = { + messages: spec.messages || [], + responseSchema: spec.responseSchema?.name || null, + model: spec.config?.model || config().model + }; + const metadata = { + requestId: spec.requestId || null, + sessionId: spec.sessionId || null, + circuitKey: spec.circuitKey || null, + source: spec.source || 'openrouter', + model: spec.config?.model || config().model, + botId: spec.botId || null, + playerId: spec.playerId || null, + turnId: spec.turnId || null + }; + return LangfuseTracing.withObservation( + 'openrouter.generation', + input, + metadata, + async (observation) => { + const result = await requestUntraced(spec); + if (observation && result?.telemetry) { + const usage = result.usage || result.telemetry.usage || {}; + observation.update({ + model: result.telemetry.model || null, + modelParameters: { + temperature: Number((spec.config || {}).temperature ?? config().temperature), + max_tokens: Number((spec.config || {}).maxTokens ?? config().maxTokens) + }, + usageDetails: { + input: Number(usage.promptTokens || 0), + output: Number(usage.completionTokens || 0), + total: Number(usage.totalTokens || 0) + }, + costDetails: Number.isFinite(Number(usage.cost)) ? { total: Number(usage.cost) } : undefined, + metadata: { + outcome: result.telemetry.outcome, + status: result.telemetry.status, + finishReason: result.telemetry.finishReason + } + }); + result.telemetry.traceId = observation.traceId || LangfuseTracing.activeTraceId(); + result.telemetry.observationId = observation.id || null; + } + return result; + }, + 'generation' + ); +} + const OpenRouterGateway = { OPENROUTER_URL, DEFAULTS, diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js index 64df3e8b..fe9d2d46 100644 --- a/src/GameServer/Bot/BotManager.js +++ b/src/GameServer/Bot/BotManager.js @@ -753,6 +753,28 @@ const BotManager = { const directCommandTarget = addressedToBot || selectedBot || companionBot; const groupAddress = /\b(bot|bots|guys|party|team|help)\b/.test(text) || /(бот|боты|ребят|народ|пати|команда|кто-нибудь)/.test(text); + // When the developer LLM path is enabled, every addressed hot-bot + // message must enter the same conversation pipeline. The legacy + // regex replies intentionally remain only as the disabled-mode + // fallback; otherwise they silently bypass OpenRouter and lose + // the persona, memory, and Langfuse trace for the turn. + const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); + const llmAddressed = BotBrain.isEnabled() && (directCommandTarget || (groupAddress && !brainGroupResponderPicked)); + if (llmAddressed) { + if (groupAddress && !directCommandTarget) brainGroupResponderPicked = true; + BotDialogueArbiter.route({ + playerSession, + botSession: session, + text: rawText, + channel: 'local_chat', + source: 'local_chat', + allowFallback: true + }).catch((error) => { + utils.infoWarn('BotDialogue', 'local LLM chat route failed: %s', error.message); + }); + return; + } + if (directCommandTarget && INSULT_PATTERN.test(rawText)) { handledByRule = true; BotSocialMemory.recordEvent(playerSession, session, 'insulted', 'chat'); diff --git a/src/NodeL2.js b/src/NodeL2.js index 3f29cd30..d1e7dac2 100644 --- a/src/NodeL2.js +++ b/src/NodeL2.js @@ -1,5 +1,8 @@ require('./Global'); +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); +LangfuseTracing.init(); + // User imports const AuthSession = invoke('AuthenticationServer/Session'); const GameSession = invoke('GameServer/Session'); @@ -24,6 +27,7 @@ function shutdown(signal) { forceExit.unref?.(); CharacterWriteQueue.flushAll() .catch((error) => utils.infoWarn('DB', 'final buffered flush failed: %s', error.message)) + .then(() => LangfuseTracing.shutdown()) .finally(() => process.exit(0)); } diff --git a/tests/test_bot_chat_commands.js b/tests/test_bot_chat_commands.js index ed67f045..b1d40856 100644 --- a/tests/test_bot_chat_commands.js +++ b/tests/test_bot_chat_commands.js @@ -2,6 +2,12 @@ const assert = require('assert'); require('../src/Global'); +// This file exercises the legacy deterministic command fallback. Keep the +// developer's ignored config/local.ini from changing that contract underneath +// the test suite; LLM routing is covered by the hot conversation flow tests. +const originalOpenRouterEnabled = options.default.OpenRouter?.enabled; +if (options.default.OpenRouter) options.default.OpenRouter.enabled = false; + const BotManager = invoke('GameServer/Bot/BotManager'); const BotAI = invoke('GameServer/Bot/BotAI'); const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); @@ -264,4 +270,5 @@ try { BotSocialMemory.recordEvent = originalRecordEvent; Generics.skillExec = originalSkillExec; BotManager.botTell = originalBotTell; + if (options.default.OpenRouter) options.default.OpenRouter.enabled = originalOpenRouterEnabled; } diff --git a/tests/test_openrouter_gateway.js b/tests/test_openrouter_gateway.js index 5806340d..a7456c3f 100644 --- a/tests/test_openrouter_gateway.js +++ b/tests/test_openrouter_gateway.js @@ -136,6 +136,36 @@ async function main() { }); assert.strictEqual(timedOut.reason, 'timeout'); + OpenRouterGateway.resetCircuit(); + let interactiveSignalAborted = false; + OpenRouterGateway.setTransport(async (_url, init) => { + init.signal.addEventListener('abort', () => { interactiveSignalAborted = true; }, { once: true }); + await new Promise((resolve) => setTimeout(resolve, 20)); + return response({ choices: [{ message: { content: JSON.stringify({ reply: 'no artificial timeout' }) } }] }); + }); + const interactive = await OpenRouterGateway.request({ + config: { ...baseConfig, timeoutMs: 0 }, + circuitKey: 'interactive-bot:1:player:2', + circuitBreaker: false, + requestId: 'interactive-no-timeout' + }); + assert.strictEqual(interactive.ok, true, 'interactive chat must wait for the provider instead of timing out locally'); + assert.strictEqual(interactiveSignalAborted, false, 'interactive chat must not abort the provider request'); + + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.setTransport(async () => response({ error: { message: 'background failure' } }, 503)); + for (let index = 0; index < 3; index += 1) { + await OpenRouterGateway.request({ config: baseConfig, circuitKey: 'background', requestId: `background-failure-${index}` }); + } + OpenRouterGateway.setTransport(async () => response({ choices: [{ message: { content: JSON.stringify({ reply: 'interactive recovered' }) } }] })); + const circuitBypass = await OpenRouterGateway.request({ + config: baseConfig, + circuitKey: 'background', + circuitBreaker: false, + requestId: 'interactive-circuit-bypass' + }); + assert.strictEqual(circuitBypass.ok, true, 'interactive chat must not inherit a background circuit breaker'); + OpenRouterGateway.resetCircuit(); OpenRouterGateway.setTransport(async () => response({ choices: [{ message: { content: '{not-json' } }] From 7b888c49a3e4c384d1bd2cbfe1ab4b9e175030f1 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:02:51 -0400 Subject: [PATCH 14/59] Route player chat through hot and cold bot dialogue --- config/default.ini | 1 + src/GameServer/Bot/AI/BotBrain.js | 4 + .../Bot/AI/BotConversationService.js | 18 +- src/GameServer/Bot/AI/BotRemoteChat.js | 279 ++++++++++++------ src/GameServer/Bot/AI/ChatArrivalState.js | 85 ++++++ src/GameServer/Bot/AI/OpenRouterGateway.js | 6 + src/GameServer/Bot/BotAI.js | 13 +- .../Bot/Population/PopulationService.js | 1 + src/GameServer/Network/Request/Speak.js | 27 +- src/GameServer/World/World.js | 2 + tests/test_bot_brain_state_change.js | 8 + tests/test_cold_bot_chat.js | 123 ++++++++ 12 files changed, 470 insertions(+), 97 deletions(-) create mode 100644 src/GameServer/Bot/AI/ChatArrivalState.js create mode 100644 tests/test_cold_bot_chat.js diff --git a/config/default.ini b/config/default.ini index 3280f102..81064026 100644 --- a/config/default.ini +++ b/config/default.ini @@ -33,6 +33,7 @@ maxTokens = 320 timeoutMs = 3500 cooldownMs = 45000 chatCooldownMs = 0 +backgroundInferenceEnabled = false visibilityRadius = 6000 maxPromptPrice = 0.15 maxCompletionPrice = 0.60 diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index b1d3bb44..10549f5b 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -458,6 +458,10 @@ const BotBrain = { debugSkip(session, cfg, `event_not_chat:${event}`); return false; } + if (event !== 'player_chat' && cfg.backgroundInferenceEnabled !== true) { + debugSkip(session, cfg, 'background_inference_disabled'); + return false; + } if (!cfg.enabled) { debugSkip(session, cfg, 'disabled'); return false; diff --git a/src/GameServer/Bot/AI/BotConversationService.js b/src/GameServer/Bot/AI/BotConversationService.js index b52df068..8b0b517c 100644 --- a/src/GameServer/Bot/AI/BotConversationService.js +++ b/src/GameServer/Bot/AI/BotConversationService.js @@ -8,6 +8,19 @@ function actorId(session) { return Number(session?.actor?.fetchId?.() || session?.characterId || 0); } +function actorName(session) { + return session?.actor?.fetchName?.() || session?.name || null; +} + +function isBotIdentity(session) { + if (!session) return false; + const account = String(session.accountId || session.accountName || '').toLowerCase(); + if (account.startsWith('bot_')) return true; + // Cold life-state snapshots have no live actor, but remain authoritative + // bot identities for conversation persistence. + return !session.actor && actorId(session) > 0 && !!actorName(session); +} + function normalizeChannel(value) { return String(value || 'local').toLowerCase().replace(/[^a-z0-9_:-]/g, '_').slice(0, 32) || 'local'; } @@ -19,11 +32,10 @@ function normalizeText(value) { function validPair(playerSession, botSession) { return !!( playerSession?.actor && - botSession?.actor && actorId(playerSession) > 0 && actorId(botSession) > 0 && !String(playerSession.accountId || '').startsWith('bot_') && - String(botSession.accountId || '').startsWith('bot_') + isBotIdentity(botSession) ); } @@ -70,7 +82,7 @@ function beginTurn(input = {}) { meta: { source: input.source || channel, playerName: input.playerSession.actor.fetchName?.() || null, - botName: input.botSession.actor.fetchName?.() || null + botName: actorName(input.botSession) } }).then((stored) => BotConversationStore.context(playerId, botId, { limit: input.recentTurns || DEFAULT_RECENT_TURNS diff --git a/src/GameServer/Bot/AI/BotRemoteChat.js b/src/GameServer/Bot/AI/BotRemoteChat.js index 6ce86db2..e3f68e28 100644 --- a/src/GameServer/Bot/AI/BotRemoteChat.js +++ b/src/GameServer/Bot/AI/BotRemoteChat.js @@ -3,11 +3,24 @@ const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); const LifeEvents = invoke('GameServer/Bot/Population/BotLifeEvents'); const BotPersona = invoke('GameServer/Bot/AI/BotPersona'); const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); +const BotConversationService = invoke('GameServer/Bot/AI/BotConversationService'); -const cooldowns = new Map(); +// A player can send several tells while the provider is answering. Keep the +// pair ordered so each request sees the previous answer in conversation +// history, but do not add an artificial delay or discard any message. +const queues = new Map(); function config() { - return OpenRouterGateway.config({ maxTokens: 120 }); + return OpenRouterGateway.config({ maxTokens: 160, timeoutMs: 0 }); +} + +function enqueue(key, work) { + const previous = queues.get(key) || Promise.resolve(); + const current = previous.catch(() => {}).then(work); + queues.set(key, current); + return current.finally(() => { + if (queues.get(key) === current) queues.delete(key); + }); } function playerSummary(playerSession) { @@ -31,11 +44,13 @@ function stateSummary(state) { id: state.characterId, name: state.name, level: state.level, + classId: state.classId || state.stats?.classId || null, phase: state.phase, activity: state.activity, homeRegion: state.homeRegion, currentRegion: state.currentRegion, spotId: state.spotId, + loc: state.loc || null, hpPct: state.vitals?.maxHp ? Math.round((state.vitals.hp / state.vitals.maxHp) * 100) : null, mpPct: state.vitals?.maxMp ? Math.round((state.vitals.mp / state.vitals.maxMp) * 100) : null, adena: state.adena, @@ -59,7 +74,7 @@ function personaForState(state) { } function compactEvents(events) { - return events.map((event) => ({ + return (events || []).map((event) => ({ type: event.type, summary: event.summary, ageSec: event.createdAt ? Math.max(0, Math.round((Date.now() - event.createdAt) / 1000)) : null @@ -110,13 +125,13 @@ function schema() { return { type: 'object', properties: { - reply: { + action: { type: 'string', - description: 'Short in-character private reply. Long factual lists may be up to 360 chars.' + enum: ['say', 'none', 'come_to_player'] }, - intent: { + reply: { type: 'string', - enum: ['none', 'open_to_party', 'decline_party', 'keep_hunting', 'resting', 'traveling'] + description: 'Short in-character private reply. Do not claim arrival until the server confirms it.' }, reason: { type: 'string', @@ -128,28 +143,40 @@ function schema() { maximum: 1 } }, - required: ['reply', 'intent', 'reason', 'confidence'], + required: ['action', 'reply', 'reason', 'confidence'], additionalProperties: false }; } function systemPrompt() { return [ - 'You are replying as one Lineage 2 bot in private chat.', - 'Use only the provided state, persona, social memory, availability, and life events.', + 'You are replying as one Lineage 2 bot in a private chat while the bot is cold/off-screen.', + 'The state below is a persistent snapshot of the bot life, not a live actor. Use only the provided state, persona, social memory, availability, conversation, and life events.', 'The persona shapes tone and high-level preferences, never facts, safety, or available actions.', - 'Do not invent items, rewards, locations, levels, party membership, or combat results.', + 'Do not invent items, rewards, locations, levels, party membership, combat results, or live observations.', 'Keep the reply short, grounded, and in character.', - 'You may express a high-level intent, but server code decides all real actions.' + 'Use action=say for ordinary conversation and action=none when no reply is needed.', + 'Use action=come_to_player only when the player explicitly asks this bot to come, arrive, teleport, or meet them here.', + 'The server will validate availability and perform the arrival. Never claim that the bot arrived or joined a party before the server confirms the action.', + 'A cold chat never activates the bot by itself unless the validated action is come_to_player.' ].join(' '); } -async function requestLlmReply(payload, cfg, sessionId) { +async function requestLlmReply(payload, cfg, turn, state, playerSession) { + const playerId = playerSession.actor.fetchId(); + const botId = Number(state.characterId || 0); + const sessionId = `cold-bot:${botId}:player:${playerId}`; const result = await OpenRouterGateway.request({ config: cfg, - circuitKey: 'cold', - requestId: `cold-${sessionId || 'bot'}-${Date.now()}`, + circuitKey: `cold-chat:${botId}:${playerId}`, + circuitBreaker: false, + timeoutMs: 0, + requestId: turn.turnId, sessionId, + source: 'cold_chat', + botId, + playerId, + turnId: turn.turnId, messages: [ { role: 'system', content: systemPrompt() }, { role: 'user', content: JSON.stringify(payload) } @@ -171,94 +198,176 @@ async function requestLlmReply(payload, cfg, sessionId) { const parsed = result.data; const BotChatText = invoke('GameServer/Bot/AI/BotChatText'); - const reply = BotChatText.normalize(parsed.reply).slice(0, BotChatText.DEFAULT_LINE_LIMIT * BotChatText.DEFAULT_MAX_LINES); + const reply = BotChatText.normalize(parsed.reply) + .slice(0, BotChatText.DEFAULT_LINE_LIMIT * BotChatText.DEFAULT_MAX_LINES); if (!reply || Number(parsed.confidence || 0) < 0.35) return null; return { reply, - intent: parsed.intent || 'none', + action: parsed.action || 'say', reason: parsed.reason || 'llm', + confidence: Number(parsed.confidence || 0), llm: true, usage: result.usage, llmTelemetry: result.telemetry }; } -const BotRemoteChat = { - replyForState(playerSession, state, text) { - if (!playerSession?.actor || !state) { - return Promise.resolve({ ok: false, reason: 'missing_context' }); - } +function playerLocation(playerSession) { + const actor = playerSession?.actor; + if (!actor) return null; + return { + locX: actor.fetchLocX(), + locY: actor.fetchLocY(), + locZ: actor.fetchLocZ() + }; +} - const cfg = config(); - const key = `${playerSession.actor.fetchId()}:${state.characterId}`; - const lastAt = cooldowns.get(key) || 0; - if (lastAt && Date.now() - lastAt < cfg.remoteChatCooldownMs) { - return Promise.resolve({ - ok: true, - reply: `Give me a moment, I'm still sorting things out.`, - intent: 'none', - reason: 'cooldown' - }); +function activateNearPlayer(playerSession, state) { + const PopulationService = invoke('GameServer/Bot/Population/PopulationService'); + const BotManager = invoke('GameServer/Bot/BotManager'); + const World = invoke('GameServer/World/World'); + const availability = BotAvailability.evaluateState(playerSession, state, { ignoreDistance: true }); + if (!availability.available) { + return Promise.resolve({ ok: false, reason: availability.reason, availability }); + } + + return PopulationService.requestActivation(state, 'remote_chat_come', { + playerLoc: playerLocation(playerSession), + forceNearPlayer: true, + readyOnActivation: true, + recoverOnActivation: true + }).then((activation) => { + if (!activation?.ok) return { ok: false, reason: activation?.reason || 'activation_failed' }; + return World.waitForBotSession(BotManager, state.name, 40).then((targetSession) => { + if (!targetSession) return { ok: false, reason: 'activation_session_timeout' }; + + const ChatArrivalState = invoke('GameServer/Bot/AI/ChatArrivalState'); + ChatArrivalState.start(targetSession, playerSession); + return { ok: true, targetSession, activation }; + }); + }); +} + +function recordReply(playerSession, state, turn, result, extra = {}) { + if (!result?.reply) return Promise.resolve(false); + return BotConversationService.recordBotReply({ + playerSession, + botSession: state, + turnId: turn.turnId, + channel: turn.channel, + text: result.reply, + requestId: turn.turnId, + delivered: true, + meta: { + action: result.action || 'say', + reason: result.reason || null, + providerOutcome: result.providerOutcome || null, + ...extra } - cooldowns.set(key, Date.now()); + }); +} - const availability = BotAvailability.evaluateState(playerSession, state); - return LifeEvents.recentForBot(state.characterId, 5).then((events) => { - const memory = BotSocialMemory.getSnapshot(playerSession, state); - const payload = { - event: 'remote_chat', - playerMessage: String(text || '').slice(0, 240), - player: playerSummary(playerSession), - bot: stateSummary(state), - social: { - relationship: BotSocialMemory.relationship(memory), - trust: memory.trust, - familiarity: memory.familiarity, - groupRuns: memory.groupRuns, - tradesCompleted: memory.tradesCompleted - }, - availability: { - available: availability.available, - reason: availability.reason, - reasonText: availability.reasonText - }, - recentEvents: compactEvents(events), - constraints: { - privateReply: true, - noActivation: true, - noCombatMicromanagement: true, - noInventedFacts: true - } - }; +function replyForStateNow(playerSession, state, text, channel = 'client_tell') { + const cfg = config(); + const availability = BotAvailability.evaluateState(playerSession, state); + const fallback = { + ok: true, + reply: fallbackReply(state, availability, text), + action: 'say', + reason: 'fallback' + }; + const begin = BotConversationService.beginTurn({ + playerSession, + botSession: state, + text, + channel, + source: 'cold_chat' + }); - const fallback = { - ok: true, - reply: fallbackReply(state, availability, text), - intent: 'none', - reason: 'fallback' - }; + return Promise.all([ + begin, + LifeEvents.recentForBot(state.characterId, 5) + ]).then(([turn, events]) => { + const memory = BotSocialMemory.getSnapshot(playerSession, state); + const payload = { + event: 'cold_chat', + playerMessage: String(text || '').slice(0, 240), + player: playerSummary(playerSession), + bot: stateSummary(state), + social: { + relationship: BotSocialMemory.relationship(memory), + trust: memory.trust, + familiarity: memory.familiarity, + groupRuns: memory.groupRuns, + tradesCompleted: memory.tradesCompleted + }, + availability: { + available: availability.available, + reason: availability.reason, + reasonText: availability.reasonText + }, + recentEvents: compactEvents(events), + conversation: turn.context, + constraints: { + privateReply: true, + remainColdUnlessCome: true, + noCombatMicromanagement: true, + noInventedFacts: true + } + }; - const llmReady = cfg.enabled && !!cfg.apiKey; - if (!llmReady) return fallback; + const llmReady = cfg.enabled && !!cfg.apiKey; + if (!llmReady) return recordReply(playerSession, state, turn, fallback).then(() => fallback); - const sessionId = `cold-bot:${state.characterId}:player:${playerSession.actor.fetchId()}`; - return requestLlmReply(payload, cfg, sessionId).then((result) => { - if (result?.providerFailure) { - state.lastRemoteChatTelemetry = result.llmTelemetry || null; - return { - ...fallback, - providerOutcome: result.providerOutcome, - usage: result.usage || null, - llmTelemetry: result.llmTelemetry || null - }; - } - return result || fallback; - }); - }).then((result) => { - BotSocialMemory.recordEvent(playerSession, state, 'chat', result.reason || 'remote_chat'); - return result; + return requestLlmReply(payload, cfg, turn, state, playerSession).then((result) => { + if (result?.providerFailure) { + state.lastRemoteChatTelemetry = result.llmTelemetry || null; + const failed = { + ...fallback, + providerOutcome: result.providerOutcome, + usage: result.usage || null, + llmTelemetry: result.llmTelemetry || null + }; + return recordReply(playerSession, state, turn, failed).then(() => failed); + } + const reply = result || fallback; + if (reply.action === 'come_to_player') { + return activateNearPlayer(playerSession, state).then((actionResult) => { + const confirmed = actionResult.ok; + const actionReply = confirmed + ? reply + : { + ...fallback, + reason: `come_to_player:${actionResult.reason || 'rejected'}`, + providerOutcome: 'action_rejected' + }; + return recordReply(playerSession, state, turn, actionReply, { + actionResult: { ok: confirmed, reason: actionResult.reason || null } + }).then(() => ({ + ...actionReply, + action: confirmed ? 'come_to_player' : 'say', + actionResult: { ok: confirmed, reason: actionResult.reason || null } + })); + }); + } + return recordReply(playerSession, state, turn, reply).then(() => reply); }); + }); +} + +const BotRemoteChat = { + replyForState(playerSession, state, text, channel = 'client_tell') { + if (!playerSession?.actor || !state) { + return Promise.resolve({ ok: false, reason: 'missing_context' }); + } + + const key = `${playerSession.actor.fetchId()}:${state.characterId}`; + return enqueue(key, () => replyForStateNow(playerSession, state, text, channel)) + .then((result) => { + BotSocialMemory.recordEvent(playerSession, state, 'chat', result.reason || 'remote_chat'); + return result; + }); } }; diff --git a/src/GameServer/Bot/AI/ChatArrivalState.js b/src/GameServer/Bot/AI/ChatArrivalState.js new file mode 100644 index 00000000..a24232ef --- /dev/null +++ b/src/GameServer/Bot/AI/ChatArrivalState.js @@ -0,0 +1,85 @@ +const HOLD_DISTANCE = 450; +const DEFAULT_HOLD_MS = 60000; + +function online(session) { + return !!session?.actor?.fetchIsOnline?.(); +} + +function distance2d(first, second) { + if (!first || !second) return Infinity; + const dx = first.fetchLocX() - second.fetchLocX(); + const dy = first.fetchLocY() - second.fetchLocY(); + return Math.sqrt((dx * dx) + (dy * dy)); +} + +function clear(session, reason = 'cleared') { + if (!session) return false; + session.chatArrivalActive = false; + session.chatArrivalTargetSession = null; + session.chatArrivalUntil = 0; + session.chatArrivalLastMoveAt = 0; + session.chatArrivalReason = reason; + return true; +} + +function start(session, targetSession, options = {}) { + if (!session?.actor || !online(targetSession)) return false; + session.chatArrivalActive = true; + session.chatArrivalTargetSession = targetSession; + session.chatArrivalUntil = Date.now() + Math.max(10000, Number(options.holdMs || DEFAULT_HOLD_MS)); + session.chatArrivalLastMoveAt = 0; + session.chatArrivalReason = options.reason || 'remote_chat_come'; + session.currentTargetId = undefined; + session.targetTrackId = undefined; + session.incomingThreatId = undefined; + session.incomingThreatAt = undefined; + session.lastTargetEvaluation = undefined; + session.lastCombatDecision = undefined; + session.actor.unselect?.(); + session.actor.automation?.abortAll?.(session.actor); + return true; +} + +function tick(session, bot) { + if (!session?.chatArrivalActive) return false; + const targetSession = session.chatArrivalTargetSession; + const player = targetSession?.actor; + if (!online(targetSession) || Date.now() >= Number(session.chatArrivalUntil || 0)) { + clear(session, 'expired'); + return false; + } + if (!bot || bot.isDead?.()) return true; + + const distance = distance2d(bot, player); + if (distance > HOLD_DISTANCE) { + const now = Date.now(); + if (now - Number(session.chatArrivalLastMoveAt || 0) >= 1200) { + session.chatArrivalLastMoveAt = now; + bot.moveTo?.({ + from: { + locX: bot.fetchLocX(), + locY: bot.fetchLocY(), + locZ: bot.fetchLocZ() + }, + to: { + locX: player.fetchLocX() + utils.oneFromSpan(-80, 80), + locY: player.fetchLocY() + utils.oneFromSpan(-80, 80), + locZ: player.fetchLocZ() + } + }); + } + return true; + } + + if (bot.state?.inMotion?.()) bot.automation?.abortAll?.(bot); + bot.unselect?.(); + return true; +} + +module.exports = { + HOLD_DISTANCE, + DEFAULT_HOLD_MS, + start, + tick, + clear +}; diff --git a/src/GameServer/Bot/AI/OpenRouterGateway.js b/src/GameServer/Bot/AI/OpenRouterGateway.js index 16cb7efd..8d3e580e 100644 --- a/src/GameServer/Bot/AI/OpenRouterGateway.js +++ b/src/GameServer/Bot/AI/OpenRouterGateway.js @@ -10,6 +10,7 @@ const DEFAULTS = Object.freeze({ timeoutMs: 3500, cooldownMs: 45000, chatCooldownMs: 0, + backgroundInferenceEnabled: false, remoteChatCooldownMs: 10000, visibilityRadius: 6000, maxPromptPrice: 0, @@ -66,6 +67,7 @@ function config(overrides = {}) { timeoutMs: num(optn.timeoutMs, DEFAULTS.timeoutMs), cooldownMs: num(optn.cooldownMs, DEFAULTS.cooldownMs), chatCooldownMs: num(optn.chatCooldownMs, DEFAULTS.chatCooldownMs), + backgroundInferenceEnabled: bool(optn.backgroundInferenceEnabled, DEFAULTS.backgroundInferenceEnabled), remoteChatCooldownMs: num(optn.remoteChatCooldownMs, DEFAULTS.remoteChatCooldownMs), visibilityRadius: num(optn.visibilityRadius, DEFAULTS.visibilityRadius), maxPromptPrice: num(optn.maxPromptPrice, DEFAULTS.maxPromptPrice), @@ -95,6 +97,10 @@ function config(overrides = {}) { timeoutMs: num(overrides.timeoutMs, source.timeoutMs), maxPromptPrice: num(overrides.maxPromptPrice, source.maxPromptPrice), maxCompletionPrice: num(overrides.maxCompletionPrice, source.maxCompletionPrice), + backgroundInferenceEnabled: bool( + overrides.backgroundInferenceEnabled, + source.backgroundInferenceEnabled + ), usageInclude: bool(overrides.usageInclude, source.usageInclude), requireProviderParameters: bool( overrides.requireProviderParameters, diff --git a/src/GameServer/Bot/BotAI.js b/src/GameServer/Bot/BotAI.js index 6c05b8f4..a5edeb7f 100644 --- a/src/GameServer/Bot/BotAI.js +++ b/src/GameServer/Bot/BotAI.js @@ -11,6 +11,7 @@ const TownRespawn = invoke('GameServer/World/TownRespawn'); const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); const BotTradeService = invoke('GameServer/Bot/BotTradeService'); const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); +const ChatArrivalState = invoke('GameServer/Bot/AI/ChatArrivalState'); const CHAT_PHRASES = { foundTarget: [ @@ -179,6 +180,7 @@ const BotAI = { session.pendingBrainTurns = []; session.pendingBrainTurn = null; HotBotPolicyOverlay.clearForCold(session); + ChatArrivalState.clear(session, 'ai_stop'); try { invoke('GameServer/Bot/AI/BotAmbientDirector').cleanup(session, 'ai_stop'); } catch (_) { /* optional ambient module */ } try { invoke('GameServer/Bot/AI/BotInferenceBudget').reset(session); } catch (_) { /* optional budget module */ } if (session.aiTimeout) { @@ -382,7 +384,16 @@ const BotAI = { HotBotPolicyOverlay.get(session); } session.botStatus = BotStatus.getStatus(session); - if (!botDead) maybeTriggerStateChange(session, session.botStatus); + // Autonomous state changes remain owned by the deterministic brain. + // LLM inference is reserved for explicit player communication. + + // A cold bot explicitly asked to come is temporarily held near the + // player. Keep this deterministic and independent from the LLM so the + // normal hunting state cannot immediately overwrite the arrival. + if (!botDead && ChatArrivalState.tick(session, bot)) { + session.botStatus = BotStatus.getStatus(session); + return; + } const isCompanion = !!session.followPlayerSession && session.partyCompanion === true; const World = invoke('GameServer/World/World'); diff --git a/src/GameServer/Bot/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js index 2967f1cd..ac9dba62 100644 --- a/src/GameServer/Bot/Population/PopulationService.js +++ b/src/GameServer/Bot/Population/PopulationService.js @@ -805,6 +805,7 @@ const PopulationService = { const candidates = BotManager.sessions .filter((session) => session.actor && session.accountId && String(session.accountId).startsWith('bot_')) .filter((session) => { + if (session.chatArrivalActive) return false; if (session.plan === 'merchant' && !session.coldMarketState && !session.coldCraftState) return false; // Red-name bots are part of the visible PK population, not // disposable ambient population. Keep them hot until their diff --git a/src/GameServer/Network/Request/Speak.js b/src/GameServer/Network/Request/Speak.js index 335d57e9..93694ad7 100644 --- a/src/GameServer/Network/Request/Speak.js +++ b/src/GameServer/Network/Request/Speak.js @@ -57,19 +57,30 @@ function handlePrivateTell(session, data) { const botSession = BotManager.findSessionByName(target); if (botSession) { session.dataSendToMe(ServerResponse.speak(session.actor, data)); - World.messageBotByName(session, session.actor, target, text, 'client_tell'); - return; + return World.messageBotByName(session, session.actor, target, text, 'client_tell'); } const targetSession = findOnlineUserByName(target); - if (!targetSession) { - session.dataSendToMe(ServerResponse.actionFailed()); - return; + if (targetSession) { + const packet = ServerResponse.speak(session.actor, data); + targetSession.dataSendToMe(packet); + session.dataSendToMe(packet); + return Promise.resolve(true); } - const packet = ServerResponse.speak(session.actor, data); - targetSession.dataSendToMe(packet); - session.dataSendToMe(packet); + // Cold bots have no online actor, but their persistent life-state is still + // a valid private-tell target. Resolve that identity before rejecting the + // tell so the message can enter the same cold LLM dialogue path. + const LifeState = invoke('GameServer/Bot/Population/BotLifeState'); + return LifeState.findByName(target).then((state) => { + if (!state) { + session.dataSendToMe(ServerResponse.actionFailed()); + return false; + } + + session.dataSendToMe(ServerResponse.speak(session.actor, data)); + return World.messageBotByName(session, session.actor, target, text, 'client_tell'); + }); } function consume(session, data) { diff --git a/src/GameServer/World/World.js b/src/GameServer/World/World.js index 5baf54f4..13ea94c6 100644 --- a/src/GameServer/World/World.js +++ b/src/GameServer/World/World.js @@ -53,6 +53,8 @@ function waitForBotSession(BotManager, name, attempts = 40) { } const World = { + waitForBotSession, + init() { this.user = { sessions : [], revision: 0 }; this.npc = { spawns : [], grid: {}, nextId: 1000000 }; diff --git a/tests/test_bot_brain_state_change.js b/tests/test_bot_brain_state_change.js index 92dd3f37..5962cad0 100644 --- a/tests/test_bot_brain_state_change.js +++ b/tests/test_bot_brain_state_change.js @@ -48,6 +48,7 @@ async function main() { model: 'test/state-change', cooldownMs: 0, chatCooldownMs: 0, + backgroundInferenceEnabled: true, hotBotMaxRequestsPerMinute: 5, hotBotPromptTokenBudgetPerMinute: 12000, hotBotCompletionTokenBudgetPerMinute: 2400 @@ -110,6 +111,13 @@ async function main() { false, 'disabled OpenRouter must keep state-change routing inert' ); + options.default.OpenRouter.enabled = true; + options.default.OpenRouter.backgroundInferenceEnabled = false; + assert.strictEqual( + BotBrain.maybeThink(botSession, 'state_change', { available: true }, 'background-disabled probe'), + false, + 'background inference must be opt-in even when OpenRouter is enabled' + ); console.log('Bot brain state-change checks passed'); } finally { Math.random = originalRandom; diff --git a/tests/test_cold_bot_chat.js b/tests/test_cold_bot_chat.js new file mode 100644 index 00000000..b5fbb9ec --- /dev/null +++ b/tests/test_cold_bot_chat.js @@ -0,0 +1,123 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotConversationStore = invoke('GameServer/Bot/AI/BotConversationStore'); +const BotRemoteChat = invoke('GameServer/Bot/AI/BotRemoteChat'); +const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); +const BotManager = invoke('GameServer/Bot/BotManager'); +const PopulationService = invoke('GameServer/Bot/Population/PopulationService'); + +function actor(id, name, x = 0) { + return { + fetchId: () => id, + fetchName: () => name, + fetchLevel: () => 20, + fetchHp: () => 100, + fetchMaxHp: () => 100, + fetchMp: () => 100, + fetchMaxMp: () => 100, + fetchKarma: () => 0, + fetchLocX: () => x, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchIsOnline: () => true + }; +} + +function response(body) { + return { ok: true, status: 200, json: async () => body }; +} + +async function main() { + const originalConfig = options.default.OpenRouter; + const requests = []; + try { + options.default.OpenRouter = { + ...originalConfig, + enabled: true, + apiKey: 'cold-chat-test-key', + model: 'test/cold-chat', + remoteChatCooldownMs: 0 + }; + BotConversationStore.resetMemory(); + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.setTransport(async (_url, init) => { + requests.push(JSON.parse(init.body)); + return response({ + choices: [{ message: { content: JSON.stringify({ + action: 'say', + reply: `reply-${requests.length}`, + reason: 'test_reply', + confidence: 0.95 + }) } }] + }); + }); + + const playerSession = { accountId: 'player_cold', actor: actor(9101, 'ColdPlayer', 100) }; + const state = { + characterId: 9102, + accountName: 'bot_cold_chat', + name: 'ColdChatBot', + level: 20, + phase: 'cold', + activity: 'hunting', + homeRegion: 'Talking Island', + currentRegion: 'Talking Island', + loc: { locX: 1000, locY: 1000, locZ: 0 }, + vitals: { hp: 100, maxHp: 100, mp: 100, maxMp: 100 }, + stats: { generatedIndex: 19 } + }; + + const first = BotRemoteChat.replyForState(playerSession, state, 'hello'); + const second = BotRemoteChat.replyForState(playerSession, state, 'where are you?'); + const results = await Promise.all([first, second]); + + assert.deepStrictEqual(results.map((result) => result.reply), ['reply-1', 'reply-2']); + assert.strictEqual(requests.length, 2, 'every cold tell must reach the provider without a cooldown drop'); + const secondPayload = JSON.parse(requests[1].messages[1].content); + assert.deepStrictEqual( + secondPayload.conversation.recentTurns.map((turn) => turn.text), + ['hello', 'reply-1', 'where are you?'] + ); + assert.strictEqual(requests[0].session_id, 'cold-bot:9102:player:9101'); + assert.strictEqual(requests[0].provider?.require_parameters, true); + + const originalFindSessionByName = BotManager.findSessionByName; + const originalRequestActivation = PopulationService.requestActivation; + const hotSession = { accountId: 'bot_cold_chat', actor: actor(9102, 'ColdChatBot', 180) }; + try { + BotManager.findSessionByName = (name) => String(name).toLowerCase() === 'coldchatbot' ? hotSession : null; + PopulationService.requestActivation = async () => ({ ok: true, reason: 'remote_chat_come' }); + OpenRouterGateway.setTransport(async (_url, init) => { + requests.push(JSON.parse(init.body)); + return response({ + choices: [{ message: { content: JSON.stringify({ + action: 'come_to_player', + reply: 'I am coming over.', + reason: 'explicit_request', + confidence: 0.98 + }) } }] + }); + }); + const arrival = await BotRemoteChat.replyForState(playerSession, state, 'come to me'); + assert.strictEqual(arrival.action, 'come_to_player'); + assert.strictEqual(arrival.actionResult.ok, true); + assert.strictEqual(hotSession.chatArrivalActive, true, 'confirmed cold arrival should enter deterministic hold state'); + } finally { + BotManager.findSessionByName = originalFindSessionByName; + PopulationService.requestActivation = originalRequestActivation; + } + console.log('Cold bot chat checks passed'); + } finally { + options.default.OpenRouter = originalConfig; + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.resetTransport(); + BotConversationStore.resetMemory(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From 4bd5f302096579d6c1ad91d9f7eb15a374d42ba5 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:09:43 -0400 Subject: [PATCH 15/59] Bound bot dialogue context and trace tool outcomes --- src/GameServer/Bot/AI/BotBrain.js | 26 ++-- src/GameServer/Bot/AI/BotContextAssembler.js | 40 ++++-- .../Bot/AI/BotConversationService.js | 35 ++++- .../Bot/AI/BotConversationSummarizer.js | 42 ++++-- src/GameServer/Bot/AI/BotRemoteChat.js | 134 +++++++++++------- src/GameServer/Bot/AI/BotToolRegistry.js | 5 +- tests/test_bot_context_assembler.js | 2 +- 7 files changed, 192 insertions(+), 92 deletions(-) diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 10549f5b..7688382c 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -210,19 +210,18 @@ function schema(allowedActions = BotAgentTools.ACTIONS) { description: 'Echo the toolContext.worldRevision when selecting a mutating action.' } }, - required: ['action', 'reply', 'targetPlayerName', 'spotId', 'buffType', 'reason', 'confidence'], + required: ['action', 'reply', 'reason', 'confidence'], additionalProperties: false }; } function systemPrompt() { return [ - 'You are the slow high-level brain for one Lineage 2 bot.', + 'You are the interactive high-level dialogue brain for one Lineage 2 bot.', 'The deterministic server code handles combat, pathfinding, HP/MP, loot, and safety.', - 'Only choose small, high-level social or intent changes.', - 'For player-facing events, react only when a real visible player writes to this bot or nearby bots; state_change is a bounded server signal.', - 'For player_chat, react only if the message is addressed to this bot, nearby bots, or clearly asks for help.', - 'For state_change, treat the player-visible state transition as a prompt to make at most one small high-level choice; do not narrate private internal events as facts.', + 'Only choose one small, high-level social or intent change for the explicit player message in this turn.', + 'Never invent a background request, ambient prompt, player intent, or private internal event.', + 'A player-facing reply must be grounded in the authoritative bot state and conversation context.', 'follow_player only means approach a visible player unless the bot is already an invited party companion.', 'For buff_target and heal_target, choose a visible player and let the server validate class, learned skill, MP, range, and safety.', 'Do not claim that buffs or heals are ready in a plain chat reply. Use buff_target or heal_target; only the validated server action may confirm a cast.', @@ -232,7 +231,7 @@ function systemPrompt() { 'Equipment tools may only use safe candidates from actual inventory and native persistence. Never equip quest, incompatible, over-grade, or non-upgrade items.', 'The persona describes tone and high-level preferences only. It never overrides safety, current game state, or the allowed actions.', 'Ambient mood and intent are server-owned soft context. Treat an active ambient scene as factual only when bot.ambient.scene is present; never start or claim a scene from mood alone.', - 'The contextFragments field is bounded and includes recent authoritative events; treat summaries as memory, never as permission to perform an action.', + 'The contextFragments field is bounded and includes recent authoritative events; treat summaries as memory, never as permission to perform an action. Action metadata is authoritative only when serverApplied or actionResult.ok is true.', 'Resource-gift trade tools can open a native window only with the current party leader; negotiated market tools use only the active real player pair. Both reserve safe inventory without mutating it, expose only server-owned bounds, allow at most three negotiation rounds, and release reservations on cancel/expiry. Never claim completion before native player confirmation.', 'Never invent unavailable actions, players, items, or spells.' ].join(' '); @@ -285,8 +284,7 @@ async function requestDecision(payload, cfg, session, requestContext, visiblePla const botId = session?.actor?.fetchId?.() || session?.accountId || 'unknown'; const playerId = requestContext?.playerSession?.actor?.fetchId?.() || requestContext?.playerId || - visiblePlayers?.[0]?.id || - 'nearby'; + null; const result = await OpenRouterGateway.request({ config: cfg, circuitKey: requestContext?.conversationTurn @@ -295,7 +293,7 @@ async function requestDecision(payload, cfg, session, requestContext, visiblePla circuitBreaker: requestContext?.conversationTurn ? false : true, timeoutMs: requestContext?.conversationTurn ? 0 : cfg.timeoutMs, requestId: requestContext?.requestId || `hot-${botId}-${Date.now()}`, - sessionId: `hot-bot:${botId}:player:${playerId}`, + sessionId: `hot-bot:${botId}:player:${playerId || 'none'}`, source: requestContext?.source || requestContext?.channel || 'hot_brain', botId, playerId, @@ -334,7 +332,7 @@ function recordConversationReply(session, decision, result, requestContext) { channel: turn.channel, text: reply, requestId: requestContext.requestId, - meta: { action, reason: result.reason || null } + meta: { action, reason: result.reason || null, serverApplied: result.applied === true } }).catch(() => {}); } @@ -625,9 +623,10 @@ const BotBrain = { rememberTelemetry(session, providerResult); if (providerResult?.ok === false) { fallbackReply(session, requestContext, providerResult.reason); - return; + return providerResult; } - applyDecision(session, providerResult, visiblePlayers, requestContext); + const applied = applyDecision(session, providerResult, visiblePlayers, requestContext); + return { ...providerResult, applied }; } catch (err) { providerResult = { ok: false, @@ -637,6 +636,7 @@ const BotBrain = { recordInferenceEvent(session, event, providerResult, requestContext); utils.infoWarn('BotBrain', 'decision request failed for %s: %s', bot.fetchName(), err.message); fallbackReply(session, requestContext, 'provider_error'); + return providerResult; } finally { finishTurn(); } diff --git a/src/GameServer/Bot/AI/BotContextAssembler.js b/src/GameServer/Bot/AI/BotContextAssembler.js index 6812c618..d96a7ba9 100644 --- a/src/GameServer/Bot/AI/BotContextAssembler.js +++ b/src/GameServer/Bot/AI/BotContextAssembler.js @@ -19,12 +19,31 @@ function trimText(value, max) { } function compactRecentTurns(turns, max = 8) { - return (turns || []).slice(-max).map((turn) => ({ - role: turn.role, - channel: turn.channel, - text: trimText(turn.text, 240), - createdAt: turn.createdAt - })); + return (turns || []).slice(-max).map((turn) => { + const compact = { + role: turn.role, + channel: turn.channel, + text: trimText(turn.text, 240), + createdAt: turn.createdAt + }; + const meta = compactTurnMeta(turn.meta); + if (meta) compact.meta = meta; + return compact; + }); +} + +function compactTurnMeta(meta = {}) { + const result = {}; + ['action', 'reason', 'providerOutcome', 'serverApplied'].forEach((key) => { + if (meta[key] !== undefined && meta[key] !== null) result[key] = meta[key]; + }); + if (meta.actionResult && typeof meta.actionResult === 'object') { + result.actionResult = { + ok: meta.actionResult.ok === true, + reason: meta.actionResult.reason || null + }; + } + return Object.keys(result).length ? result : null; } function compactJournal(events, max = 10) { @@ -85,7 +104,6 @@ async function assemble(input = {}) { const conversation = requestContext.conversation || null; const fragments = [ - { id: 'bot_state', priority: 100, value: bot }, { id: 'conversation_summary', priority: 95, @@ -104,7 +122,9 @@ async function assemble(input = {}) { ].filter((fragment) => fragment.value !== null && fragment.value !== undefined); const selected = []; - let used = 0; + // `bot` is a canonical payload field (rather than a duplicated fragment), + // but it still consumes the same prompt budget. + let used = estimateTokens(bot); fragments.sort((a, b) => b.priority - a.priority).forEach((fragment) => { const cost = estimateTokens(fragment.value); if (used + cost <= budget) { @@ -126,14 +146,14 @@ async function assemble(input = {}) { // The status is kept as a separate compatibility field, but the bounded // fragments are the canonical prompt input. Never let a malformed fixture // or a long player name break the hard cap. - let serializedCost = estimateTokens(selected.map((fragment) => ({ id: fragment.id, value: fragment.value }))); + let serializedCost = estimateTokens({ bot, fragments: selected.map((fragment) => ({ id: fragment.id, value: fragment.value })) }); if (serializedCost > hardMaxTokens) { const overflow = serializedCost - hardMaxTokens; const last = selected[selected.length - 1]; if (last) { const fitted = fitFragment(last, Math.max(48, estimateTokens(last.value) - overflow)); selected[selected.length - 1] = fitted; - serializedCost = estimateTokens(selected.map((fragment) => ({ id: fragment.id, value: fragment.value }))); + serializedCost = estimateTokens({ bot, fragments: selected.map((fragment) => ({ id: fragment.id, value: fragment.value })) }); } } diff --git a/src/GameServer/Bot/AI/BotConversationService.js b/src/GameServer/Bot/AI/BotConversationService.js index 8b0b517c..cb36d2df 100644 --- a/src/GameServer/Bot/AI/BotConversationService.js +++ b/src/GameServer/Bot/AI/BotConversationService.js @@ -29,6 +29,22 @@ function normalizeText(value) { return String(value || '').replace(/\s+/g, ' ').trim().slice(0, BotConversationStore.MAX_TEXT_CHARS); } +function conversationMeta(meta) { + if (!meta || typeof meta !== 'object') return null; + const compact = {}; + ['action', 'reason', 'providerOutcome'].forEach((key) => { + if (meta[key]) compact[key] = meta[key]; + }); + if (meta.serverApplied === true) compact.serverApplied = true; + if (meta.actionResult && typeof meta.actionResult === 'object') { + compact.actionResult = { + ok: meta.actionResult.ok === true, + reason: meta.actionResult.reason || null + }; + } + return Object.keys(compact).length ? compact : null; +} + function validPair(playerSession, botSession) { return !!( playerSession?.actor && @@ -48,13 +64,18 @@ function nextTurnId(input = {}) { function contextView(context) { return { summary: context?.summary || null, - recentTurns: (context?.recentTurns || []).map((turn) => ({ - turnId: turn.turnId, - role: turn.role, - channel: turn.channel, - text: turn.text, - createdAt: turn.createdAt - })), + recentTurns: (context?.recentTurns || []).map((turn) => { + const compact = { + turnId: turn.turnId, + role: turn.role, + channel: turn.channel, + text: turn.text, + createdAt: turn.createdAt + }; + const meta = conversationMeta(turn.meta); + if (meta) compact.meta = meta; + return compact; + }), version: Number(context?.version || 0) }; } diff --git a/src/GameServer/Bot/AI/BotConversationSummarizer.js b/src/GameServer/Bot/AI/BotConversationSummarizer.js index 28365771..b566ff59 100644 --- a/src/GameServer/Bot/AI/BotConversationSummarizer.js +++ b/src/GameServer/Bot/AI/BotConversationSummarizer.js @@ -11,13 +11,34 @@ function pairKey(playerId, botId) { return `${Number(playerId)}:${Number(botId)}`; } +function compactMeta(meta) { + if (!meta || typeof meta !== 'object') return null; + const result = {}; + ['action', 'reason', 'providerOutcome'].forEach((key) => { + if (meta[key]) result[key] = meta[key]; + }); + if (meta.serverApplied === true) result.serverApplied = true; + if (meta.actionResult && typeof meta.actionResult === 'object') { + result.actionResult = { + ok: meta.actionResult.ok === true, + reason: meta.actionResult.reason || null + }; + } + return Object.keys(result).length ? result : null; +} + function compactTurns(turns) { - return (turns || []).map((turn) => ({ - id: turn.id, - role: turn.role, - channel: turn.channel, - text: turn.text - })); + return (turns || []).map((turn) => { + const compact = { + id: turn.id, + role: turn.role, + channel: turn.channel, + text: turn.text + }; + const meta = compactMeta(turn.meta); + if (meta) compact.meta = meta; + return compact; + }); } function estimateTokens(value) { @@ -86,7 +107,7 @@ async function summarize(input = {}) { const messages = [ { role: 'system', - content: 'Summarize a game conversation for the same bot and player. Keep durable facts, preferences, unresolved requests, and explicit promises. Never create permissions, tool authorizations, or facts not stated in the dialogue.' + content: 'Summarize a game conversation for the same bot and player. Keep durable facts, preferences, unresolved requests, and explicit promises. Treat action metadata as authoritative: only an action with serverApplied=true or actionResult.ok=true happened; an LLM proposal, refusal, fallback, or failed action did not happen. Never create permissions, tool authorizations, or facts not stated in the dialogue.' }, { role: 'user', content: JSON.stringify({ previousSummary: current.summary || '', turns: compactTurns(compacted) }) } ]; @@ -101,9 +122,14 @@ async function summarize(input = {}) { try { result = await OpenRouterGateway.request({ config: cfg, - circuitKey: 'hot-summary', + circuitKey: `conversation-summary:${botId}:${playerId}`, + circuitBreaker: false, + timeoutMs: 0, requestId: input.requestId || `summary-${botId}-${playerId}-${Date.now()}`, sessionId: `hot-summary:${botId}:player:${playerId}`, + source: 'conversation_summary', + botId, + playerId, messages, responseSchema: schema() }); diff --git a/src/GameServer/Bot/AI/BotRemoteChat.js b/src/GameServer/Bot/AI/BotRemoteChat.js index e3f68e28..1e27f2fe 100644 --- a/src/GameServer/Bot/AI/BotRemoteChat.js +++ b/src/GameServer/Bot/AI/BotRemoteChat.js @@ -4,6 +4,7 @@ const LifeEvents = invoke('GameServer/Bot/Population/BotLifeEvents'); const BotPersona = invoke('GameServer/Bot/AI/BotPersona'); const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); const BotConversationService = invoke('GameServer/Bot/AI/BotConversationService'); +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); // A player can send several tells while the provider is answering. Keep the // pair ordered so each request sees the previous answer in conversation @@ -224,29 +225,43 @@ function playerLocation(playerSession) { } function activateNearPlayer(playerSession, state) { - const PopulationService = invoke('GameServer/Bot/Population/PopulationService'); - const BotManager = invoke('GameServer/Bot/BotManager'); - const World = invoke('GameServer/World/World'); - const availability = BotAvailability.evaluateState(playerSession, state, { ignoreDistance: true }); - if (!availability.available) { - return Promise.resolve({ ok: false, reason: availability.reason, availability }); - } + const run = () => { + const PopulationService = invoke('GameServer/Bot/Population/PopulationService'); + const BotManager = invoke('GameServer/Bot/BotManager'); + const World = invoke('GameServer/World/World'); + const availability = BotAvailability.evaluateState(playerSession, state, { ignoreDistance: true }); + if (!availability.available) { + return Promise.resolve({ ok: false, reason: availability.reason, availability }); + } - return PopulationService.requestActivation(state, 'remote_chat_come', { - playerLoc: playerLocation(playerSession), - forceNearPlayer: true, - readyOnActivation: true, - recoverOnActivation: true - }).then((activation) => { - if (!activation?.ok) return { ok: false, reason: activation?.reason || 'activation_failed' }; - return World.waitForBotSession(BotManager, state.name, 40).then((targetSession) => { - if (!targetSession) return { ok: false, reason: 'activation_session_timeout' }; + return PopulationService.requestActivation(state, 'remote_chat_come', { + playerLoc: playerLocation(playerSession), + forceNearPlayer: true, + readyOnActivation: true, + recoverOnActivation: true + }).then((activation) => { + if (!activation?.ok) return { ok: false, reason: activation?.reason || 'activation_failed' }; + return World.waitForBotSession(BotManager, state.name, 40).then((targetSession) => { + if (!targetSession) return { ok: false, reason: 'activation_session_timeout' }; - const ChatArrivalState = invoke('GameServer/Bot/AI/ChatArrivalState'); - ChatArrivalState.start(targetSession, playerSession); - return { ok: true, targetSession, activation }; + const ChatArrivalState = invoke('GameServer/Bot/AI/ChatArrivalState'); + ChatArrivalState.start(targetSession, playerSession); + return { ok: true, targetSession, activation }; + }); }); - }); + }; + return LangfuseTracing.withObservation( + 'bot.tool.come_to_player', + { player: playerSummary(playerSession), bot: stateSummary(state), playerLoc: playerLocation(playerSession) }, + { + source: 'cold_chat', + tool: 'come_to_player', + botId: state.characterId, + playerId: playerSession.actor.fetchId() + }, + run, + 'tool' + ); } function recordReply(playerSession, state, turn, result, extra = {}) { @@ -317,42 +332,57 @@ function replyForStateNow(playerSession, state, text, channel = 'client_tell') { } }; - const llmReady = cfg.enabled && !!cfg.apiKey; - if (!llmReady) return recordReply(playerSession, state, turn, fallback).then(() => fallback); + return LangfuseTracing.withObservation( + 'cold-bot.dialogue', + payload, + { + event: 'cold_chat', + source: 'cold_chat', + botId: state.characterId, + playerId: playerSession.actor.fetchId(), + turnId: turn.turnId, + requestId: turn.turnId + }, + async () => { + const llmReady = cfg.enabled && !!cfg.apiKey; + if (!llmReady) return recordReply(playerSession, state, turn, fallback).then(() => fallback); - return requestLlmReply(payload, cfg, turn, state, playerSession).then((result) => { - if (result?.providerFailure) { - state.lastRemoteChatTelemetry = result.llmTelemetry || null; - const failed = { - ...fallback, - providerOutcome: result.providerOutcome, - usage: result.usage || null, - llmTelemetry: result.llmTelemetry || null - }; - return recordReply(playerSession, state, turn, failed).then(() => failed); - } - const reply = result || fallback; - if (reply.action === 'come_to_player') { - return activateNearPlayer(playerSession, state).then((actionResult) => { - const confirmed = actionResult.ok; - const actionReply = confirmed - ? reply - : { + return requestLlmReply(payload, cfg, turn, state, playerSession).then((result) => { + if (result?.providerFailure) { + state.lastRemoteChatTelemetry = result.llmTelemetry || null; + const failed = { ...fallback, - reason: `come_to_player:${actionResult.reason || 'rejected'}`, - providerOutcome: 'action_rejected' + providerOutcome: result.providerOutcome, + usage: result.usage || null, + llmTelemetry: result.llmTelemetry || null }; - return recordReply(playerSession, state, turn, actionReply, { - actionResult: { ok: confirmed, reason: actionResult.reason || null } - }).then(() => ({ - ...actionReply, - action: confirmed ? 'come_to_player' : 'say', - actionResult: { ok: confirmed, reason: actionResult.reason || null } - })); + return recordReply(playerSession, state, turn, failed).then(() => failed); + } + const reply = result || fallback; + if (reply.action === 'come_to_player') { + return activateNearPlayer(playerSession, state).then((actionResult) => { + const confirmed = actionResult.ok; + const actionReply = confirmed + ? reply + : { + ...fallback, + reason: `come_to_player:${actionResult.reason || 'rejected'}`, + providerOutcome: 'action_rejected' + }; + return recordReply(playerSession, state, turn, actionReply, { + actionResult: { ok: confirmed, reason: actionResult.reason || null } + }).then(() => ({ + ...actionReply, + action: confirmed ? 'come_to_player' : 'say', + actionResult: { ok: confirmed, reason: actionResult.reason || null } + })); + }); + } + return recordReply(playerSession, state, turn, reply).then(() => reply); }); - } - return recordReply(playerSession, state, turn, reply).then(() => reply); - }); + }, + 'agent' + ); }); } diff --git a/src/GameServer/Bot/AI/BotToolRegistry.js b/src/GameServer/Bot/AI/BotToolRegistry.js index 7939bcc4..0abed8b9 100644 --- a/src/GameServer/Bot/AI/BotToolRegistry.js +++ b/src/GameServer/Bot/AI/BotToolRegistry.js @@ -107,11 +107,14 @@ function availableNames(session = null) { } function audit(context, outcome, reason, meta = {}) { + const argumentsForTrace = { ...(context.decision || {}) }; + delete argumentsForTrace.usage; + delete argumentsForTrace.llmTelemetry; const observation = LangfuseTracing.startObservation( `bot.tool.${text(context.decision?.action || 'unknown', 64)}`, { action: context.decision?.action || null, - arguments: context.decision || null, + arguments: argumentsForTrace, expectedWorldRevision: context.expectedWorldRevision || null }, { diff --git a/tests/test_bot_context_assembler.js b/tests/test_bot_context_assembler.js index 3b6c94cf..18c26d5a 100644 --- a/tests/test_bot_context_assembler.js +++ b/tests/test_bot_context_assembler.js @@ -35,7 +35,7 @@ async function main() { } } }); - assert(assembled.fragments.some((fragment) => fragment.id === 'bot_state')); + assert(assembled.bot?.name === 'BoundedBot', 'authoritative bot state should remain a canonical payload field'); assert(assembled.fragments.some((fragment) => fragment.id === 'recent_dialogue')); assert(assembled.fragments.some((fragment) => fragment.id === 'authoritative_events')); assert(assembled.telemetry.skillIntent, 'skill intent should include the skill fragment path'); From 77267413ad04944932f48e4191f4f10cb90c256d Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:13:15 -0400 Subject: [PATCH 16/59] Refresh queued dialogue context before inference --- src/GameServer/Bot/AI/BotBrain.js | 56 +++++++-- tests/test_hot_conversation_history_queue.js | 115 +++++++++++++++++++ 2 files changed, 162 insertions(+), 9 deletions(-) create mode 100644 tests/test_hot_conversation_history_queue.js diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 7688382c..ba9970ba 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -1,5 +1,7 @@ const SpotService = invoke('GameServer/Bot/AI/SpotService'); const BotBrainContext = invoke('GameServer/Bot/AI/BotBrainContext'); +const BotContextAssembler = invoke('GameServer/Bot/AI/BotContextAssembler'); +const BotConversationService = invoke('GameServer/Bot/AI/BotConversationService'); const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); @@ -325,7 +327,7 @@ function recordConversationReply(session, decision, result, requestContext) { .slice(0, BotChatText.DEFAULT_LINE_LIMIT * BotChatText.DEFAULT_MAX_LINES); if (!reply) return; - invoke('GameServer/Bot/AI/BotConversationService').recordBotReply({ + queueConversationWrite(session, () => BotConversationService.recordBotReply({ playerSession: requestContext.playerSession, botSession: session, turnId: turn.turnId, @@ -333,7 +335,14 @@ function recordConversationReply(session, decision, result, requestContext) { text: reply, requestId: requestContext.requestId, meta: { action, reason: result.reason || null, serverApplied: result.applied === true } - }).catch(() => {}); + })); +} + +function queueConversationWrite(session, work) { + const previous = session.lastConversationWrite || Promise.resolve(); + const next = previous.catch(() => {}).then(work).catch(() => false); + session.lastConversationWrite = next; + return next; } function applyDecision(session, decision, visiblePlayers, requestContext) { @@ -351,14 +360,14 @@ function applyDecision(session, decision, visiblePlayers, requestContext) { const reply = BotAgentTools.rejectionReply(result); BotManager.botTell(session, requestContext.playerSession, reply); if (requestContext.conversationTurn) { - invoke('GameServer/Bot/AI/BotConversationService').recordFallback({ + queueConversationWrite(session, () => BotConversationService.recordFallback({ playerSession: requestContext.playerSession, botSession: session, turnId: requestContext.conversationTurn.turnId, channel: requestContext.conversationTurn.channel, text: reply, reason: `tool_rejected:${result.reason}` - }).catch(() => {}); + })); } } return result.applied; @@ -428,14 +437,14 @@ function fallbackReply(session, requestContext, outcome) { BotManager.botTell(session, playerSession, reply); if (requestContext?.conversationTurn) { - invoke('GameServer/Bot/AI/BotConversationService').recordFallback({ + queueConversationWrite(session, () => BotConversationService.recordFallback({ playerSession, botSession: session, turnId: requestContext.conversationTurn.turnId, channel: requestContext.conversationTurn.channel, text: reply, reason: outcome || 'fallback' - }).catch(() => {}); + })); } return true; } @@ -604,16 +613,45 @@ const BotBrain = { const pending = queue.shift() || null; session.pendingBrainTurn = queue[0] || null; if (pending) { - Promise.resolve().then(() => { + const startPending = () => Promise.resolve().then(async () => { + let requestContext = { ...pending.requestContext, queued: true }; + // The player turn is persisted before admission, but the + // previous bot reply may finish while this request waits + // in the FIFO. Refresh the bounded context at dequeue so + // the next prompt sees the latest delivered turn. + if (pending.event === 'player_chat' && requestContext.playerSession) { + try { + const fresh = await BotConversationService.contextFor( + requestContext.playerSession, + session + ); + const previousCount = requestContext.conversation?.recentTurns?.length || 0; + if ((fresh?.recentTurns?.length || 0) >= previousCount) { + requestContext.conversation = fresh; + requestContext.assembledContext = await BotContextAssembler.assemble({ + session, + status: pending.status, + text: pending.text, + requestContext + }); + } + } catch (_) { + // Keep the ingress snapshot if persistence is + // temporarily unavailable. + } + } const started = BotBrain.maybeThink( session, pending.event, pending.status, pending.text, - { ...pending.requestContext, queued: true } + requestContext ); - if (!started) fallbackReply(session, pending.requestContext, 'queued_not_started'); + if (!started) fallbackReply(session, requestContext, 'queued_not_started'); }); + Promise.resolve(session.lastConversationWrite) + .catch(() => {}) + .then(startPending); } }; const runTurn = async () => { diff --git a/tests/test_hot_conversation_history_queue.js b/tests/test_hot_conversation_history_queue.js new file mode 100644 index 00000000..fed61132 --- /dev/null +++ b/tests/test_hot_conversation_history_queue.js @@ -0,0 +1,115 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotConversationStore = invoke('GameServer/Bot/AI/BotConversationStore'); +const BotDialogueArbiter = invoke('GameServer/Bot/AI/BotDialogueArbiter'); +const BotContextAssembler = invoke('GameServer/Bot/AI/BotContextAssembler'); +const BotAI = invoke('GameServer/Bot/BotAI'); +const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); +const World = invoke('GameServer/World/World'); + +function actor(id, name, x = 0) { + return { + fetchId: () => id, + fetchName: () => name, + fetchLevel: () => 20, + fetchHp: () => 100, + fetchMaxHp: () => 100, + fetchMp: () => 100, + fetchMaxMp: () => 100, + fetchKarma: () => 0, + fetchLocX: () => x, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchDestId: () => 0, + fetchIsOnline: () => true, + isDead: () => false + }; +} + +function response(body) { + return { ok: true, status: 200, json: async () => body }; +} + +async function main() { + const originalConfig = options.default.OpenRouter; + const originalWorldUser = World.user; + const originalWorldNpc = World.npc; + const originalFetchVisibleUsers = World.fetchVisibleUsers; + const originalStatus = BotAI.getStatus; + const originalAssemble = BotContextAssembler.assemble; + const requests = []; + let releaseFirst; + + try { + options.default.OpenRouter = { + ...originalConfig, + enabled: true, + apiKey: 'hot-history-test-key', + model: 'test/hot-history', + cooldownMs: 0, + chatCooldownMs: 0 + }; + BotConversationStore.resetMemory(); + const playerSession = { + accountId: 'player_history', + actor: actor(9201, 'HistoryPlayer'), + dataSendToMe() {} + }; + const botSession = { accountId: 'bot_history', actor: actor(9202, 'HistoryBot', 100), plan: 'hunting' }; + World.user = { sessions: [playerSession, botSession] }; + World.npc = { spawns: [] }; + World.fetchVisibleUsers = () => [playerSession]; + BotAI.getStatus = () => ({ available: true, name: 'HistoryBot', mode: 'hunting', level: 20 }); + BotContextAssembler.assemble = async () => ({ bot: {}, fragments: [], telemetry: {} }); + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.setTransport(async (_url, init) => { + requests.push(JSON.parse(init.body)); + if (requests.length === 1) await new Promise((resolve) => { releaseFirst = resolve; }); + return response({ + choices: [{ message: { content: JSON.stringify({ + action: 'none', + reply: requests.length === 1 ? 'first reply' : 'second reply', + reason: 'history_test', + confidence: 0.95 + }) } }] + }); + }); + + const first = BotDialogueArbiter.route({ playerSession, botSession, text: 'first message', channel: 'client_tell' }); + for (let i = 0; i < 20 && requests.length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + const second = BotDialogueArbiter.route({ playerSession, botSession, text: 'second message', channel: 'client_tell' }); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.strictEqual(requests.length, 1, 'second tell should wait in FIFO while the first provider call is active'); + releaseFirst(); + await Promise.all([first, second]); + await new Promise((resolve) => setTimeout(resolve, 30)); + + assert.strictEqual(requests.length, 2); + const secondPayload = JSON.parse(requests[1].messages[1].content); + assert.deepStrictEqual( + secondPayload.conversation.recentTurns.map((turn) => turn.text), + ['first message', 'second message', 'first reply'] + ); + console.log('Hot conversation queue history checks passed'); + } finally { + releaseFirst?.(); + options.default.OpenRouter = originalConfig; + World.user = originalWorldUser; + World.npc = originalWorldNpc; + World.fetchVisibleUsers = originalFetchVisibleUsers; + BotAI.getStatus = originalStatus; + BotContextAssembler.assemble = originalAssemble; + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.resetTransport(); + BotConversationStore.resetMemory(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From e2057278d36c86a1f60dc113829f67066d71bb9f Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:15:44 -0400 Subject: [PATCH 17/59] Include dialogue regressions in the test suite --- scripts/run-tests.js | 2 ++ src/GameServer/Bot/AI/BotConversationService.js | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 8c80d222..77f043fb 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -12,6 +12,7 @@ const tests = [ 'tests/test_bot_conversation_store.js', 'tests/test_bot_dialogue_arbiter.js', 'tests/test_hot_bot_conversation_flow.js', + 'tests/test_hot_conversation_history_queue.js', 'tests/test_bot_activity_journal.js', 'tests/test_bot_context_assembler.js', 'tests/test_bot_conversation_summary.js', @@ -56,6 +57,7 @@ const tests = [ 'tests/test_bot_spot_risk_baseline.js', 'tests/test_bot_persona_party_decision.js', 'tests/test_bot_remote_chat_persona.js', + 'tests/test_cold_bot_chat.js', 'tests/test_bot_friendship.js', 'tests/test_bot_goal_planner.js', 'tests/test_bot_goal_market_priority.js', diff --git a/src/GameServer/Bot/AI/BotConversationService.js b/src/GameServer/Bot/AI/BotConversationService.js index cb36d2df..f6b6d6b9 100644 --- a/src/GameServer/Bot/AI/BotConversationService.js +++ b/src/GameServer/Bot/AI/BotConversationService.js @@ -50,7 +50,7 @@ function validPair(playerSession, botSession) { playerSession?.actor && actorId(playerSession) > 0 && actorId(botSession) > 0 && - !String(playerSession.accountId || '').startsWith('bot_') && + !String(playerSession.accountId || '').toLowerCase().startsWith('bot_') && isBotIdentity(botSession) ); } From 07ca94eeddb2e79f743e82f8e8c636cafefee3df Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:17:46 -0400 Subject: [PATCH 18/59] Always route explicit hot tells to the LLM --- src/GameServer/Bot/AI/BotBrain.js | 8 ++++---- tests/test_bot_brain_state_change.js | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index ba9970ba..38a8b813 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -9,7 +9,7 @@ const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); const BotLLMTurnStore = invoke('GameServer/Bot/AI/BotLLMTurnStore'); const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); -const ALLOWED_PLANS = ['hunting', 'following', 'resting', 'shopping', 'pk_hunting', 'merchant']; +const ALLOWED_PLANS = ['hunting', 'following', 'resting', 'shopping', 'pk_hunting', 'merchant', 'getting_buffed']; const ALLOWED_EVENTS = new Set(['player_chat', 'state_change']); function config() { return OpenRouterGateway.config({ maxTokens: 320 }); @@ -494,7 +494,7 @@ const BotBrain = { debugSkip(session, cfg, 'request_in_flight'); return false; } - if (bot.isDead && bot.isDead()) { + if (event !== 'player_chat' && bot.isDead && bot.isDead()) { debugSkip(session, cfg, 'dead'); return false; } @@ -502,11 +502,11 @@ const BotBrain = { debugSkip(session, cfg, 'merchant_plan'); return false; } - if (session.plan === 'getting_buffed') { + if (event !== 'player_chat' && session.plan === 'getting_buffed') { debugSkip(session, cfg, 'refreshing_buffs'); return false; } - if (!ALLOWED_PLANS.includes(session.plan || 'hunting')) { + if (event !== 'player_chat' && !ALLOWED_PLANS.includes(session.plan || 'hunting')) { debugSkip(session, cfg, `plan_not_allowed:${session.plan}`); return false; } diff --git a/tests/test_bot_brain_state_change.js b/tests/test_bot_brain_state_change.js index 5962cad0..193d61a2 100644 --- a/tests/test_bot_brain_state_change.js +++ b/tests/test_bot_brain_state_change.js @@ -118,6 +118,28 @@ async function main() { false, 'background inference must be opt-in even when OpenRouter is enabled' ); + + // An explicit tell is still an interactive LLM turn while the hot bot + // is dead or in a transient deterministic plan. + options.default.OpenRouter.backgroundInferenceEnabled = false; + botSession.plan = 'fleeing'; + botSession.actor.isDead = () => true; + const beforeChat = requests.length; + assert.strictEqual( + BotBrain.maybeThink(botSession, 'player_chat', { + available: true, + mode: 'fleeing', + level: 20, + vitals: { hpPct: 0, mpPct: 0 } + }, + 'Are you alive?', + { playerSession } + ), + true, + 'player chat must enter the LLM path even for a dead/transient hot bot' + ); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.strictEqual(requests.length, beforeChat + 1); console.log('Bot brain state-change checks passed'); } finally { Math.random = originalRandom; From 50ced5c5ab379ef0d771b9915ac22b2977a6403c Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:29:59 -0400 Subject: [PATCH 19/59] Fix LLM trace roots and outcome statuses --- scripts/run-tests.js | 1 + src/GameServer/Bot/AI/BotBrain.js | 44 ++++++++++++++++--- src/GameServer/Bot/AI/BotRemoteChat.js | 5 ++- src/GameServer/Bot/AI/BotToolRegistry.js | 5 ++- src/GameServer/Bot/AI/LangfuseTracing.js | 56 ++++++++++++++++++++++-- tests/test_langfuse_tracing.js | 32 ++++++++++++++ 6 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 tests/test_langfuse_tracing.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 77f043fb..ed8c4a5b 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -7,6 +7,7 @@ const tests = [ 'tests/test_armor_stats.js', 'tests/test_bot_ai_visibility.js', 'tests/test_openrouter_gateway.js', + 'tests/test_langfuse_tracing.js', 'tests/test_bot_inference_budget.js', 'tests/test_bot_brain_state_change.js', 'tests/test_bot_conversation_store.js', diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 38a8b813..943123e1 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -318,6 +318,22 @@ async function requestDecision(payload, cfg, session, requestContext, visiblePla }; } +function conversationSessionId(session, requestContext) { + const botId = session?.actor?.fetchId?.() || session?.accountId || 'unknown'; + const playerId = requestContext?.playerSession?.actor?.fetchId?.() || + requestContext?.playerId || 'none'; + return `hot-bot:${botId}:player:${playerId}`; +} + +function compactActionResult(result) { + if (!result) return null; + return { + ok: result.applied === true, + reason: result.reason || null, + idempotent: result.idempotent === true + }; +} + function recordConversationReply(session, decision, result, requestContext) { const turn = requestContext?.conversationTurn; const action = decision?.action; @@ -370,7 +386,7 @@ function applyDecision(session, decision, visiblePlayers, requestContext) { })); } } - return result.applied; + return result; } function rememberTelemetry(session, result) { @@ -446,7 +462,7 @@ function fallbackReply(session, requestContext, outcome) { reason: outcome || 'fallback' })); } - return true; + return reply; } const BotBrain = { @@ -663,8 +679,23 @@ const BotBrain = { fallbackReply(session, requestContext, providerResult.reason); return providerResult; } - const applied = applyDecision(session, providerResult, visiblePlayers, requestContext); - return { ...providerResult, applied }; + const actionResult = applyDecision(session, providerResult, visiblePlayers, requestContext); + const playerVisibleReply = actionResult.applied + ? providerResult.reply || null + : BotAgentTools.rejectionReply(actionResult); + providerResult = { + ...providerResult, + applied: actionResult.applied === true, + actionResult: compactActionResult(actionResult), + traceOutput: { + providerOutcome: providerResult.llmTelemetry?.outcome || providerResult.telemetry?.outcome || 'success', + requestedAction: providerResult.action || null, + toolOutcome: compactActionResult(actionResult), + applied: actionResult.applied === true, + playerVisibleReply + } + }; + return providerResult; } catch (err) { providerResult = { ok: false, @@ -679,7 +710,7 @@ const BotBrain = { finishTurn(); } }; - LangfuseTracing.withObservation( + LangfuseTracing.withRootObservation( 'hot-bot.dialogue', payload, { @@ -688,7 +719,8 @@ const BotBrain = { playerId: requestContext?.playerSession?.actor?.fetchId?.() || requestContext?.playerId || null, turnId, requestId: requestContext?.requestId || turnId, - source: requestContext?.source || event + source: requestContext?.source || event, + sessionId: conversationSessionId(session, requestContext) }, runTurn, 'agent' diff --git a/src/GameServer/Bot/AI/BotRemoteChat.js b/src/GameServer/Bot/AI/BotRemoteChat.js index 1e27f2fe..fac2613e 100644 --- a/src/GameServer/Bot/AI/BotRemoteChat.js +++ b/src/GameServer/Bot/AI/BotRemoteChat.js @@ -332,7 +332,7 @@ function replyForStateNow(playerSession, state, text, channel = 'client_tell') { } }; - return LangfuseTracing.withObservation( + return LangfuseTracing.withRootObservation( 'cold-bot.dialogue', payload, { @@ -341,7 +341,8 @@ function replyForStateNow(playerSession, state, text, channel = 'client_tell') { botId: state.characterId, playerId: playerSession.actor.fetchId(), turnId: turn.turnId, - requestId: turn.turnId + requestId: turn.turnId, + sessionId: `cold-bot:${Number(state.characterId || 0)}:player:${playerSession.actor.fetchId()}` }, async () => { const llmReady = cfg.enabled && !!cfg.apiKey; diff --git a/src/GameServer/Bot/AI/BotToolRegistry.js b/src/GameServer/Bot/AI/BotToolRegistry.js index 0abed8b9..734711e5 100644 --- a/src/GameServer/Bot/AI/BotToolRegistry.js +++ b/src/GameServer/Bot/AI/BotToolRegistry.js @@ -126,7 +126,10 @@ function audit(context, outcome, reason, meta = {}) { }, 'tool' ); - observation?.end({ outcome, reason, ...meta }); + const status = outcome === 'rejected' + ? LangfuseTracing.observationStatus({ applied: false, reason }) + : {}; + observation?.end({ outcome, reason, ...meta }, status); BotToolAudit.record({ playerId: playerId(context), botId: actorId(context.session), diff --git a/src/GameServer/Bot/AI/LangfuseTracing.js b/src/GameServer/Bot/AI/LangfuseTracing.js index 442ce8df..b597ffef 100644 --- a/src/GameServer/Bot/AI/LangfuseTracing.js +++ b/src/GameServer/Bot/AI/LangfuseTracing.js @@ -7,6 +7,8 @@ let tracing = null; let initialized = false; let initError = null; let envCache = { filename: null, values: {} }; +let otelContext = null; +let rootContext = null; function bool(value, fallback = false) { if (value === undefined || value === null || value === '') return fallback; @@ -105,6 +107,35 @@ function observationOutput(value, cfg) { return { captured: false, type: typeof value }; } +function observationStatus(value) { + const telemetry = value?.llmTelemetry || value?.telemetry || {}; + const outcome = String( + value?.traceOutcome || value?.outcome || value?.reason || telemetry.outcome || '' + ).toLowerCase(); + const reason = String(value?.actionResult?.reason || '').toLowerCase(); + + if (value?.ok === false || [ + 'schema_error', 'provider_error', 'timeout', 'circuit_open', 'missing_api_key', + 'disabled', 'transport_error' + ].includes(outcome)) { + return { + level: 'ERROR', + statusMessage: text(value?.reason || telemetry.statusMessage || outcome || 'failed', 240) + }; + } + if (value?.applied === false || outcome === 'stale_world_state' || reason === 'stale_world_state') { + return { + level: 'WARNING', + statusMessage: text(value?.reason || reason || outcome || 'action_rejected', 240) + }; + } + return {}; +} + +function traceOutput(value) { + return value?.traceOutput === undefined ? value : value.traceOutput; +} + function updateObservation(observation, attributes) { if (typeof observation?.update === 'function') observation.update(attributes); } @@ -122,6 +153,9 @@ function init(overrides = {}) { try { const { NodeSDK } = require('@opentelemetry/sdk-node'); const { LangfuseSpanProcessor } = require('@langfuse/otel'); + const api = require('@opentelemetry/api'); + otelContext = api.context; + rootContext = api.ROOT_CONTEXT; tracing = require('@langfuse/tracing'); processor = new LangfuseSpanProcessor({ publicKey: cfg.publicKey, @@ -166,7 +200,7 @@ function withObservation(name, input, metadata, work, asType = 'span') { try { tracing.propagateAttributes?.({ userId: text(metadata?.playerId, 200) || undefined, - sessionId: text(metadata?.turnId || metadata?.sessionId, 200) || undefined, + sessionId: text(metadata?.sessionId || metadata?.turnId, 200) || undefined, traceName: text(name, 200), metadata: Object.fromEntries(Object.entries(metadata || {}) .filter(([, value]) => value !== undefined && value !== null) @@ -178,7 +212,10 @@ function withObservation(name, input, metadata, work, asType = 'span') { updateObservation(observation, { input: observationInput(input, cfg), metadata: serializable(metadata || {}) }); try { const result = await work(observation); - updateObservation(observation, { output: observationOutput(result, cfg) }); + updateObservation(observation, { + output: observationOutput(traceOutput(result), cfg), + ...observationStatus(result) + }); return result; } catch (error) { updateObservation(observation, { @@ -191,6 +228,11 @@ function withObservation(name, input, metadata, work, asType = 'span') { }, { asType, endOnExit: true }); } +function withRootObservation(name, input, metadata, work, asType = 'span') { + if (!otelContext?.with || !rootContext) return withObservation(name, input, metadata, work, asType); + return otelContext.with(rootContext, () => withObservation(name, input, metadata, work, asType)); +} + function startObservation(name, input, metadata, asType = 'span') { if (!initialized || !tracing?.startObservation) return null; const cfg = config(); @@ -206,8 +248,12 @@ function startObservation(name, input, metadata, asType = 'span') { output: value.output === undefined ? undefined : observationOutput(value.output, cfg) }); }, - end(value) { - if (value !== undefined) observation.update({ output: observationOutput(value, cfg) }); + end(value, status = {}) { + const attributes = {}; + if (value !== undefined) attributes.output = observationOutput(value, cfg); + if (status.level) attributes.level = status.level; + if (status.statusMessage) attributes.statusMessage = text(status.statusMessage, 240); + if (Object.keys(attributes).length > 0) observation.update(attributes); observation.end(); }, traceId: observation.traceId, @@ -238,7 +284,9 @@ module.exports = { init, status, withObservation, + withRootObservation, startObservation, + observationStatus, activeTraceId, shutdown }; diff --git a/tests/test_langfuse_tracing.js b/tests/test_langfuse_tracing.js new file mode 100644 index 00000000..167ed46d --- /dev/null +++ b/tests/test_langfuse_tracing.js @@ -0,0 +1,32 @@ +const assert = require('assert'); + +require('../src/Global'); + +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); + +function main() { + assert.deepStrictEqual( + LangfuseTracing.observationStatus({ ok: false, reason: 'schema_error' }), + { level: 'ERROR', statusMessage: 'schema_error' } + ); + assert.deepStrictEqual( + LangfuseTracing.observationStatus({ ok: true, applied: false, reason: 'stale_world_state' }), + { level: 'WARNING', statusMessage: 'stale_world_state' } + ); + assert.deepStrictEqual( + LangfuseTracing.observationStatus({ ok: true, applied: true, reason: 'say' }), + {} + ); + assert.deepStrictEqual( + LangfuseTracing.observationStatus({ outcome: 'provider_error' }), + { level: 'ERROR', statusMessage: 'provider_error' } + ); + console.log('Langfuse tracing checks passed'); +} + +try { + main(); +} catch (error) { + console.error(error); + process.exitCode = 1; +} From 3ffda3ae8fdbe0193ca01993fab68c7b3f98275a Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:32:09 -0400 Subject: [PATCH 20/59] Preserve FIFO order in queued bot dialogue --- src/GameServer/Bot/AI/BotBrain.js | 45 ++++++++++++++++---- tests/test_hot_conversation_history_queue.js | 2 +- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 943123e1..16ab7fbd 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -334,6 +334,27 @@ function compactActionResult(result) { }; } +function orderedConversation(conversation) { + if (!conversation?.recentTurns?.length) return conversation; + const groups = new Map(); + conversation.recentTurns.forEach((turn, index) => { + const key = turn.turnId || `anonymous:${index}`; + const group = groups.get(key) || { firstIndex: index, turns: [] }; + group.turns.push({ turn, index }); + groups.set(key, group); + }); + const recentTurns = [...groups.values()] + .sort((left, right) => left.firstIndex - right.firstIndex) + .flatMap((group) => group.turns + .sort((left, right) => { + const leftRole = left.turn.role === 'player' ? 0 : 1; + const rightRole = right.turn.role === 'player' ? 0 : 1; + return leftRole - rightRole || left.index - right.index; + }) + .map(({ turn }) => turn)); + return { ...conversation, recentTurns }; +} + function recordConversationReply(session, decision, result, requestContext) { const turn = requestContext?.conversationTurn; const action = decision?.action; @@ -533,6 +554,8 @@ const BotBrain = { return false; } + if (requestContext && !requestContext.enqueuedAt) requestContext.enqueuedAt = Date.now(); + const cooldown = cfg.cooldownMs; const lastAt = session.lastBrainThinkAt; if (event !== 'player_chat' && lastAt && requestContext?.queued !== true && Date.now() - lastAt < cooldown) { @@ -635,7 +658,7 @@ const BotBrain = { // previous bot reply may finish while this request waits // in the FIFO. Refresh the bounded context at dequeue so // the next prompt sees the latest delivered turn. - if (pending.event === 'player_chat' && requestContext.playerSession) { + if (pending.event === 'player_chat' && requestContext.playerSession) { try { const fresh = await BotConversationService.contextFor( requestContext.playerSession, @@ -644,17 +667,20 @@ const BotBrain = { const previousCount = requestContext.conversation?.recentTurns?.length || 0; if ((fresh?.recentTurns?.length || 0) >= previousCount) { requestContext.conversation = fresh; - requestContext.assembledContext = await BotContextAssembler.assemble({ - session, - status: pending.status, - text: pending.text, - requestContext - }); } } catch (_) { // Keep the ingress snapshot if persistence is // temporarily unavailable. } + requestContext.conversation = orderedConversation(requestContext.conversation); + if (requestContext.assembledContext) { + requestContext.assembledContext = await BotContextAssembler.assemble({ + session, + status: pending.status, + text: pending.text, + requestContext + }); + } } const started = BotBrain.maybeThink( session, @@ -720,7 +746,10 @@ const BotBrain = { turnId, requestId: requestContext?.requestId || turnId, source: requestContext?.source || event, - sessionId: conversationSessionId(session, requestContext) + sessionId: conversationSessionId(session, requestContext), + queueWaitMs: requestContext?.enqueuedAt + ? Math.max(0, Date.now() - requestContext.enqueuedAt) + : 0 }, runTurn, 'agent' diff --git a/tests/test_hot_conversation_history_queue.js b/tests/test_hot_conversation_history_queue.js index fed61132..1dd4003f 100644 --- a/tests/test_hot_conversation_history_queue.js +++ b/tests/test_hot_conversation_history_queue.js @@ -92,7 +92,7 @@ async function main() { const secondPayload = JSON.parse(requests[1].messages[1].content); assert.deepStrictEqual( secondPayload.conversation.recentTurns.map((turn) => turn.text), - ['first message', 'second message', 'first reply'] + ['first message', 'first reply', 'second message'] ); console.log('Hot conversation queue history checks passed'); } finally { From 6dcf16ffb5b9fa2dc2d39456945a1bf86a2487be Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:37:17 -0400 Subject: [PATCH 21/59] Relax freshness checks for safe bot dialogue actions --- src/GameServer/Bot/AI/BotToolRegistry.js | 20 +++++++++++++++----- tests/test_bot_tool_registry.js | 18 +++++++++++++++++- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/GameServer/Bot/AI/BotToolRegistry.js b/src/GameServer/Bot/AI/BotToolRegistry.js index 734711e5..cb9f134d 100644 --- a/src/GameServer/Bot/AI/BotToolRegistry.js +++ b/src/GameServer/Bot/AI/BotToolRegistry.js @@ -3,6 +3,9 @@ const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); const definitions = new Map(); +const SOFT_FRESHNESS_ACTIONS = new Set([ + 'none', 'say', 'follow_player', 'stay_here', 'hunt', 'rest' +]); function text(value, max = 160) { return String(value || '').replace(/\s+/g, ' ').trim().slice(0, max); @@ -71,6 +74,10 @@ function isPkLocked(session, action) { ]).has(action); } +function requiresFreshWorld(action) { + return !SOFT_FRESHNESS_ACTIONS.has(String(action || '')); +} + function isAvailable(definition, session) { if (typeof definition.available !== 'function') return true; return definition.available(session) !== false; @@ -122,14 +129,15 @@ function audit(context, outcome, reason, meta = {}) { playerId: playerId(context), turnId: turnId(context), outcome, - reason + reason, + phase: outcome === 'requested' ? 'request' : 'result' }, 'tool' ); const status = outcome === 'rejected' ? LangfuseTracing.observationStatus({ applied: false, reason }) : {}; - observation?.end({ outcome, reason, ...meta }, status); + observation?.end({ outcome, reason, phase: outcome === 'requested' ? 'request' : 'result', ...meta }, status); BotToolAudit.record({ playerId: playerId(context), botId: actorId(context.session), @@ -159,7 +167,8 @@ function execute(context = {}) { audit({ ...context, decision: { ...decision, action } }, 'requested', 'requested', { expectedRevision, - currentRevision + currentRevision, + freshness: requiresFreshWorld(action) ? 'strict' : 'soft' }); if (!definition) { @@ -177,11 +186,12 @@ function execute(context = {}) { audit({ ...context, decision: { ...decision, action } }, 'rejected', rejected.reason); return rejected; } - if (expectedRevision && expectedRevision !== currentRevision) { + if (expectedRevision && expectedRevision !== currentRevision && requiresFreshWorld(action)) { const rejected = result(false, 'stale_world_state'); audit({ ...context, decision: { ...decision, action } }, 'rejected', rejected.reason, { expectedRevision, - currentRevision + currentRevision, + freshness: 'strict' }); return rejected; } diff --git a/tests/test_bot_tool_registry.js b/tests/test_bot_tool_registry.js index c3dd96d8..06bfbcf7 100644 --- a/tests/test_bot_tool_registry.js +++ b/tests/test_bot_tool_registry.js @@ -51,7 +51,23 @@ function main() { const secondMutation = BotAgentTools.execute(session, decision('rest', 'turn-1'), [], context('turn-1')); assert.deepStrictEqual(secondMutation, { applied: false, reason: 'one_mutation_per_turn' }); - const audit = BotToolAudit.recent({ botId: 200, limit: 10 }); + const softFreshness = BotAgentTools.execute( + session, + { ...decision('say', 'turn-2'), reply: 'hello' }, + [], + { ...context('turn-2'), worldRevision: 'obsolete-before-llm' } + ); + assert.strictEqual(softFreshness.applied, true, 'low-risk dialogue must not fail on a moved world revision'); + + const strictFreshness = BotAgentTools.execute( + session, + decision('move_to_spot', 'turn-3'), + [], + { ...context('turn-3'), worldRevision: 'obsolete-before-llm' } + ); + assert.deepStrictEqual(strictFreshness, { applied: false, reason: 'stale_world_state' }); + + const audit = BotToolAudit.recent({ botId: 200, limit: 20 }); assert(audit.some((event) => event.outcome === 'requested')); assert(audit.some((event) => event.outcome === 'applied')); assert(audit.some((event) => event.reason === 'one_mutation_per_turn')); From 4ceb77331235922c05b666ab9ab71e86d87c7aed Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:37:21 -0400 Subject: [PATCH 22/59] Repair truncated structured bot responses --- src/GameServer/Bot/AI/BotBrain.js | 5 ++- src/GameServer/Bot/AI/BotRemoteChat.js | 3 +- src/GameServer/Bot/AI/OpenRouterGateway.js | 51 ++++++++++++++++++++-- tests/test_openrouter_gateway.js | 29 ++++++++++++ 4 files changed, 82 insertions(+), 6 deletions(-) diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 16ab7fbd..cb2ce6c1 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -12,7 +12,7 @@ const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); const ALLOWED_PLANS = ['hunting', 'following', 'resting', 'shopping', 'pk_hunting', 'merchant', 'getting_buffed']; const ALLOWED_EVENTS = new Set(['player_chat', 'state_change']); function config() { - return OpenRouterGateway.config({ maxTokens: 320 }); + return OpenRouterGateway.config({ maxTokens: 640 }); } function debugSkip(session, cfg, reason) { @@ -278,7 +278,8 @@ function estimateRequestPromptTokens(payload, session) { responseSchema: { name: 'bot_brain_decision', schema: schema(BotAgentTools.availableActions(session)) - } + }, + repairSchema: true }); } diff --git a/src/GameServer/Bot/AI/BotRemoteChat.js b/src/GameServer/Bot/AI/BotRemoteChat.js index fac2613e..0bb4e258 100644 --- a/src/GameServer/Bot/AI/BotRemoteChat.js +++ b/src/GameServer/Bot/AI/BotRemoteChat.js @@ -185,7 +185,8 @@ async function requestLlmReply(payload, cfg, turn, state, playerSession) { responseSchema: { name: 'bot_remote_chat', schema: schema() - } + }, + repairSchema: true }); if (!result.ok) { return { diff --git a/src/GameServer/Bot/AI/OpenRouterGateway.js b/src/GameServer/Bot/AI/OpenRouterGateway.js index 8d3e580e..c8bea94f 100644 --- a/src/GameServer/Bot/AI/OpenRouterGateway.js +++ b/src/GameServer/Bot/AI/OpenRouterGateway.js @@ -194,7 +194,40 @@ function telemetry(request, cfg, outcome, startedAt, extra = {}) { finishReason: extra.finishReason || null, providerRequestId: extra.providerRequestId || null, rawContent: extra.rawContent || null, - responsePreview: extra.responsePreview || null + responsePreview: extra.responsePreview || null, + attempts: Number(extra.attempts || 1), + repairTriggered: extra.repairTriggered === true, + initialOutcome: extra.initialOutcome || null, + initialRawContent: extra.initialRawContent || null + }; +} + +function shouldRepairSchema(spec, result) { + return spec.repairSchema === true && + spec.responseSchema?.schema && + result?.reason === 'schema_error' && + spec.schemaRepairAttempt !== true; +} + +function repairConfig(spec) { + const current = Number(spec.config?.maxTokens || config().maxTokens); + return { + ...(spec.config || {}), + maxTokens: Math.min(640, Math.max(512, current * 2)) + }; +} + +function repairedResult(initial, repaired) { + const telemetry = repaired?.telemetry || {}; + return { + ...repaired, + telemetry: { + ...telemetry, + attempts: 2, + repairTriggered: true, + initialOutcome: initial?.reason || null, + initialRawContent: initial?.telemetry?.rawContent || null + } }; } @@ -417,14 +450,26 @@ async function request(spec = {}) { input, metadata, async (observation) => { - const result = await requestUntraced(spec); + let result = await requestUntraced(spec); + if (shouldRepairSchema(spec, result)) { + const repaired = await requestUntraced({ + ...spec, + config: repairConfig(spec), + schemaRepairAttempt: true, + circuitBreaker: false + }); + result = repairedResult(result, repaired); + } if (observation && result?.telemetry) { const usage = result.usage || result.telemetry.usage || {}; + const effectiveMaxTokens = result.telemetry.repairTriggered + ? repairConfig(spec).maxTokens + : Number((spec.config || {}).maxTokens ?? config().maxTokens); observation.update({ model: result.telemetry.model || null, modelParameters: { temperature: Number((spec.config || {}).temperature ?? config().temperature), - max_tokens: Number((spec.config || {}).maxTokens ?? config().maxTokens) + max_tokens: effectiveMaxTokens }, usageDetails: { input: Number(usage.promptTokens || 0), diff --git a/tests/test_openrouter_gateway.js b/tests/test_openrouter_gateway.js index a7456c3f..af766c36 100644 --- a/tests/test_openrouter_gateway.js +++ b/tests/test_openrouter_gateway.js @@ -82,6 +82,35 @@ async function main() { assert.strictEqual(captured.body.response_format.json_schema.strict, true); assert.deepStrictEqual(captured.body.response_format.json_schema.schema, schema.schema); + OpenRouterGateway.resetCircuit(); + const repairBodies = []; + OpenRouterGateway.setTransport(async (_url, init) => { + repairBodies.push(JSON.parse(init.body)); + if (repairBodies.length === 1) { + return response({ + choices: [{ message: { content: '{"reply":', finish_reason: 'length' } }] + }); + } + return response({ + choices: [{ message: { content: JSON.stringify({ reply: 'repaired' }) } }], + usage: { prompt_tokens: 11, completion_tokens: 4, total_tokens: 15 } + }); + }); + const repaired = await OpenRouterGateway.request({ + config: { ...baseConfig, maxTokens: 320 }, + requestId: 'gateway-repair', + messages: [{ role: 'user', content: 'repair me' }], + responseSchema: schema, + repairSchema: true + }); + assert.strictEqual(repaired.ok, true); + assert.deepStrictEqual(repaired.data, { reply: 'repaired' }); + assert.strictEqual(repairBodies[0].max_tokens, 320); + assert.strictEqual(repairBodies[1].max_tokens, 640); + assert.strictEqual(repaired.telemetry.attempts, 2); + assert.strictEqual(repaired.telemetry.repairTriggered, true); + assert.strictEqual(repaired.telemetry.initialRawContent, '{"reply":'); + OpenRouterGateway.resetCircuit(); OpenRouterGateway.setTransport(async () => response({ error: { message: 'unavailable' } }, 503)); for (let index = 0; index < 3; index += 1) { From 70749cf3bb265ae91634a883ff08fcc49b0fd080 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:37:27 -0400 Subject: [PATCH 23/59] Account mandatory chat usage and refresh ambient state --- src/GameServer/Bot/AI/BotAmbientDirector.js | 11 ++++++++++- src/GameServer/Bot/AI/BotInferenceBudget.js | 17 ++++++++++++----- tests/test_bot_ambient_director.js | 6 ++++++ tests/test_bot_inference_budget.js | 12 ++++++++++++ 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/GameServer/Bot/AI/BotAmbientDirector.js b/src/GameServer/Bot/AI/BotAmbientDirector.js index 66e35d2c..ee3b08cf 100644 --- a/src/GameServer/Bot/AI/BotAmbientDirector.js +++ b/src/GameServer/Bot/AI/BotAmbientDirector.js @@ -5,6 +5,7 @@ const BotPersona = invoke('GameServer/Bot/AI/BotPersona'); const DEFAULT_SCENE_COOLDOWN_MS = 3 * 60 * 1000; const DEFAULT_PAIR_COOLDOWN_MS = 90 * 1000; const DEFAULT_SCENE_TTL_MS = 8 * 1000; +const DEFAULT_STATE_TTL_MS = 5 * 1000; const MAX_REASON_CHARS = 120; const MOODS = Object.freeze(['calm', 'focused', 'sociable', 'restless', 'tired', 'guarded']); @@ -109,7 +110,10 @@ function deriveMood(session) { } function snapshot(session, now = Date.now()) { - const current = session?.ambientState || refresh(session, now); + const previous = session?.ambientState; + const current = !previous || now - Number(previous.updatedAt || 0) >= stateTtlMs() + ? refresh(session, now) + : previous; const scene = session?.ambientScene; return { mood: MOODS.includes(current.mood) ? current.mood : 'calm', @@ -159,6 +163,10 @@ function sceneTtlMs() { return Math.max(2500, number(config().ambientSceneTtlMs, DEFAULT_SCENE_TTL_MS)); } +function stateTtlMs() { + return Math.max(1000, number(config().ambientStateTtlMs, DEFAULT_STATE_TTL_MS)); +} + function pairKey(first, second) { return [actorId(first), actorId(second)].sort((a, b) => a - b).join(':'); } @@ -302,6 +310,7 @@ const BotAmbientDirector = { DEFAULT_SCENE_COOLDOWN_MS, DEFAULT_PAIR_COOLDOWN_MS, DEFAULT_SCENE_TTL_MS, + DEFAULT_STATE_TTL_MS, enabled, deriveMood, refresh, diff --git a/src/GameServer/Bot/AI/BotInferenceBudget.js b/src/GameServer/Bot/AI/BotInferenceBudget.js index 5f482817..19998cd7 100644 --- a/src/GameServer/Bot/AI/BotInferenceBudget.js +++ b/src/GameServer/Bot/AI/BotInferenceBudget.js @@ -45,6 +45,10 @@ function knownCost(bucket) { return bucket.entries.reduce((total, entry) => total + (Number.isFinite(Number(entry.cost)) ? Number(entry.cost) : 0), 0); } +function bypassedCount(bucket) { + return bucket.entries.reduce((total, entry) => total + (entry.bypass === true ? 1 : 0), 0); +} + function rejection(bucket, reason, now, retryAfterMs = 0) { bucket.lastDeniedAt = now; bucket.lastDeniedReason = reason; @@ -61,7 +65,7 @@ function reserve(session, input = {}) { if (!id) return { ok: false, reason: 'missing_bot' }; const cfg = config(); - if (cfg.hotBotBudgetEnabled === false || input.bypass === true) { + if (cfg.hotBotBudgetEnabled === false) { return { ok: true, bypassed: true, reservation: null, status: status(session) }; } @@ -74,15 +78,15 @@ function reserve(session, input = {}) { const promptTokens = nonNegative(input.estimatedPromptTokens); const completionTokens = nonNegative(input.maxCompletionTokens ?? cfg.maxTokens); - if (bucket.entries.length >= maxRequests) { + if (input.bypass !== true && bucket.entries.length >= maxRequests) { const oldest = bucket.entries[0]; return rejection(bucket, 'inference_budget_requests', now, oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS); } - if (sum(bucket, 'promptTokens') + promptTokens > promptBudget) { + if (input.bypass !== true && sum(bucket, 'promptTokens') + promptTokens > promptBudget) { const oldest = bucket.entries[0]; return rejection(bucket, 'inference_budget_prompt_tokens', now, oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS); } - if (sum(bucket, 'completionTokens') + completionTokens > completionBudget) { + if (input.bypass !== true && sum(bucket, 'completionTokens') + completionTokens > completionBudget) { const oldest = bucket.entries[0]; return rejection(bucket, 'inference_budget_completion_tokens', now, oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS); } @@ -96,11 +100,12 @@ function reserve(session, input = {}) { reservedPromptTokens: promptTokens, reservedCompletionTokens: completionTokens, settled: false, + bypass: input.bypass === true, event: String(input.event || 'hot_decision').slice(0, 48), priority: String(input.priority || 'normal').slice(0, 24) }; bucket.entries.push(reservation); - return { ok: true, reservation, status: status(session, now) }; + return { ok: true, bypassed: input.bypass === true, reservation, status: status(session, now) }; } function reserveForBotId(botId, input = {}) { @@ -127,6 +132,7 @@ function status(session, now = Date.now()) { enabled: cfg.hotBotBudgetEnabled !== false, windowMs: WINDOW_MS, requests: 0, + bypassedRequests: 0, maxRequests: Math.max(1, Math.floor(number(cfg.hotBotMaxRequestsPerMinute, 6))), promptTokens: 0, promptBudget: Math.max(240, number(cfg.hotBotPromptTokenBudgetPerMinute, 12000)), @@ -153,6 +159,7 @@ function status(session, now = Date.now()) { enabled: cfg.hotBotBudgetEnabled !== false, windowMs: WINDOW_MS, requests: bucket.entries.length, + bypassedRequests: bypassedCount(bucket), maxRequests, promptTokens, promptBudget, diff --git a/tests/test_bot_ambient_director.js b/tests/test_bot_ambient_director.js index d442fb73..00a52ae8 100644 --- a/tests/test_bot_ambient_director.js +++ b/tests/test_bot_ambient_director.js @@ -85,6 +85,12 @@ assert.strictEqual(BotAmbientDirector.deriveMood(lowVitals).mood, 'tired', 'low const commerce = bot(2000106, 'Merchant', { plan: 'merchant', activeTrade: { id: 'trade-1' } }); assert.strictEqual(BotAmbientDirector.deriveMood(commerce).mood, 'focused', 'commerce should keep mood focused'); +const staleAmbient = bot(2000110, 'FreshMood'); +staleAmbient.ambientState = { mood: 'tired', intent: 'recover', reason: 'old_snapshot', updatedAt: now }; +const refreshedAmbient = BotAmbientDirector.snapshot(staleAmbient, now + BotAmbientDirector.DEFAULT_STATE_TTL_MS + 1); +assert.strictEqual(refreshedAmbient.mood, 'sociable', 'ambient mood must refresh after its TTL'); +assert.strictEqual(refreshedAmbient.reason, 'social_persona'); + const staleA = bot(2000107, 'StaleA'); const staleB = bot(2000108, 'StaleB'); const stale = BotAmbientDirector.start(staleA, staleB, now + 400000); diff --git a/tests/test_bot_inference_budget.js b/tests/test_bot_inference_budget.js index 3d384102..0c11aa03 100644 --- a/tests/test_bot_inference_budget.js +++ b/tests/test_bot_inference_budget.js @@ -44,6 +44,18 @@ try { assert.strictEqual(mid.completionTokens, 50); assert.strictEqual(mid.remainingRequests, 0); + const mandatoryChat = BotInferenceBudget.reserve(bot, { + event: 'player_chat', + bypass: true, + estimatedPromptTokens: 999, + maxCompletionTokens: 999, + now: 3000 + }); + assert.strictEqual(mandatoryChat.ok, true, 'explicit player chat must remain admissible over the soft quota'); + assert.strictEqual(mandatoryChat.bypassed, true); + BotInferenceBudget.settle(mandatoryChat.reservation, { promptTokens: 30, completionTokens: 12 }); + assert.strictEqual(BotInferenceBudget.status(bot, 3000).bypassedRequests, 1); + const requestDenied = BotInferenceBudget.reserve(bot, { estimatedPromptTokens: 1, maxCompletionTokens: 1, From 3b6b84f37fe26aecb327de061bd8af1e1baa026e Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:43:56 -0400 Subject: [PATCH 24/59] Add dialogue stages and deterministic party replies --- scripts/run-tests.js | 1 + src/GameServer/Bot/AI/BotBrain.js | 131 +++++++++++++++++++-- src/GameServer/Bot/AI/BotRemoteChat.js | 59 +++++++--- src/GameServer/Bot/AI/LangfuseTracing.js | 2 +- src/GameServer/Bot/AI/OpenRouterGateway.js | 24 +++- tests/test_bot_llm_party_policy.js | 63 ++++++++++ tests/test_openrouter_gateway.js | 4 +- 7 files changed, 258 insertions(+), 26 deletions(-) create mode 100644 tests/test_bot_llm_party_policy.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index ed8c4a5b..263ae245 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -10,6 +10,7 @@ const tests = [ 'tests/test_langfuse_tracing.js', 'tests/test_bot_inference_budget.js', 'tests/test_bot_brain_state_change.js', + 'tests/test_bot_llm_party_policy.js', 'tests/test_bot_conversation_store.js', 'tests/test_bot_dialogue_arbiter.js', 'tests/test_hot_bot_conversation_flow.js', diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index cb2ce6c1..17af40e3 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -8,6 +8,7 @@ const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); const BotLLMTurnStore = invoke('GameServer/Bot/AI/BotLLMTurnStore'); const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); +const BotAvailability = invoke('GameServer/Bot/AI/BotAvailability'); const ALLOWED_PLANS = ['hunting', 'following', 'resting', 'shopping', 'pk_hunting', 'merchant', 'getting_buffed']; const ALLOWED_EVENTS = new Set(['player_chat', 'state_change']); @@ -356,6 +357,55 @@ function orderedConversation(conversation) { return { ...conversation, recentTurns }; } +function validateDecisionResult(result, session) { + if (result?.ok === false) return result; + const action = String(result?.action || ''); + const allowed = BotAgentTools.availableActions(session); + if (!action || !allowed.includes(action) || typeof result?.reply !== 'string') { + return { + ...result, + ok: false, + reason: 'schema_error', + telemetry: { + ...(result?.llmTelemetry || result?.telemetry || {}), + outcome: 'schema_error', + validation: 'decision_shape' + } + }; + } + return result; +} + +function isPartyRequest(text) { + return /\b(party|group|team|join|invite)\b|пати|групп|команд|присоедин/i.test(String(text || '')); +} + +function applyPartyPolicy(session, decision, requestContext, text) { + if (!isPartyRequest(text) || !requestContext?.playerSession) return decision; + if (session.partyCompanion === true && session.followPlayerSession === requestContext.playerSession) { + return { + ...decision, + action: 'say', + reply: 'I am already with you. Let me know what you need.', + reason: 'party_policy:already_grouped', + confidence: Math.max(0.9, Number(decision.confidence || 0)) + }; + } + + const availability = BotAvailability.evaluate(requestContext.playerSession, session); + const reply = availability.available + ? 'I am open to a party. Send me an invite and I will decide in the moment.' + : `I cannot join right now: ${availability.reasonText || availability.reason || 'not available'}.`; + return { + ...decision, + action: 'say', + reply, + targetPlayerName: requestContext.playerSession.actor.fetchName?.() || decision.targetPlayerName || '', + reason: `party_policy:${availability.reason || 'available'}`, + confidence: Math.max(0.9, Number(decision.confidence || 0)) + }; +} + function recordConversationReply(session, decision, result, requestContext) { const turn = requestContext?.conversationTurn; const action = decision?.action; @@ -378,7 +428,17 @@ function recordConversationReply(session, decision, result, requestContext) { function queueConversationWrite(session, work) { const previous = session.lastConversationWrite || Promise.resolve(); - const next = previous.catch(() => {}).then(work).catch(() => false); + const persist = () => LangfuseTracing.withObservation( + 'bot.conversation.persist', + { botId: session?.actor?.fetchId?.() || session?.accountId || null }, + { + botId: session?.actor?.fetchId?.() || session?.accountId || null, + source: 'hot_dialogue' + }, + work, + 'chain' + ); + const next = previous.catch(() => {}).then(persist).catch(() => false); session.lastConversationWrite = next; return next; } @@ -493,6 +553,8 @@ const BotBrain = { return cfg.enabled && !!cfg.apiKey; }, + applyPartyPolicy, + visibleRealPlayers, maybeThink(session, event, status, text = '', requestContext = null) { @@ -659,7 +721,7 @@ const BotBrain = { // previous bot reply may finish while this request waits // in the FIFO. Refresh the bounded context at dequeue so // the next prompt sees the latest delivered turn. - if (pending.event === 'player_chat' && requestContext.playerSession) { + if (pending.event === 'player_chat' && requestContext.playerSession) { try { const fresh = await BotConversationService.contextFor( requestContext.playerSession, @@ -699,14 +761,62 @@ const BotBrain = { }; const runTurn = async () => { try { - providerResult = await requestDecision(payload, cfg, session, requestContext, visiblePlayers); - recordInferenceEvent(session, event, providerResult, requestContext); + const stageMetadata = { + botId: bot.fetchId?.(), + playerId: requestContext?.playerSession?.actor?.fetchId?.() || requestContext?.playerId || null, + turnId, + requestId: requestContext?.requestId || turnId, + sessionId: conversationSessionId(session, requestContext) + }; + LangfuseTracing.withObservation( + 'bot.context.assemble', + { + fragments: requestContext?.assembledContext?.telemetry?.included || [], + estimatedTokens: requestContext?.assembledContext?.estimatedTokens || null + }, + stageMetadata, + async () => ({ + included: requestContext?.assembledContext?.telemetry?.included || [], + estimatedTokens: requestContext?.assembledContext?.estimatedTokens || null + }), + 'chain' + ).catch(() => {}); + providerResult = await LangfuseTracing.withObservation( + 'bot.schema.validate', + { event, requestedAction: null }, + stageMetadata, + async () => validateDecisionResult( + await requestDecision(payload, cfg, session, requestContext, visiblePlayers), + session + ), + 'chain' + ); rememberTelemetry(session, providerResult); if (providerResult?.ok === false) { - fallbackReply(session, requestContext, providerResult.reason); + recordInferenceEvent(session, event, providerResult, requestContext); + const playerVisibleReply = fallbackReply(session, requestContext, providerResult.reason); + providerResult = { + ...providerResult, + applied: false, + traceOutput: { + providerOutcome: providerResult.reason || providerResult.telemetry?.outcome || 'provider_error', + requestedAction: null, + toolOutcome: null, + applied: false, + playerVisibleReply: playerVisibleReply || null + } + }; return providerResult; } - const actionResult = applyDecision(session, providerResult, visiblePlayers, requestContext); + providerResult = applyPartyPolicy(session, providerResult, requestContext, text); + recordInferenceEvent(session, event, providerResult, requestContext); + const actionResult = await LangfuseTracing.withObservation( + 'bot.reply.deliver', + { action: providerResult.action || null, reply: providerResult.reply || null }, + stageMetadata, + async () => applyDecision(session, providerResult, visiblePlayers, requestContext), + 'chain' + ); const playerVisibleReply = actionResult.applied ? providerResult.reply || null : BotAgentTools.rejectionReply(actionResult); @@ -731,7 +841,14 @@ const BotBrain = { }; recordInferenceEvent(session, event, providerResult, requestContext); utils.infoWarn('BotBrain', 'decision request failed for %s: %s', bot.fetchName(), err.message); - fallbackReply(session, requestContext, 'provider_error'); + const playerVisibleReply = fallbackReply(session, requestContext, 'provider_error'); + providerResult.traceOutput = { + providerOutcome: 'provider_error', + requestedAction: null, + toolOutcome: null, + applied: false, + playerVisibleReply: playerVisibleReply || null + }; return providerResult; } finally { finishTurn(); diff --git a/src/GameServer/Bot/AI/BotRemoteChat.js b/src/GameServer/Bot/AI/BotRemoteChat.js index 0bb4e258..09979814 100644 --- a/src/GameServer/Bot/AI/BotRemoteChat.js +++ b/src/GameServer/Bot/AI/BotRemoteChat.js @@ -267,21 +267,33 @@ function activateNearPlayer(playerSession, state) { function recordReply(playerSession, state, turn, result, extra = {}) { if (!result?.reply) return Promise.resolve(false); - return BotConversationService.recordBotReply({ - playerSession, - botSession: state, - turnId: turn.turnId, - channel: turn.channel, - text: result.reply, - requestId: turn.turnId, - delivered: true, - meta: { - action: result.action || 'say', - reason: result.reason || null, - providerOutcome: result.providerOutcome || null, - ...extra - } - }); + return LangfuseTracing.withObservation( + 'bot.conversation.persist', + { botId: state.characterId, playerId: playerSession.actor.fetchId(), turnId: turn.turnId }, + { + source: 'cold_chat', + botId: state.characterId, + playerId: playerSession.actor.fetchId(), + turnId: turn.turnId, + sessionId: `cold-bot:${Number(state.characterId || 0)}:player:${playerSession.actor.fetchId()}` + }, + () => BotConversationService.recordBotReply({ + playerSession, + botSession: state, + turnId: turn.turnId, + channel: turn.channel, + text: result.reply, + requestId: turn.turnId, + delivered: true, + meta: { + action: result.action || 'say', + reason: result.reason || null, + providerOutcome: result.providerOutcome || null, + ...extra + } + }), + 'chain' + ); } function replyForStateNow(playerSession, state, text, channel = 'client_tell') { @@ -349,7 +361,22 @@ function replyForStateNow(playerSession, state, text, channel = 'client_tell') { const llmReady = cfg.enabled && !!cfg.apiKey; if (!llmReady) return recordReply(playerSession, state, turn, fallback).then(() => fallback); - return requestLlmReply(payload, cfg, turn, state, playerSession).then((result) => { + const stageMetadata = { + event: 'cold_chat', + source: 'cold_chat', + botId: state.characterId, + playerId: playerSession.actor.fetchId(), + turnId: turn.turnId, + requestId: turn.turnId, + sessionId: `cold-bot:${Number(state.characterId || 0)}:player:${playerSession.actor.fetchId()}` + }; + return LangfuseTracing.withObservation( + 'bot.schema.validate', + { event: 'cold_chat', responseSchema: 'bot_remote_chat' }, + stageMetadata, + () => requestLlmReply(payload, cfg, turn, state, playerSession), + 'chain' + ).then((result) => { if (result?.providerFailure) { state.lastRemoteChatTelemetry = result.llmTelemetry || null; const failed = { diff --git a/src/GameServer/Bot/AI/LangfuseTracing.js b/src/GameServer/Bot/AI/LangfuseTracing.js index b597ffef..476e755c 100644 --- a/src/GameServer/Bot/AI/LangfuseTracing.js +++ b/src/GameServer/Bot/AI/LangfuseTracing.js @@ -123,7 +123,7 @@ function observationStatus(value) { statusMessage: text(value?.reason || telemetry.statusMessage || outcome || 'failed', 240) }; } - if (value?.applied === false || outcome === 'stale_world_state' || reason === 'stale_world_state') { + if (value?.applied === false || value?.actionResult?.ok === false || outcome === 'stale_world_state' || reason === 'stale_world_state') { return { level: 'WARNING', statusMessage: text(value?.reason || reason || outcome || 'action_rejected', 240) diff --git a/src/GameServer/Bot/AI/OpenRouterGateway.js b/src/GameServer/Bot/AI/OpenRouterGateway.js index c8bea94f..f8bb50da 100644 --- a/src/GameServer/Bot/AI/OpenRouterGateway.js +++ b/src/GameServer/Bot/AI/OpenRouterGateway.js @@ -217,16 +217,38 @@ function repairConfig(spec) { }; } +function combinedUsage(first, second) { + if (!first && !second) return null; + const left = first || {}; + const right = second || {}; + const sum = (key) => Number(left[key] || 0) + Number(right[key] || 0); + const cost = Number.isFinite(Number(left.cost)) || Number.isFinite(Number(right.cost)) + ? Number(left.cost || 0) + Number(right.cost || 0) + : null; + return { + promptTokens: sum('promptTokens'), + completionTokens: sum('completionTokens'), + totalTokens: sum('totalTokens'), + cachedPromptTokens: sum('cachedPromptTokens'), + cacheWriteTokens: sum('cacheWriteTokens'), + cost + }; +} + function repairedResult(initial, repaired) { const telemetry = repaired?.telemetry || {}; + const usage = combinedUsage(initial?.usage, repaired?.usage); return { ...repaired, + usage, telemetry: { ...telemetry, + usage, attempts: 2, repairTriggered: true, initialOutcome: initial?.reason || null, - initialRawContent: initial?.telemetry?.rawContent || null + initialRawContent: initial?.telemetry?.rawContent || null, + initialUsage: initial?.usage || null } }; } diff --git a/tests/test_bot_llm_party_policy.js b/tests/test_bot_llm_party_policy.js new file mode 100644 index 00000000..043e55fa --- /dev/null +++ b/tests/test_bot_llm_party_policy.js @@ -0,0 +1,63 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); + +function actor(id, name) { + return { + fetchId: () => id, + fetchName: () => name, + fetchLevel: () => 20, + fetchLocX: () => 0, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchIsOnline: () => true, + isDead: () => false + }; +} + +function decision() { + return { + action: 'say', + reply: 'Sure, I would love to join your party.', + reason: 'model_positive', + confidence: 0.95 + }; +} + +function main() { + const playerSession = { accountId: 'party_player', actor: actor(9101, 'PartyPlayer') }; + const soloSession = { + accountId: 'bot_solo_party', + actor: actor(9102, 'SoloBot'), + persona: { + primaryDrive: 'wealth', + traits: { sociability: 0.1, empathy: 0, commitment: 0 } + } + }; + const solo = BotBrain.applyPartyPolicy(soloSession, decision(), { playerSession }, 'wanna party?'); + assert.strictEqual(solo.action, 'say'); + assert.match(solo.reply, /cannot join right now/i); + assert.match(solo.reason, /^party_policy:/); + + const socialSession = { + accountId: 'bot_social_party', + actor: actor(9103, 'SocialBot'), + persona: { + primaryDrive: 'social', + traits: { sociability: 0.95, empathy: 0.8, commitment: 0.8 } + } + }; + const social = BotBrain.applyPartyPolicy(socialSession, decision(), { playerSession }, 'join our group'); + assert.match(social.reply, /send me an invite/i); + assert.strictEqual(social.reason, 'party_policy:available'); + console.log('LLM party policy checks passed'); +} + +try { + main(); +} catch (error) { + console.error(error); + process.exitCode = 1; +} diff --git a/tests/test_openrouter_gateway.js b/tests/test_openrouter_gateway.js index af766c36..23b8db25 100644 --- a/tests/test_openrouter_gateway.js +++ b/tests/test_openrouter_gateway.js @@ -88,7 +88,8 @@ async function main() { repairBodies.push(JSON.parse(init.body)); if (repairBodies.length === 1) { return response({ - choices: [{ message: { content: '{"reply":', finish_reason: 'length' } }] + choices: [{ message: { content: '{"reply":' } }], + usage: { prompt_tokens: 7, completion_tokens: 2, total_tokens: 9 } }); } return response({ @@ -110,6 +111,7 @@ async function main() { assert.strictEqual(repaired.telemetry.attempts, 2); assert.strictEqual(repaired.telemetry.repairTriggered, true); assert.strictEqual(repaired.telemetry.initialRawContent, '{"reply":'); + assert.strictEqual(repaired.usage.totalTokens, 24, 'repair usage must include both provider attempts'); OpenRouterGateway.resetCircuit(); OpenRouterGateway.setTransport(async () => response({ error: { message: 'unavailable' } }, 503)); From 0b4f1952dc2866d9233606c3a8b9be3510c244b1 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:45:48 -0400 Subject: [PATCH 25/59] Classify stale actions as warning telemetry --- src/GameServer/Bot/AI/BotToolRegistry.js | 2 +- src/GameServer/Bot/AI/LangfuseTracing.js | 8 +++++++- tests/test_langfuse_tracing.js | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/GameServer/Bot/AI/BotToolRegistry.js b/src/GameServer/Bot/AI/BotToolRegistry.js index cb9f134d..63f0b93d 100644 --- a/src/GameServer/Bot/AI/BotToolRegistry.js +++ b/src/GameServer/Bot/AI/BotToolRegistry.js @@ -4,7 +4,7 @@ const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); const definitions = new Map(); const SOFT_FRESHNESS_ACTIONS = new Set([ - 'none', 'say', 'follow_player', 'stay_here', 'hunt', 'rest' + 'none', 'say', 'follow_player', 'hunt', 'rest' ]); function text(value, max = 160) { diff --git a/src/GameServer/Bot/AI/LangfuseTracing.js b/src/GameServer/Bot/AI/LangfuseTracing.js index 476e755c..f87abbb5 100644 --- a/src/GameServer/Bot/AI/LangfuseTracing.js +++ b/src/GameServer/Bot/AI/LangfuseTracing.js @@ -114,6 +114,12 @@ function observationStatus(value) { ).toLowerCase(); const reason = String(value?.actionResult?.reason || '').toLowerCase(); + if (outcome === 'stale_world_state' || reason === 'stale_world_state') { + return { + level: 'WARNING', + statusMessage: text(value?.reason || reason || outcome || 'action_rejected', 240) + }; + } if (value?.ok === false || [ 'schema_error', 'provider_error', 'timeout', 'circuit_open', 'missing_api_key', 'disabled', 'transport_error' @@ -123,7 +129,7 @@ function observationStatus(value) { statusMessage: text(value?.reason || telemetry.statusMessage || outcome || 'failed', 240) }; } - if (value?.applied === false || value?.actionResult?.ok === false || outcome === 'stale_world_state' || reason === 'stale_world_state') { + if (value?.applied === false || value?.actionResult?.ok === false) { return { level: 'WARNING', statusMessage: text(value?.reason || reason || outcome || 'action_rejected', 240) diff --git a/tests/test_langfuse_tracing.js b/tests/test_langfuse_tracing.js index 167ed46d..bbd9589b 100644 --- a/tests/test_langfuse_tracing.js +++ b/tests/test_langfuse_tracing.js @@ -10,7 +10,7 @@ function main() { { level: 'ERROR', statusMessage: 'schema_error' } ); assert.deepStrictEqual( - LangfuseTracing.observationStatus({ ok: true, applied: false, reason: 'stale_world_state' }), + LangfuseTracing.observationStatus({ ok: false, applied: false, reason: 'stale_world_state' }), { level: 'WARNING', statusMessage: 'stale_world_state' } ); assert.deepStrictEqual( From fd98107310d0c983b8b453be2dbcd5810ce54c69 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:54:15 -0400 Subject: [PATCH 26/59] Enable structured response repair for hot dialogue --- scripts/run-tests.js | 1 + src/GameServer/Bot/AI/BotBrain.js | 3 +- tests/test_hot_bot_schema_repair.js | 120 ++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 tests/test_hot_bot_schema_repair.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 263ae245..a72be925 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -14,6 +14,7 @@ const tests = [ 'tests/test_bot_conversation_store.js', 'tests/test_bot_dialogue_arbiter.js', 'tests/test_hot_bot_conversation_flow.js', + 'tests/test_hot_bot_schema_repair.js', 'tests/test_hot_conversation_history_queue.js', 'tests/test_bot_activity_journal.js', 'tests/test_bot_context_assembler.js', diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 17af40e3..f36e444d 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -309,7 +309,8 @@ async function requestDecision(payload, cfg, session, requestContext, visiblePla responseSchema: { name: 'bot_brain_decision', schema: schema(BotAgentTools.availableActions(session)) - } + }, + repairSchema: true }); if (!result.ok) return result; diff --git a/tests/test_hot_bot_schema_repair.js b/tests/test_hot_bot_schema_repair.js new file mode 100644 index 00000000..903433c2 --- /dev/null +++ b/tests/test_hot_bot_schema_repair.js @@ -0,0 +1,120 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); +const BotBrainContext = invoke('GameServer/Bot/AI/BotBrainContext'); +const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); +const World = invoke('GameServer/World/World'); + +function actor(id, name, x = 0) { + return { + fetchId: () => id, + fetchName: () => name, + fetchLevel: () => 20, + fetchHp: () => 100, + fetchMaxHp: () => 100, + fetchMp: () => 100, + fetchMaxMp: () => 100, + fetchLocX: () => x, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchDestId: () => 0, + fetchIsOnline: () => true, + isDead: () => false + }; +} + +function response(content, usage) { + return { + ok: true, + status: 200, + json: async () => ({ + choices: [{ message: { content } }], + usage + }) + }; +} + +async function main() { + const originalConfig = options.default.OpenRouter; + const originalWorldUser = World.user; + const originalFetchVisibleUsers = World.fetchVisibleUsers; + const originalCompactStatus = BotBrainContext.compactStatus; + const requests = []; + + try { + options.default.OpenRouter = { + ...originalConfig, + enabled: true, + apiKey: 'hot-schema-repair-test-key', + model: 'test/hot-schema-repair', + hotBotBudgetEnabled: false, + chatCooldownMs: 0 + }; + + const playerSession = { + accountId: 'player_hot_schema_repair', + actor: actor(9301, 'SchemaPlayer') + }; + const botSession = { + accountId: 'bot_hot_schema_repair', + actor: actor(9302, 'SchemaBot', 100), + plan: 'merchant' + }; + World.user = { sessions: [playerSession, botSession] }; + World.fetchVisibleUsers = () => [playerSession]; + BotBrainContext.compactStatus = (_session, status) => status; + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.setTransport(async (_url, init) => { + requests.push(JSON.parse(init.body)); + if (requests.length === 1) { + return response('{"action":', { prompt_tokens: 12, completion_tokens: 2, total_tokens: 14 }); + } + return response(JSON.stringify({ + action: 'none', + reply: 'Recovered response.', + targetPlayerName: '', + spotId: '', + buffType: '', + reason: 'schema_repair', + confidence: 0.9 + }), { prompt_tokens: 14, completion_tokens: 5, total_tokens: 19 }); + }); + + const started = BotBrain.maybeThink( + botSession, + 'player_chat', + { available: true, mode: 'merchant', level: 20, name: 'SchemaBot' }, + 'Please answer even when the first JSON is truncated.', + { + playerSession, + conversationTurn: { turnId: 'hot-schema-repair-turn', channel: 'client_tell' }, + requestId: 'hot-schema-repair-turn' + } + ); + assert.strictEqual(started, true); + + for (let index = 0; index < 40 && requests.length < 2; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + assert.strictEqual(requests.length, 2, 'hot dialogue must repair one malformed structured response'); + assert.strictEqual(requests[0].max_tokens, 640); + assert.strictEqual(requests[1].max_tokens, 640); + assert.strictEqual(botSession.brainInFlight, false, 'repaired turn must settle'); + console.log('Hot bot schema repair checks passed'); + } finally { + options.default.OpenRouter = originalConfig; + World.user = originalWorldUser; + World.fetchVisibleUsers = originalFetchVisibleUsers; + BotBrainContext.compactStatus = originalCompactStatus; + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.resetTransport(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From b8e3b34aba03366917e84ae027e77120fdf0d6f5 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:58:54 -0400 Subject: [PATCH 27/59] Preserve canonical bot conversation turn order --- database/sql/sqlite.sql | 6 + src/Database.js | 46 ++++- src/GameServer/Bot/AI/BotConversationStore.js | 160 +++++++++++++++--- .../Bot/AI/BotConversationSummarizer.js | 25 ++- tests/test_hot_conversation_history_queue.js | 7 + 5 files changed, 215 insertions(+), 29 deletions(-) diff --git a/database/sql/sqlite.sql b/database/sql/sqlite.sql index 2c667b99..8ca90363 100644 --- a/database/sql/sqlite.sql +++ b/database/sql/sqlite.sql @@ -245,6 +245,8 @@ CREATE TABLE IF NOT EXISTS bot_conversations ( botId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, summary TEXT NOT NULL DEFAULT '', summaryThroughId INTEGER NOT NULL DEFAULT 0, + summaryThroughOrdinal INTEGER NOT NULL DEFAULT 0, + nextTurnOrdinal INTEGER NOT NULL DEFAULT 0, version INTEGER NOT NULL DEFAULT 0, createdAt INTEGER NOT NULL DEFAULT 0, updatedAt INTEGER NOT NULL DEFAULT 0, @@ -263,10 +265,14 @@ CREATE TABLE IF NOT EXISTS bot_conversation_messages ( delivered INTEGER NOT NULL DEFAULT 1, createdAt INTEGER NOT NULL DEFAULT 0, metaJson TEXT, + turnOrdinal INTEGER NOT NULL DEFAULT 0, + messageOrder INTEGER NOT NULL DEFAULT 0, + compacted INTEGER NOT NULL DEFAULT 0, UNIQUE(conversationId, turnId, role) ); CREATE INDEX IF NOT EXISTS bot_conversation_messages_recent ON bot_conversation_messages(conversationId, id DESC); CREATE INDEX IF NOT EXISTS bot_conversation_messages_turn ON bot_conversation_messages(conversationId, turnId, role); +CREATE INDEX IF NOT EXISTS bot_conversation_messages_order ON bot_conversation_messages(conversationId, compacted, turnOrdinal, messageOrder, id); CREATE TABLE IF NOT EXISTS bot_activity_journal ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/src/Database.js b/src/Database.js index 82b7f95b..89ddb059 100644 --- a/src/Database.js +++ b/src/Database.js @@ -288,8 +288,50 @@ function applySchemaMigrations() { ); CREATE INDEX IF NOT EXISTS bot_llm_turns_bot_recent ON bot_llm_turns(botId, id DESC); CREATE INDEX IF NOT EXISTS bot_llm_turns_player_recent ON bot_llm_turns(playerId, id DESC); - CREATE INDEX IF NOT EXISTS bot_llm_turns_state_recent ON bot_llm_turns(state, id DESC); - `)] + CREATE INDEX IF NOT EXISTS bot_llm_turns_state_recent ON bot_llm_turns(state, id DESC); + `)], + [8, () => { + const addColumn = (table, name, definition) => { + const columns = connection.prepare(`PRAGMA table_info(${table})`).all(); + if (!columns.some((column) => column.name === name)) { + connection.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`); + } + }; + addColumn('bot_conversations', 'summaryThroughOrdinal', 'INTEGER NOT NULL DEFAULT 0'); + addColumn('bot_conversations', 'nextTurnOrdinal', 'INTEGER NOT NULL DEFAULT 0'); + addColumn('bot_conversation_messages', 'turnOrdinal', 'INTEGER NOT NULL DEFAULT 0'); + addColumn('bot_conversation_messages', 'messageOrder', 'INTEGER NOT NULL DEFAULT 0'); + addColumn('bot_conversation_messages', 'compacted', 'INTEGER NOT NULL DEFAULT 0'); + connection.exec(` + UPDATE bot_conversation_messages + SET turnOrdinal = COALESCE(( + SELECT MIN(first.id) + FROM bot_conversation_messages first + WHERE first.conversationId = bot_conversation_messages.conversationId + AND first.turnId = bot_conversation_messages.turnId + ), id) + WHERE turnOrdinal = 0; + UPDATE bot_conversation_messages + SET messageOrder = CASE role WHEN 'player' THEN 0 WHEN 'bot' THEN 1 ELSE 2 END; + UPDATE bot_conversations + SET nextTurnOrdinal = COALESCE(( + SELECT MAX(turnOrdinal) + FROM bot_conversation_messages + WHERE conversationId = bot_conversations.id + ), 0) + WHERE nextTurnOrdinal = 0; + UPDATE bot_conversations + SET summaryThroughOrdinal = COALESCE(( + SELECT MAX(turnOrdinal) + FROM bot_conversation_messages + WHERE conversationId = bot_conversations.id + AND id <= bot_conversations.summaryThroughId + ), 0) + WHERE summaryThroughOrdinal = 0 AND summaryThroughId > 0; + CREATE INDEX IF NOT EXISTS bot_conversation_messages_order + ON bot_conversation_messages(conversationId, compacted, turnOrdinal, messageOrder, id); + `); + }] ]; const applied = new Set(connection.prepare('SELECT version FROM schema_migrations').all().map((row) => Number(row.version))); migrations.forEach(([version, apply]) => { diff --git a/src/GameServer/Bot/AI/BotConversationStore.js b/src/GameServer/Bot/AI/BotConversationStore.js index e56bf50f..817d8061 100644 --- a/src/GameServer/Bot/AI/BotConversationStore.js +++ b/src/GameServer/Bot/AI/BotConversationStore.js @@ -43,6 +43,8 @@ function normalizeConversation(row) { botId: numericId(row.botId), summary: text(row.summary, 1600), summaryThroughId: Number(row.summaryThroughId || 0), + summaryThroughOrdinal: Number(row.summaryThroughOrdinal || 0), + nextTurnOrdinal: Number(row.nextTurnOrdinal || 0), version: Number(row.version || 0), createdAt: Number(row.createdAt || 0), updatedAt: Number(row.updatedAt || 0) @@ -61,10 +63,27 @@ function normalizeTurn(row) { requestId: row.requestId || null, delivered: Number(row.delivered || 0) === 1, createdAt: Number(row.createdAt || 0), + turnOrdinal: Number(row.turnOrdinal || row.id || 0), + messageOrder: Number(row.messageOrder ?? roleOrder(row.role)), + compacted: Number(row.compacted || 0) === 1, meta: normalizeMeta(row.metaJson || row.meta) }; } +function roleOrder(role) { + if (role === 'player') return 0; + if (role === 'bot') return 1; + return 2; +} + +function orderedTurns(turns) { + return [...(turns || [])].sort((left, right) => ( + Number(left.turnOrdinal || left.id || 0) - Number(right.turnOrdinal || right.id || 0) || + Number(left.messageOrder ?? roleOrder(left.role)) - Number(right.messageOrder ?? roleOrder(right.role)) || + Number(left.id || 0) - Number(right.id || 0) + )); +} + function memoryEntry(playerId, botId) { const key = pairKey(playerId, botId); let entry = memory.get(key); @@ -77,6 +96,8 @@ function memoryEntry(playerId, botId) { botId: numericId(botId), summary: '', summaryThroughId: 0, + summaryThroughOrdinal: 0, + nextTurnOrdinal: 0, version: 0, createdAt, updatedAt: createdAt @@ -114,7 +135,7 @@ function ensureSchema() { async function loadFromDatabase(playerId, botId) { if (!(await ensureSchema())) return null; const rows = await Database.execute([ - `SELECT id, playerId, botId, summary, summaryThroughId, version, createdAt, updatedAt + `SELECT id, playerId, botId, summary, summaryThroughId, summaryThroughOrdinal, nextTurnOrdinal, version, createdAt, updatedAt FROM bot_conversations WHERE playerId = ? AND botId = ? LIMIT 1`, [numericId(playerId), numericId(botId)] ], 'bot-conversation:load'); @@ -141,8 +162,8 @@ async function ensureConversation(playerId, botId) { const createdAt = now(); try { await Database.execute([ - `INSERT INTO bot_conversations (playerId, botId, summary, summaryThroughId, version, createdAt, updatedAt) - VALUES (?, ?, '', 0, 0, ?, ?) + `INSERT INTO bot_conversations (playerId, botId, summary, summaryThroughId, summaryThroughOrdinal, nextTurnOrdinal, version, createdAt, updatedAt) + VALUES (?, ?, '', 0, 0, 0, 0, ?, ?) ON CONFLICT(playerId, botId) DO NOTHING`, [player, bot, createdAt, createdAt] ], 'bot-conversation:create'); @@ -162,32 +183,89 @@ async function ensureConversation(playerId, botId) { async function loadTurns(entry, limit = DEFAULT_RECENT_TURNS, includeCompacted = false) { if (!entry?.conversation) return []; const conversationId = entry.conversation.id; + const count = Math.max(1, Number(limit) || DEFAULT_RECENT_TURNS); + const summaryThroughOrdinal = Number(entry.conversation.summaryThroughOrdinal || 0); if (String(conversationId).startsWith('memory:')) { - return entry.turns - .filter((turn) => includeCompacted || turn.id > Number(entry.conversation.summaryThroughId || 0)) - .slice(-Math.max(1, Number(limit) || DEFAULT_RECENT_TURNS)) + return orderedTurns(entry.turns) + .filter((turn) => includeCompacted || (!turn.compacted && Number(turn.turnOrdinal || turn.id) > summaryThroughOrdinal)) + .slice(-count) .map(copyTurn); } try { const rows = await Database.execute([ - `SELECT id, conversationId, turnId, role, channel, text, requestId, delivered, createdAt, metaJson - FROM bot_conversation_messages - WHERE conversationId = ? ${includeCompacted ? '' : 'AND id > ?'} - ORDER BY id DESC LIMIT ?`, + `SELECT id, conversationId, turnId, role, channel, text, requestId, delivered, createdAt, metaJson, + turnOrdinal, messageOrder, compacted + FROM ( + SELECT id, conversationId, turnId, role, channel, text, requestId, delivered, createdAt, metaJson, + turnOrdinal, messageOrder, compacted + FROM bot_conversation_messages + WHERE conversationId = ? ${includeCompacted ? '' : 'AND compacted = 0 AND turnOrdinal > ?'} + ORDER BY turnOrdinal DESC, messageOrder DESC, id DESC + LIMIT ? + ) + ORDER BY turnOrdinal ASC, messageOrder ASC, id ASC`, includeCompacted - ? [conversationId, Math.max(1, Number(limit) || DEFAULT_RECENT_TURNS)] - : [conversationId, Number(entry.conversation.summaryThroughId || 0), Math.max(1, Number(limit) || DEFAULT_RECENT_TURNS)] + ? [conversationId, count] + : [conversationId, summaryThroughOrdinal, count] ], 'bot-conversation:recent'); - return rows.reverse().map(normalizeTurn); + return rows.map(normalizeTurn); } catch (_) { - return entry.turns - .filter((turn) => includeCompacted || turn.id > Number(entry.conversation.summaryThroughId || 0)) - .slice(-Math.max(1, Number(limit) || DEFAULT_RECENT_TURNS)) + return orderedTurns(entry.turns) + .filter((turn) => includeCompacted || (!turn.compacted && Number(turn.turnOrdinal || turn.id) > summaryThroughOrdinal)) + .slice(-count) .map(copyTurn); } } +async function turnOrdinalFor(entry, turnId) { + const existing = orderedTurns(entry.turns).find((turn) => turn.turnId === turnId); + if (existing?.turnOrdinal) return Number(existing.turnOrdinal); + + if (!String(entry.conversation.id).startsWith('memory:')) { + try { + const rows = await Database.execute([ + `SELECT turnOrdinal + FROM bot_conversation_messages + WHERE conversationId = ? AND turnId = ? + ORDER BY id ASC LIMIT 1`, + [entry.conversation.id, turnId] + ], 'bot-conversation:turn-ordinal'); + const persisted = Number(rows[0]?.turnOrdinal || 0); + if (persisted > 0) return persisted; + } catch (_) { + // Fall through to the local allocator while the database is unavailable. + } + } + + if (!String(entry.conversation.id).startsWith('memory:')) { + try { + await Database.execute([ + 'UPDATE bot_conversations SET nextTurnOrdinal = nextTurnOrdinal + 1 WHERE id = ?', + [entry.conversation.id] + ], 'bot-conversation:allocate-turn'); + const rows = await Database.execute([ + 'SELECT nextTurnOrdinal FROM bot_conversations WHERE id = ? LIMIT 1', + [entry.conversation.id] + ], 'bot-conversation:read-turn-ordinal'); + const persisted = Number(rows[0]?.nextTurnOrdinal || 0); + if (persisted > 0) { + entry.conversation.nextTurnOrdinal = persisted; + return persisted; + } + } catch (_) { + // Fall through to the in-memory allocator. + } + } + + const next = Math.max( + Number(entry.conversation.nextTurnOrdinal || 0), + ...entry.turns.map((turn) => Number(turn.turnOrdinal || 0)) + ) + 1; + entry.conversation.nextTurnOrdinal = next; + return next; +} + async function appendTurn(input = {}) { const entry = await ensureConversation(input.playerId, input.botId); const conversation = entry.conversation; @@ -200,6 +278,7 @@ async function appendTurn(input = {}) { return { conversation: copyConversation(conversation), turn: copyTurn(existing), inserted: false }; } + const turnOrdinal = Number(input.turnOrdinal || 0) || await turnOrdinalFor(entry, turnId); const createdAt = Number(input.createdAt || now()); const turn = { id: ++memoryMessageSequence, @@ -211,6 +290,9 @@ async function appendTurn(input = {}) { requestId: input.requestId ? text(input.requestId, 128) : null, delivered: input.delivered !== false, createdAt, + turnOrdinal, + messageOrder: Number(input.messageOrder ?? roleOrder(role)), + compacted: false, meta: input.meta && typeof input.meta === 'object' ? { ...input.meta } : null }; entry.turns.push(turn); @@ -220,8 +302,8 @@ async function appendTurn(input = {}) { try { const result = await Database.execute([ `INSERT OR IGNORE INTO bot_conversation_messages - (conversationId, turnId, role, channel, text, requestId, delivered, createdAt, metaJson) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (conversationId, turnId, role, channel, text, requestId, delivered, createdAt, metaJson, turnOrdinal, messageOrder, compacted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ conversation.id, turn.turnId, @@ -231,13 +313,17 @@ async function appendTurn(input = {}) { turn.requestId, turn.delivered ? 1 : 0, turn.createdAt, - turn.meta ? JSON.stringify(turn.meta) : null + turn.meta ? JSON.stringify(turn.meta) : null, + turn.turnOrdinal, + turn.messageOrder, + turn.compacted ? 1 : 0 ] ], 'bot-conversation:append'); if (Number(result.affectedRows || 0) === 0) { entry.turns.pop(); const rows = await Database.execute([ - `SELECT id, conversationId, turnId, role, channel, text, requestId, delivered, createdAt, metaJson + `SELECT id, conversationId, turnId, role, channel, text, requestId, delivered, createdAt, metaJson, + turnOrdinal, messageOrder, compacted FROM bot_conversation_messages WHERE conversationId = ? AND turnId = ? AND role = ? LIMIT 1`, [conversation.id, turn.turnId, turn.role] ], 'bot-conversation:dedupe'); @@ -246,7 +332,8 @@ async function appendTurn(input = {}) { return { conversation: copyConversation(conversation), turn: copyTurn(existingRow), inserted: false }; } const rows = await Database.execute([ - `SELECT id, conversationId, turnId, role, channel, text, requestId, delivered, createdAt, metaJson + `SELECT id, conversationId, turnId, role, channel, text, requestId, delivered, createdAt, metaJson, + turnOrdinal, messageOrder, compacted FROM bot_conversation_messages WHERE conversationId = ? AND turnId = ? AND role = ? LIMIT 1`, [conversation.id, turn.turnId, turn.role] ], 'bot-conversation:append-row'); @@ -281,6 +368,7 @@ async function context(playerId, botId, options = {}) { recentTurns, summary: entry.conversation.summary || null, summaryThroughId: Number(entry.conversation.summaryThroughId || 0), + summaryThroughOrdinal: Number(entry.conversation.summaryThroughOrdinal || 0), version: Number(entry.conversation.version || 0) }; } @@ -295,18 +383,43 @@ async function setSummary(input = {}) { const summary = text(input.summary, 1600); const throughId = Math.max(0, Number(input.summaryThroughId || 0)); + let throughOrdinal = Math.max(0, Number(input.summaryThroughOrdinal || 0)); + if (!throughOrdinal && throughId) { + throughOrdinal = Number( + orderedTurns(entry.turns).find((turn) => Number(turn.id) === throughId)?.turnOrdinal || 0 + ); + } + if (!throughOrdinal && throughId && !String(conversation.id).startsWith('memory:')) { + try { + const rows = await Database.execute([ + `SELECT turnOrdinal + FROM bot_conversation_messages + WHERE conversationId = ? AND id = ? LIMIT 1`, + [conversation.id, throughId] + ], 'bot-conversation:summary-ordinal'); + throughOrdinal = Number(rows[0]?.turnOrdinal || 0); + } catch (_) { + // Keep the legacy id boundary if the ordinal cannot be read. + } + } const nextVersion = expectedVersion + 1; if (!String(conversation.id).startsWith('memory:')) { try { const result = await Database.execute([ `UPDATE bot_conversations - SET summary = ?, summaryThroughId = ?, version = ?, updatedAt = ? + SET summary = ?, summaryThroughId = ?, summaryThroughOrdinal = ?, version = ?, updatedAt = ? WHERE id = ? AND version = ?`, - [summary, throughId, nextVersion, now(), conversation.id, expectedVersion] + [summary, throughId, throughOrdinal, nextVersion, now(), conversation.id, expectedVersion] ], 'bot-conversation:summary'); if (Number(result.affectedRows || 0) === 0) { return { ok: false, reason: 'version_conflict', conversation: copyConversation(conversation) }; } + Database.execute([ + `UPDATE bot_conversation_messages + SET compacted = 1 + WHERE conversationId = ? AND turnOrdinal <= ?`, + [conversation.id, throughOrdinal] + ], 'bot-conversation:mark-compacted').catch(() => {}); } catch (_) { return { ok: false, reason: 'persistence_error', conversation: copyConversation(conversation) }; } @@ -314,6 +427,7 @@ async function setSummary(input = {}) { conversation.summary = summary; conversation.summaryThroughId = throughId; + conversation.summaryThroughOrdinal = throughOrdinal; conversation.version = nextVersion; conversation.updatedAt = now(); return { ok: true, conversation: copyConversation(conversation) }; diff --git a/src/GameServer/Bot/AI/BotConversationSummarizer.js b/src/GameServer/Bot/AI/BotConversationSummarizer.js index b566ff59..53f8a04b 100644 --- a/src/GameServer/Bot/AI/BotConversationSummarizer.js +++ b/src/GameServer/Bot/AI/BotConversationSummarizer.js @@ -91,15 +91,25 @@ async function summarize(input = {}) { }); const turns = current.recentTurns || []; const summaryThroughId = Number(current.summaryThroughId || 0); - const uncompacted = turns.filter((turn) => Number(turn.id || 0) > summaryThroughId); + const summaryThroughOrdinal = Number(current.summaryThroughOrdinal || 0); + const uncompacted = turns.filter((turn) => ( + Number(turn.turnOrdinal || turn.id || 0) > summaryThroughOrdinal && turn.compacted !== true + )); if (uncompacted.length < Math.max(4, Number(input.threshold || SUMMARY_THRESHOLD_MESSAGES))) { return { ok: false, reason: 'below_threshold', conversation: current.conversation }; } - const compactUntilIndex = Math.max(0, uncompacted.length - SUMMARY_RECENT_TURNS); + let compactUntilIndex = Math.max(0, uncompacted.length - SUMMARY_RECENT_TURNS); + while (compactUntilIndex > 0 && compactUntilIndex < uncompacted.length && + Number(uncompacted[compactUntilIndex - 1].turnOrdinal || 0) === + Number(uncompacted[compactUntilIndex].turnOrdinal || 0)) { + compactUntilIndex -= 1; + } const compacted = uncompacted.slice(0, compactUntilIndex); const throughId = Number(compacted[compacted.length - 1]?.id || 0); - if (!throughId || throughId <= summaryThroughId) { + const throughOrdinal = Number(compacted[compacted.length - 1]?.turnOrdinal || 0); + if ((!throughId && !throughOrdinal) || + (throughOrdinal > 0 ? throughOrdinal <= summaryThroughOrdinal : throughId <= summaryThroughId)) { return { ok: false, reason: 'nothing_to_compact', conversation: current.conversation }; } @@ -147,10 +157,17 @@ async function summarize(input = {}) { botId, summary, summaryThroughId: throughId, + summaryThroughOrdinal: throughOrdinal, expectedVersion: Number(current.version || 0) }); if (!saved.ok) return saved; - return { ok: true, summary, summaryThroughId: throughId, conversation: saved.conversation }; + return { + ok: true, + summary, + summaryThroughId: throughId, + summaryThroughOrdinal: throughOrdinal, + conversation: saved.conversation + }; })().finally(() => inFlight.delete(key)); inFlight.set(key, work); diff --git a/tests/test_hot_conversation_history_queue.js b/tests/test_hot_conversation_history_queue.js index 1dd4003f..3c79b9c8 100644 --- a/tests/test_hot_conversation_history_queue.js +++ b/tests/test_hot_conversation_history_queue.js @@ -94,6 +94,13 @@ async function main() { secondPayload.conversation.recentTurns.map((turn) => turn.text), ['first message', 'first reply', 'second message'] ); + await botSession.lastConversationWrite; + const finalContext = await BotConversationStore.context(9201, 9202, { limit: 10 }); + assert.deepStrictEqual( + finalContext.recentTurns.map((turn) => turn.text), + ['first message', 'first reply', 'second message', 'second reply'], + 'persistent context must keep complete player/bot turn groups ordered' + ); console.log('Hot conversation queue history checks passed'); } finally { releaseFirst?.(); From 07a5e042fad07b6df8de55e4c46a8ee8a053c1e0 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:00:10 -0400 Subject: [PATCH 28/59] Align queued dialogue with prepared world revision --- src/GameServer/Bot/AI/BotAgentTools.js | 2 +- src/GameServer/Bot/AI/BotBrain.js | 31 +++++++++++++++++--------- tests/test_bot_tool_registry.js | 24 ++++++++++++++++++++ 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/GameServer/Bot/AI/BotAgentTools.js b/src/GameServer/Bot/AI/BotAgentTools.js index 8985776b..1905fa4d 100644 --- a/src/GameServer/Bot/AI/BotAgentTools.js +++ b/src/GameServer/Bot/AI/BotAgentTools.js @@ -742,7 +742,7 @@ function execute(session, decision, visiblePlayers, requestContext = null) { decision, visiblePlayers, requestContext, - expectedWorldRevision: requestContext?.worldRevision + expectedWorldRevision: requestContext?.preparedWorldRevision || requestContext?.worldRevision }); const { idempotent, ...publicOutcome } = outcome; return { ...publicOutcome, applied: outcome.applied, reason: outcome.reason }; diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index f36e444d..8af1a2d3 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -242,6 +242,8 @@ function systemPrompt() { function userPayload(event, session, status, visiblePlayers, text, requestContext = null) { const assembled = requestContext?.assembledContext; + const preparedWorldRevision = requestContext?.preparedWorldRevision || + requestContext?.worldRevision || BotAgentTools.worldRevision(session); return { event, playerMessage: text || '', @@ -251,7 +253,7 @@ function userPayload(event, session, status, visiblePlayers, text, requestContex allowedActions: BotAgentTools.availableActions(session), tools: BotAgentTools.toolDescriptions(session), toolContext: { - worldRevision: BotAgentTools.worldRevision(session) + worldRevision: preparedWorldRevision }, constraints: { keepReplyShort: true, @@ -636,6 +638,10 @@ const BotBrain = { session.lastBrainThinkAt = Date.now(); } + if (requestContext) { + requestContext.preparedWorldRevision = BotAgentTools.worldRevision(session); + requestContext.worldRevision = requestContext.preparedWorldRevision; + } const payload = userPayload(event, session, status, visiblePlayers, text, requestContext); const admission = BotInferenceBudget.reserve(session, { event, @@ -718,11 +724,18 @@ const BotBrain = { if (pending) { const startPending = () => Promise.resolve().then(async () => { let requestContext = { ...pending.requestContext, queued: true }; + let pendingStatus = pending.status; // The player turn is persisted before admission, but the // previous bot reply may finish while this request waits // in the FIFO. Refresh the bounded context at dequeue so // the next prompt sees the latest delivered turn. if (pending.event === 'player_chat' && requestContext.playerSession) { + try { + const BotAI = invoke('GameServer/Bot/BotAI'); + pendingStatus = BotAI.getStatus(session) || pendingStatus; + } catch (_) { + // Keep the ingress status if the live snapshot is unavailable. + } try { const fresh = await BotConversationService.contextFor( requestContext.playerSession, @@ -737,19 +750,17 @@ const BotBrain = { // temporarily unavailable. } requestContext.conversation = orderedConversation(requestContext.conversation); - if (requestContext.assembledContext) { - requestContext.assembledContext = await BotContextAssembler.assemble({ - session, - status: pending.status, - text: pending.text, - requestContext - }); - } + requestContext.assembledContext = await BotContextAssembler.assemble({ + session, + status: pendingStatus, + text: pending.text, + requestContext + }); } const started = BotBrain.maybeThink( session, pending.event, - pending.status, + pendingStatus, pending.text, requestContext ); diff --git a/tests/test_bot_tool_registry.js b/tests/test_bot_tool_registry.js index 06bfbcf7..095cfbb0 100644 --- a/tests/test_bot_tool_registry.js +++ b/tests/test_bot_tool_registry.js @@ -3,6 +3,7 @@ require('../src/Global'); const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); const BotToolAudit = invoke('GameServer/Bot/AI/BotToolAudit'); +const World = invoke('GameServer/World/World'); function actor() { return { @@ -39,6 +40,9 @@ function context(turnId) { } function main() { + const originalNpc = World.npc; + World.npc = { spawns: [] }; + try { BotToolAudit.resetMemory(); const session = { accountId: 'bot_registry', plan: 'hunting', actor: actor(), dataSendToOthers() {} }; const first = BotAgentTools.execute(session, decision('stay_here', 'turn-1'), [], context('turn-1')); @@ -67,11 +71,31 @@ function main() { ); assert.deepStrictEqual(strictFreshness, { applied: false, reason: 'stale_world_state' }); + const preparedRevision = BotAgentTools.worldRevision(session); + const preparedFreshness = BotAgentTools.execute( + session, + decision('move_to_spot', 'turn-4'), + [], + { + ...context('turn-4'), + worldRevision: 'obsolete-ingress-revision', + preparedWorldRevision: preparedRevision + } + ); + assert.deepStrictEqual( + preparedFreshness, + { applied: false, reason: 'invalid_spot' }, + 'strict tools must validate against the revision captured for the actual prompt' + ); + const audit = BotToolAudit.recent({ botId: 200, limit: 20 }); assert(audit.some((event) => event.outcome === 'requested')); assert(audit.some((event) => event.outcome === 'applied')); assert(audit.some((event) => event.reason === 'one_mutation_per_turn')); console.log('Bot tool registry checks passed'); + } finally { + World.npc = originalNpc; + } } try { From ced2f91b0d212352a7fd2e99d658cc4998977ecb Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:03:09 -0400 Subject: [PATCH 29/59] Arbitrate one LLM responder per chat message --- src/GameServer/Bot/BotManager.js | 52 +++++++++++++++++++++++++----- tests/test_bot_dialogue_arbiter.js | 23 +++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js index fe9d2d46..b6d6d283 100644 --- a/src/GameServer/Bot/BotManager.js +++ b/src/GameServer/Bot/BotManager.js @@ -735,8 +735,46 @@ const BotManager = { const SpeckMath = invoke('GameServer/SpeckMath'); const playerPt = new SpeckMath.Point3D(player.fetchLocX(), player.fetchLocY(), player.fetchLocZ()); - let brainGroupResponderPicked = false; + const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); + const llmEnabled = BotBrain.isEnabled(); + const groupAddress = /\b(bot|bots|guys|party|team|help)\b/.test(text) || /(бот|боты|ребят|народ|пати|команда|кто-нибудь)/.test(text); + const partyChannel = Number(data.kind) === 3; + const candidates = this.sessions + .filter((session) => session.actor) + .map((session) => { + const bot = session.actor; + const distance = new SpeckMath.Point3D(bot.fetchLocX(), bot.fetchLocY(), bot.fetchLocZ()).distance(playerPt); + const botName = bot.fetchName().toLowerCase(); + const shortBotName = botName.replace(/^bot_/, ''); + return { + session, + distance, + addressed: text.includes(botName) || text.includes(shortBotName), + selected: typeof player.fetchDestId === 'function' && player.fetchDestId() === bot.fetchId(), + companion: session.followPlayerSession === playerSession && session.partyCompanion === true + }; + }) + .filter((candidate) => candidate.distance <= 1500); + + let llmResponder = null; + if (llmEnabled) { + const explicit = candidates.find((candidate) => candidate.selected) || + candidates.find((candidate) => candidate.addressed); + const activeId = Number(playerSession.botDialogueResponderId || 0); + const activeAt = Number(playerSession.botDialogueResponderAt || 0); + const active = activeId && Date.now() - activeAt <= 60000 + ? candidates.find((candidate) => candidate.session.actor.fetchId() === activeId) + : null; + llmResponder = explicit || active || + (partyChannel ? candidates.find((candidate) => candidate.companion) : null) || + (groupAddress ? candidates[0] : null); + if (llmResponder) { + playerSession.botDialogueResponderId = llmResponder.session.actor.fetchId(); + playerSession.botDialogueResponderAt = Date.now(); + } + } + let brainGroupResponderPicked = false; this.sessions.forEach((session) => { const bot = session.actor; if (!bot) return; @@ -751,23 +789,21 @@ const BotManager = { const selectedBot = typeof player.fetchDestId === 'function' && player.fetchDestId() === bot.fetchId(); const companionBot = session.followPlayerSession === playerSession && session.partyCompanion === true; const directCommandTarget = addressedToBot || selectedBot || companionBot; - const groupAddress = /\b(bot|bots|guys|party|team|help)\b/.test(text) || /(бот|боты|ребят|народ|пати|команда|кто-нибудь)/.test(text); // When the developer LLM path is enabled, every addressed hot-bot // message must enter the same conversation pipeline. The legacy // regex replies intentionally remain only as the disabled-mode // fallback; otherwise they silently bypass OpenRouter and lose // the persona, memory, and Langfuse trace for the turn. - const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); - const llmAddressed = BotBrain.isEnabled() && (directCommandTarget || (groupAddress && !brainGroupResponderPicked)); - if (llmAddressed) { - if (groupAddress && !directCommandTarget) brainGroupResponderPicked = true; + const llmAddressed = llmEnabled && llmResponder?.session === session; + if (llmEnabled) { + if (!llmAddressed) return; BotDialogueArbiter.route({ playerSession, botSession: session, text: rawText, - channel: 'local_chat', - source: 'local_chat', + channel: partyChannel ? 'party_chat' : 'local_chat', + source: partyChannel ? 'party_chat' : 'local_chat', allowFallback: true }).catch((error) => { utils.infoWarn('BotDialogue', 'local LLM chat route failed: %s', error.message); diff --git a/tests/test_bot_dialogue_arbiter.js b/tests/test_bot_dialogue_arbiter.js index 1f52cd31..6000a556 100644 --- a/tests/test_bot_dialogue_arbiter.js +++ b/tests/test_bot_dialogue_arbiter.js @@ -35,6 +35,7 @@ async function main() { BotConversationStore.resetMemory(); const originalBrain = BotBrain.maybeThink; + const originalEnabled = BotBrain.isEnabled; const originalStatus = BotAI.getStatus; const originalBotTell = BotManager.botTell; const originalRecordEvent = BotSocialMemory.recordEvent; @@ -48,6 +49,7 @@ async function main() { const bot = session('bot_dialogue_arbiter', actor(201, 'Aria')); const captured = []; BotSocialMemory.recordEvent = () => Promise.resolve(null); + BotBrain.isEnabled = () => true; BotAI.getStatus = () => ({ available: true, mode: 'hunting', level: 10, name: 'Aria' }); BotBrain.maybeThink = (_bot, _event, _status, text, requestContext) => { captured.push({ text, requestContext }); @@ -118,6 +120,10 @@ async function main() { const world = invoke('GameServer/World/World'); world.user = { sessions: [player, bot] }; const botTwo = session('bot_dialogue_two', actor(202, 'Belen', 100)); + bot.followPlayerSession = player; + bot.partyCompanion = true; + botTwo.followPlayerSession = player; + botTwo.partyCompanion = true; BotManager.sessions = [bot, botTwo]; const routes = []; BotDialogueArbiter.route = (input) => { @@ -126,8 +132,25 @@ async function main() { }; BotManager.handlePlayerSpeak(player, { text: 'bots, can anyone help?' }); assert.deepStrictEqual(routes, ['Aria'], 'a group message must select one hot responder'); + + delete player.botDialogueResponderId; + delete player.botDialogueResponderAt; + routes.length = 0; + BotManager.handlePlayerSpeak(player, { text: 'nice weather today' }); + assert.deepStrictEqual(routes, [], 'unaddressed local chat must not fan out to hot bots'); + + routes.length = 0; + BotManager.handlePlayerSpeak(player, { text: 'Belen, are you there?' }); + assert.deepStrictEqual(routes, ['Belen'], 'a named bot message must select only the named responder'); + + delete player.botDialogueResponderId; + delete player.botDialogueResponderAt; + routes.length = 0; + BotManager.handlePlayerSpeak(player, { kind: 3, text: 'party, regroup' }); + assert.deepStrictEqual(routes, ['Aria'], 'party chat must select one companion responder'); } finally { BotBrain.maybeThink = originalBrain; + BotBrain.isEnabled = originalEnabled; BotAI.getStatus = originalStatus; BotManager.botTell = originalBotTell; BotSocialMemory.recordEvent = originalRecordEvent; From ff6b3960a13e0f55d2627e39cd2ec68db582575e Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:05:04 -0400 Subject: [PATCH 30/59] Separate hot dialogue Langfuse stages --- src/GameServer/Bot/AI/BotBrain.js | 43 +++++++++++++++++-------- tests/test_hot_bot_conversation_flow.js | 21 ++++++++++++ 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 8af1a2d3..8a2bd542 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -774,33 +774,33 @@ const BotBrain = { const runTurn = async () => { try { const stageMetadata = { + event, + source: requestContext?.source || event, + channel: requestContext?.channel || null, botId: bot.fetchId?.(), playerId: requestContext?.playerSession?.actor?.fetchId?.() || requestContext?.playerId || null, turnId, requestId: requestContext?.requestId || turnId, sessionId: conversationSessionId(session, requestContext) }; - LangfuseTracing.withObservation( + await LangfuseTracing.withObservation( 'bot.context.assemble', { + event, + playerMessage: text || '', fragments: requestContext?.assembledContext?.telemetry?.included || [], estimatedTokens: requestContext?.assembledContext?.estimatedTokens || null }, stageMetadata, - async () => ({ - included: requestContext?.assembledContext?.telemetry?.included || [], - estimatedTokens: requestContext?.assembledContext?.estimatedTokens || null - }), + async () => requestContext?.assembledContext || null, 'chain' - ).catch(() => {}); + ); + const providerDecision = await requestDecision(payload, cfg, session, requestContext, visiblePlayers); providerResult = await LangfuseTracing.withObservation( 'bot.schema.validate', - { event, requestedAction: null }, + { event, providerOutcome: providerDecision?.telemetry?.outcome || providerDecision?.llmTelemetry?.outcome || null }, stageMetadata, - async () => validateDecisionResult( - await requestDecision(payload, cfg, session, requestContext, visiblePlayers), - session - ), + async () => validateDecisionResult(providerDecision, session), 'chain' ); rememberTelemetry(session, providerResult); @@ -823,15 +823,30 @@ const BotBrain = { providerResult = applyPartyPolicy(session, providerResult, requestContext, text); recordInferenceEvent(session, event, providerResult, requestContext); const actionResult = await LangfuseTracing.withObservation( - 'bot.reply.deliver', - { action: providerResult.action || null, reply: providerResult.reply || null }, + 'bot.tool.execute', + { + action: providerResult.action || null, + confidence: providerResult.confidence || null, + worldRevision: requestContext?.preparedWorldRevision || null + }, stageMetadata, async () => applyDecision(session, providerResult, visiblePlayers, requestContext), - 'chain' + 'tool' ); const playerVisibleReply = actionResult.applied ? providerResult.reply || null : BotAgentTools.rejectionReply(actionResult); + await LangfuseTracing.withObservation( + 'bot.reply.deliver', + { + action: providerResult.action || null, + reply: playerVisibleReply, + applied: actionResult.applied === true + }, + stageMetadata, + async () => playerVisibleReply, + 'chain' + ); providerResult = { ...providerResult, applied: actionResult.applied === true, diff --git a/tests/test_hot_bot_conversation_flow.js b/tests/test_hot_bot_conversation_flow.js index 08552a97..72d609ef 100644 --- a/tests/test_hot_bot_conversation_flow.js +++ b/tests/test_hot_bot_conversation_flow.js @@ -5,6 +5,7 @@ require('../src/Global'); const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); const BotBrainContext = invoke('GameServer/Bot/AI/BotBrainContext'); const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); const World = invoke('GameServer/World/World'); function actor(id, name, x = 0) { @@ -32,8 +33,11 @@ async function main() { const originalConfig = options.default.OpenRouter; const originalWorldUser = World.user; const originalCompactStatus = BotBrainContext.compactStatus; + const originalWithObservation = LangfuseTracing.withObservation; + const originalWithRootObservation = LangfuseTracing.withRootObservation; let firstRequestRelease; const requests = []; + const observations = []; try { options.default.OpenRouter = { @@ -54,6 +58,14 @@ async function main() { }; World.user = { sessions: [playerSession, botSession] }; BotBrainContext.compactStatus = (_session, status) => status; + LangfuseTracing.withObservation = (name, input, metadata, work) => { + observations.push(name); + return Promise.resolve(work(null)); + }; + LangfuseTracing.withRootObservation = (name, input, metadata, work) => { + observations.push(name); + return Promise.resolve(work(null)); + }; OpenRouterGateway.resetCircuit(); OpenRouterGateway.setTransport(async (_url, init) => { requests.push(JSON.parse(init.body)); @@ -121,6 +133,10 @@ async function main() { assert.strictEqual(thirdStarted, true); assert.strictEqual(botSession.pendingBrainTurns.length, 2, 'queued turns should be retained in FIFO order'); + for (let attempt = 0; attempt < 20 && typeof firstRequestRelease !== 'function'; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + assert.strictEqual(typeof firstRequestRelease, 'function', 'the first provider request should start'); firstRequestRelease(); await new Promise((resolve) => setTimeout(resolve, 80)); assert.strictEqual(requests.length, 3, 'all queued turns should be submitted after the first turn completes'); @@ -129,10 +145,15 @@ async function main() { secondPayload.conversation.recentTurns.map((turn) => turn.text), ['first', 'second'] ); + for (const stage of ['hot-bot.dialogue', 'bot.context.assemble', 'openrouter.generation', 'bot.schema.validate', 'bot.tool.execute', 'bot.reply.deliver']) { + assert.strictEqual(observations.filter((name) => name === stage).length, 3, `${stage} should be emitted once per queued turn`); + } } finally { options.default.OpenRouter = originalConfig; World.user = originalWorldUser; BotBrainContext.compactStatus = originalCompactStatus; + LangfuseTracing.withObservation = originalWithObservation; + LangfuseTracing.withRootObservation = originalWithRootObservation; OpenRouterGateway.resetCircuit(); OpenRouterGateway.resetTransport(); } From a5e5b63e1ef86b3572197c1d07e42c9050b36eda Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:06:41 -0400 Subject: [PATCH 31/59] Trace cold chat context validation and delivery --- src/GameServer/Bot/AI/BotRemoteChat.js | 111 ++++++++++++++++--------- tests/test_cold_bot_chat.js | 18 ++++ 2 files changed, 91 insertions(+), 38 deletions(-) diff --git a/src/GameServer/Bot/AI/BotRemoteChat.js b/src/GameServer/Bot/AI/BotRemoteChat.js index 09979814..a2452b47 100644 --- a/src/GameServer/Bot/AI/BotRemoteChat.js +++ b/src/GameServer/Bot/AI/BotRemoteChat.js @@ -196,7 +196,16 @@ async function requestLlmReply(payload, cfg, turn, state, playerSession) { llmTelemetry: result.telemetry }; } - if (!result.data) return null; + return { + data: result.data || null, + usage: result.usage, + llmTelemetry: result.telemetry + }; +} + +function validateLlmReply(result) { + if (result?.providerFailure) return result; + if (!result?.data) return null; const parsed = result.data; const BotChatText = invoke('GameServer/Bot/AI/BotChatText'); @@ -211,7 +220,7 @@ async function requestLlmReply(payload, cfg, turn, state, playerSession) { confidence: Number(parsed.confidence || 0), llm: true, usage: result.usage, - llmTelemetry: result.telemetry + llmTelemetry: result.llmTelemetry }; } @@ -359,8 +368,6 @@ function replyForStateNow(playerSession, state, text, channel = 'client_tell') { }, async () => { const llmReady = cfg.enabled && !!cfg.apiKey; - if (!llmReady) return recordReply(playerSession, state, turn, fallback).then(() => fallback); - const stageMetadata = { event: 'cold_chat', source: 'cold_chat', @@ -370,45 +377,73 @@ function replyForStateNow(playerSession, state, text, channel = 'client_tell') { requestId: turn.turnId, sessionId: `cold-bot:${Number(state.characterId || 0)}:player:${playerSession.actor.fetchId()}` }; - return LangfuseTracing.withObservation( + await LangfuseTracing.withObservation( + 'bot.context.assemble', + { + event: 'cold_chat', + conversationTurns: payload.conversation?.recentTurns?.length || 0, + recentEvents: payload.recentEvents?.length || 0 + }, + stageMetadata, + async () => payload, + 'chain' + ); + + const deliver = (reply, extra = {}) => LangfuseTracing.withObservation( + 'bot.reply.deliver', + { + action: reply?.action || 'say', + reply: reply?.reply || null, + providerOutcome: reply?.providerOutcome || null + }, + stageMetadata, + () => recordReply(playerSession, state, turn, reply, extra).then(() => reply), + 'chain' + ); + + if (!llmReady) return deliver(fallback); + + const providerResult = await requestLlmReply(payload, cfg, turn, state, playerSession); + const result = await LangfuseTracing.withObservation( 'bot.schema.validate', - { event: 'cold_chat', responseSchema: 'bot_remote_chat' }, + { + event: 'cold_chat', + providerOutcome: providerResult?.llmTelemetry?.outcome || providerResult?.providerOutcome || null + }, stageMetadata, - () => requestLlmReply(payload, cfg, turn, state, playerSession), + async () => validateLlmReply(providerResult), 'chain' - ).then((result) => { - if (result?.providerFailure) { - state.lastRemoteChatTelemetry = result.llmTelemetry || null; - const failed = { + ); + if (result?.providerFailure) { + state.lastRemoteChatTelemetry = result.llmTelemetry || null; + const failed = { + ...fallback, + providerOutcome: result.providerOutcome, + usage: result.usage || null, + llmTelemetry: result.llmTelemetry || null + }; + return deliver(failed); + } + const reply = result || fallback; + if (reply.action === 'come_to_player') { + const actionResult = await activateNearPlayer(playerSession, state); + const confirmed = actionResult.ok; + const actionReply = confirmed + ? reply + : { ...fallback, - providerOutcome: result.providerOutcome, - usage: result.usage || null, - llmTelemetry: result.llmTelemetry || null + reason: `come_to_player:${actionResult.reason || 'rejected'}`, + providerOutcome: 'action_rejected' }; - return recordReply(playerSession, state, turn, failed).then(() => failed); - } - const reply = result || fallback; - if (reply.action === 'come_to_player') { - return activateNearPlayer(playerSession, state).then((actionResult) => { - const confirmed = actionResult.ok; - const actionReply = confirmed - ? reply - : { - ...fallback, - reason: `come_to_player:${actionResult.reason || 'rejected'}`, - providerOutcome: 'action_rejected' - }; - return recordReply(playerSession, state, turn, actionReply, { - actionResult: { ok: confirmed, reason: actionResult.reason || null } - }).then(() => ({ - ...actionReply, - action: confirmed ? 'come_to_player' : 'say', - actionResult: { ok: confirmed, reason: actionResult.reason || null } - })); - }); - } - return recordReply(playerSession, state, turn, reply).then(() => reply); - }); + return deliver({ + ...actionReply, + action: confirmed ? 'come_to_player' : 'say', + actionResult: { ok: confirmed, reason: actionResult.reason || null } + }, { + actionResult: { ok: confirmed, reason: actionResult.reason || null } + }); + } + return deliver(reply); }, 'agent' ); diff --git a/tests/test_cold_bot_chat.js b/tests/test_cold_bot_chat.js index b5fbb9ec..7778b382 100644 --- a/tests/test_cold_bot_chat.js +++ b/tests/test_cold_bot_chat.js @@ -5,6 +5,7 @@ require('../src/Global'); const BotConversationStore = invoke('GameServer/Bot/AI/BotConversationStore'); const BotRemoteChat = invoke('GameServer/Bot/AI/BotRemoteChat'); const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); const BotManager = invoke('GameServer/Bot/BotManager'); const PopulationService = invoke('GameServer/Bot/Population/PopulationService'); @@ -31,7 +32,10 @@ function response(body) { async function main() { const originalConfig = options.default.OpenRouter; + const originalWithObservation = LangfuseTracing.withObservation; + const originalWithRootObservation = LangfuseTracing.withRootObservation; const requests = []; + const observations = []; try { options.default.OpenRouter = { ...originalConfig, @@ -41,6 +45,14 @@ async function main() { remoteChatCooldownMs: 0 }; BotConversationStore.resetMemory(); + LangfuseTracing.withObservation = (name, input, metadata, work) => { + observations.push(name); + return Promise.resolve(work(null)); + }; + LangfuseTracing.withRootObservation = (name, input, metadata, work) => { + observations.push(name); + return Promise.resolve(work(null)); + }; OpenRouterGateway.resetCircuit(); OpenRouterGateway.setTransport(async (_url, init) => { requests.push(JSON.parse(init.body)); @@ -108,9 +120,15 @@ async function main() { BotManager.findSessionByName = originalFindSessionByName; PopulationService.requestActivation = originalRequestActivation; } + for (const stage of ['cold-bot.dialogue', 'bot.context.assemble', 'openrouter.generation', 'bot.schema.validate', 'bot.reply.deliver']) { + assert.strictEqual(observations.filter((name) => name === stage).length, 3, `${stage} should be emitted for every cold reply`); + } + assert.strictEqual(observations.filter((name) => name === 'bot.tool.come_to_player').length, 1, 'come_to_player should have one tool observation'); console.log('Cold bot chat checks passed'); } finally { options.default.OpenRouter = originalConfig; + LangfuseTracing.withObservation = originalWithObservation; + LangfuseTracing.withRootObservation = originalWithRootObservation; OpenRouterGateway.resetCircuit(); OpenRouterGateway.resetTransport(); BotConversationStore.resetMemory(); From ada81f00503d06b04324dc4b60f9c481e38708ef Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:10:39 -0400 Subject: [PATCH 32/59] Bound LLM traffic with global inference admission --- config/default.ini | 5 + config/local.example.ini | 5 + src/GameServer/Bot/AI/BotBrain.js | 18 ++- src/GameServer/Bot/AI/BotInferenceBudget.js | 115 +++++++++++++++++++- src/GameServer/Bot/AI/BotRemoteChat.js | 52 ++++++++- src/GameServer/Bot/AI/OpenRouterGateway.js | 15 +++ tests/test_bot_inference_budget.js | 32 ++++++ 7 files changed, 234 insertions(+), 8 deletions(-) diff --git a/config/default.ini b/config/default.ini index 81064026..abebd880 100644 --- a/config/default.ini +++ b/config/default.ini @@ -45,6 +45,11 @@ hotBotBudgetEnabled = true hotBotMaxRequestsPerMinute = 6 hotBotPromptTokenBudgetPerMinute = 12000 hotBotCompletionTokenBudgetPerMinute = 2400 +hotBotGlobalBudgetEnabled = true +hotBotGlobalMaxInFlight = 32 +hotBotGlobalMaxRequestsPerMinute = 240 +hotBotGlobalPromptTokenBudgetPerMinute = 300000 +hotBotGlobalCompletionTokenBudgetPerMinute = 64000 debug = false negotiationEnabled = false diff --git a/config/local.example.ini b/config/local.example.ini index dbc36400..190dfbf4 100644 --- a/config/local.example.ini +++ b/config/local.example.ini @@ -8,3 +8,8 @@ hotBotBudgetEnabled = true hotBotMaxRequestsPerMinute = 6 hotBotPromptTokenBudgetPerMinute = 12000 hotBotCompletionTokenBudgetPerMinute = 2400 +hotBotGlobalBudgetEnabled = true +hotBotGlobalMaxInFlight = 32 +hotBotGlobalMaxRequestsPerMinute = 240 +hotBotGlobalPromptTokenBudgetPerMinute = 300000 +hotBotGlobalCompletionTokenBudgetPerMinute = 64000 diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 8a2bd542..cecc7bdc 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -643,14 +643,30 @@ const BotBrain = { requestContext.worldRevision = requestContext.preparedWorldRevision; } const payload = userPayload(event, session, status, visiblePlayers, text, requestContext); + const estimatedPromptTokens = estimateRequestPromptTokens(payload, session); const admission = BotInferenceBudget.reserve(session, { event, bypass: event === 'player_chat', priority: event === 'player_chat' ? 'interactive' : 'normal', - estimatedPromptTokens: estimateRequestPromptTokens(payload, session), + estimatedPromptTokens, maxCompletionTokens: cfg.maxTokens }); if (!admission.ok) { + LangfuseTracing.withObservation( + 'bot.inference.admission', + { event, estimatedPromptTokens }, + { + event, + source: requestContext?.source || event, + botId: bot.fetchId?.(), + playerId: requestContext?.playerSession?.actor?.fetchId?.() || requestContext?.playerId || null, + turnId: requestContext?.conversationTurn?.turnId || requestContext?.requestId || null, + reason: admission.reason, + retryAfterMs: admission.retryAfterMs || 0 + }, + async () => ({ ok: false, reason: admission.reason, retryAfterMs: admission.retryAfterMs || 0 }), + 'chain' + ).catch(() => {}); session.lastBrainBudget = { ...BotInferenceBudget.status(session), deniedReason: admission.reason, diff --git a/src/GameServer/Bot/AI/BotInferenceBudget.js b/src/GameServer/Bot/AI/BotInferenceBudget.js index 19998cd7..4104f358 100644 --- a/src/GameServer/Bot/AI/BotInferenceBudget.js +++ b/src/GameServer/Bot/AI/BotInferenceBudget.js @@ -2,6 +2,7 @@ const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); const WINDOW_MS = 60 * 1000; const buckets = new Map(); +const globalBucket = { entries: [], inFlight: 0, lastDeniedAt: 0, lastDeniedReason: null }; let reservationSequence = 0; function actorId(session) { @@ -27,6 +28,17 @@ function bucketFor(id) { return buckets.get(id); } +function globalConfig() { + const cfg = config(); + return { + enabled: cfg.hotBotGlobalBudgetEnabled !== false, + maxInFlight: Math.max(1, Math.floor(number(cfg.hotBotGlobalMaxInFlight, 32))), + maxRequests: Math.max(1, Math.floor(number(cfg.hotBotGlobalMaxRequestsPerMinute, 240))), + promptBudget: Math.max(240, number(cfg.hotBotGlobalPromptTokenBudgetPerMinute, 300000)), + completionBudget: Math.max(64, number(cfg.hotBotGlobalCompletionTokenBudgetPerMinute, 64000)) + }; +} + function prune(bucket, now) { bucket.entries = bucket.entries.filter((entry) => now - entry.startedAt < WINDOW_MS); } @@ -60,6 +72,17 @@ function rejection(bucket, reason, now, retryAfterMs = 0) { }; } +function globalRejection(reason, now, retryAfterMs = 0) { + globalBucket.lastDeniedAt = now; + globalBucket.lastDeniedReason = reason; + return { + ok: false, + reason, + retryAfterMs: Math.max(0, Math.ceil(retryAfterMs)), + status: null + }; +} + function reserve(session, input = {}) { const id = actorId(session); if (!id) return { ok: false, reason: 'missing_bot' }; @@ -91,6 +114,38 @@ function reserve(session, input = {}) { return rejection(bucket, 'inference_budget_completion_tokens', now, oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS); } + const global = globalConfig(); + if (global.enabled) { + prune(globalBucket, now); + if (globalBucket.inFlight >= global.maxInFlight) { + return globalRejection('inference_budget_global_concurrency', now, 1000); + } + if (globalBucket.entries.length >= global.maxRequests) { + const oldest = globalBucket.entries[0]; + return globalRejection( + 'inference_budget_global_requests', + now, + oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS + ); + } + if (sum(globalBucket, 'promptTokens') + promptTokens > global.promptBudget) { + const oldest = globalBucket.entries[0]; + return globalRejection( + 'inference_budget_global_prompt_tokens', + now, + oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS + ); + } + if (sum(globalBucket, 'completionTokens') + completionTokens > global.completionBudget) { + const oldest = globalBucket.entries[0]; + return globalRejection( + 'inference_budget_global_completion_tokens', + now, + oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS + ); + } + } + const reservation = { id: `inference-${id}-${++reservationSequence}`, botId: id, @@ -102,9 +157,15 @@ function reserve(session, input = {}) { settled: false, bypass: input.bypass === true, event: String(input.event || 'hot_decision').slice(0, 48), - priority: String(input.priority || 'normal').slice(0, 24) + priority: String(input.priority || 'normal').slice(0, 24), + globalEntry: null }; bucket.entries.push(reservation); + if (global.enabled) { + reservation.globalEntry = reservation; + globalBucket.entries.push(reservation); + globalBucket.inFlight += 1; + } return { ok: true, bypassed: input.bypass === true, reservation, status: status(session, now) }; } @@ -121,9 +182,36 @@ function settle(reservation, usage = null) { reservation.totalTokens = reservation.promptTokens + reservation.completionTokens; reservation.cost = Number.isFinite(Number(usage?.cost)) ? Number(usage.cost) : null; reservation.settled = true; + if (reservation.globalEntry) globalBucket.inFlight = Math.max(0, globalBucket.inFlight - 1); return true; } +function globalStatus(now = Date.now()) { + const cfg = globalConfig(); + prune(globalBucket, now); + const promptTokens = sum(globalBucket, 'promptTokens'); + const completionTokens = sum(globalBucket, 'completionTokens'); + const oldest = globalBucket.entries[0]; + return { + enabled: cfg.enabled, + windowMs: WINDOW_MS, + inFlight: globalBucket.inFlight, + maxInFlight: cfg.maxInFlight, + requests: globalBucket.entries.length, + maxRequests: cfg.maxRequests, + promptTokens, + promptBudget: cfg.promptBudget, + completionTokens, + completionBudget: cfg.completionBudget, + remainingRequests: Math.max(0, cfg.maxRequests - globalBucket.entries.length), + remainingPromptTokens: Math.max(0, cfg.promptBudget - promptTokens), + remainingCompletionTokens: Math.max(0, cfg.completionBudget - completionTokens), + nextResetAt: oldest ? oldest.startedAt + WINDOW_MS : null, + lastDeniedReason: globalBucket.lastDeniedReason || null, + lastDeniedAt: globalBucket.lastDeniedAt || null + }; +} + function status(session, now = Date.now()) { const id = actorId(session); const cfg = config(); @@ -143,7 +231,8 @@ function status(session, now = Date.now()) { remainingPromptTokens: 0, remainingCompletionTokens: 0, nextResetAt: null, - lastDeniedReason: null + lastDeniedReason: null, + global: globalStatus(now) }; } @@ -171,7 +260,8 @@ function status(session, now = Date.now()) { remainingCompletionTokens: Math.max(0, completionBudget - completionTokens), nextResetAt: oldest ? oldest.startedAt + WINDOW_MS : null, lastDeniedReason: bucket.lastDeniedReason || null, - lastDeniedAt: bucket.lastDeniedAt || null + lastDeniedAt: bucket.lastDeniedAt || null, + global: globalStatus(now) }; } @@ -182,13 +272,30 @@ const BotInferenceBudget = { settle, status, snapshot: status, + globalStatus, reset(session = null) { if (session) { const id = actorId(session); - if (id) buckets.delete(id); + if (id) { + const bucket = buckets.get(id); + if (bucket) { + bucket.entries.forEach((entry) => { + if (entry.globalEntry && !entry.settled) { + globalBucket.inFlight = Math.max(0, globalBucket.inFlight - 1); + } + entry.globalEntry = null; + }); + } + globalBucket.entries = globalBucket.entries.filter((entry) => entry.botId !== id); + buckets.delete(id); + } return; } buckets.clear(); + globalBucket.entries = []; + globalBucket.inFlight = 0; + globalBucket.lastDeniedAt = 0; + globalBucket.lastDeniedReason = null; reservationSequence = 0; }, bucketCount() { return buckets.size; } diff --git a/src/GameServer/Bot/AI/BotRemoteChat.js b/src/GameServer/Bot/AI/BotRemoteChat.js index a2452b47..15629433 100644 --- a/src/GameServer/Bot/AI/BotRemoteChat.js +++ b/src/GameServer/Bot/AI/BotRemoteChat.js @@ -3,6 +3,7 @@ const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); const LifeEvents = invoke('GameServer/Bot/Population/BotLifeEvents'); const BotPersona = invoke('GameServer/Bot/AI/BotPersona'); const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); +const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); const BotConversationService = invoke('GameServer/Bot/AI/BotConversationService'); const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); @@ -15,6 +16,10 @@ function config() { return OpenRouterGateway.config({ maxTokens: 160, timeoutMs: 0 }); } +function estimatePromptTokens(payload) { + try { return Math.max(1, Math.ceil(JSON.stringify(payload).length / 4)); } catch (_) { return 1; } +} + function enqueue(key, work) { const previous = queues.get(key) || Promise.resolve(); const current = previous.catch(() => {}).then(work); @@ -354,7 +359,24 @@ function replyForStateNow(playerSession, state, text, channel = 'client_tell') { } }; - return LangfuseTracing.withRootObservation( + const llmReady = cfg.enabled && !!cfg.apiKey; + const estimatedPromptTokens = estimatePromptTokens({ + messages: [ + { role: 'system', content: systemPrompt() }, + { role: 'user', content: JSON.stringify(payload) } + ] + }); + const admission = llmReady + ? BotInferenceBudget.reserveForBotId(state.characterId, { + event: 'cold_chat', + bypass: true, + priority: 'interactive', + estimatedPromptTokens, + maxCompletionTokens: cfg.maxTokens + }) + : { ok: false, reason: 'disabled', reservation: null }; + + const rootPromise = LangfuseTracing.withRootObservation( 'cold-bot.dialogue', payload, { @@ -367,7 +389,6 @@ function replyForStateNow(playerSession, state, text, channel = 'client_tell') { sessionId: `cold-bot:${Number(state.characterId || 0)}:player:${playerSession.actor.fetchId()}` }, async () => { - const llmReady = cfg.enabled && !!cfg.apiKey; const stageMetadata = { event: 'cold_chat', source: 'cold_chat', @@ -401,7 +422,25 @@ function replyForStateNow(playerSession, state, text, channel = 'client_tell') { 'chain' ); - if (!llmReady) return deliver(fallback); + if (!llmReady || !admission.ok) { + if (llmReady && !admission.ok) { + await LangfuseTracing.withObservation( + 'bot.inference.admission', + { event: 'cold_chat', estimatedPromptTokens }, + { ...stageMetadata, reason: admission.reason, retryAfterMs: admission.retryAfterMs || 0 }, + async () => ({ ok: false, reason: admission.reason, retryAfterMs: admission.retryAfterMs || 0 }), + 'chain' + ); + } + const failed = !llmReady + ? fallback + : { + ...fallback, + providerOutcome: admission.reason, + reason: admission.reason + }; + return deliver(failed); + } const providerResult = await requestLlmReply(payload, cfg, turn, state, playerSession); const result = await LangfuseTracing.withObservation( @@ -447,6 +486,13 @@ function replyForStateNow(playerSession, state, text, channel = 'client_tell') { }, 'agent' ); + return rootPromise.then((result) => { + BotInferenceBudget.settle(admission.reservation, result?.usage || result?.llmTelemetry?.usage); + return result; + }, (error) => { + BotInferenceBudget.settle(admission.reservation); + throw error; + }); }); } diff --git a/src/GameServer/Bot/AI/OpenRouterGateway.js b/src/GameServer/Bot/AI/OpenRouterGateway.js index f8bb50da..cd961f63 100644 --- a/src/GameServer/Bot/AI/OpenRouterGateway.js +++ b/src/GameServer/Bot/AI/OpenRouterGateway.js @@ -23,6 +23,11 @@ const DEFAULTS = Object.freeze({ hotBotMaxRequestsPerMinute: 6, hotBotPromptTokenBudgetPerMinute: 12000, hotBotCompletionTokenBudgetPerMinute: 2400, + hotBotGlobalBudgetEnabled: true, + hotBotGlobalMaxInFlight: 32, + hotBotGlobalMaxRequestsPerMinute: 240, + hotBotGlobalPromptTokenBudgetPerMinute: 300000, + hotBotGlobalCompletionTokenBudgetPerMinute: 64000, debug: false }); @@ -83,6 +88,11 @@ function config(overrides = {}) { hotBotMaxRequestsPerMinute: num(optn.hotBotMaxRequestsPerMinute, DEFAULTS.hotBotMaxRequestsPerMinute), hotBotPromptTokenBudgetPerMinute: num(optn.hotBotPromptTokenBudgetPerMinute, DEFAULTS.hotBotPromptTokenBudgetPerMinute), hotBotCompletionTokenBudgetPerMinute: num(optn.hotBotCompletionTokenBudgetPerMinute, DEFAULTS.hotBotCompletionTokenBudgetPerMinute), + hotBotGlobalBudgetEnabled: bool(optn.hotBotGlobalBudgetEnabled, DEFAULTS.hotBotGlobalBudgetEnabled), + hotBotGlobalMaxInFlight: num(optn.hotBotGlobalMaxInFlight, DEFAULTS.hotBotGlobalMaxInFlight), + hotBotGlobalMaxRequestsPerMinute: num(optn.hotBotGlobalMaxRequestsPerMinute, DEFAULTS.hotBotGlobalMaxRequestsPerMinute), + hotBotGlobalPromptTokenBudgetPerMinute: num(optn.hotBotGlobalPromptTokenBudgetPerMinute, DEFAULTS.hotBotGlobalPromptTokenBudgetPerMinute), + hotBotGlobalCompletionTokenBudgetPerMinute: num(optn.hotBotGlobalCompletionTokenBudgetPerMinute, DEFAULTS.hotBotGlobalCompletionTokenBudgetPerMinute), debug: bool(optn.debug, DEFAULTS.debug) }; @@ -115,6 +125,11 @@ function config(overrides = {}) { hotBotMaxRequestsPerMinute: Math.max(1, num(overrides.hotBotMaxRequestsPerMinute, source.hotBotMaxRequestsPerMinute)), hotBotPromptTokenBudgetPerMinute: Math.max(240, num(overrides.hotBotPromptTokenBudgetPerMinute, source.hotBotPromptTokenBudgetPerMinute)), hotBotCompletionTokenBudgetPerMinute: Math.max(64, num(overrides.hotBotCompletionTokenBudgetPerMinute, source.hotBotCompletionTokenBudgetPerMinute)), + hotBotGlobalBudgetEnabled: bool(overrides.hotBotGlobalBudgetEnabled, source.hotBotGlobalBudgetEnabled), + hotBotGlobalMaxInFlight: Math.max(1, num(overrides.hotBotGlobalMaxInFlight, source.hotBotGlobalMaxInFlight)), + hotBotGlobalMaxRequestsPerMinute: Math.max(1, num(overrides.hotBotGlobalMaxRequestsPerMinute, source.hotBotGlobalMaxRequestsPerMinute)), + hotBotGlobalPromptTokenBudgetPerMinute: Math.max(240, num(overrides.hotBotGlobalPromptTokenBudgetPerMinute, source.hotBotGlobalPromptTokenBudgetPerMinute)), + hotBotGlobalCompletionTokenBudgetPerMinute: Math.max(64, num(overrides.hotBotGlobalCompletionTokenBudgetPerMinute, source.hotBotGlobalCompletionTokenBudgetPerMinute)), debug: bool(overrides.debug, source.debug) }; } diff --git a/tests/test_bot_inference_budget.js b/tests/test_bot_inference_budget.js index 0c11aa03..13529a85 100644 --- a/tests/test_bot_inference_budget.js +++ b/tests/test_bot_inference_budget.js @@ -18,6 +18,10 @@ try { hotBotMaxRequestsPerMinute: 2, hotBotPromptTokenBudgetPerMinute: 300, hotBotCompletionTokenBudgetPerMinute: 100, + hotBotGlobalMaxInFlight: 8, + hotBotGlobalMaxRequestsPerMinute: 20, + hotBotGlobalPromptTokenBudgetPerMinute: 5000, + hotBotGlobalCompletionTokenBudgetPerMinute: 5000, maxTokens: 40 }; BotInferenceBudget.reset(); @@ -82,6 +86,34 @@ try { }); assert.strictEqual(afterWindow.ok, true, 'entries should expire from the sliding window'); + BotInferenceBudget.reset(); + options.default.OpenRouter.hotBotGlobalMaxInFlight = 1; + const globalFirst = BotInferenceBudget.reserve(session(2000202), { + event: 'state_change', estimatedPromptTokens: 10, maxCompletionTokens: 10, now: 1000 + }); + assert.strictEqual(globalFirst.ok, true); + assert.strictEqual(BotInferenceBudget.globalStatus(1000).inFlight, 1); + const globalConcurrent = BotInferenceBudget.reserve(session(2000203), { + event: 'state_change', estimatedPromptTokens: 10, maxCompletionTokens: 10, now: 1001 + }); + assert.strictEqual(globalConcurrent.ok, false); + assert.strictEqual(globalConcurrent.reason, 'inference_budget_global_concurrency'); + BotInferenceBudget.settle(globalFirst.reservation, { promptTokens: 5, completionTokens: 5 }); + assert.strictEqual(BotInferenceBudget.globalStatus(1001).inFlight, 0); + + options.default.OpenRouter.hotBotGlobalMaxRequestsPerMinute = 1; + BotInferenceBudget.reset(); + const globalRequest = BotInferenceBudget.reserve(session(2000204), { + event: 'state_change', estimatedPromptTokens: 10, maxCompletionTokens: 10, now: 2000 + }); + assert.strictEqual(globalRequest.ok, true); + BotInferenceBudget.settle(globalRequest.reservation, { promptTokens: 5, completionTokens: 5 }); + const globalRequestDenied = BotInferenceBudget.reserve(session(2000205), { + event: 'state_change', estimatedPromptTokens: 10, maxCompletionTokens: 10, now: 2001 + }); + assert.strictEqual(globalRequestDenied.ok, false); + assert.strictEqual(globalRequestDenied.reason, 'inference_budget_global_requests'); + options.default.OpenRouter.hotBotBudgetEnabled = false; BotInferenceBudget.reset(); const bypassed = BotInferenceBudget.reserve(bot, { estimatedPromptTokens: 999999, maxCompletionTokens: 999999 }); From 5cbd0de855c59d430bc33827e685691d087340e7 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:14:53 -0400 Subject: [PATCH 33/59] Cover cold chat global admission --- tests/test_cold_bot_chat.js | 42 +++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/tests/test_cold_bot_chat.js b/tests/test_cold_bot_chat.js index 7778b382..3ac503a8 100644 --- a/tests/test_cold_bot_chat.js +++ b/tests/test_cold_bot_chat.js @@ -6,6 +6,7 @@ const BotConversationStore = invoke('GameServer/Bot/AI/BotConversationStore'); const BotRemoteChat = invoke('GameServer/Bot/AI/BotRemoteChat'); const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); +const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); const BotManager = invoke('GameServer/Bot/BotManager'); const PopulationService = invoke('GameServer/Bot/Population/PopulationService'); @@ -120,8 +121,45 @@ async function main() { BotManager.findSessionByName = originalFindSessionByName; PopulationService.requestActivation = originalRequestActivation; } - for (const stage of ['cold-bot.dialogue', 'bot.context.assemble', 'openrouter.generation', 'bot.schema.validate', 'bot.reply.deliver']) { - assert.strictEqual(observations.filter((name) => name === stage).length, 3, `${stage} should be emitted for every cold reply`); + + BotInferenceBudget.reset(); + options.default.OpenRouter.hotBotGlobalMaxInFlight = 1; + options.default.OpenRouter.hotBotGlobalMaxRequestsPerMinute = 50; + options.default.OpenRouter.hotBotGlobalPromptTokenBudgetPerMinute = 50000; + options.default.OpenRouter.hotBotGlobalCompletionTokenBudgetPerMinute = 5000; + const beforeAdmissionRequests = requests.length; + let releaseAdmissionRequest; + OpenRouterGateway.setTransport(async (_url, init) => { + requests.push(JSON.parse(init.body)); + if (requests.length === beforeAdmissionRequests + 1) { + await new Promise((resolve) => { releaseAdmissionRequest = resolve; }); + } + return response({ + choices: [{ message: { content: JSON.stringify({ + action: 'say', reply: 'admission-reply', reason: 'test_reply', confidence: 0.95 + }) } }] + }); + }); + const blockedState = { ...state, characterId: 9111, accountName: 'blocked_cold_chat', name: 'BlockedColdChat' }; + const firstAdmission = BotRemoteChat.replyForState(playerSession, blockedState, 'first admission'); + for (let attempt = 0; attempt < 20 && typeof releaseAdmissionRequest !== 'function'; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + assert.strictEqual(typeof releaseAdmissionRequest, 'function', 'the first cold request should occupy the global slot'); + const secondAdmission = await BotRemoteChat.replyForState( + playerSession, + { ...state, characterId: 9112, accountName: 'rejected_cold_chat', name: 'RejectedColdChat' }, + 'second admission' + ); + assert.strictEqual(secondAdmission.reason, 'inference_budget_global_concurrency'); + assert.strictEqual(requests.length, beforeAdmissionRequests + 1, 'global admission must reject the second cold request before OpenRouter'); + releaseAdmissionRequest(); + await firstAdmission; + for (const stage of ['cold-bot.dialogue', 'bot.context.assemble', 'bot.reply.deliver']) { + assert.strictEqual(observations.filter((name) => name === stage).length, 5, `${stage} should be emitted for every cold reply`); + } + for (const stage of ['openrouter.generation', 'bot.schema.validate']) { + assert.strictEqual(observations.filter((name) => name === stage).length, 4, `${stage} should be emitted for every admitted cold request`); } assert.strictEqual(observations.filter((name) => name === 'bot.tool.come_to_player').length, 1, 'come_to_player should have one tool observation'); console.log('Cold bot chat checks passed'); From f056978eef2ab773c057abbd00ce948a2e0bc2ac Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:10:01 -0400 Subject: [PATCH 34/59] Fix legacy SQLite conversation migration startup --- database/sql/sqlite.sql | 1 - scripts/run-tests.js | 1 + .../test_sqlite_bot_conversation_migration.js | 85 +++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 tests/test_sqlite_bot_conversation_migration.js diff --git a/database/sql/sqlite.sql b/database/sql/sqlite.sql index 8ca90363..9c480f88 100644 --- a/database/sql/sqlite.sql +++ b/database/sql/sqlite.sql @@ -272,7 +272,6 @@ CREATE TABLE IF NOT EXISTS bot_conversation_messages ( ); CREATE INDEX IF NOT EXISTS bot_conversation_messages_recent ON bot_conversation_messages(conversationId, id DESC); CREATE INDEX IF NOT EXISTS bot_conversation_messages_turn ON bot_conversation_messages(conversationId, turnId, role); -CREATE INDEX IF NOT EXISTS bot_conversation_messages_order ON bot_conversation_messages(conversationId, compacted, turnOrdinal, messageOrder, id); CREATE TABLE IF NOT EXISTS bot_activity_journal ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/scripts/run-tests.js b/scripts/run-tests.js index a72be925..51cac301 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -112,6 +112,7 @@ const tests = [ 'tests/test_cast_interrupt.js', 'tests/test_character_write_queue.js', 'tests/test_hot_bot_load_test.js', + 'tests/test_sqlite_bot_conversation_migration.js', 'tests/test_sqlite_account_casefold.js', 'tests/test_character_status_persistence.js', 'tests/test_change_class.js', diff --git a/tests/test_sqlite_bot_conversation_migration.js b/tests/test_sqlite_bot_conversation_migration.js new file mode 100644 index 00000000..5d3ce0b9 --- /dev/null +++ b/tests/test_sqlite_bot_conversation_migration.js @@ -0,0 +1,85 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { DatabaseSync } = require('node:sqlite'); + +require('../src/Global'); + +const Database = invoke('Database'); +const databasePath = path.join(process.cwd(), 'tmp', 'test-sqlite-bot-conversation-migration.sqlite'); + +fs.rmSync(databasePath, { force: true }); + +// Reproduce a database created before the conversation ordering/compaction +// columns were introduced. The bootstrap SQL must be safe to run before the +// additive migration gets a chance to add those columns. +const legacy = new DatabaseSync(databasePath); +legacy.exec(` + CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, appliedAt INTEGER NOT NULL); + CREATE TABLE bot_conversations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playerId INTEGER NOT NULL, + botId INTEGER NOT NULL, + summary TEXT NOT NULL DEFAULT '', + summaryThroughId INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL DEFAULT 0, + createdAt INTEGER NOT NULL DEFAULT 0, + updatedAt INTEGER NOT NULL DEFAULT 0, + UNIQUE(playerId, botId) + ); + CREATE TABLE bot_conversation_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversationId INTEGER NOT NULL, + turnId TEXT NOT NULL, + role TEXT NOT NULL, + channel TEXT NOT NULL DEFAULT 'local', + text TEXT NOT NULL DEFAULT '', + requestId TEXT, + delivered INTEGER NOT NULL DEFAULT 1, + createdAt INTEGER NOT NULL DEFAULT 0, + metaJson TEXT, + UNIQUE(conversationId, turnId, role) + ); + INSERT INTO schema_migrations(version, appliedAt) + VALUES (1, 0), (2, 0), (3, 0), (4, 0), (5, 0), (6, 0), (7, 0); + INSERT INTO bot_conversations(playerId, botId, createdAt, updatedAt) + VALUES (1, 2, 1, 1); + INSERT INTO bot_conversation_messages(conversationId, turnId, role, text) + VALUES (1, 'legacy-turn', 'player', 'hello'); +`); +legacy.close(); + +options.default.Database.path = path.relative(process.cwd(), databasePath); +Database.init(); + +(async () => { + const columns = await Database.execute(['PRAGMA table_info(bot_conversation_messages)'], 'test:migration-columns'); + const names = columns.map((column) => column.name); + assert(names.includes('turnOrdinal'), 'legacy conversation table must receive turnOrdinal'); + assert(names.includes('messageOrder'), 'legacy conversation table must receive messageOrder'); + assert(names.includes('compacted'), 'legacy conversation table must receive compacted'); + + const index = await Database.execute([ + "SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?", + ['bot_conversation_messages_order'] + ], 'test:migration-index'); + assert.strictEqual(index.length, 1, 'the ordering index must be created after migration columns exist'); + + const migrations = await Database.execute(['SELECT version FROM schema_migrations ORDER BY version'], 'test:migration-versions'); + assert.strictEqual(migrations.at(-1).version, 8, 'conversation schema migration must complete on a legacy database'); + + const migratedMessage = await Database.execute([ + 'SELECT turnOrdinal, messageOrder, compacted FROM bot_conversation_messages WHERE turnId = ?', + ['legacy-turn'] + ], 'test:migration-backfill'); + assert.deepStrictEqual( + migratedMessage.map((row) => [row.turnOrdinal, row.messageOrder, row.compacted]), + [[1, 0, 0]], + 'legacy conversation messages must be backfilled with canonical ordering' + ); + + console.log('sqlite legacy bot conversation migration ok'); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From b59361ca741492484590ea612d2d17f28e3d4971 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:10:19 -0400 Subject: [PATCH 35/59] Allow reasoning-aware interactive bot completions --- config/default.ini | 5 + src/GameServer/Bot/AI/BotBrain.js | 3 +- src/GameServer/Bot/AI/BotRemoteChat.js | 3 +- src/GameServer/Bot/AI/OpenRouterGateway.js | 109 ++++++++++++++++++--- tests/test_hot_bot_schema_repair.js | 5 +- tests/test_openrouter_gateway.js | 9 +- 6 files changed, 114 insertions(+), 20 deletions(-) diff --git a/config/default.ini b/config/default.ini index abebd880..6eb0482f 100644 --- a/config/default.ini +++ b/config/default.ini @@ -30,6 +30,11 @@ apiKey = model = google/gemini-2.5-flash-lite temperature = 0.35 maxTokens = 320 +maxCompletionTokens = +interactiveMaxCompletionTokens = 0 +reasoningEnabled = true +reasoningEffort = high +reasoningExclude = true timeoutMs = 3500 cooldownMs = 45000 chatCooldownMs = 0 diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index cecc7bdc..dfb9e0a8 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -13,7 +13,7 @@ const BotAvailability = invoke('GameServer/Bot/AI/BotAvailability'); const ALLOWED_PLANS = ['hunting', 'following', 'resting', 'shopping', 'pk_hunting', 'merchant', 'getting_buffed']; const ALLOWED_EVENTS = new Set(['player_chat', 'state_change']); function config() { - return OpenRouterGateway.config({ maxTokens: 640 }); + return OpenRouterGateway.config(); } function debugSkip(session, cfg, reason) { @@ -297,6 +297,7 @@ async function requestDecision(payload, cfg, session, requestContext, visiblePla ? `hot-chat:${botId}:${playerId}` : 'hot-background', circuitBreaker: requestContext?.conversationTurn ? false : true, + interactive: !!requestContext?.conversationTurn, timeoutMs: requestContext?.conversationTurn ? 0 : cfg.timeoutMs, requestId: requestContext?.requestId || `hot-${botId}-${Date.now()}`, sessionId: `hot-bot:${botId}:player:${playerId || 'none'}`, diff --git a/src/GameServer/Bot/AI/BotRemoteChat.js b/src/GameServer/Bot/AI/BotRemoteChat.js index 15629433..5c742bc9 100644 --- a/src/GameServer/Bot/AI/BotRemoteChat.js +++ b/src/GameServer/Bot/AI/BotRemoteChat.js @@ -13,7 +13,7 @@ const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); const queues = new Map(); function config() { - return OpenRouterGateway.config({ maxTokens: 160, timeoutMs: 0 }); + return OpenRouterGateway.config({ timeoutMs: 0 }); } function estimatePromptTokens(payload) { @@ -176,6 +176,7 @@ async function requestLlmReply(payload, cfg, turn, state, playerSession) { config: cfg, circuitKey: `cold-chat:${botId}:${playerId}`, circuitBreaker: false, + interactive: true, timeoutMs: 0, requestId: turn.turnId, sessionId, diff --git a/src/GameServer/Bot/AI/OpenRouterGateway.js b/src/GameServer/Bot/AI/OpenRouterGateway.js index cd961f63..e3823736 100644 --- a/src/GameServer/Bot/AI/OpenRouterGateway.js +++ b/src/GameServer/Bot/AI/OpenRouterGateway.js @@ -7,6 +7,11 @@ const DEFAULTS = Object.freeze({ model: 'google/gemini-2.5-flash-lite', temperature: 0.35, maxTokens: 320, + maxCompletionTokens: null, + interactiveMaxCompletionTokens: 0, + reasoningEnabled: true, + reasoningEffort: 'high', + reasoningExclude: true, timeoutMs: 3500, cooldownMs: 45000, chatCooldownMs: 0, @@ -60,6 +65,12 @@ function num(value, fallback) { return Number.isFinite(parsed) ? parsed : fallback; } +function nullableNum(value, fallback = null) { + if (value === undefined || value === null || value === '') return fallback; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + function config(overrides = {}) { const optn = options.default.OpenRouter || {}; const source = { @@ -69,6 +80,14 @@ function config(overrides = {}) { model: process.env.OPENROUTER_MODEL || optn.model || DEFAULTS.model, temperature: num(optn.temperature, DEFAULTS.temperature), maxTokens: num(optn.maxTokens, DEFAULTS.maxTokens), + maxCompletionTokens: nullableNum(optn.maxCompletionTokens, DEFAULTS.maxCompletionTokens), + interactiveMaxCompletionTokens: nullableNum( + optn.interactiveMaxCompletionTokens, + DEFAULTS.interactiveMaxCompletionTokens + ), + reasoningEnabled: bool(optn.reasoningEnabled, DEFAULTS.reasoningEnabled), + reasoningEffort: String(optn.reasoningEffort || DEFAULTS.reasoningEffort), + reasoningExclude: bool(optn.reasoningExclude, DEFAULTS.reasoningExclude), timeoutMs: num(optn.timeoutMs, DEFAULTS.timeoutMs), cooldownMs: num(optn.cooldownMs, DEFAULTS.cooldownMs), chatCooldownMs: num(optn.chatCooldownMs, DEFAULTS.chatCooldownMs), @@ -104,6 +123,14 @@ function config(overrides = {}) { model: overrides.model || source.model, temperature: num(overrides.temperature, source.temperature), maxTokens: num(overrides.maxTokens, source.maxTokens), + maxCompletionTokens: nullableNum(overrides.maxCompletionTokens, source.maxCompletionTokens), + interactiveMaxCompletionTokens: nullableNum( + overrides.interactiveMaxCompletionTokens, + source.interactiveMaxCompletionTokens + ), + reasoningEnabled: bool(overrides.reasoningEnabled, source.reasoningEnabled), + reasoningEffort: String(overrides.reasoningEffort || source.reasoningEffort), + reasoningExclude: bool(overrides.reasoningExclude, source.reasoningExclude), timeoutMs: num(overrides.timeoutMs, source.timeoutMs), maxPromptPrice: num(overrides.maxPromptPrice, source.maxPromptPrice), maxCompletionPrice: num(overrides.maxCompletionPrice, source.maxCompletionPrice), @@ -224,11 +251,27 @@ function shouldRepairSchema(spec, result) { spec.schemaRepairAttempt !== true; } +function completionLimit(cfg, request = {}) { + const configured = request.maxCompletionTokens !== undefined + ? request.maxCompletionTokens + : request.interactive === true + ? cfg.interactiveMaxCompletionTokens + : (cfg.maxCompletionTokens ?? cfg.maxTokens); + const value = Number(configured); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : null; +} + function repairConfig(spec) { - const current = Number(spec.config?.maxTokens || config().maxTokens); + const source = config(spec.config || {}); + const current = completionLimit(source, spec); + const rescue = current === null + ? 32768 + : Math.max(2048, current * 2); return { ...(spec.config || {}), - maxTokens: Math.min(640, Math.max(512, current * 2)) + maxTokens: rescue, + maxCompletionTokens: rescue, + interactiveMaxCompletionTokens: rescue }; } @@ -243,6 +286,8 @@ function combinedUsage(first, second) { return { promptTokens: sum('promptTokens'), completionTokens: sum('completionTokens'), + reasoningTokens: sum('reasoningTokens'), + visibleCompletionTokens: sum('visibleCompletionTokens'), totalTokens: sum('totalTokens'), cachedPromptTokens: sum('cachedPromptTokens'), cacheWriteTokens: sum('cacheWriteTokens'), @@ -305,9 +350,19 @@ function markSuccess(key = 'default') { function normalizeUsage(usage) { if (!usage || typeof usage !== 'object') return null; + const completionTokens = Number(usage.completion_tokens || usage.completionTokens || 0); + const reasoningTokens = Number( + usage.completion_tokens_details?.reasoning_tokens ?? + usage.completionTokensDetails?.reasoningTokens ?? + usage.reasoning_tokens ?? + usage.reasoningTokens ?? + 0 + ); return { promptTokens: Number(usage.prompt_tokens || 0), - completionTokens: Number(usage.completion_tokens || 0), + completionTokens, + reasoningTokens: Math.max(0, reasoningTokens), + visibleCompletionTokens: Math.max(0, completionTokens - Math.max(0, reasoningTokens)), totalTokens: Number(usage.total_tokens || 0), cachedPromptTokens: Number(usage.prompt_tokens_details?.cached_tokens || 0), cacheWriteTokens: Number(usage.prompt_tokens_details?.cache_write_tokens || 0), @@ -366,10 +421,19 @@ async function requestUntraced(spec = {}) { const body = { model: cfg.model, messages: Array.isArray(requestData.messages) ? requestData.messages : [], - temperature: cfg.temperature, - max_tokens: cfg.maxTokens + temperature: cfg.temperature }; + const maxCompletionTokens = completionLimit(cfg, requestData); + if (maxCompletionTokens !== null) body.max_completion_tokens = maxCompletionTokens; + + if (cfg.reasoningEnabled) { + body.reasoning = { + effort: cfg.reasoningEffort, + exclude: cfg.reasoningExclude + }; + } + if (requestData.responseSchema) { body.response_format = { type: 'json_schema', @@ -470,7 +534,8 @@ async function request(spec = {}) { const input = { messages: spec.messages || [], responseSchema: spec.responseSchema?.name || null, - model: spec.config?.model || config().model + model: spec.config?.model || config().model, + interactive: spec.interactive === true }; const metadata = { requestId: spec.requestId || null, @@ -499,25 +564,41 @@ async function request(spec = {}) { } if (observation && result?.telemetry) { const usage = result.usage || result.telemetry.usage || {}; - const effectiveMaxTokens = result.telemetry.repairTriggered - ? repairConfig(spec).maxTokens - : Number((spec.config || {}).maxTokens ?? config().maxTokens); + const effectiveConfig = config(result.telemetry.repairTriggered + ? repairConfig(spec) + : (spec.config || {})); + const effectiveMaxTokens = completionLimit(effectiveConfig, { + ...spec, + interactive: spec.interactive === true + }); + const modelParameters = { + temperature: Number((spec.config || {}).temperature ?? config().temperature), + reasoning: { + enabled: effectiveConfig.reasoningEnabled, + effort: effectiveConfig.reasoningEffort, + exclude: effectiveConfig.reasoningExclude + } + }; + if (effectiveMaxTokens !== null) modelParameters.max_completion_tokens = effectiveMaxTokens; observation.update({ model: result.telemetry.model || null, - modelParameters: { - temperature: Number((spec.config || {}).temperature ?? config().temperature), - max_tokens: effectiveMaxTokens - }, + modelParameters, usageDetails: { input: Number(usage.promptTokens || 0), output: Number(usage.completionTokens || 0), + reasoning: Number(usage.reasoningTokens || 0), + visible_output: Number(usage.visibleCompletionTokens || 0), total: Number(usage.totalTokens || 0) }, costDetails: Number.isFinite(Number(usage.cost)) ? { total: Number(usage.cost) } : undefined, metadata: { outcome: result.telemetry.outcome, status: result.telemetry.status, - finishReason: result.telemetry.finishReason + finishReason: result.telemetry.finishReason, + interactive: spec.interactive === true, + reasoningTokens: Number(usage.reasoningTokens || 0), + visibleCompletionTokens: Number(usage.visibleCompletionTokens || 0), + maxCompletionTokens: effectiveMaxTokens } }); result.telemetry.traceId = observation.traceId || LangfuseTracing.activeTraceId(); diff --git a/tests/test_hot_bot_schema_repair.js b/tests/test_hot_bot_schema_repair.js index 903433c2..fe81e9ef 100644 --- a/tests/test_hot_bot_schema_repair.js +++ b/tests/test_hot_bot_schema_repair.js @@ -100,8 +100,9 @@ async function main() { } assert.strictEqual(requests.length, 2, 'hot dialogue must repair one malformed structured response'); - assert.strictEqual(requests[0].max_tokens, 640); - assert.strictEqual(requests[1].max_tokens, 640); + assert.strictEqual(requests[0].max_completion_tokens, undefined); + assert.strictEqual(requests[1].max_completion_tokens, 32768); + assert.deepStrictEqual(requests[0].reasoning, { effort: 'high', exclude: true }); assert.strictEqual(botSession.brainInFlight, false, 'repaired turn must settle'); console.log('Hot bot schema repair checks passed'); } finally { diff --git a/tests/test_openrouter_gateway.js b/tests/test_openrouter_gateway.js index 23b8db25..06620626 100644 --- a/tests/test_openrouter_gateway.js +++ b/tests/test_openrouter_gateway.js @@ -48,6 +48,7 @@ async function main() { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13, + completion_tokens_details: { reasoning_tokens: 2 }, prompt_tokens_details: { cached_tokens: 4, cache_write_tokens: 5 }, cost: 0.01 } @@ -67,6 +68,8 @@ async function main() { assert.deepStrictEqual(success.usage, { promptTokens: 10, completionTokens: 3, + reasoningTokens: 2, + visibleCompletionTokens: 1, totalTokens: 13, cachedPromptTokens: 4, cacheWriteTokens: 5, @@ -77,6 +80,8 @@ async function main() { assert.strictEqual(captured.headers.Authorization, 'Bearer test-key'); assert.strictEqual(captured.body.session_id, 'hot-bot:1:player:2'); assert.deepStrictEqual(captured.body.usage, { include: true }); + assert.strictEqual(captured.body.max_completion_tokens, 320); + assert.deepStrictEqual(captured.body.reasoning, { effort: 'high', exclude: true }); assert.strictEqual(captured.body.provider.require_parameters, true); assert.strictEqual(captured.body.response_format.type, 'json_schema'); assert.strictEqual(captured.body.response_format.json_schema.strict, true); @@ -106,8 +111,8 @@ async function main() { }); assert.strictEqual(repaired.ok, true); assert.deepStrictEqual(repaired.data, { reply: 'repaired' }); - assert.strictEqual(repairBodies[0].max_tokens, 320); - assert.strictEqual(repairBodies[1].max_tokens, 640); + assert.strictEqual(repairBodies[0].max_completion_tokens, 320); + assert.strictEqual(repairBodies[1].max_completion_tokens, 2048); assert.strictEqual(repaired.telemetry.attempts, 2); assert.strictEqual(repaired.telemetry.repairTriggered, true); assert.strictEqual(repaired.telemetry.initialRawContent, '{"reply":'); From a45d3eadc6e64a815f2e320c1149e5b3b42eb10f Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:15:07 -0400 Subject: [PATCH 36/59] Keep interactive inference ahead of soft quotas --- scripts/run-tests.js | 1 + src/GameServer/Bot/AI/BotBrain.js | 20 +++- src/GameServer/Bot/AI/BotInferenceBudget.js | 91 ++++++++++++++++--- src/GameServer/Bot/AI/BotRemoteChat.js | 23 +++-- tests/test_bot_inference_budget.js | 32 +++++++ tests/test_bot_inference_interactive_queue.js | 70 ++++++++++++++ 6 files changed, 212 insertions(+), 25 deletions(-) create mode 100644 tests/test_bot_inference_interactive_queue.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 51cac301..03f2fc33 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -9,6 +9,7 @@ const tests = [ 'tests/test_openrouter_gateway.js', 'tests/test_langfuse_tracing.js', 'tests/test_bot_inference_budget.js', + 'tests/test_bot_inference_interactive_queue.js', 'tests/test_bot_brain_state_change.js', 'tests/test_bot_llm_party_policy.js', 'tests/test_bot_conversation_store.js', diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index dfb9e0a8..32be370a 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -650,7 +650,7 @@ const BotBrain = { bypass: event === 'player_chat', priority: event === 'player_chat' ? 'interactive' : 'normal', estimatedPromptTokens, - maxCompletionTokens: cfg.maxTokens + maxCompletionTokens: event === 'player_chat' ? 0 : cfg.maxTokens }); if (!admission.ok) { LangfuseTracing.withObservation( @@ -687,6 +687,13 @@ const BotBrain = { } session.brainInFlight = true; + let reservation = admission.reservation; + const admissionReady = admission.ready + ? admission.ready.then((granted) => { + reservation = granted?.reservation || null; + return granted; + }) + : Promise.resolve(admission); const turnId = requestContext?.conversationTurn?.turnId || requestContext?.requestId || `${event}:${bot.fetchId?.()}:${Date.now()}`; if (requestContext && !requestContext.requestId) requestContext.requestId = turnId; const turnPersistence = BotLLMTurnStore.begin({ @@ -714,7 +721,7 @@ const BotBrain = { let providerResult = null; const finishTurn = () => { - BotInferenceBudget.settle(admission.reservation, providerResult?.usage); + BotInferenceBudget.settle(reservation, providerResult?.usage); const finalTelemetry = providerResult?.llmTelemetry || providerResult?.telemetry || {}; turnPersistence.then(() => BotLLMTurnStore.finish({ turnId, @@ -790,6 +797,15 @@ const BotBrain = { }; const runTurn = async () => { try { + const grantedAdmission = await admissionReady; + if (!grantedAdmission?.ok) { + providerResult = { + ok: false, + reason: grantedAdmission?.reason || 'inference_budget_unavailable', + telemetry: { outcome: grantedAdmission?.reason || 'inference_budget_unavailable' } + }; + return providerResult; + } const stageMetadata = { event, source: requestContext?.source || event, diff --git a/src/GameServer/Bot/AI/BotInferenceBudget.js b/src/GameServer/Bot/AI/BotInferenceBudget.js index 4104f358..71f443b7 100644 --- a/src/GameServer/Bot/AI/BotInferenceBudget.js +++ b/src/GameServer/Bot/AI/BotInferenceBudget.js @@ -3,6 +3,7 @@ const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); const WINDOW_MS = 60 * 1000; const buckets = new Map(); const globalBucket = { entries: [], inFlight: 0, lastDeniedAt: 0, lastDeniedReason: null }; +const globalWaiters = []; let reservationSequence = 0; function actorId(session) { @@ -53,6 +54,18 @@ function sum(bucket, field) { return bucket.entries.reduce((total, entry) => total + nonNegative(entry[field]), 0); } +function budgetEntries(bucket) { + return bucket.entries.filter((entry) => entry.bypass !== true); +} + +function budgetCount(bucket) { + return budgetEntries(bucket).length; +} + +function budgetSum(bucket, field) { + return budgetEntries(bucket).reduce((total, entry) => total + nonNegative(entry[field]), 0); +} + function knownCost(bucket) { return bucket.entries.reduce((total, entry) => total + (Number.isFinite(Number(entry.cost)) ? Number(entry.cost) : 0), 0); } @@ -83,6 +96,35 @@ function globalRejection(reason, now, retryAfterMs = 0) { }; } +function queueInteractiveReservation(session, input, now) { + let resolveReady; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + globalWaiters.push({ session, input: { ...input }, now, resolve: resolveReady }); + return { + ok: true, + bypassed: true, + queued: true, + reservation: null, + ready, + status: status(session, now) + }; +} + +function pumpGlobalWaiters() { + const global = globalConfig(); + while (globalWaiters.length > 0 && (!global.enabled || globalBucket.inFlight < global.maxInFlight)) { + const waiter = globalWaiters.shift(); + const result = reserve(waiter.session, { + ...waiter.input, + now: waiter.now, + _grantingQueued: true + }); + waiter.resolve(result); + } +} + function reserve(session, input = {}) { const id = actorId(session); if (!id) return { ok: false, reason: 'missing_bot' }; @@ -101,15 +143,15 @@ function reserve(session, input = {}) { const promptTokens = nonNegative(input.estimatedPromptTokens); const completionTokens = nonNegative(input.maxCompletionTokens ?? cfg.maxTokens); - if (input.bypass !== true && bucket.entries.length >= maxRequests) { + if (input.bypass !== true && budgetCount(bucket) >= maxRequests) { const oldest = bucket.entries[0]; return rejection(bucket, 'inference_budget_requests', now, oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS); } - if (input.bypass !== true && sum(bucket, 'promptTokens') + promptTokens > promptBudget) { + if (input.bypass !== true && budgetSum(bucket, 'promptTokens') + promptTokens > promptBudget) { const oldest = bucket.entries[0]; return rejection(bucket, 'inference_budget_prompt_tokens', now, oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS); } - if (input.bypass !== true && sum(bucket, 'completionTokens') + completionTokens > completionBudget) { + if (input.bypass !== true && budgetSum(bucket, 'completionTokens') + completionTokens > completionBudget) { const oldest = bucket.entries[0]; return rejection(bucket, 'inference_budget_completion_tokens', now, oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS); } @@ -117,10 +159,13 @@ function reserve(session, input = {}) { const global = globalConfig(); if (global.enabled) { prune(globalBucket, now); - if (globalBucket.inFlight >= global.maxInFlight) { + if (globalBucket.inFlight >= global.maxInFlight && input.bypass === true && input._grantingQueued !== true) { + return queueInteractiveReservation(session, input, now); + } + if (globalBucket.inFlight >= global.maxInFlight && input._grantingQueued !== true) { return globalRejection('inference_budget_global_concurrency', now, 1000); } - if (globalBucket.entries.length >= global.maxRequests) { + if (input.bypass !== true && budgetCount(globalBucket) >= global.maxRequests) { const oldest = globalBucket.entries[0]; return globalRejection( 'inference_budget_global_requests', @@ -128,7 +173,7 @@ function reserve(session, input = {}) { oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS ); } - if (sum(globalBucket, 'promptTokens') + promptTokens > global.promptBudget) { + if (input.bypass !== true && budgetSum(globalBucket, 'promptTokens') + promptTokens > global.promptBudget) { const oldest = globalBucket.entries[0]; return globalRejection( 'inference_budget_global_prompt_tokens', @@ -136,7 +181,7 @@ function reserve(session, input = {}) { oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS ); } - if (sum(globalBucket, 'completionTokens') + completionTokens > global.completionBudget) { + if (input.bypass !== true && budgetSum(globalBucket, 'completionTokens') + completionTokens > global.completionBudget) { const oldest = globalBucket.entries[0]; return globalRejection( 'inference_budget_global_completion_tokens', @@ -183,6 +228,7 @@ function settle(reservation, usage = null) { reservation.cost = Number.isFinite(Number(usage?.cost)) ? Number(usage.cost) : null; reservation.settled = true; if (reservation.globalEntry) globalBucket.inFlight = Math.max(0, globalBucket.inFlight - 1); + pumpGlobalWaiters(); return true; } @@ -191,6 +237,9 @@ function globalStatus(now = Date.now()) { prune(globalBucket, now); const promptTokens = sum(globalBucket, 'promptTokens'); const completionTokens = sum(globalBucket, 'completionTokens'); + const quotaPromptTokens = budgetSum(globalBucket, 'promptTokens'); + const quotaCompletionTokens = budgetSum(globalBucket, 'completionTokens'); + const quotaRequests = budgetCount(globalBucket); const oldest = globalBucket.entries[0]; return { enabled: cfg.enabled, @@ -203,12 +252,13 @@ function globalStatus(now = Date.now()) { promptBudget: cfg.promptBudget, completionTokens, completionBudget: cfg.completionBudget, - remainingRequests: Math.max(0, cfg.maxRequests - globalBucket.entries.length), - remainingPromptTokens: Math.max(0, cfg.promptBudget - promptTokens), - remainingCompletionTokens: Math.max(0, cfg.completionBudget - completionTokens), + remainingRequests: Math.max(0, cfg.maxRequests - quotaRequests), + remainingPromptTokens: Math.max(0, cfg.promptBudget - quotaPromptTokens), + remainingCompletionTokens: Math.max(0, cfg.completionBudget - quotaCompletionTokens), nextResetAt: oldest ? oldest.startedAt + WINDOW_MS : null, lastDeniedReason: globalBucket.lastDeniedReason || null, - lastDeniedAt: globalBucket.lastDeniedAt || null + lastDeniedAt: globalBucket.lastDeniedAt || null, + queuedRequests: globalWaiters.length }; } @@ -243,6 +293,9 @@ function status(session, now = Date.now()) { const completionBudget = Math.max(64, number(cfg.hotBotCompletionTokenBudgetPerMinute, 2400)); const promptTokens = sum(bucket, 'promptTokens'); const completionTokens = sum(bucket, 'completionTokens'); + const quotaPromptTokens = budgetSum(bucket, 'promptTokens'); + const quotaCompletionTokens = budgetSum(bucket, 'completionTokens'); + const quotaRequests = budgetCount(bucket); const oldest = bucket.entries[0]; return { enabled: cfg.hotBotBudgetEnabled !== false, @@ -255,9 +308,9 @@ function status(session, now = Date.now()) { completionTokens, completionBudget, cost: knownCost(bucket), - remainingRequests: Math.max(0, maxRequests - bucket.entries.length), - remainingPromptTokens: Math.max(0, promptBudget - promptTokens), - remainingCompletionTokens: Math.max(0, completionBudget - completionTokens), + remainingRequests: Math.max(0, maxRequests - quotaRequests), + remainingPromptTokens: Math.max(0, promptBudget - quotaPromptTokens), + remainingCompletionTokens: Math.max(0, completionBudget - quotaCompletionTokens), nextResetAt: oldest ? oldest.startedAt + WINDOW_MS : null, lastDeniedReason: bucket.lastDeniedReason || null, lastDeniedAt: bucket.lastDeniedAt || null, @@ -289,6 +342,13 @@ const BotInferenceBudget = { globalBucket.entries = globalBucket.entries.filter((entry) => entry.botId !== id); buckets.delete(id); } + for (let index = globalWaiters.length - 1; index >= 0; index -= 1) { + if (actorId(globalWaiters[index].session) === id) { + globalWaiters[index].resolve({ ok: false, reason: 'inference_budget_reset' }); + globalWaiters.splice(index, 1); + } + } + pumpGlobalWaiters(); return; } buckets.clear(); @@ -296,6 +356,9 @@ const BotInferenceBudget = { globalBucket.inFlight = 0; globalBucket.lastDeniedAt = 0; globalBucket.lastDeniedReason = null; + while (globalWaiters.length > 0) { + globalWaiters.shift().resolve({ ok: false, reason: 'inference_budget_reset' }); + } reservationSequence = 0; }, bucketCount() { return buckets.size; } diff --git a/src/GameServer/Bot/AI/BotRemoteChat.js b/src/GameServer/Bot/AI/BotRemoteChat.js index 5c742bc9..b436b723 100644 --- a/src/GameServer/Bot/AI/BotRemoteChat.js +++ b/src/GameServer/Bot/AI/BotRemoteChat.js @@ -373,9 +373,10 @@ function replyForStateNow(playerSession, state, text, channel = 'client_tell') { bypass: true, priority: 'interactive', estimatedPromptTokens, - maxCompletionTokens: cfg.maxTokens + maxCompletionTokens: 0 }) : { ok: false, reason: 'disabled', reservation: null }; + let reservation = admission.reservation; const rootPromise = LangfuseTracing.withRootObservation( 'cold-bot.dialogue', @@ -423,13 +424,17 @@ function replyForStateNow(playerSession, state, text, channel = 'client_tell') { 'chain' ); - if (!llmReady || !admission.ok) { - if (llmReady && !admission.ok) { + const grantedAdmission = admission.ready + ? await admission.ready + : admission; + reservation = grantedAdmission?.reservation || reservation; + if (!llmReady || !grantedAdmission?.ok) { + if (llmReady && !grantedAdmission?.ok) { await LangfuseTracing.withObservation( 'bot.inference.admission', { event: 'cold_chat', estimatedPromptTokens }, - { ...stageMetadata, reason: admission.reason, retryAfterMs: admission.retryAfterMs || 0 }, - async () => ({ ok: false, reason: admission.reason, retryAfterMs: admission.retryAfterMs || 0 }), + { ...stageMetadata, reason: grantedAdmission.reason, retryAfterMs: grantedAdmission.retryAfterMs || 0 }, + async () => ({ ok: false, reason: grantedAdmission.reason, retryAfterMs: grantedAdmission.retryAfterMs || 0 }), 'chain' ); } @@ -437,8 +442,8 @@ function replyForStateNow(playerSession, state, text, channel = 'client_tell') { ? fallback : { ...fallback, - providerOutcome: admission.reason, - reason: admission.reason + providerOutcome: grantedAdmission.reason, + reason: grantedAdmission.reason }; return deliver(failed); } @@ -488,10 +493,10 @@ function replyForStateNow(playerSession, state, text, channel = 'client_tell') { 'agent' ); return rootPromise.then((result) => { - BotInferenceBudget.settle(admission.reservation, result?.usage || result?.llmTelemetry?.usage); + BotInferenceBudget.settle(reservation, result?.usage || result?.llmTelemetry?.usage); return result; }, (error) => { - BotInferenceBudget.settle(admission.reservation); + BotInferenceBudget.settle(reservation); throw error; }); }); diff --git a/tests/test_bot_inference_budget.js b/tests/test_bot_inference_budget.js index 13529a85..ce22fb80 100644 --- a/tests/test_bot_inference_budget.js +++ b/tests/test_bot_inference_budget.js @@ -101,6 +101,38 @@ try { BotInferenceBudget.settle(globalFirst.reservation, { promptTokens: 5, completionTokens: 5 }); assert.strictEqual(BotInferenceBudget.globalStatus(1001).inFlight, 0); + BotInferenceBudget.reset(); + options.default.OpenRouter.hotBotGlobalMaxInFlight = 8; + options.default.OpenRouter.hotBotGlobalMaxRequestsPerMinute = 1; + options.default.OpenRouter.hotBotGlobalPromptTokenBudgetPerMinute = 10; + options.default.OpenRouter.hotBotGlobalCompletionTokenBudgetPerMinute = 10; + const interactive = BotInferenceBudget.reserve(session(2000206), { + event: 'player_chat', + bypass: true, + estimatedPromptTokens: 9999, + maxCompletionTokens: 0, + now: 1500 + }); + assert.strictEqual(interactive.ok, true, 'direct chat must bypass global soft quotas'); + BotInferenceBudget.settle(interactive.reservation, { + promptTokens: 9999, + completionTokens: 9999 + }); + assert.strictEqual(BotInferenceBudget.globalStatus(1500).promptTokens, 9999); + assert.strictEqual( + BotInferenceBudget.globalStatus(1500).remainingPromptTokens, + 240, + 'interactive usage is observable but excluded from the global soft quota' + ); + const normalAfterInteractive = BotInferenceBudget.reserve(session(2000207), { + event: 'state_change', + estimatedPromptTokens: 1, + maxCompletionTokens: 1, + now: 1501 + }); + assert.strictEqual(normalAfterInteractive.ok, true, 'interactive usage must not starve background admission'); + BotInferenceBudget.settle(normalAfterInteractive.reservation, { promptTokens: 1, completionTokens: 1 }); + options.default.OpenRouter.hotBotGlobalMaxRequestsPerMinute = 1; BotInferenceBudget.reset(); const globalRequest = BotInferenceBudget.reserve(session(2000204), { diff --git a/tests/test_bot_inference_interactive_queue.js b/tests/test_bot_inference_interactive_queue.js new file mode 100644 index 00000000..7de6afff --- /dev/null +++ b/tests/test_bot_inference_interactive_queue.js @@ -0,0 +1,70 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); + +function session(id) { + return { actor: { fetchId: () => id } }; +} + +function tick() { + return new Promise((resolve) => setImmediate(resolve)); +} + +async function main() { + const originalConfig = options.default.OpenRouter; + try { + options.default.OpenRouter = { + ...originalConfig, + hotBotBudgetEnabled: true, + hotBotGlobalBudgetEnabled: true, + hotBotGlobalMaxInFlight: 1, + hotBotGlobalMaxRequestsPerMinute: 1, + hotBotGlobalPromptTokenBudgetPerMinute: 240, + hotBotGlobalCompletionTokenBudgetPerMinute: 64 + }; + BotInferenceBudget.reset(); + + const first = BotInferenceBudget.reserve(session(9401), { + event: 'state_change', + estimatedPromptTokens: 20, + maxCompletionTokens: 20, + now: 1000 + }); + assert.strictEqual(first.ok, true); + + const queued = BotInferenceBudget.reserve(session(9402), { + event: 'player_chat', + bypass: true, + estimatedPromptTokens: 9000, + maxCompletionTokens: 0, + now: 1001 + }); + assert.strictEqual(queued.ok, true, 'interactive admission must queue behind concurrency'); + assert.strictEqual(queued.queued, true); + assert.strictEqual(BotInferenceBudget.globalStatus(1001).queuedRequests, 1); + + let resolved = false; + queued.ready.then(() => { resolved = true; }); + await tick(); + assert.strictEqual(resolved, false, 'queued interactive admission must wait for a global slot'); + + BotInferenceBudget.settle(first.reservation, { promptTokens: 20, completionTokens: 20 }); + const granted = await queued.ready; + assert.strictEqual(granted.ok, true); + assert(granted.reservation, 'queued admission must receive a real reservation'); + assert.strictEqual(BotInferenceBudget.globalStatus(1001).queuedRequests, 0); + BotInferenceBudget.settle(granted.reservation, { promptTokens: 9000, completionTokens: 9000 }); + assert.strictEqual(BotInferenceBudget.globalStatus(1001).inFlight, 0); + console.log('Interactive inference queue checks passed'); + } finally { + options.default.OpenRouter = originalConfig; + BotInferenceBudget.reset(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From 65e8d2d25f7ef87f1278aa36f2d33117d50953a3 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:17:19 -0400 Subject: [PATCH 37/59] Recover truncated structured bot responses --- src/GameServer/Bot/AI/LangfuseTracing.js | 2 +- src/GameServer/Bot/AI/OpenRouterGateway.js | 53 ++++++++++++++++++-- tests/test_openrouter_gateway.js | 58 ++++++++++++++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/src/GameServer/Bot/AI/LangfuseTracing.js b/src/GameServer/Bot/AI/LangfuseTracing.js index f87abbb5..f4fdaf2a 100644 --- a/src/GameServer/Bot/AI/LangfuseTracing.js +++ b/src/GameServer/Bot/AI/LangfuseTracing.js @@ -121,7 +121,7 @@ function observationStatus(value) { }; } if (value?.ok === false || [ - 'schema_error', 'provider_error', 'timeout', 'circuit_open', 'missing_api_key', + 'schema_error', 'output_truncated', 'provider_error', 'timeout', 'circuit_open', 'missing_api_key', 'disabled', 'transport_error' ].includes(outcome)) { return { diff --git a/src/GameServer/Bot/AI/OpenRouterGateway.js b/src/GameServer/Bot/AI/OpenRouterGateway.js index e3823736..e45dfdfc 100644 --- a/src/GameServer/Bot/AI/OpenRouterGateway.js +++ b/src/GameServer/Bot/AI/OpenRouterGateway.js @@ -47,6 +47,7 @@ const metrics = { timeout: 0, providerError: 0, schemaError: 0, + outputTruncated: 0, disabled: 0, missingApiKey: 0, circuitOpen: 0, @@ -210,6 +211,7 @@ function recordMetric(outcome, latencyMs, meta = {}) { if (outcome === 'timeout') metrics.timeout += 1; if (outcome === 'provider_error') metrics.providerError += 1; if (outcome === 'schema_error') metrics.schemaError += 1; + if (outcome === 'output_truncated') metrics.outputTruncated += 1; if (outcome === 'disabled') metrics.disabled += 1; if (outcome === 'missing_api_key') metrics.missingApiKey += 1; if (outcome === 'circuit_open') metrics.circuitOpen += 1; @@ -239,8 +241,10 @@ function telemetry(request, cfg, outcome, startedAt, extra = {}) { responsePreview: extra.responsePreview || null, attempts: Number(extra.attempts || 1), repairTriggered: extra.repairTriggered === true, + repairType: extra.repairType || null, initialOutcome: extra.initialOutcome || null, - initialRawContent: extra.initialRawContent || null + initialRawContent: extra.initialRawContent || null, + initialFinishReason: extra.initialFinishReason || null }; } @@ -251,6 +255,27 @@ function shouldRepairSchema(spec, result) { spec.schemaRepairAttempt !== true; } +function shouldRecoverTruncation(spec, result) { + return spec.repairSchema === true && + spec.responseSchema?.schema && + result?.reason === 'output_truncated' && + spec.schemaRepairAttempt !== true; +} + +function recoveryMessages(spec, result) { + const messages = Array.isArray(spec.messages) ? [...spec.messages] : []; + messages.push({ + role: 'system', + content: [ + 'The previous structured response could not be used.', + `Failure: ${String(result?.telemetry?.responsePreview || result?.reason || 'invalid_response').slice(0, 240)}.`, + 'Re-evaluate the original request and return one complete JSON object matching the required schema.', + 'Do not continue or quote the partial response.' + ].join(' ') + }); + return messages; +} + function completionLimit(cfg, request = {}) { const configured = request.maxCompletionTokens !== undefined ? request.maxCompletionTokens @@ -295,7 +320,7 @@ function combinedUsage(first, second) { }; } -function repairedResult(initial, repaired) { +function repairedResult(initial, repaired, repairType = 'schema') { const telemetry = repaired?.telemetry || {}; const usage = combinedUsage(initial?.usage, repaired?.usage); return { @@ -306,8 +331,10 @@ function repairedResult(initial, repaired) { usage, attempts: 2, repairTriggered: true, + repairType, initialOutcome: initial?.reason || null, initialRawContent: initial?.telemetry?.rawContent || null, + initialFinishReason: initial?.telemetry?.finishReason || null, initialUsage: initial?.usage || null } }; @@ -478,6 +505,15 @@ async function requestUntraced(spec = {}) { const finishReason = choice.finish_reason || null; const rawContent = typeof content === 'string' ? content.slice(0, 12000) : null; let data; + if (finishReason === 'length') { + return complete(requestData, cfg, 'output_truncated', startedAt, { + usage: normalizeUsage(json.usage), + finishReason, + providerRequestId: json.id || null, + rawContent, + responsePreview: 'provider_output_truncated' + }); + } try { data = parseContent(content); } catch (_) { @@ -553,9 +589,19 @@ async function request(spec = {}) { metadata, async (observation) => { let result = await requestUntraced(spec); - if (shouldRepairSchema(spec, result)) { + if (shouldRecoverTruncation(spec, result)) { + const repaired = await requestUntraced({ + ...spec, + messages: recoveryMessages(spec, result), + config: repairConfig(spec), + schemaRepairAttempt: true, + circuitBreaker: false + }); + result = repairedResult(result, repaired, 'truncation'); + } else if (shouldRepairSchema(spec, result)) { const repaired = await requestUntraced({ ...spec, + messages: recoveryMessages(spec, result), config: repairConfig(spec), schemaRepairAttempt: true, circuitBreaker: false @@ -596,6 +642,7 @@ async function request(spec = {}) { status: result.telemetry.status, finishReason: result.telemetry.finishReason, interactive: spec.interactive === true, + repairType: result.telemetry.repairType || null, reasoningTokens: Number(usage.reasoningTokens || 0), visibleCompletionTokens: Number(usage.visibleCompletionTokens || 0), maxCompletionTokens: effectiveMaxTokens diff --git a/tests/test_openrouter_gateway.js b/tests/test_openrouter_gateway.js index 06620626..ca2a884b 100644 --- a/tests/test_openrouter_gateway.js +++ b/tests/test_openrouter_gateway.js @@ -209,6 +209,63 @@ async function main() { const invalid = await OpenRouterGateway.request({ config: baseConfig, requestId: 'schema-error' }); assert.strictEqual(invalid.reason, 'schema_error'); + OpenRouterGateway.resetCircuit(); + let truncationAttempts = 0; + const truncationBodies = []; + OpenRouterGateway.setTransport(async (_url, init) => { + truncationBodies.push(JSON.parse(init.body)); + truncationAttempts += 1; + if (truncationAttempts === 1) { + return response({ + choices: [{ + finish_reason: 'length', + message: { content: '{"action":' } + }], + usage: { + prompt_tokens: 20, + completion_tokens: 640, + total_tokens: 660, + completion_tokens_details: { reasoning_tokens: 600 } + } + }); + } + return response({ + choices: [{ + finish_reason: 'stop', + message: { content: JSON.stringify({ reply: 'recovered' }) } + }], + usage: { prompt_tokens: 22, completion_tokens: 8, total_tokens: 30 } + }); + }); + const truncated = await OpenRouterGateway.request({ + config: { ...baseConfig, circuitBreakerFailureThreshold: 1 }, + circuitKey: 'truncated', + circuitBreaker: false, + interactive: true, + requestId: 'output-truncated', + messages: [{ role: 'user', content: 'recover a truncated decision' }], + responseSchema: schema, + repairSchema: true + }); + assert.strictEqual(truncated.ok, true); + assert.strictEqual(truncated.telemetry.repairType, 'truncation'); + assert.strictEqual(truncated.telemetry.initialOutcome, 'output_truncated'); + assert.strictEqual(truncationBodies[0].max_completion_tokens, undefined); + assert.strictEqual(truncationBodies[1].max_completion_tokens, 32768); + assert.strictEqual(truncated.usage.reasoningTokens, 600); + assert.strictEqual(truncated.usage.visibleCompletionTokens, 48); + + OpenRouterGateway.setTransport(async () => response({ + choices: [{ message: { content: JSON.stringify({ reply: 'circuit remains healthy' }) } }] + })); + const afterTruncation = await OpenRouterGateway.request({ + config: { ...baseConfig, circuitBreakerFailureThreshold: 1 }, + circuitKey: 'truncated', + requestId: 'after-output-truncated', + messages: [] + }); + assert.strictEqual(afterTruncation.ok, true, 'truncation must not open the provider circuit'); + OpenRouterGateway.setTransport(async () => { throw new Error('transport should not be called'); }); @@ -228,6 +285,7 @@ async function main() { assert.ok(metrics.timeout >= 1); assert.ok(metrics.providerError >= 3); assert.ok(metrics.schemaError >= 1); + assert.ok(metrics.outputTruncated >= 1); assert.ok(metrics.circuitOpen >= 1); console.log('OpenRouter gateway checks passed'); From b44b2bdf3d8b51afd85ca05a2f53cac231991539 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:22:25 -0400 Subject: [PATCH 38/59] Make chat item context and arrival actions truthful --- scripts/run-tests.js | 1 + src/GameServer/Bot/AI/BotAgentTools.js | 18 +++++++++- src/GameServer/Bot/AI/BotBrain.js | 1 + src/GameServer/Bot/AI/BotBrainContext.js | 12 +++++-- src/GameServer/Bot/AI/BotContextAssembler.js | 2 +- src/GameServer/Bot/AI/ChatArrivalState.js | 14 ++++++-- src/GameServer/Bot/BotTradeService.js | 22 +++++++++--- .../Bot/Economy/BotNegotiationService.js | 19 +++++++--- tests/test_bot_context_assembler.js | 15 ++++++-- tests/test_chat_arrival_state.js | 36 +++++++++++++++++++ tests/test_llm_trade_tools.js | 3 +- 11 files changed, 124 insertions(+), 19 deletions(-) create mode 100644 tests/test_chat_arrival_state.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 03f2fc33..f0c5ac80 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -10,6 +10,7 @@ const tests = [ 'tests/test_langfuse_tracing.js', 'tests/test_bot_inference_budget.js', 'tests/test_bot_inference_interactive_queue.js', + 'tests/test_chat_arrival_state.js', 'tests/test_bot_brain_state_change.js', 'tests/test_bot_llm_party_policy.js', 'tests/test_bot_conversation_store.js', diff --git a/src/GameServer/Bot/AI/BotAgentTools.js b/src/GameServer/Bot/AI/BotAgentTools.js index 1905fa4d..6e13d28e 100644 --- a/src/GameServer/Bot/AI/BotAgentTools.js +++ b/src/GameServer/Bot/AI/BotAgentTools.js @@ -193,6 +193,10 @@ function applyBuffTarget(session, bot, decision, targetSession) { return { applied: true, reason: `buff_requested:${buffType}` }; } +function clearChatArrival(session, reason) { + try { invoke('GameServer/Bot/AI/ChatArrivalState').clear(session, reason); } catch (_) { /* optional movement overlay */ } +} + function applyHealTarget(session, bot, decision, targetSession) { const target = targetSession?.actor; if (!target) return { applied: false, reason: 'invalid_heal_target' }; @@ -239,16 +243,24 @@ function executeLegacy(session, decision, visiblePlayers) { if (!targetSession) return { applied: false, reason: 'missing_target_player' }; stand(session, bot); if (isPartyCompanionOf(session, targetSession)) { + clearChatArrival(session, 'party_follow'); session.plan = 'following'; session.botStay = false; say(session, decision.reply || `Following you, ${targetSession.actor.fetchName()}!`, targetSession); } else { + const ChatArrivalState = invoke('GameServer/Bot/AI/ChatArrivalState'); + ChatArrivalState.start(session, targetSession, { + reason: 'player_chat_follow', + persistent: true, + stopOnArrival: true + }); approachPlayer(session, bot, targetSession); say(session, decision.reply || `Coming closer. Invite me if you want party follow.`, targetSession); } return { applied: true, reason: 'follow_player' }; } if (action === 'stay_here') { + clearChatArrival(session, 'stay_here'); session.botStay = true; session.stayLocation = { locX: bot.fetchLocX(), @@ -262,6 +274,7 @@ function executeLegacy(session, decision, visiblePlayers) { return { applied: true, reason: 'stay_here' }; } if (action === 'hunt') { + clearChatArrival(session, 'hunt'); stand(session, bot); if (session.partyCompanion === true && session.followPlayerSession) { session.plan = 'hunting'; @@ -278,6 +291,7 @@ function executeLegacy(session, decision, visiblePlayers) { return { applied: true, reason: 'hunt' }; } if (action === 'rest') { + clearChatArrival(session, 'rest'); const hpRatio = bot.fetchHp() / Math.max(1, bot.fetchMaxHp()); const mpRatio = bot.fetchMp() / Math.max(1, bot.fetchMaxMp()); if (hpRatio >= 0.95 && mpRatio >= 0.95) { @@ -300,6 +314,7 @@ function executeLegacy(session, decision, visiblePlayers) { return { applied: true, reason: 'rest' }; } if (action === 'shop') { + clearChatArrival(session, 'shop'); if (startShopping(session, bot)) { say(session, decision.reply, targetSession); } else { @@ -308,6 +323,7 @@ function executeLegacy(session, decision, visiblePlayers) { return { applied: true, reason: 'shop' }; } if (action === 'move_to_spot') { + clearChatArrival(session, 'move_to_spot'); if (session.partyCompanion === true && session.followPlayerSession) { say(session, decision.reply || 'I will stay with the party.', targetSession); return { applied: true, reason: 'party_companion_stays_with_party' }; @@ -637,7 +653,7 @@ function registerTools() { const descriptions = { none: 'Do nothing when no useful response is needed.', say: 'Send a short in-character reply to the target visible player.', - follow_player: 'Approach a visible player. Real party follow still requires an invite.', + follow_player: 'Persistently approach a visible player until arrival. Real party follow still requires an invite.', stay_here: 'Hold the current position.', hunt: 'Return to independent hunting.', rest: 'Sit and recover.', diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 32be370a..e436d232 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -226,6 +226,7 @@ function systemPrompt() { 'Never invent a background request, ambient prompt, player intent, or private internal event.', 'A player-facing reply must be grounded in the authoritative bot state and conversation context.', 'follow_player only means approach a visible player unless the bot is already an invited party companion.', + 'For a non-party follow request, say that you are on your way unless the authoritative distance is already near the player; never claim to be beside them before arrival.', 'For buff_target and heal_target, choose a visible player and let the server validate class, learned skill, MP, range, and safety.', 'Do not claim that buffs or heals are ready in a plain chat reply. Use buff_target or heal_target; only the validated server action may confirm a cast.', 'Party pull, skill preference, stance, and equipment tools are temporary hot-session controls. They require the current human party leader; never invent authority.', diff --git a/src/GameServer/Bot/AI/BotBrainContext.js b/src/GameServer/Bot/AI/BotBrainContext.js index a4374c59..a9976bef 100644 --- a/src/GameServer/Bot/AI/BotBrainContext.js +++ b/src/GameServer/Bot/AI/BotBrainContext.js @@ -32,6 +32,12 @@ function safeNumber(read, fallback = 0) { } } +function textRequestsInventory(text = '') { + const lower = String(text || '').toLowerCase(); + return /\b(item|items|inventory|gear|weapon|armor|adena|shot|shots|soulshot|soulshots|spiritshot|spiritshots|trade|loot|give|bring|spare|need|have|sell|buy)\b/.test(lower) || + /(инвент|вещ|шмот|оруж|брон|аден|сос|шоты|шот|трейд|лут|дай|принес|принести|запас|нужн|есть|прод)/.test(lower); +} + function compactTarget(target) { if (!target) return null; return { @@ -177,8 +183,7 @@ function inventorySnapshot(actor, text = '') { const items = backpack.fetchItems(); const lower = String(text || '').toLowerCase(); - const wantsItems = /\b(item|items|inventory|gear|weapon|armor|adena|shot|trade|loot|give|sell|buy)\b/.test(lower) || - /(инвент|вещ|шмот|оруж|брон|аден|сос|трейд|лут|дай|прод)/.test(lower); + const wantsItems = textRequestsInventory(lower); const shotPlan = ShotStock.planForActor(actor); const shotItem = backpack.fetchItemFromSelfId(shotPlan.selfId); @@ -284,5 +289,6 @@ function compactStatus(session, status, text = '', options = {}) { } module.exports = { - compactStatus + compactStatus, + textRequestsInventory }; diff --git a/src/GameServer/Bot/AI/BotContextAssembler.js b/src/GameServer/Bot/AI/BotContextAssembler.js index d96a7ba9..3921c9c4 100644 --- a/src/GameServer/Bot/AI/BotContextAssembler.js +++ b/src/GameServer/Bot/AI/BotContextAssembler.js @@ -80,7 +80,7 @@ async function assemble(input = {}) { const requestContext = input.requestContext || {}; const budget = Math.max(240, Number(input.budget || DEFAULT_BUDGET)); const hardMaxTokens = Math.max(budget, Number(input.hardMaxTokens || HARD_MAX_TOKENS)); - const itemIntent = textWants(text, /\b(item|items|inventory|gear|weapon|armor|adena|shot|trade|loot|give|sell|buy)\b|инвент|вещ|шмот|оруж|брон|аден|сос|трейд|лут|дай|прод/); + const itemIntent = BotBrainContext.textRequestsInventory(text); const skillIntent = textWants(text, /\b(skill|skills|heal|buff|haste|shield|might|wind walk|windwalk|spoil|sweep)\b|скилл|хил|баф|хаст|щит|майт|винд|спойл|свип/); let bot; try { diff --git a/src/GameServer/Bot/AI/ChatArrivalState.js b/src/GameServer/Bot/AI/ChatArrivalState.js index a24232ef..403b55db 100644 --- a/src/GameServer/Bot/AI/ChatArrivalState.js +++ b/src/GameServer/Bot/AI/ChatArrivalState.js @@ -19,6 +19,8 @@ function clear(session, reason = 'cleared') { session.chatArrivalUntil = 0; session.chatArrivalLastMoveAt = 0; session.chatArrivalReason = reason; + session.chatArrivalPersistent = false; + session.chatArrivalStopOnArrival = false; return true; } @@ -26,7 +28,11 @@ function start(session, targetSession, options = {}) { if (!session?.actor || !online(targetSession)) return false; session.chatArrivalActive = true; session.chatArrivalTargetSession = targetSession; - session.chatArrivalUntil = Date.now() + Math.max(10000, Number(options.holdMs || DEFAULT_HOLD_MS)); + session.chatArrivalPersistent = options.persistent === true; + session.chatArrivalStopOnArrival = options.stopOnArrival === true; + session.chatArrivalUntil = session.chatArrivalPersistent + ? 0 + : Date.now() + Math.max(10000, Number(options.holdMs || DEFAULT_HOLD_MS)); session.chatArrivalLastMoveAt = 0; session.chatArrivalReason = options.reason || 'remote_chat_come'; session.currentTargetId = undefined; @@ -44,7 +50,7 @@ function tick(session, bot) { if (!session?.chatArrivalActive) return false; const targetSession = session.chatArrivalTargetSession; const player = targetSession?.actor; - if (!online(targetSession) || Date.now() >= Number(session.chatArrivalUntil || 0)) { + if (!online(targetSession) || (!session.chatArrivalPersistent && Date.now() >= Number(session.chatArrivalUntil || 0))) { clear(session, 'expired'); return false; } @@ -73,6 +79,10 @@ function tick(session, bot) { if (bot.state?.inMotion?.()) bot.automation?.abortAll?.(bot); bot.unselect?.(); + if (session.chatArrivalStopOnArrival) { + clear(session, 'arrived'); + return false; + } return true; } diff --git a/src/GameServer/Bot/BotTradeService.js b/src/GameServer/Bot/BotTradeService.js index 374da034..96f3eb5a 100644 --- a/src/GameServer/Bot/BotTradeService.js +++ b/src/GameServer/Bot/BotTradeService.js @@ -51,6 +51,16 @@ function lineFor(item, count) { }; } +function resolveInventoryItem(backpack, identifier) { + const id = Number(identifier); + if (!backpack || !Number.isInteger(id) || id <= 0) return null; + const direct = backpack.fetchItemRaw?.(id); + if (direct) return direct; + const candidates = (backpack.fetchItems?.() || []) + .filter((item) => Number(item.fetchSelfId?.()) === id); + return candidates.length === 1 ? candidates[0] : null; +} + function isSafeOfferItem(item) { if (!item || item.fetchEquipped?.()) return false; const kind = String(item.fetchKind?.() || ''); @@ -247,14 +257,15 @@ function addPlayerItem(playerSession, objectId, amount) { function offerBotItem(botSession, objectId, amount) { const trade = activeTradeFor(botSession); if (!trade || trade.botSession !== botSession) return { ok: false, reason: 'no_active_trade' }; - const item = botSession.actor.backpack.fetchItemRaw(objectId); + const item = resolveInventoryItem(botSession.actor.backpack, objectId); if (!isSafeOfferItem(item)) return { ok: false, reason: 'item_not_tradable' }; - const current = trade.botItems.get(Number(objectId)); + const canonicalObjectId = Number(item.fetchId()); + const current = trade.botItems.get(canonicalObjectId); if (!current && trade.botItems.size >= MAX_TRADE_LINES) return { ok: false, reason: 'trade_line_limit' }; const requested = Math.max(1, Math.min(MAX_ITEM_AMOUNT, Math.floor(Number(amount) || 1))); const reservations = botReservations(botSession); - const previousReservation = reservations.get(Number(objectId)); + const previousReservation = reservations.get(canonicalObjectId); if (previousReservation && previousReservation.tradeId !== trade.id) { return { ok: false, reason: 'insufficient_item' }; } @@ -272,8 +283,8 @@ function offerBotItem(botSession, objectId, amount) { if (ledger.units + delta > MAX_BOT_GIFT_UNITS) return { ok: false, reason: 'gift_budget_exceeded' }; ledger.units += delta; const line = lineFor(item, nextCount); - trade.botItems.set(Number(objectId), line); - reservations.set(Number(objectId), { tradeId: trade.id, count: nextCount }); + trade.botItems.set(canonicalObjectId, line); + reservations.set(canonicalObjectId, { tradeId: trade.id, count: nextCount }); trade.botConfirmed = false; sendToPlayer(trade, ServerResponse.tradeOtherAdd(line)); console.info("BotTrade :: %s offered %d %s to %s", actorName(botSession), line.count, item.fetchName(), actorName(trade.playerSession)); @@ -468,6 +479,7 @@ module.exports = { TRADE_RANGE, TRADE_TTL_MS, activeTradeSummary, + resolveInventoryItem, addItem: addPlayerItem, cancel, cleanup, diff --git a/src/GameServer/Bot/Economy/BotNegotiationService.js b/src/GameServer/Bot/Economy/BotNegotiationService.js index 05b3263a..0097daf9 100644 --- a/src/GameServer/Bot/Economy/BotNegotiationService.js +++ b/src/GameServer/Bot/Economy/BotNegotiationService.js @@ -234,10 +234,20 @@ function setTerminal(negotiation, state, reason) { } function stockValid(negotiation) { - const item = negotiation.botSession?.actor?.backpack?.fetchItemRaw?.(negotiation.itemObjectId); + const item = resolveInventoryItem(negotiation.botSession?.actor?.backpack, negotiation.itemObjectId); return safeItem(item) && Number(item.fetchSelfId()) === Number(negotiation.itemSelfId) && Number(item.fetchAmount()) >= negotiation.quantity; } +function resolveInventoryItem(backpack, identifier) { + const id = Number(identifier); + if (!backpack || !Number.isInteger(id) || id <= 0) return null; + const direct = backpack.fetchItemRaw?.(id); + if (direct) return direct; + const candidates = (backpack.fetchItems?.() || []) + .filter((item) => Number(item.fetchSelfId?.()) === id); + return candidates.length === 1 ? candidates[0] : null; +} + function activeFor(session) { const negotiation = session?.activeNegotiation; if (!negotiation) return null; @@ -267,11 +277,12 @@ function quoteItem(bot, player, itemObjectId, amount = 1) { const access = canAccess(bot, player); if (access) return { ok: false, reason: access }; if (activeFor(bot)) return { ok: false, reason: 'negotiation_active' }; - const item = bot.actor.backpack.fetchItemRaw(Number(itemObjectId)); + const item = resolveInventoryItem(bot.actor.backpack, itemObjectId); if (!safeItem(item)) return { ok: false, reason: 'item_not_negotiable' }; + const canonicalObjectId = Number(item.fetchId()); const quantity = Math.max(1, Math.min(MAX_QUANTITY, Math.floor(Number(amount) || 1))); const reservations = bot.botNegotiationReservations || (bot.botNegotiationReservations = new Map()); - const reservation = reservations.get(Number(itemObjectId)); + const reservation = reservations.get(canonicalObjectId); if (reservation) return { ok: false, reason: 'stock_reserved' }; if (Number(item.fetchAmount()) < quantity) return { ok: false, reason: 'insufficient_stock' }; @@ -280,7 +291,7 @@ function quoteItem(bot, player, itemObjectId, amount = 1) { id: negotiationId(bot, player), botSession: bot, playerSession: player, - itemObjectId: Number(itemObjectId), + itemObjectId: canonicalObjectId, itemSelfId: Number(item.fetchSelfId()), itemName: item.fetchName(), quantity, diff --git a/tests/test_bot_context_assembler.js b/tests/test_bot_context_assembler.js index 18c26d5a..1ad31722 100644 --- a/tests/test_bot_context_assembler.js +++ b/tests/test_bot_context_assembler.js @@ -7,8 +7,11 @@ const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); async function main() { const originalCompact = BotBrainContext.compactStatus; + let compactOptions = null; BotEventJournal.resetMemory(); - BotBrainContext.compactStatus = () => ({ + BotBrainContext.compactStatus = (_session, _status, _text, options) => { + compactOptions = options; + return { available: true, name: 'BoundedBot', level: 20, @@ -16,7 +19,8 @@ async function main() { party: { members: [] }, inventory: null, skills: null - }); + }; + }; try { await BotEventJournal.record({ botId: 20, playerId: 10, eventType: 'level_up', summary: 'Reached level 20.', weight: 4 }); await BotEventJournal.record({ botId: 20, playerId: 10, eventType: 'trade_completed', summary: 'Received healing potions from the player.', weight: 4 }); @@ -41,6 +45,13 @@ async function main() { assert(assembled.telemetry.skillIntent, 'skill intent should include the skill fragment path'); assert(assembled.estimatedTokens <= assembled.hardMaxTokens); assert(BotContextAssembler.estimateTokens(assembled.fragments) <= 1800); + await BotContextAssembler.assemble({ + session: { actor: { fetchId: () => 20 } }, + status: { available: true }, + text: 'Do you have soulshots, and can you bring me 100?', + requestContext: { playerId: 10 } + }); + assert.strictEqual(compactOptions.includeInventory, true, 'soulshots/bring must include inventory context'); console.log('Bot context assembler checks passed'); } finally { BotBrainContext.compactStatus = originalCompact; diff --git a/tests/test_chat_arrival_state.js b/tests/test_chat_arrival_state.js new file mode 100644 index 00000000..855c9111 --- /dev/null +++ b/tests/test_chat_arrival_state.js @@ -0,0 +1,36 @@ +const assert = require('assert'); + +require('../src/Global'); + +const ChatArrivalState = invoke('GameServer/Bot/AI/ChatArrivalState'); + +function actor(id, x = 0) { + return { + fetchId: () => id, + fetchLocX: () => x, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchIsOnline: () => true, + isDead: () => false, + state: { inMotion: () => false }, + unselect() {} + }; +} + +try { + const target = { actor: actor(9501, 0) }; + const session = { actor: actor(9502, 100), aiActive: true }; + ChatArrivalState.start(session, target, { + reason: 'player_chat_follow', + persistent: true, + stopOnArrival: true + }); + assert.strictEqual(session.chatArrivalPersistent, true); + assert.strictEqual(session.chatArrivalUntil, 0); + assert.strictEqual(ChatArrivalState.tick(session, session.actor), false, 'arrival should clear after reaching the player'); + assert.strictEqual(session.chatArrivalActive, false); + console.log('Chat arrival state checks passed'); +} catch (error) { + console.error(error); + process.exitCode = 1; +} diff --git a/tests/test_llm_trade_tools.js b/tests/test_llm_trade_tools.js index c740aa42..bc120d85 100644 --- a/tests/test_llm_trade_tools.js +++ b/tests/test_llm_trade_tools.js @@ -69,9 +69,10 @@ try { assert.strictEqual(proposed.reason, 'trade_proposed'); assert.strictEqual(packets[0][0], 0x1e, 'propose_trade must open the native trade window'); - const offered = BotAgentTools.execute(bot, decision('offer_resources', 'trade-tool-2', { tradeItemId: 612, tradeAmount: 2 }), [], context('trade-tool-2')); + const offered = BotAgentTools.execute(bot, decision('offer_resources', 'trade-tool-2', { tradeItemId: 6012, tradeAmount: 2 }), [], context('trade-tool-2')); assert.strictEqual(offered.applied, true); assert.strictEqual(offered.line.count, 2); + assert.strictEqual(offered.line.objectId, 612, 'template self id input must resolve to the canonical inventory object id'); assert.strictEqual(packets[1][0], 0x21, 'offer_resources must use native TradeOtherAdd'); const stranger = { accountId: 'player_stranger', actor: actor(699, 'Stranger', backpack([])) }; From 2e8dc3a32e6bde26f2ba7445b6bfe46767e0ccda Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:26:12 -0400 Subject: [PATCH 39/59] Render truthful bot replies after tool execution --- src/GameServer/Bot/AI/BotAgentTools.js | 44 ++++++++++--------- src/GameServer/Bot/AI/BotBrain.js | 44 ++++++++++++------- src/GameServer/Bot/AI/BotConversationStore.js | 16 ++++--- src/GameServer/Bot/AI/BotRemoteChat.js | 11 ++--- tests/test_bot_llm_party_policy.js | 2 +- 5 files changed, 69 insertions(+), 48 deletions(-) diff --git a/src/GameServer/Bot/AI/BotAgentTools.js b/src/GameServer/Bot/AI/BotAgentTools.js index 6e13d28e..6b916196 100644 --- a/src/GameServer/Bot/AI/BotAgentTools.js +++ b/src/GameServer/Bot/AI/BotAgentTools.js @@ -237,7 +237,7 @@ function executeLegacy(session, decision, visiblePlayers) { return { applied: true, reason: 'none' }; } if (action === 'say') { - return { applied: say(session, decision.reply, targetSession), reason: 'say' }; + return { applied: say(session, decision.reply, targetSession), reason: 'say', replyDelivered: !!decision.reply }; } if (action === 'follow_player') { if (!targetSession) return { applied: false, reason: 'missing_target_player' }; @@ -246,7 +246,8 @@ function executeLegacy(session, decision, visiblePlayers) { clearChatArrival(session, 'party_follow'); session.plan = 'following'; session.botStay = false; - say(session, decision.reply || `Following you, ${targetSession.actor.fetchName()}!`, targetSession); + const replyDelivered = say(session, decision.reply || `Following you, ${targetSession.actor.fetchName()}!`, targetSession); + return { applied: true, reason: 'follow_player', replyDelivered }; } else { const ChatArrivalState = invoke('GameServer/Bot/AI/ChatArrivalState'); ChatArrivalState.start(session, targetSession, { @@ -255,9 +256,9 @@ function executeLegacy(session, decision, visiblePlayers) { stopOnArrival: true }); approachPlayer(session, bot, targetSession); - say(session, decision.reply || `Coming closer. Invite me if you want party follow.`, targetSession); + const replyDelivered = say(session, decision.reply || `Coming closer. Invite me if you want party follow.`, targetSession); + return { applied: true, reason: 'follow_player', replyDelivered }; } - return { applied: true, reason: 'follow_player' }; } if (action === 'stay_here') { clearChatArrival(session, 'stay_here'); @@ -270,8 +271,8 @@ function executeLegacy(session, decision, visiblePlayers) { if (session.followPlayerSession && session.partyCompanion === true) { session.plan = 'following'; } - say(session, decision.reply || 'Holding this position.', targetSession); - return { applied: true, reason: 'stay_here' }; + const replyDelivered = say(session, decision.reply || 'Holding this position.', targetSession); + return { applied: true, reason: 'stay_here', replyDelivered }; } if (action === 'hunt') { clearChatArrival(session, 'hunt'); @@ -279,16 +280,16 @@ function executeLegacy(session, decision, visiblePlayers) { if (session.partyCompanion === true && session.followPlayerSession) { session.plan = 'hunting'; session.botStay = false; - say(session, decision.reply || 'Hunting with the party.', targetSession); - return { applied: true, reason: 'party_hunt' }; + const replyDelivered = say(session, decision.reply || 'Hunting with the party.', targetSession); + return { applied: true, reason: 'party_hunt', replyDelivered }; } session.plan = 'hunting'; session.followPlayerSession = null; session.partyCompanion = false; session.botStay = false; - say(session, decision.reply, targetSession); - return { applied: true, reason: 'hunt' }; + const replyDelivered = say(session, decision.reply, targetSession); + return { applied: true, reason: 'hunt', replyDelivered }; } if (action === 'rest') { clearChatArrival(session, 'rest'); @@ -302,36 +303,37 @@ function executeLegacy(session, decision, visiblePlayers) { } else { session.plan = 'hunting'; } - say(session, decision.reply || "I'm already recovered.", targetSession); - return { applied: true, reason: 'already_recovered' }; + const replyDelivered = say(session, decision.reply || "I'm already recovered.", targetSession); + return { applied: true, reason: 'already_recovered', replyDelivered }; } session.plan = 'resting'; session.currentTargetId = undefined; bot.unselect(); sit(session, bot); - say(session, decision.reply, targetSession); - return { applied: true, reason: 'rest' }; + const replyDelivered = say(session, decision.reply, targetSession); + return { applied: true, reason: 'rest', replyDelivered }; } if (action === 'shop') { clearChatArrival(session, 'shop'); if (startShopping(session, bot)) { - say(session, decision.reply, targetSession); + const replyDelivered = say(session, decision.reply, targetSession); + return { applied: true, reason: 'shop', replyDelivered }; } else { - say(session, decision.reply || 'I will stay with the party and sell later.', targetSession); + const replyDelivered = say(session, decision.reply || 'I will stay with the party and sell later.', targetSession); + return { applied: true, reason: 'shop', replyDelivered }; } - return { applied: true, reason: 'shop' }; } if (action === 'move_to_spot') { clearChatArrival(session, 'move_to_spot'); if (session.partyCompanion === true && session.followPlayerSession) { - say(session, decision.reply || 'I will stay with the party.', targetSession); - return { applied: true, reason: 'party_companion_stays_with_party' }; + const replyDelivered = say(session, decision.reply || 'I will stay with the party.', targetSession); + return { applied: true, reason: 'party_companion_stays_with_party', replyDelivered }; } const applied = applyMoveToSpot(session, bot, decision.spotId); - if (applied) say(session, decision.reply, targetSession); - return { applied, reason: applied ? 'move_to_spot' : 'invalid_spot' }; + const replyDelivered = applied ? say(session, decision.reply, targetSession) : false; + return { applied, reason: applied ? 'move_to_spot' : 'invalid_spot', replyDelivered }; } if (action === 'buff_target') { return applyBuffTarget(session, bot, decision, targetSession); diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index e436d232..1ae3671b 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -337,7 +337,8 @@ function compactActionResult(result) { return { ok: result.applied === true, reason: result.reason || null, - idempotent: result.idempotent === true + idempotent: result.idempotent === true, + replyDelivered: result.replyDelivered === true }; } @@ -399,7 +400,7 @@ function applyPartyPolicy(session, decision, requestContext, text) { const availability = BotAvailability.evaluate(requestContext.playerSession, session); const reply = availability.available - ? 'I am open to a party. Send me an invite and I will decide in the moment.' + ? String(decision.reply || '').trim() || 'I am open to a party. Send me an invite and I will decide in the moment.' : `I cannot join right now: ${availability.reasonText || availability.reason || 'not available'}.`; return { ...decision, @@ -414,7 +415,7 @@ function applyPartyPolicy(session, decision, requestContext, text) { function recordConversationReply(session, decision, result, requestContext) { const turn = requestContext?.conversationTurn; const action = decision?.action; - if (!turn || !result?.applied || !decision?.reply || ['buff_target', 'heal_target'].includes(action)) return; + if (!turn || !result?.applied || result.replyDelivered !== true || !decision?.reply || ['buff_target', 'heal_target'].includes(action)) return; const BotChatText = invoke('GameServer/Bot/AI/BotChatText'); const reply = BotChatText.normalize(decision.reply) .slice(0, BotChatText.DEFAULT_LINE_LIMIT * BotChatText.DEFAULT_MAX_LINES); @@ -449,19 +450,28 @@ function queueConversationWrite(session, work) { } function applyDecision(session, decision, visiblePlayers, requestContext) { - const result = BotAgentTools.execute(session, decision, visiblePlayers, requestContext); + let result = BotAgentTools.execute(session, decision, visiblePlayers, requestContext); BotAgentTools.remember(session, decision, result, config().model); - if (result.applied && decision.action === 'none' && decision.reply && requestContext?.playerSession?.actor) { - // `none` means “no world mutation”, not “drop the player's message”. - // A direct hot dialogue still needs a visible reply when the model - // chooses to speak without selecting a tool. - invoke('GameServer/Bot/BotManager').botTell(session, requestContext.playerSession, decision.reply); + const playerSession = requestContext?.playerSession; + let playerVisibleReply = null; + if (result.applied) { + // Skill requests are confirmed by the native cast/effect path. Do not + // persist or claim the model's speculative reply before that happens. + if (!['buff_target', 'heal_target'].includes(decision.action)) { + playerVisibleReply = decision.reply || null; + if (result.replyDelivered !== true && playerVisibleReply && playerSession?.actor) { + invoke('GameServer/Bot/BotManager').botTell(session, playerSession, playerVisibleReply); + result = { ...result, replyDelivered: true }; + } + recordConversationReply(session, decision, result, requestContext); + } + return { ...result, playerVisibleReply }; } - recordConversationReply(session, decision, result, requestContext); if (!result.applied && requestContext?.playerSession?.actor) { const BotManager = invoke('GameServer/Bot/BotManager'); const reply = BotAgentTools.rejectionReply(result); BotManager.botTell(session, requestContext.playerSession, reply); + playerVisibleReply = reply; if (requestContext.conversationTurn) { queueConversationWrite(session, () => BotConversationService.recordFallback({ playerSession: requestContext.playerSession, @@ -473,7 +483,7 @@ function applyDecision(session, decision, visiblePlayers, requestContext) { })); } } - return result; + return { ...result, replyDelivered: !!playerVisibleReply, playerVisibleReply }; } function rememberTelemetry(session, result) { @@ -867,15 +877,16 @@ const BotBrain = { async () => applyDecision(session, providerResult, visiblePlayers, requestContext), 'tool' ); - const playerVisibleReply = actionResult.applied - ? providerResult.reply || null - : BotAgentTools.rejectionReply(actionResult); + const playerVisibleReply = actionResult.playerVisibleReply || + (actionResult.applied && actionResult.replyDelivered ? providerResult.reply || null : null) || + (!actionResult.applied ? BotAgentTools.rejectionReply(actionResult) : null); await LangfuseTracing.withObservation( 'bot.reply.deliver', { action: providerResult.action || null, reply: playerVisibleReply, - applied: actionResult.applied === true + applied: actionResult.applied === true, + delivered: actionResult.replyDelivered === true }, stageMetadata, async () => playerVisibleReply, @@ -890,7 +901,8 @@ const BotBrain = { requestedAction: providerResult.action || null, toolOutcome: compactActionResult(actionResult), applied: actionResult.applied === true, - playerVisibleReply + playerVisibleReply, + replyDelivered: actionResult.replyDelivered === true } }; return providerResult; diff --git a/src/GameServer/Bot/AI/BotConversationStore.js b/src/GameServer/Bot/AI/BotConversationStore.js index 817d8061..4fcf4caf 100644 --- a/src/GameServer/Bot/AI/BotConversationStore.js +++ b/src/GameServer/Bot/AI/BotConversationStore.js @@ -84,6 +84,10 @@ function orderedTurns(turns) { )); } +function modelVisibleTurn(turn) { + return turn?.role !== 'bot' || turn?.meta?.fallback !== true; +} + function memoryEntry(playerId, botId) { const key = pairKey(playerId, botId); let entry = memory.get(key); @@ -187,6 +191,7 @@ async function loadTurns(entry, limit = DEFAULT_RECENT_TURNS, includeCompacted = const summaryThroughOrdinal = Number(entry.conversation.summaryThroughOrdinal || 0); if (String(conversationId).startsWith('memory:')) { return orderedTurns(entry.turns) + .filter((turn) => modelVisibleTurn(turn)) .filter((turn) => includeCompacted || (!turn.compacted && Number(turn.turnOrdinal || turn.id) > summaryThroughOrdinal)) .slice(-count) .map(copyTurn); @@ -202,16 +207,17 @@ async function loadTurns(entry, limit = DEFAULT_RECENT_TURNS, includeCompacted = FROM bot_conversation_messages WHERE conversationId = ? ${includeCompacted ? '' : 'AND compacted = 0 AND turnOrdinal > ?'} ORDER BY turnOrdinal DESC, messageOrder DESC, id DESC - LIMIT ? + LIMIT ? ) ORDER BY turnOrdinal ASC, messageOrder ASC, id ASC`, - includeCompacted - ? [conversationId, count] - : [conversationId, summaryThroughOrdinal, count] + includeCompacted + ? [conversationId, count * 2] + : [conversationId, summaryThroughOrdinal, count * 2] ], 'bot-conversation:recent'); - return rows.map(normalizeTurn); + return rows.map(normalizeTurn).filter((turn) => modelVisibleTurn(turn)).slice(-count); } catch (_) { return orderedTurns(entry.turns) + .filter((turn) => modelVisibleTurn(turn)) .filter((turn) => includeCompacted || (!turn.compacted && Number(turn.turnOrdinal || turn.id) > summaryThroughOrdinal)) .slice(-count) .map(copyTurn); diff --git a/src/GameServer/Bot/AI/BotRemoteChat.js b/src/GameServer/Bot/AI/BotRemoteChat.js index b436b723..af53591f 100644 --- a/src/GameServer/Bot/AI/BotRemoteChat.js +++ b/src/GameServer/Bot/AI/BotRemoteChat.js @@ -300,11 +300,12 @@ function recordReply(playerSession, state, turn, result, extra = {}) { text: result.reply, requestId: turn.turnId, delivered: true, - meta: { - action: result.action || 'say', - reason: result.reason || null, - providerOutcome: result.providerOutcome || null, - ...extra + meta: { + action: result.action || 'say', + reason: result.reason || null, + providerOutcome: result.providerOutcome || null, + fallback: result.providerFailure === true || result.reason === 'fallback', + ...extra } }), 'chain' diff --git a/tests/test_bot_llm_party_policy.js b/tests/test_bot_llm_party_policy.js index 043e55fa..17e56a70 100644 --- a/tests/test_bot_llm_party_policy.js +++ b/tests/test_bot_llm_party_policy.js @@ -50,7 +50,7 @@ function main() { } }; const social = BotBrain.applyPartyPolicy(socialSession, decision(), { playerSession }, 'join our group'); - assert.match(social.reply, /send me an invite/i); + assert.strictEqual(social.reply, decision().reply, 'available party policy must preserve the model personality'); assert.strictEqual(social.reason, 'party_policy:available'); console.log('LLM party policy checks passed'); } From 9cfb559d670e53c2a9a365f2d69d57ebe66fe88e Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:33:23 -0400 Subject: [PATCH 40/59] Align bot response tests with delivery metadata --- tests/test_bot_dialogue_arbiter.js | 5 ++++- tests/test_bot_tool_registry.js | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_bot_dialogue_arbiter.js b/tests/test_bot_dialogue_arbiter.js index 6000a556..16e5f06e 100644 --- a/tests/test_bot_dialogue_arbiter.js +++ b/tests/test_bot_dialogue_arbiter.js @@ -115,7 +115,10 @@ async function main() { assert.strictEqual(fallback.started, false); assert.strictEqual(fallbackReplies.length, 1); const fallbackContext = await BotConversationService.contextFor(player, bot, { limit: 10 }); - assert.ok(fallbackContext.recentTurns.some((turn) => turn.text === fallback.reply)); + assert.ok( + !fallbackContext.recentTurns.some((turn) => turn.text === fallback.reply), + 'deterministic fallback replies must not become model-visible history' + ); const world = invoke('GameServer/World/World'); world.user = { sessions: [player, bot] }; diff --git a/tests/test_bot_tool_registry.js b/tests/test_bot_tool_registry.js index 095cfbb0..49757e2a 100644 --- a/tests/test_bot_tool_registry.js +++ b/tests/test_bot_tool_registry.js @@ -46,7 +46,7 @@ function main() { BotToolAudit.resetMemory(); const session = { accountId: 'bot_registry', plan: 'hunting', actor: actor(), dataSendToOthers() {} }; const first = BotAgentTools.execute(session, decision('stay_here', 'turn-1'), [], context('turn-1')); - assert.deepStrictEqual(first, { applied: true, reason: 'stay_here' }); + assert.deepStrictEqual(first, { applied: true, reason: 'stay_here', replyDelivered: true }); assert.strictEqual(session.botStay, true); const replay = BotAgentTools.execute(session, decision('stay_here', 'turn-1'), [], context('turn-1')); @@ -84,7 +84,7 @@ function main() { ); assert.deepStrictEqual( preparedFreshness, - { applied: false, reason: 'invalid_spot' }, + { applied: false, reason: 'invalid_spot', replyDelivered: false }, 'strict tools must validate against the revision captured for the actual prompt' ); From 3bb1a132fff39ad9971c2dc0475af9ddc0d3c7d7 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:03:54 -0400 Subject: [PATCH 41/59] Fix reasoning recovery and cold chat delivery --- src/GameServer/Bot/AI/BotConversationStore.js | 2 +- src/GameServer/Bot/AI/BotInferenceBudget.js | 2 +- src/GameServer/Bot/AI/BotRemoteChat.js | 63 +++++++++++++++---- src/GameServer/Bot/AI/OpenRouterGateway.js | 11 +++- src/GameServer/World/World.js | 3 +- tests/test_cold_bot_chat.js | 22 +++++-- tests/test_hot_bot_schema_repair.js | 2 +- tests/test_openrouter_gateway.js | 2 +- 8 files changed, 80 insertions(+), 27 deletions(-) diff --git a/src/GameServer/Bot/AI/BotConversationStore.js b/src/GameServer/Bot/AI/BotConversationStore.js index 4fcf4caf..c6c433d5 100644 --- a/src/GameServer/Bot/AI/BotConversationStore.js +++ b/src/GameServer/Bot/AI/BotConversationStore.js @@ -85,7 +85,7 @@ function orderedTurns(turns) { } function modelVisibleTurn(turn) { - return turn?.role !== 'bot' || turn?.meta?.fallback !== true; + return turn?.role !== 'bot' || (turn?.delivered !== false && turn?.meta?.fallback !== true); } function memoryEntry(playerId, botId) { diff --git a/src/GameServer/Bot/AI/BotInferenceBudget.js b/src/GameServer/Bot/AI/BotInferenceBudget.js index 71f443b7..13d782f1 100644 --- a/src/GameServer/Bot/AI/BotInferenceBudget.js +++ b/src/GameServer/Bot/AI/BotInferenceBudget.js @@ -118,7 +118,7 @@ function pumpGlobalWaiters() { const waiter = globalWaiters.shift(); const result = reserve(waiter.session, { ...waiter.input, - now: waiter.now, + now: Date.now(), _grantingQueued: true }); waiter.resolve(result); diff --git a/src/GameServer/Bot/AI/BotRemoteChat.js b/src/GameServer/Bot/AI/BotRemoteChat.js index af53591f..5cfa8eef 100644 --- a/src/GameServer/Bot/AI/BotRemoteChat.js +++ b/src/GameServer/Bot/AI/BotRemoteChat.js @@ -6,6 +6,7 @@ const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); const BotConversationService = invoke('GameServer/Bot/AI/BotConversationService'); const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); +const ServerResponse = invoke('GameServer/Network/Response'); // A player can send several tells while the provider is answering. Keep the // pair ordered so each request sees the previous answer in conversation @@ -282,6 +283,10 @@ function activateNearPlayer(playerSession, state) { function recordReply(playerSession, state, turn, result, extra = {}) { if (!result?.reply) return Promise.resolve(false); + const fallback = result.providerFailure === true || + result.isFallback === true || + result.reason === 'fallback' || + extra.fallback === true; return LangfuseTracing.withObservation( 'bot.conversation.persist', { botId: state.characterId, playerId: playerSession.actor.fetchId(), turnId: turn.turnId }, @@ -299,19 +304,39 @@ function recordReply(playerSession, state, turn, result, extra = {}) { channel: turn.channel, text: result.reply, requestId: turn.turnId, - delivered: true, - meta: { - action: result.action || 'say', - reason: result.reason || null, - providerOutcome: result.providerOutcome || null, - fallback: result.providerFailure === true || result.reason === 'fallback', - ...extra + delivered: result.delivered === true, + meta: { + action: result.action || 'say', + reason: result.reason || null, + providerOutcome: result.providerOutcome || null, + fallback, + ...extra } }), 'chain' ); } +function deliverReply(playerSession, state, text) { + if (!state || !playerSession?.dataSendToMe) return false; + const BotChatText = invoke('GameServer/Bot/AI/BotChatText'); + const lines = BotChatText.splitForTell(text); + if (!lines.length) return false; + try { + lines.forEach((line) => { + playerSession.dataSendToMe( + ServerResponse.speak({ + fetchId: () => Number(state.characterId || 0), + fetchName: () => state.name || 'Bot' + }, { kind: 2, text: line }) + ); + }); + return true; + } catch (_) { + return false; + } +} + function replyForStateNow(playerSession, state, text, channel = 'client_tell') { const cfg = config(); const availability = BotAvailability.evaluateState(playerSession, state); @@ -319,7 +344,8 @@ function replyForStateNow(playerSession, state, text, channel = 'client_tell') { ok: true, reply: fallbackReply(state, availability, text), action: 'say', - reason: 'fallback' + reason: 'fallback', + isFallback: true }; const begin = BotConversationService.beginTurn({ playerSession, @@ -421,7 +447,17 @@ function replyForStateNow(playerSession, state, text, channel = 'client_tell') { providerOutcome: reply?.providerOutcome || null }, stageMetadata, - () => recordReply(playerSession, state, turn, reply, extra).then(() => reply), + () => { + const delivered = deliverReply(playerSession, state, reply?.reply); + const deliveredReply = { ...reply, delivered }; + return recordReply( + playerSession, + state, + turn, + deliveredReply, + { ...extra, fallback: deliveredReply.isFallback === true } + ).then(() => deliveredReply); + }, 'chain' ); @@ -444,7 +480,8 @@ function replyForStateNow(playerSession, state, text, channel = 'client_tell') { : { ...fallback, providerOutcome: grantedAdmission.reason, - reason: grantedAdmission.reason + reason: grantedAdmission.reason, + isFallback: true }; return deliver(failed); } @@ -466,7 +503,8 @@ function replyForStateNow(playerSession, state, text, channel = 'client_tell') { ...fallback, providerOutcome: result.providerOutcome, usage: result.usage || null, - llmTelemetry: result.llmTelemetry || null + llmTelemetry: result.llmTelemetry || null, + isFallback: true }; return deliver(failed); } @@ -479,7 +517,8 @@ function replyForStateNow(playerSession, state, text, channel = 'client_tell') { : { ...fallback, reason: `come_to_player:${actionResult.reason || 'rejected'}`, - providerOutcome: 'action_rejected' + providerOutcome: 'action_rejected', + isFallback: true }; return deliver({ ...actionReply, diff --git a/src/GameServer/Bot/AI/OpenRouterGateway.js b/src/GameServer/Bot/AI/OpenRouterGateway.js index e45dfdfc..dd8bd1a5 100644 --- a/src/GameServer/Bot/AI/OpenRouterGateway.js +++ b/src/GameServer/Bot/AI/OpenRouterGateway.js @@ -289,9 +289,14 @@ function completionLimit(cfg, request = {}) { function repairConfig(spec) { const source = config(spec.config || {}); const current = completionLimit(source, spec); - const rescue = current === null - ? 32768 - : Math.max(2048, current * 2); + if (current === null) { + return { + ...(spec.config || {}), + maxCompletionTokens: null, + interactiveMaxCompletionTokens: 0 + }; + } + const rescue = Math.max(2048, current * 2); return { ...(spec.config || {}), maxTokens: rescue, diff --git a/src/GameServer/World/World.js b/src/GameServer/World/World.js index 13ea94c6..dbfe0fd7 100644 --- a/src/GameServer/World/World.js +++ b/src/GameServer/World/World.js @@ -298,12 +298,11 @@ const World = { } return BotRemoteChat.replyForState(session, state, message).then((result) => { - if (!result?.ok || !result.reply) { + if (!result?.ok || !result.reply || result.delivered !== true) { session.dataSendToMe(ServerResponse.actionFailed()); return false; } - coldBotTell(session, state, result.reply); console.info( 'BotRemoteChat :: %s replied to %s reason=%s', state.name || lookup, diff --git a/tests/test_cold_bot_chat.js b/tests/test_cold_bot_chat.js index 3ac503a8..e1a4e364 100644 --- a/tests/test_cold_bot_chat.js +++ b/tests/test_cold_bot_chat.js @@ -67,7 +67,12 @@ async function main() { }); }); - const playerSession = { accountId: 'player_cold', actor: actor(9101, 'ColdPlayer', 100) }; + const deliveredPackets = []; + const playerSession = { + accountId: 'player_cold', + actor: actor(9101, 'ColdPlayer', 100), + dataSendToMe(packet) { deliveredPackets.push(packet); } + }; const state = { characterId: 9102, accountName: 'bot_cold_chat', @@ -87,6 +92,8 @@ async function main() { const results = await Promise.all([first, second]); assert.deepStrictEqual(results.map((result) => result.reply), ['reply-1', 'reply-2']); + assert.deepStrictEqual(results.map((result) => result.delivered), [true, true]); + assert.strictEqual(deliveredPackets.length, 2, 'cold replies should be delivered inside the traced stage'); assert.strictEqual(requests.length, 2, 'every cold tell must reach the provider without a cooldown drop'); const secondPayload = JSON.parse(requests[1].messages[1].content); assert.deepStrictEqual( @@ -146,20 +153,23 @@ async function main() { await new Promise((resolve) => setTimeout(resolve, 0)); } assert.strictEqual(typeof releaseAdmissionRequest, 'function', 'the first cold request should occupy the global slot'); - const secondAdmission = await BotRemoteChat.replyForState( + const secondAdmissionPromise = BotRemoteChat.replyForState( playerSession, { ...state, characterId: 9112, accountName: 'rejected_cold_chat', name: 'RejectedColdChat' }, 'second admission' ); - assert.strictEqual(secondAdmission.reason, 'inference_budget_global_concurrency'); - assert.strictEqual(requests.length, beforeAdmissionRequests + 1, 'global admission must reject the second cold request before OpenRouter'); + assert.strictEqual(requests.length, beforeAdmissionRequests + 1, 'global admission must queue the second cold request before OpenRouter'); releaseAdmissionRequest(); - await firstAdmission; + const [firstAdmissionResult, secondAdmission] = await Promise.all([firstAdmission, secondAdmissionPromise]); + assert.strictEqual(firstAdmissionResult.delivered, true); + assert.strictEqual(secondAdmission.reason, 'test_reply'); + assert.strictEqual(secondAdmission.delivered, true); + assert.strictEqual(requests.length, beforeAdmissionRequests + 2, 'queued cold chat must reach OpenRouter after the slot is released'); for (const stage of ['cold-bot.dialogue', 'bot.context.assemble', 'bot.reply.deliver']) { assert.strictEqual(observations.filter((name) => name === stage).length, 5, `${stage} should be emitted for every cold reply`); } for (const stage of ['openrouter.generation', 'bot.schema.validate']) { - assert.strictEqual(observations.filter((name) => name === stage).length, 4, `${stage} should be emitted for every admitted cold request`); + assert.strictEqual(observations.filter((name) => name === stage).length, 5, `${stage} should be emitted for every admitted cold request`); } assert.strictEqual(observations.filter((name) => name === 'bot.tool.come_to_player').length, 1, 'come_to_player should have one tool observation'); console.log('Cold bot chat checks passed'); diff --git a/tests/test_hot_bot_schema_repair.js b/tests/test_hot_bot_schema_repair.js index fe81e9ef..301cc899 100644 --- a/tests/test_hot_bot_schema_repair.js +++ b/tests/test_hot_bot_schema_repair.js @@ -101,7 +101,7 @@ async function main() { assert.strictEqual(requests.length, 2, 'hot dialogue must repair one malformed structured response'); assert.strictEqual(requests[0].max_completion_tokens, undefined); - assert.strictEqual(requests[1].max_completion_tokens, 32768); + assert.strictEqual(requests[1].max_completion_tokens, undefined); assert.deepStrictEqual(requests[0].reasoning, { effort: 'high', exclude: true }); assert.strictEqual(botSession.brainInFlight, false, 'repaired turn must settle'); console.log('Hot bot schema repair checks passed'); diff --git a/tests/test_openrouter_gateway.js b/tests/test_openrouter_gateway.js index ca2a884b..948aaa0d 100644 --- a/tests/test_openrouter_gateway.js +++ b/tests/test_openrouter_gateway.js @@ -251,7 +251,7 @@ async function main() { assert.strictEqual(truncated.telemetry.repairType, 'truncation'); assert.strictEqual(truncated.telemetry.initialOutcome, 'output_truncated'); assert.strictEqual(truncationBodies[0].max_completion_tokens, undefined); - assert.strictEqual(truncationBodies[1].max_completion_tokens, 32768); + assert.strictEqual(truncationBodies[1].max_completion_tokens, undefined); assert.strictEqual(truncated.usage.reasoningTokens, 600); assert.strictEqual(truncated.usage.visibleCompletionTokens, 48); From a263337db7d9cfb6e6fade0d3a38077753b690ff Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:54:52 -0400 Subject: [PATCH 42/59] Preserve truthful bot replies across chat failures --- src/GameServer/Bot/AI/BotAgentTools.js | 56 ++++++++++++--------- src/GameServer/Bot/AI/BotBrain.js | 42 +++++++++++----- src/GameServer/Bot/AI/BotDialogueArbiter.js | 8 +++ src/GameServer/World/World.js | 2 +- tests/test_bot_dialogue_arbiter.js | 19 +++++++ tests/test_bot_tool_registry.js | 9 +++- 6 files changed, 97 insertions(+), 39 deletions(-) diff --git a/src/GameServer/Bot/AI/BotAgentTools.js b/src/GameServer/Bot/AI/BotAgentTools.js index 6b916196..70723991 100644 --- a/src/GameServer/Bot/AI/BotAgentTools.js +++ b/src/GameServer/Bot/AI/BotAgentTools.js @@ -97,6 +97,15 @@ function say(session, text, targetSession = null) { return true; } +function replyOutcome(session, text, targetSession = null) { + const line = clean(text); + const replyDelivered = line ? say(session, line, targetSession) : false; + return { + replyDelivered, + playerVisibleReply: replyDelivered ? line : null + }; +} + function sit(session, bot) { if (bot.state.fetchSeated()) return; bot.state.setSeated(true); @@ -237,7 +246,8 @@ function executeLegacy(session, decision, visiblePlayers) { return { applied: true, reason: 'none' }; } if (action === 'say') { - return { applied: say(session, decision.reply, targetSession), reason: 'say', replyDelivered: !!decision.reply }; + const reply = replyOutcome(session, decision.reply, targetSession); + return { applied: reply.replyDelivered, reason: 'say', ...reply }; } if (action === 'follow_player') { if (!targetSession) return { applied: false, reason: 'missing_target_player' }; @@ -246,8 +256,8 @@ function executeLegacy(session, decision, visiblePlayers) { clearChatArrival(session, 'party_follow'); session.plan = 'following'; session.botStay = false; - const replyDelivered = say(session, decision.reply || `Following you, ${targetSession.actor.fetchName()}!`, targetSession); - return { applied: true, reason: 'follow_player', replyDelivered }; + const reply = replyOutcome(session, decision.reply || `Following you, ${targetSession.actor.fetchName()}!`, targetSession); + return { applied: true, reason: 'follow_player', ...reply }; } else { const ChatArrivalState = invoke('GameServer/Bot/AI/ChatArrivalState'); ChatArrivalState.start(session, targetSession, { @@ -256,8 +266,8 @@ function executeLegacy(session, decision, visiblePlayers) { stopOnArrival: true }); approachPlayer(session, bot, targetSession); - const replyDelivered = say(session, decision.reply || `Coming closer. Invite me if you want party follow.`, targetSession); - return { applied: true, reason: 'follow_player', replyDelivered }; + const reply = replyOutcome(session, decision.reply || `Coming closer. Invite me if you want party follow.`, targetSession); + return { applied: true, reason: 'follow_player', ...reply }; } } if (action === 'stay_here') { @@ -271,8 +281,8 @@ function executeLegacy(session, decision, visiblePlayers) { if (session.followPlayerSession && session.partyCompanion === true) { session.plan = 'following'; } - const replyDelivered = say(session, decision.reply || 'Holding this position.', targetSession); - return { applied: true, reason: 'stay_here', replyDelivered }; + const reply = replyOutcome(session, decision.reply || 'Holding this position.', targetSession); + return { applied: true, reason: 'stay_here', ...reply }; } if (action === 'hunt') { clearChatArrival(session, 'hunt'); @@ -280,16 +290,16 @@ function executeLegacy(session, decision, visiblePlayers) { if (session.partyCompanion === true && session.followPlayerSession) { session.plan = 'hunting'; session.botStay = false; - const replyDelivered = say(session, decision.reply || 'Hunting with the party.', targetSession); - return { applied: true, reason: 'party_hunt', replyDelivered }; + const reply = replyOutcome(session, decision.reply || 'Hunting with the party.', targetSession); + return { applied: true, reason: 'party_hunt', ...reply }; } session.plan = 'hunting'; session.followPlayerSession = null; session.partyCompanion = false; session.botStay = false; - const replyDelivered = say(session, decision.reply, targetSession); - return { applied: true, reason: 'hunt', replyDelivered }; + const reply = replyOutcome(session, decision.reply, targetSession); + return { applied: true, reason: 'hunt', ...reply }; } if (action === 'rest') { clearChatArrival(session, 'rest'); @@ -303,37 +313,37 @@ function executeLegacy(session, decision, visiblePlayers) { } else { session.plan = 'hunting'; } - const replyDelivered = say(session, decision.reply || "I'm already recovered.", targetSession); - return { applied: true, reason: 'already_recovered', replyDelivered }; + const reply = replyOutcome(session, decision.reply || "I'm already recovered.", targetSession); + return { applied: true, reason: 'already_recovered', ...reply }; } session.plan = 'resting'; session.currentTargetId = undefined; bot.unselect(); sit(session, bot); - const replyDelivered = say(session, decision.reply, targetSession); - return { applied: true, reason: 'rest', replyDelivered }; + const reply = replyOutcome(session, decision.reply, targetSession); + return { applied: true, reason: 'rest', ...reply }; } if (action === 'shop') { clearChatArrival(session, 'shop'); if (startShopping(session, bot)) { - const replyDelivered = say(session, decision.reply, targetSession); - return { applied: true, reason: 'shop', replyDelivered }; + const reply = replyOutcome(session, decision.reply, targetSession); + return { applied: true, reason: 'shop', ...reply }; } else { - const replyDelivered = say(session, decision.reply || 'I will stay with the party and sell later.', targetSession); - return { applied: true, reason: 'shop', replyDelivered }; + const reply = replyOutcome(session, decision.reply || 'I will stay with the party and sell later.', targetSession); + return { applied: true, reason: 'shop', ...reply }; } } if (action === 'move_to_spot') { clearChatArrival(session, 'move_to_spot'); if (session.partyCompanion === true && session.followPlayerSession) { - const replyDelivered = say(session, decision.reply || 'I will stay with the party.', targetSession); - return { applied: true, reason: 'party_companion_stays_with_party', replyDelivered }; + const reply = replyOutcome(session, decision.reply || 'I will stay with the party.', targetSession); + return { applied: true, reason: 'party_companion_stays_with_party', ...reply }; } const applied = applyMoveToSpot(session, bot, decision.spotId); - const replyDelivered = applied ? say(session, decision.reply, targetSession) : false; - return { applied, reason: applied ? 'move_to_spot' : 'invalid_spot', replyDelivered }; + const reply = applied ? replyOutcome(session, decision.reply, targetSession) : { replyDelivered: false, playerVisibleReply: null }; + return { applied, reason: applied ? 'move_to_spot' : 'invalid_spot', ...reply }; } if (action === 'buff_target') { return applyBuffTarget(session, bot, decision, targetSession); diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 1ae3671b..08896ba7 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -415,9 +415,10 @@ function applyPartyPolicy(session, decision, requestContext, text) { function recordConversationReply(session, decision, result, requestContext) { const turn = requestContext?.conversationTurn; const action = decision?.action; - if (!turn || !result?.applied || result.replyDelivered !== true || !decision?.reply || ['buff_target', 'heal_target'].includes(action)) return; + const visibleReply = result?.playerVisibleReply || decision?.reply; + if (!turn || !result?.applied || result.replyDelivered !== true || !visibleReply || ['buff_target', 'heal_target'].includes(action)) return; const BotChatText = invoke('GameServer/Bot/AI/BotChatText'); - const reply = BotChatText.normalize(decision.reply) + const reply = BotChatText.normalize(visibleReply) .slice(0, BotChatText.DEFAULT_LINE_LIMIT * BotChatText.DEFAULT_MAX_LINES); if (!reply) return; @@ -458,10 +459,10 @@ function applyDecision(session, decision, visiblePlayers, requestContext) { // Skill requests are confirmed by the native cast/effect path. Do not // persist or claim the model's speculative reply before that happens. if (!['buff_target', 'heal_target'].includes(decision.action)) { - playerVisibleReply = decision.reply || null; + playerVisibleReply = result.playerVisibleReply || decision.reply || null; if (result.replyDelivered !== true && playerVisibleReply && playerSession?.actor) { invoke('GameServer/Bot/BotManager').botTell(session, playerSession, playerVisibleReply); - result = { ...result, replyDelivered: true }; + result = { ...result, replyDelivered: true, playerVisibleReply }; } recordConversationReply(session, decision, result, requestContext); } @@ -757,14 +758,14 @@ const BotBrain = { const pending = queue.shift() || null; session.pendingBrainTurn = queue[0] || null; if (pending) { - const startPending = () => Promise.resolve().then(async () => { - let requestContext = { ...pending.requestContext, queued: true }; - let pendingStatus = pending.status; + const startPending = (nextPending) => Promise.resolve().then(async () => { + let requestContext = { ...nextPending.requestContext, queued: true }; + let pendingStatus = nextPending.status; // The player turn is persisted before admission, but the // previous bot reply may finish while this request waits // in the FIFO. Refresh the bounded context at dequeue so // the next prompt sees the latest delivered turn. - if (pending.event === 'player_chat' && requestContext.playerSession) { + if (nextPending.event === 'player_chat' && requestContext.playerSession) { try { const BotAI = invoke('GameServer/Bot/BotAI'); pendingStatus = BotAI.getStatus(session) || pendingStatus; @@ -788,22 +789,37 @@ const BotBrain = { requestContext.assembledContext = await BotContextAssembler.assemble({ session, status: pendingStatus, - text: pending.text, + text: nextPending.text, requestContext }); } const started = BotBrain.maybeThink( session, - pending.event, + nextPending.event, pendingStatus, - pending.text, + nextPending.text, requestContext ); if (!started) fallbackReply(session, requestContext, 'queued_not_started'); }); - Promise.resolve(session.lastConversationWrite) + const continuePending = (nextPending) => Promise.resolve(session.lastConversationWrite) .catch(() => {}) - .then(startPending); + .then(() => startPending(nextPending)) + .catch((error) => { + utils.infoWarn('BotBrain', 'queued dialogue failed for %s: %s', bot.fetchName(), error.message); + try { + fallbackReply(session, nextPending.requestContext, 'queued_context_error'); + } catch (fallbackError) { + utils.infoWarn('BotBrain', 'queued dialogue fallback failed for %s: %s', bot.fetchName(), fallbackError.message); + } + + const following = queue.shift() || null; + session.pendingBrainTurn = queue[0] || null; + return following ? continuePending(following) : false; + }); + continuePending(pending).catch((error) => { + utils.infoWarn('BotBrain', 'queued dialogue drain failed for %s: %s', bot.fetchName(), error.message); + }); } }; const runTurn = async () => { diff --git a/src/GameServer/Bot/AI/BotDialogueArbiter.js b/src/GameServer/Bot/AI/BotDialogueArbiter.js index 60834fbf..4a8edc19 100644 --- a/src/GameServer/Bot/AI/BotDialogueArbiter.js +++ b/src/GameServer/Bot/AI/BotDialogueArbiter.js @@ -33,7 +33,9 @@ function route(input = {}) { return Promise.resolve({ ok: false, reason: 'invalid_hot_pair' }); } + let turnForFallback = null; return BotConversationService.beginTurn(input).then((turn) => { + turnForFallback = turn; const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); Promise.resolve(BotSocialMemory.recordEvent( input.playerSession, @@ -81,6 +83,12 @@ function route(input = {}) { return { ok: false, reason: 'conversation_error' }; } const BotManager = invoke('GameServer/Bot/BotManager'); + if (turnForFallback) { + return deliverFallback(input, turnForFallback, 'conversation_error').then((result) => ({ + ...result, + error: error.message + })); + } const reply = fallbackText(input.botSession, 'conversation_error'); BotManager.botTell(input.botSession, input.playerSession, reply); return { diff --git a/src/GameServer/World/World.js b/src/GameServer/World/World.js index dbfe0fd7..4a74621c 100644 --- a/src/GameServer/World/World.js +++ b/src/GameServer/World/World.js @@ -297,7 +297,7 @@ const World = { return false; } - return BotRemoteChat.replyForState(session, state, message).then((result) => { + return BotRemoteChat.replyForState(session, state, message, source).then((result) => { if (!result?.ok || !result.reply || result.delivered !== true) { session.dataSendToMe(ServerResponse.actionFailed()); return false; diff --git a/tests/test_bot_dialogue_arbiter.js b/tests/test_bot_dialogue_arbiter.js index 16e5f06e..ec9b9a94 100644 --- a/tests/test_bot_dialogue_arbiter.js +++ b/tests/test_bot_dialogue_arbiter.js @@ -6,6 +6,7 @@ const BotConversationStore = invoke('GameServer/Bot/AI/BotConversationStore'); const BotConversationService = invoke('GameServer/Bot/AI/BotConversationService'); const BotDialogueArbiter = invoke('GameServer/Bot/AI/BotDialogueArbiter'); const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); +const BotContextAssembler = invoke('GameServer/Bot/AI/BotContextAssembler'); const BotAI = invoke('GameServer/Bot/BotAI'); const BotManager = invoke('GameServer/Bot/BotManager'); const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); @@ -39,6 +40,7 @@ async function main() { const originalStatus = BotAI.getStatus; const originalBotTell = BotManager.botTell; const originalRecordEvent = BotSocialMemory.recordEvent; + const originalAssemble = BotContextAssembler.assemble; const originalSessions = BotManager.sessions; const originalRoute = BotDialogueArbiter.route; const originalWorldUser = invoke('GameServer/World/World').user; @@ -120,6 +122,22 @@ async function main() { 'deterministic fallback replies must not become model-visible history' ); + BotContextAssembler.assemble = async () => { throw new Error('context assembly failed'); }; + const contextFailure = await BotDialogueArbiter.route({ + playerSession: player, + botSession: bot, + channel: 'client_tell', + source: 'client_tell', + turnId: 'arbiter-context-failure', + text: 'Please answer after a context failure.' + }); + assert.strictEqual(contextFailure.reason, 'conversation_error'); + assert.strictEqual(fallbackReplies.length, 2, 'context errors must still deliver a fallback'); + const storedConversation = await BotConversationStore.ensureConversation(101, 201); + assert(storedConversation.turns.some((turn) => + turn.turnId === 'arbiter-context-failure' && turn.role === 'bot' && turn.meta?.fallback === true + ), 'context-error fallback must be persisted for audit while remaining hidden from the model'); + const world = invoke('GameServer/World/World'); world.user = { sessions: [player, bot] }; const botTwo = session('bot_dialogue_two', actor(202, 'Belen', 100)); @@ -157,6 +175,7 @@ async function main() { BotAI.getStatus = originalStatus; BotManager.botTell = originalBotTell; BotSocialMemory.recordEvent = originalRecordEvent; + BotContextAssembler.assemble = originalAssemble; BotManager.sessions = originalSessions; BotDialogueArbiter.route = originalRoute; invoke('GameServer/World/World').user = originalWorldUser; diff --git a/tests/test_bot_tool_registry.js b/tests/test_bot_tool_registry.js index 49757e2a..a2fc2426 100644 --- a/tests/test_bot_tool_registry.js +++ b/tests/test_bot_tool_registry.js @@ -46,7 +46,12 @@ function main() { BotToolAudit.resetMemory(); const session = { accountId: 'bot_registry', plan: 'hunting', actor: actor(), dataSendToOthers() {} }; const first = BotAgentTools.execute(session, decision('stay_here', 'turn-1'), [], context('turn-1')); - assert.deepStrictEqual(first, { applied: true, reason: 'stay_here', replyDelivered: true }); + assert.deepStrictEqual(first, { + applied: true, + reason: 'stay_here', + replyDelivered: true, + playerVisibleReply: 'Holding this position.' + }); assert.strictEqual(session.botStay, true); const replay = BotAgentTools.execute(session, decision('stay_here', 'turn-1'), [], context('turn-1')); @@ -84,7 +89,7 @@ function main() { ); assert.deepStrictEqual( preparedFreshness, - { applied: false, reason: 'invalid_spot', replyDelivered: false }, + { applied: false, reason: 'invalid_spot', replyDelivered: false, playerVisibleReply: null }, 'strict tools must validate against the revision captured for the actual prompt' ); From 7b1300ad528fec516f59875be9b952c43c19b9ce Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:08:35 -0400 Subject: [PATCH 43/59] Trace pre-provider bot dialogue failures --- scripts/run-tests.js | 1 + src/GameServer/Bot/AI/BotBrain.js | 111 +++++++++++-- src/GameServer/Bot/AI/BotDialogueArbiter.js | 128 +++++++++++--- tests/test_bot_dialogue_arbiter.js | 34 ++++ tests/test_hot_bot_queue_failure.js | 174 ++++++++++++++++++++ 5 files changed, 409 insertions(+), 39 deletions(-) create mode 100644 tests/test_hot_bot_queue_failure.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index f0c5ac80..2f6e5a69 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -18,6 +18,7 @@ const tests = [ 'tests/test_hot_bot_conversation_flow.js', 'tests/test_hot_bot_schema_repair.js', 'tests/test_hot_conversation_history_queue.js', + 'tests/test_hot_bot_queue_failure.js', 'tests/test_bot_activity_journal.js', 'tests/test_bot_context_assembler.js', 'tests/test_bot_conversation_summary.js', diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 08896ba7..b09f6139 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -433,14 +433,15 @@ function recordConversationReply(session, decision, result, requestContext) { })); } -function queueConversationWrite(session, work) { +function queueConversationWrite(session, work, metadata = {}) { const previous = session.lastConversationWrite || Promise.resolve(); const persist = () => LangfuseTracing.withObservation( 'bot.conversation.persist', { botId: session?.actor?.fetchId?.() || session?.accountId || null }, { botId: session?.actor?.fetchId?.() || session?.accountId || null, - source: 'hot_dialogue' + source: 'hot_dialogue', + ...metadata }, work, 'chain' @@ -537,7 +538,7 @@ function recordInferenceEvent(session, event, result, requestContext = null, ext }).catch(() => {}); } -function fallbackReply(session, requestContext, outcome) { +function fallbackReply(session, requestContext, outcome, persistMetadata = {}) { const playerSession = requestContext?.playerSession; if (!playerSession?.actor || !session?.actor) return false; @@ -551,18 +552,88 @@ function fallbackReply(session, requestContext, outcome) { BotManager.botTell(session, playerSession, reply); if (requestContext?.conversationTurn) { - queueConversationWrite(session, () => BotConversationService.recordFallback({ - playerSession, - botSession: session, - turnId: requestContext.conversationTurn.turnId, - channel: requestContext.conversationTurn.channel, - text: reply, - reason: outcome || 'fallback' - })); + queueConversationWrite( + session, + () => BotConversationService.recordFallback({ + playerSession, + botSession: session, + turnId: requestContext.conversationTurn.turnId, + channel: requestContext.conversationTurn.channel, + text: reply, + reason: outcome || 'fallback' + }), + persistMetadata + ); } return reply; } +function tracePreProviderFallback(session, requestContext, outcome, playerMessage = '', failure = null) { + const botId = session?.actor?.fetchId?.() || session?.accountId || null; + const playerId = requestContext?.playerSession?.actor?.fetchId?.() || requestContext?.playerId || null; + const turnId = requestContext?.conversationTurn?.turnId || requestContext?.requestId || null; + const metadata = { + event: 'player_chat', + source: requestContext?.source || requestContext?.channel || 'player_chat', + channel: requestContext?.channel || requestContext?.conversationTurn?.channel || null, + botId, + playerId, + turnId, + requestId: requestContext?.requestId || turnId, + sessionId: conversationSessionId(session, requestContext), + providerOutcome: outcome || 'pre_provider_error', + preProviderFallback: true, + error: failure?.message || null + }; + let fallbackDelivered = false; + + return LangfuseTracing.withRootObservation( + 'hot-bot.dialogue', + { + event: 'player_chat', + playerMessage: playerMessage || '', + conversation: requestContext?.conversation || null + }, + metadata, + async () => { + const delivery = await LangfuseTracing.withObservation( + 'bot.reply.deliver', + { action: 'fallback', reason: outcome || 'pre_provider_error' }, + metadata, + async () => { + const reply = fallbackReply(session, requestContext, outcome, metadata); + fallbackDelivered = !!reply; + return { ok: !!reply, reply: reply || null, delivered: !!reply }; + }, + 'chain' + ); + await Promise.resolve(session?.lastConversationWrite).catch(() => false); + return { + ok: delivery?.delivered === true, + applied: false, + reason: outcome || 'pre_provider_error', + traceOutput: { + providerOutcome: outcome || 'pre_provider_error', + requestedAction: null, + toolOutcome: null, + applied: false, + playerVisibleReply: delivery?.reply || null, + replyDelivered: delivery?.delivered === true, + error: failure?.message || null + } + }; + }, + 'agent' + ).catch(async (traceError) => { + utils.infoWarn('Langfuse', 'pre-provider fallback trace failed for %s: %s', session?.actor?.fetchName?.() || 'bot', traceError.message); + if (!fallbackDelivered) { + try { fallbackReply(session, requestContext, outcome, metadata); } catch (_) { /* original failure is already logged */ } + } + await Promise.resolve(session?.lastConversationWrite).catch(() => false); + return false; + }); +} + const BotBrain = { isEnabled() { const cfg = config(); @@ -800,18 +871,22 @@ const BotBrain = { nextPending.text, requestContext ); - if (!started) fallbackReply(session, requestContext, 'queued_not_started'); + if (!started) { + await tracePreProviderFallback(session, requestContext, 'queued_not_started', nextPending.text); + } }); const continuePending = (nextPending) => Promise.resolve(session.lastConversationWrite) .catch(() => {}) .then(() => startPending(nextPending)) - .catch((error) => { + .catch(async (error) => { utils.infoWarn('BotBrain', 'queued dialogue failed for %s: %s', bot.fetchName(), error.message); - try { - fallbackReply(session, nextPending.requestContext, 'queued_context_error'); - } catch (fallbackError) { - utils.infoWarn('BotBrain', 'queued dialogue fallback failed for %s: %s', bot.fetchName(), fallbackError.message); - } + await tracePreProviderFallback( + session, + nextPending.requestContext, + 'queued_context_error', + nextPending.text, + error + ); const following = queue.shift() || null; session.pendingBrainTurn = queue[0] || null; diff --git a/src/GameServer/Bot/AI/BotDialogueArbiter.js b/src/GameServer/Bot/AI/BotDialogueArbiter.js index 4a8edc19..8b0808b2 100644 --- a/src/GameServer/Bot/AI/BotDialogueArbiter.js +++ b/src/GameServer/Bot/AI/BotDialogueArbiter.js @@ -1,6 +1,7 @@ const BotConversationService = invoke('GameServer/Bot/AI/BotConversationService'); const BotContextAssembler = invoke('GameServer/Bot/AI/BotContextAssembler'); const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); function fallbackText(botSession, reason) { const plan = botSession?.plan || 'hunting'; @@ -12,20 +13,106 @@ function fallbackText(botSession, reason) { function deliverFallback(input, turn, reason) { const BotManager = invoke('GameServer/Bot/BotManager'); const reply = fallbackText(input.botSession, reason); - BotManager.botTell(input.botSession, input.playerSession, reply); - return BotConversationService.recordFallback({ - playerSession: input.playerSession, - botSession: input.botSession, - turnId: turn.turnId, - channel: turn.channel, - text: reply, - reason - }).then(() => ({ - ok: true, + const botId = input.botSession?.actor?.fetchId?.() || input.botSession?.accountId || null; + const playerId = input.playerSession?.actor?.fetchId?.() || null; + const turnId = turn?.turnId || input.turnId || input.requestId || null; + const channel = turn?.channel || input.channel || input.source || 'hot_dialogue'; + const metadata = { + event: 'player_chat', + source: input.source || channel, + channel, + botId, + playerId, + turnId, + requestId: input.requestId || turnId, + sessionId: `hot-bot:${botId || 'unknown'}:player:${playerId || 'unknown'}`, + providerOutcome: reason || 'fallback', + preProviderFallback: true + }; + let delivered = false; + let persisted = false; + const fallbackResult = (traceError = null) => ({ + ok: delivered, started: false, + applied: false, reason: reason || 'fallback', - reply - })); + reply, + delivered, + persisted, + traceOutput: { + providerOutcome: reason || 'fallback', + requestedAction: null, + toolOutcome: null, + applied: false, + playerVisibleReply: delivered ? reply : null, + replyDelivered: delivered, + traceError: traceError?.message || null + } + }); + + return LangfuseTracing.withRootObservation( + 'hot-bot.dialogue', + { + event: 'player_chat', + playerMessage: turn?.playerText || input.text || '', + conversation: turn?.context || null + }, + metadata, + async () => { + const delivery = await LangfuseTracing.withObservation( + 'bot.reply.deliver', + { action: 'fallback', reply, reason: reason || 'fallback' }, + metadata, + async () => { + BotManager.botTell(input.botSession, input.playerSession, reply); + delivered = true; + return { ok: true, reply, delivered: true }; + }, + 'chain' + ); + persisted = turnId + ? await LangfuseTracing.withObservation( + 'bot.conversation.persist', + { botId, playerId, turnId, fallback: true }, + metadata, + () => BotConversationService.recordFallback({ + playerSession: input.playerSession, + botSession: input.botSession, + turnId, + channel, + text: reply, + reason + }), + 'chain' + ) + : false; + delivered = delivery?.delivered === true; + persisted = persisted === true; + return fallbackResult(); + }, + 'agent' + ).catch(async (traceError) => { + utils.infoWarn('Langfuse', 'fallback trace failed for %s: %s', input.botSession?.actor?.fetchName?.() || 'bot', traceError.message); + if (!delivered) { + try { + BotManager.botTell(input.botSession, input.playerSession, reply); + delivered = true; + } catch (_) { + delivered = false; + } + } + if (turnId && !persisted) { + persisted = await BotConversationService.recordFallback({ + playerSession: input.playerSession, + botSession: input.botSession, + turnId, + channel, + text: reply, + reason + }).catch(() => false); + } + return fallbackResult(traceError); + }); } function route(input = {}) { @@ -82,22 +169,21 @@ function route(input = {}) { if (input.allowFallback === false || !input.playerSession?.actor || !input.botSession?.actor) { return { ok: false, reason: 'conversation_error' }; } - const BotManager = invoke('GameServer/Bot/BotManager'); if (turnForFallback) { return deliverFallback(input, turnForFallback, 'conversation_error').then((result) => ({ ...result, error: error.message })); } - const reply = fallbackText(input.botSession, 'conversation_error'); - BotManager.botTell(input.botSession, input.playerSession, reply); - return { - ok: true, - started: false, - reason: 'conversation_error', - reply, + return deliverFallback(input, { + turnId: input.turnId || input.requestId || null, + channel: input.channel || input.source || 'hot_dialogue', + playerText: input.text || '', + context: null + }, 'conversation_error').then((result) => ({ + ...result, error: error.message - }; + })); }); } diff --git a/tests/test_bot_dialogue_arbiter.js b/tests/test_bot_dialogue_arbiter.js index ec9b9a94..6071e95c 100644 --- a/tests/test_bot_dialogue_arbiter.js +++ b/tests/test_bot_dialogue_arbiter.js @@ -10,6 +10,7 @@ const BotContextAssembler = invoke('GameServer/Bot/AI/BotContextAssembler'); const BotAI = invoke('GameServer/Bot/BotAI'); const BotManager = invoke('GameServer/Bot/BotManager'); const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); function actor(id, name, x = 0) { return { @@ -41,10 +42,13 @@ async function main() { const originalBotTell = BotManager.botTell; const originalRecordEvent = BotSocialMemory.recordEvent; const originalAssemble = BotContextAssembler.assemble; + const originalWithObservation = LangfuseTracing.withObservation; + const originalWithRootObservation = LangfuseTracing.withRootObservation; const originalSessions = BotManager.sessions; const originalRoute = BotDialogueArbiter.route; const originalWorldUser = invoke('GameServer/World/World').user; + const observations = []; try { const player = session('player_dialogue', actor(101, 'Slava')); const playerTwo = session('player_dialogue_two', actor(102, 'OtherPlayer')); @@ -57,6 +61,14 @@ async function main() { captured.push({ text, requestContext }); return true; }; + LangfuseTracing.withObservation = (name, input, metadata, work) => { + observations.push(name); + return Promise.resolve(work(null)); + }; + LangfuseTracing.withRootObservation = (name, input, metadata, work) => { + observations.push(name); + return Promise.resolve(work(null)); + }; const first = await BotDialogueArbiter.route({ playerSession: player, @@ -122,6 +134,7 @@ async function main() { 'deterministic fallback replies must not become model-visible history' ); + const observationsBeforeContextFailure = observations.length; BotContextAssembler.assemble = async () => { throw new Error('context assembly failed'); }; const contextFailure = await BotDialogueArbiter.route({ playerSession: player, @@ -137,6 +150,25 @@ async function main() { assert(storedConversation.turns.some((turn) => turn.turnId === 'arbiter-context-failure' && turn.role === 'bot' && turn.meta?.fallback === true ), 'context-error fallback must be persisted for audit while remaining hidden from the model'); + assert.deepStrictEqual( + observations.slice(observationsBeforeContextFailure), + ['hot-bot.dialogue', 'bot.reply.deliver', 'bot.conversation.persist'], + 'context-error fallback must retain a complete Langfuse trace' + ); + + LangfuseTracing.withRootObservation = () => Promise.reject(new Error('trace backend unavailable')); + const traceFailure = await BotDialogueArbiter.route({ + playerSession: player, + botSession: bot, + channel: 'client_tell', + source: 'client_tell', + turnId: 'arbiter-trace-failure', + text: 'The trace backend must not block this fallback.' + }); + assert.strictEqual(traceFailure.ok, true); + assert.strictEqual(traceFailure.delivered, true); + assert.strictEqual(traceFailure.persisted, true); + assert.strictEqual(fallbackReplies.length, 3, 'trace failures must fail open without dropping the player reply'); const world = invoke('GameServer/World/World'); world.user = { sessions: [player, bot] }; @@ -176,6 +208,8 @@ async function main() { BotManager.botTell = originalBotTell; BotSocialMemory.recordEvent = originalRecordEvent; BotContextAssembler.assemble = originalAssemble; + LangfuseTracing.withObservation = originalWithObservation; + LangfuseTracing.withRootObservation = originalWithRootObservation; BotManager.sessions = originalSessions; BotDialogueArbiter.route = originalRoute; invoke('GameServer/World/World').user = originalWorldUser; diff --git a/tests/test_hot_bot_queue_failure.js b/tests/test_hot_bot_queue_failure.js new file mode 100644 index 00000000..a3017310 --- /dev/null +++ b/tests/test_hot_bot_queue_failure.js @@ -0,0 +1,174 @@ +const assert = require('assert'); +require('../src/Global'); + +const BotAI = invoke('GameServer/Bot/BotAI'); +const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); +const BotContextAssembler = invoke('GameServer/Bot/AI/BotContextAssembler'); +const BotConversationService = invoke('GameServer/Bot/AI/BotConversationService'); +const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); +const BotManager = invoke('GameServer/Bot/BotManager'); +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); +const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); +const World = invoke('GameServer/World/World'); + +function actor(id, name, x = 0) { + return { + fetchId: () => id, + fetchName: () => name, + fetchLevel: () => 20, + fetchHp: () => 100, + fetchMaxHp: () => 100, + fetchMp: () => 100, + fetchMaxMp: () => 100, + fetchLocX: () => x, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchKarma: () => 0, + fetchDestId: () => 0, + fetchIsOnline: () => true, + isDead: () => false + }; +} + +function response() { + return { + ok: true, + status: 200, + json: async () => ({ + choices: [{ message: { content: JSON.stringify({ + action: 'none', + reply: '', + reason: 'queue_failure_test', + confidence: 0.95 + }) } }] + }) + }; +} + +async function main() { + const originalConfig = options.default.OpenRouter; + const originalWorldUser = World.user; + const originalWorldNpc = World.npc; + const originalFetchVisibleUsers = World.fetchVisibleUsers; + const originalAssemble = BotContextAssembler.assemble; + const originalContextFor = BotConversationService.contextFor; + const originalStatus = BotAI.getStatus; + const originalBotTell = BotManager.botTell; + const originalWithObservation = LangfuseTracing.withObservation; + const originalWithRootObservation = LangfuseTracing.withRootObservation; + const observations = []; + const requests = []; + const fallbackReplies = []; + let releaseFirst; + + try { + options.default.OpenRouter = { + ...originalConfig, + enabled: true, + apiKey: 'queue-failure-test-key', + model: 'test/queue-failure', + timeoutMs: 1000, + cooldownMs: 0, + hotBotGlobalBudgetEnabled: false + }; + const playerSession = { + accountId: 'player_queue_failure', + actor: actor(9901, 'QueueFailurePlayer'), + dataSendToMe() {} + }; + const botSession = { + accountId: 'bot_queue_failure', + actor: actor(9902, 'QueueFailureBot', 100), + plan: 'hunting' + }; + World.user = { sessions: [playerSession, botSession] }; + World.npc = { spawns: [] }; + World.fetchVisibleUsers = () => [playerSession]; + BotAI.getStatus = () => ({ available: true, mode: 'hunting', level: 20, name: 'QueueFailureBot' }); + BotConversationService.contextFor = async () => ({ recentTurns: [] }); + BotContextAssembler.assemble = async ({ text }) => { + if (text === 'second') throw new Error('synthetic queued context failure'); + return { bot: {}, fragments: [], telemetry: {}, conversation: { recentTurns: [] } }; + }; + BotManager.botTell = (_bot, _player, text) => fallbackReplies.push(text); + LangfuseTracing.withObservation = (name, input, metadata, work) => { + observations.push({ name, metadata }); + return Promise.resolve(work(null)); + }; + LangfuseTracing.withRootObservation = (name, input, metadata, work) => { + observations.push({ name, metadata }); + return Promise.resolve(work(null)); + }; + BotInferenceBudget.reset(); + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.setTransport(async (_url, init) => { + requests.push(JSON.parse(init.body)); + if (requests.length === 1) await new Promise((resolve) => { releaseFirst = resolve; }); + return response(); + }); + + const status = BotAI.getStatus(botSession); + const requestContext = (turnId) => ({ + playerSession, + source: 'client_tell', + channel: 'client_tell', + conversation: { + recentTurns: [{ turnId, role: 'player', channel: 'client_tell', text: turnId, createdAt: Date.now() }] + }, + conversationTurn: { turnId, channel: 'client_tell' }, + requestId: turnId, + assembledContext: { bot: {}, fragments: [], telemetry: {} } + }); + + assert.strictEqual(BotBrain.maybeThink(botSession, 'player_chat', status, 'first', requestContext('first')), true); + for (let attempt = 0; attempt < 40 && !releaseFirst; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.strictEqual(typeof releaseFirst, 'function', 'the first provider request must start'); + assert.strictEqual(BotBrain.maybeThink(botSession, 'player_chat', status, 'second', requestContext('second')), true); + assert.strictEqual(BotBrain.maybeThink(botSession, 'player_chat', status, 'third', requestContext('third')), true); + releaseFirst(); + + for (let attempt = 0; attempt < 100 && (requests.length < 2 || botSession.brainInFlight); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + await Promise.resolve(botSession.lastConversationWrite); + + assert.strictEqual(requests.length, 2, 'the third turn must continue after the failed second turn'); + assert.strictEqual(fallbackReplies.length, 1, 'the failed queued turn must receive exactly one fallback'); + assert.strictEqual(botSession.pendingBrainTurns.length, 0, 'the dialogue queue must drain completely'); + assert.strictEqual(botSession.brainInFlight, false, 'the bot must leave the in-flight state'); + const failedRoot = observations.find((entry) => + entry.name === 'hot-bot.dialogue' && entry.metadata?.providerOutcome === 'queued_context_error' + ); + assert(failedRoot, 'the failed queued turn must create its own Langfuse root'); + assert(observations.some((entry) => + entry.name === 'bot.reply.deliver' && entry.metadata?.providerOutcome === 'queued_context_error' + ), 'the queued fallback delivery must be traced'); + assert(observations.some((entry) => + entry.name === 'bot.conversation.persist' && entry.metadata?.providerOutcome === 'queued_context_error' + ), 'the queued fallback persistence must be traced'); + } finally { + releaseFirst?.(); + options.default.OpenRouter = originalConfig; + World.user = originalWorldUser; + World.npc = originalWorldNpc; + World.fetchVisibleUsers = originalFetchVisibleUsers; + BotContextAssembler.assemble = originalAssemble; + BotConversationService.contextFor = originalContextFor; + BotAI.getStatus = originalStatus; + BotManager.botTell = originalBotTell; + LangfuseTracing.withObservation = originalWithObservation; + LangfuseTracing.withRootObservation = originalWithRootObservation; + BotInferenceBudget.reset(); + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.resetTransport(); + } + + console.log('Hot bot queued failure trace checks passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From 67b9a99096210d0622314abc6c503b70bc6ae575 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:17:26 -0400 Subject: [PATCH 44/59] Harden hot bot LLM gateway and summarization --- config/default.ini | 42 +-- config/local.example.ini | 19 +- scripts/run-tests.js | 1 + src/GameServer/Bot/AI/BotContextAssembler.js | 4 +- .../Bot/AI/BotConversationSummarizer.js | 53 +++- src/GameServer/Bot/AI/BotInferenceBudget.js | 117 ++++---- src/GameServer/Bot/AI/LangfuseTracing.js | 28 +- src/GameServer/Bot/AI/OpenRouterGateway.js | 259 +++++++++--------- tests/test_ai_config_surface.js | 64 +++++ tests/test_bot_conversation_summary.js | 9 +- tests/test_bot_inference_budget.js | 57 ++-- tests/test_bot_inference_interactive_queue.js | 7 +- tests/test_openrouter_gateway.js | 82 +++++- 13 files changed, 445 insertions(+), 297 deletions(-) create mode 100644 tests/test_ai_config_surface.js diff --git a/config/default.ini b/config/default.ini index 6eb0482f..5b195335 100644 --- a/config/default.ini +++ b/config/default.ini @@ -27,50 +27,18 @@ showMonsterLevel = true [OpenRouter] enabled = false apiKey = -model = google/gemini-2.5-flash-lite +model = openai/gpt-5.6-luna temperature = 0.35 -maxTokens = 320 -maxCompletionTokens = -interactiveMaxCompletionTokens = 0 -reasoningEnabled = true -reasoningEffort = high -reasoningExclude = true -timeoutMs = 3500 -cooldownMs = 45000 -chatCooldownMs = 0 -backgroundInferenceEnabled = false -visibilityRadius = 6000 -maxPromptPrice = 0.15 -maxCompletionPrice = 0.60 -usageInclude = true -requireProviderParameters = true -circuitBreakerFailureThreshold = 3 -circuitBreakerOpenMs = 30000 -hotBotBudgetEnabled = true -hotBotMaxRequestsPerMinute = 6 -hotBotPromptTokenBudgetPerMinute = 12000 -hotBotCompletionTokenBudgetPerMinute = 2400 -hotBotGlobalBudgetEnabled = true -hotBotGlobalMaxInFlight = 32 -hotBotGlobalMaxRequestsPerMinute = 240 -hotBotGlobalPromptTokenBudgetPerMinute = 300000 -hotBotGlobalCompletionTokenBudgetPerMinute = 64000 -debug = false +reasoningEffort = low +maxConcurrentRequests = 32 negotiationEnabled = false +debug = false [Langfuse] enabled = false envFile = baseUrl = http://localhost:3000 -publicKey = -secretKey = -environment = development -release = -serviceName = nodel2 -flushAt = 10 -flushInterval = 1 -captureInput = true -captureOutput = true +capturePayloads = true debug = false [BotPopulation] diff --git a/config/local.example.ini b/config/local.example.ini index 190dfbf4..161eaca0 100644 --- a/config/local.example.ini +++ b/config/local.example.ini @@ -1,15 +1,12 @@ [OpenRouter] enabled = true apiKey = sk-or-v1-your-key-here -model = google/gemini-2.5-flash-lite +model = openai/gpt-5.6-luna +debug = true + +[Langfuse] +enabled = false +envFile = +baseUrl = http://localhost:3000 +capturePayloads = true debug = true -negotiationEnabled = false -hotBotBudgetEnabled = true -hotBotMaxRequestsPerMinute = 6 -hotBotPromptTokenBudgetPerMinute = 12000 -hotBotCompletionTokenBudgetPerMinute = 2400 -hotBotGlobalBudgetEnabled = true -hotBotGlobalMaxInFlight = 32 -hotBotGlobalMaxRequestsPerMinute = 240 -hotBotGlobalPromptTokenBudgetPerMinute = 300000 -hotBotGlobalCompletionTokenBudgetPerMinute = 64000 diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 2f6e5a69..44be65ab 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -7,6 +7,7 @@ const tests = [ 'tests/test_armor_stats.js', 'tests/test_bot_ai_visibility.js', 'tests/test_openrouter_gateway.js', + 'tests/test_ai_config_surface.js', 'tests/test_langfuse_tracing.js', 'tests/test_bot_inference_budget.js', 'tests/test_bot_inference_interactive_queue.js', diff --git a/src/GameServer/Bot/AI/BotContextAssembler.js b/src/GameServer/Bot/AI/BotContextAssembler.js index 3921c9c4..68b67e4f 100644 --- a/src/GameServer/Bot/AI/BotContextAssembler.js +++ b/src/GameServer/Bot/AI/BotContextAssembler.js @@ -40,7 +40,9 @@ function compactTurnMeta(meta = {}) { if (meta.actionResult && typeof meta.actionResult === 'object') { result.actionResult = { ok: meta.actionResult.ok === true, - reason: meta.actionResult.reason || null + reason: meta.actionResult.reason || null, + outcome: meta.actionResult.outcome || null, + effect: meta.actionResult.effect || null }; } return Object.keys(result).length ? result : null; diff --git a/src/GameServer/Bot/AI/BotConversationSummarizer.js b/src/GameServer/Bot/AI/BotConversationSummarizer.js index 53f8a04b..bf223e1d 100644 --- a/src/GameServer/Bot/AI/BotConversationSummarizer.js +++ b/src/GameServer/Bot/AI/BotConversationSummarizer.js @@ -5,12 +5,37 @@ const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); const SUMMARY_THRESHOLD_MESSAGES = 24; const SUMMARY_RECENT_TURNS = BotConversationStore.DEFAULT_RECENT_TURNS; const SUMMARY_MAX_TOKENS = 220; +const SUMMARY_BACKOFF_BASE_MS = 30 * 1000; +const SUMMARY_BACKOFF_MAX_MS = 10 * 60 * 1000; const inFlight = new Map(); +const failures = new Map(); function pairKey(playerId, botId) { return `${Number(playerId)}:${Number(botId)}`; } +function backoffState(key) { + const state = failures.get(key); + if (!state || Number(state.nextRetryAt || 0) <= Date.now()) return null; + return { + ok: false, + reason: 'summary_backoff', + retryAfterMs: Math.max(1, Number(state.nextRetryAt) - Date.now()), + failureCount: Number(state.failureCount || 0), + lastFailure: state.reason || null + }; +} + +function recordFailure(key, reason) { + const previous = failures.get(key) || { failureCount: 0 }; + const failureCount = Number(previous.failureCount || 0) + 1; + const delay = Math.min( + SUMMARY_BACKOFF_MAX_MS, + SUMMARY_BACKOFF_BASE_MS * (2 ** Math.min(8, failureCount - 1)) + ); + failures.set(key, { failureCount, nextRetryAt: Date.now() + delay, reason }); +} + function compactMeta(meta) { if (!meta || typeof meta !== 'object') return null; const result = {}; @@ -21,7 +46,9 @@ function compactMeta(meta) { if (meta.actionResult && typeof meta.actionResult === 'object') { result.actionResult = { ok: meta.actionResult.ok === true, - reason: meta.actionResult.reason || null + reason: meta.actionResult.reason || null, + outcome: meta.actionResult.outcome || null, + effect: meta.actionResult.effect || null }; } return Object.keys(result).length ? result : null; @@ -82,6 +109,8 @@ async function summarize(input = {}) { const botId = Number(input.botId || 0); if (!playerId || !botId) return { ok: false, reason: 'invalid_pair' }; const key = pairKey(playerId, botId); + const blocked = backoffState(key); + if (blocked) return blocked; if (inFlight.has(key)) return inFlight.get(key); const work = (async () => { @@ -117,7 +146,7 @@ async function summarize(input = {}) { const messages = [ { role: 'system', - content: 'Summarize a game conversation for the same bot and player. Keep durable facts, preferences, unresolved requests, and explicit promises. Treat action metadata as authoritative: only an action with serverApplied=true or actionResult.ok=true happened; an LLM proposal, refusal, fallback, or failed action did not happen. Never create permissions, tool authorizations, or facts not stated in the dialogue.' + content: 'Summarize a game conversation for the same bot and player. Keep durable facts, preferences, unresolved requests, and explicit promises. Treat action metadata as authoritative: only an action with serverApplied=true or actionResult.ok=true happened; an LLM proposal, refusal, fallback, or failed action did not happen. A pending action means a server-side request or native window is active, not that the final transfer or effect completed. Never create permissions, tool authorizations, or facts not stated in the dialogue.' }, { role: 'user', content: JSON.stringify({ previousSummary: current.summary || '', turns: compactTurns(compacted) }) } ]; @@ -144,14 +173,22 @@ async function summarize(input = {}) { responseSchema: schema() }); } catch (_) { + recordFailure(key, 'summary_provider_error'); return { ok: false, reason: 'summary_provider_error' }; } finally { BotInferenceBudget.settle(admission.reservation, result?.usage); } - if (!result.ok) return { ok: false, reason: result.reason || 'summary_provider_error' }; + if (!result.ok) { + const reason = result.reason || 'summary_provider_error'; + recordFailure(key, reason); + return { ok: false, reason }; + } const summary = normalizeSummary(result.data); - if (!summary) return { ok: false, reason: 'empty_summary' }; + if (!summary) { + recordFailure(key, 'empty_summary'); + return { ok: false, reason: 'empty_summary' }; + } const saved = await BotConversationStore.setSummary({ playerId, botId, @@ -160,7 +197,11 @@ async function summarize(input = {}) { summaryThroughOrdinal: throughOrdinal, expectedVersion: Number(current.version || 0) }); - if (!saved.ok) return saved; + if (!saved.ok) { + recordFailure(key, saved.reason || 'summary_store_error'); + return saved; + } + failures.delete(key); return { ok: true, summary, @@ -178,7 +219,7 @@ const BotConversationSummarizer = { SUMMARY_THRESHOLD_MESSAGES, SUMMARY_RECENT_TURNS, summarize, - reset() { inFlight.clear(); } + reset() { inFlight.clear(); failures.clear(); } }; module.exports = BotConversationSummarizer; diff --git a/src/GameServer/Bot/AI/BotInferenceBudget.js b/src/GameServer/Bot/AI/BotInferenceBudget.js index 13d782f1..46b7d422 100644 --- a/src/GameServer/Bot/AI/BotInferenceBudget.js +++ b/src/GameServer/Bot/AI/BotInferenceBudget.js @@ -1,6 +1,14 @@ const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); const WINDOW_MS = 60 * 1000; +const LIMITS = Object.freeze({ + perBotMaxRequests: 6, + perBotPromptTokens: 12000, + perBotCompletionTokens: 2400, + globalMaxRequests: 240, + globalPromptTokens: 300000, + globalCompletionTokens: 64000 +}); const buckets = new Map(); const globalBucket = { entries: [], inFlight: 0, lastDeniedAt: 0, lastDeniedReason: null }; const globalWaiters = []; @@ -32,11 +40,10 @@ function bucketFor(id) { function globalConfig() { const cfg = config(); return { - enabled: cfg.hotBotGlobalBudgetEnabled !== false, - maxInFlight: Math.max(1, Math.floor(number(cfg.hotBotGlobalMaxInFlight, 32))), - maxRequests: Math.max(1, Math.floor(number(cfg.hotBotGlobalMaxRequestsPerMinute, 240))), - promptBudget: Math.max(240, number(cfg.hotBotGlobalPromptTokenBudgetPerMinute, 300000)), - completionBudget: Math.max(64, number(cfg.hotBotGlobalCompletionTokenBudgetPerMinute, 64000)) + maxInFlight: Math.max(1, Math.floor(number(cfg.maxConcurrentRequests, 32))), + maxRequests: LIMITS.globalMaxRequests, + promptBudget: LIMITS.globalPromptTokens, + completionBudget: LIMITS.globalCompletionTokens }; } @@ -114,7 +121,7 @@ function queueInteractiveReservation(session, input, now) { function pumpGlobalWaiters() { const global = globalConfig(); - while (globalWaiters.length > 0 && (!global.enabled || globalBucket.inFlight < global.maxInFlight)) { + while (globalWaiters.length > 0 && globalBucket.inFlight < global.maxInFlight) { const waiter = globalWaiters.shift(); const result = reserve(waiter.session, { ...waiter.input, @@ -130,16 +137,13 @@ function reserve(session, input = {}) { if (!id) return { ok: false, reason: 'missing_bot' }; const cfg = config(); - if (cfg.hotBotBudgetEnabled === false) { - return { ok: true, bypassed: true, reservation: null, status: status(session) }; - } const now = Number(input.now || Date.now()); const bucket = bucketFor(id); prune(bucket, now); - const maxRequests = Math.max(1, Math.floor(number(input.maxRequests, cfg.hotBotMaxRequestsPerMinute))); - const promptBudget = Math.max(240, number(input.promptBudget, cfg.hotBotPromptTokenBudgetPerMinute)); - const completionBudget = Math.max(64, number(input.completionBudget, cfg.hotBotCompletionTokenBudgetPerMinute)); + const maxRequests = Math.max(1, Math.floor(number(input.maxRequests, LIMITS.perBotMaxRequests))); + const promptBudget = Math.max(240, number(input.promptBudget, LIMITS.perBotPromptTokens)); + const completionBudget = Math.max(64, number(input.completionBudget, LIMITS.perBotCompletionTokens)); const promptTokens = nonNegative(input.estimatedPromptTokens); const completionTokens = nonNegative(input.maxCompletionTokens ?? cfg.maxTokens); @@ -157,38 +161,36 @@ function reserve(session, input = {}) { } const global = globalConfig(); - if (global.enabled) { - prune(globalBucket, now); - if (globalBucket.inFlight >= global.maxInFlight && input.bypass === true && input._grantingQueued !== true) { - return queueInteractiveReservation(session, input, now); - } - if (globalBucket.inFlight >= global.maxInFlight && input._grantingQueued !== true) { - return globalRejection('inference_budget_global_concurrency', now, 1000); - } - if (input.bypass !== true && budgetCount(globalBucket) >= global.maxRequests) { - const oldest = globalBucket.entries[0]; - return globalRejection( - 'inference_budget_global_requests', - now, - oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS - ); - } - if (input.bypass !== true && budgetSum(globalBucket, 'promptTokens') + promptTokens > global.promptBudget) { - const oldest = globalBucket.entries[0]; - return globalRejection( - 'inference_budget_global_prompt_tokens', - now, - oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS - ); - } - if (input.bypass !== true && budgetSum(globalBucket, 'completionTokens') + completionTokens > global.completionBudget) { - const oldest = globalBucket.entries[0]; - return globalRejection( - 'inference_budget_global_completion_tokens', - now, - oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS - ); - } + prune(globalBucket, now); + if (globalBucket.inFlight >= global.maxInFlight && input.bypass === true && input._grantingQueued !== true) { + return queueInteractiveReservation(session, input, now); + } + if (globalBucket.inFlight >= global.maxInFlight && input._grantingQueued !== true) { + return globalRejection('inference_budget_global_concurrency', now, 1000); + } + if (input.bypass !== true && budgetCount(globalBucket) >= global.maxRequests) { + const oldest = globalBucket.entries[0]; + return globalRejection( + 'inference_budget_global_requests', + now, + oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS + ); + } + if (input.bypass !== true && budgetSum(globalBucket, 'promptTokens') + promptTokens > global.promptBudget) { + const oldest = globalBucket.entries[0]; + return globalRejection( + 'inference_budget_global_prompt_tokens', + now, + oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS + ); + } + if (input.bypass !== true && budgetSum(globalBucket, 'completionTokens') + completionTokens > global.completionBudget) { + const oldest = globalBucket.entries[0]; + return globalRejection( + 'inference_budget_global_completion_tokens', + now, + oldest ? WINDOW_MS - (now - oldest.startedAt) : WINDOW_MS + ); } const reservation = { @@ -206,11 +208,9 @@ function reserve(session, input = {}) { globalEntry: null }; bucket.entries.push(reservation); - if (global.enabled) { - reservation.globalEntry = reservation; - globalBucket.entries.push(reservation); - globalBucket.inFlight += 1; - } + reservation.globalEntry = reservation; + globalBucket.entries.push(reservation); + globalBucket.inFlight += 1; return { ok: true, bypassed: input.bypass === true, reservation, status: status(session, now) }; } @@ -242,7 +242,7 @@ function globalStatus(now = Date.now()) { const quotaRequests = budgetCount(globalBucket); const oldest = globalBucket.entries[0]; return { - enabled: cfg.enabled, + enabled: true, windowMs: WINDOW_MS, inFlight: globalBucket.inFlight, maxInFlight: cfg.maxInFlight, @@ -264,18 +264,17 @@ function globalStatus(now = Date.now()) { function status(session, now = Date.now()) { const id = actorId(session); - const cfg = config(); if (!id) { return { - enabled: cfg.hotBotBudgetEnabled !== false, + enabled: true, windowMs: WINDOW_MS, requests: 0, bypassedRequests: 0, - maxRequests: Math.max(1, Math.floor(number(cfg.hotBotMaxRequestsPerMinute, 6))), + maxRequests: LIMITS.perBotMaxRequests, promptTokens: 0, - promptBudget: Math.max(240, number(cfg.hotBotPromptTokenBudgetPerMinute, 12000)), + promptBudget: LIMITS.perBotPromptTokens, completionTokens: 0, - completionBudget: Math.max(64, number(cfg.hotBotCompletionTokenBudgetPerMinute, 2400)), + completionBudget: LIMITS.perBotCompletionTokens, cost: 0, remainingRequests: 0, remainingPromptTokens: 0, @@ -288,9 +287,9 @@ function status(session, now = Date.now()) { const bucket = bucketFor(id); prune(bucket, now); - const maxRequests = Math.max(1, Math.floor(number(cfg.hotBotMaxRequestsPerMinute, 6))); - const promptBudget = Math.max(240, number(cfg.hotBotPromptTokenBudgetPerMinute, 12000)); - const completionBudget = Math.max(64, number(cfg.hotBotCompletionTokenBudgetPerMinute, 2400)); + const maxRequests = LIMITS.perBotMaxRequests; + const promptBudget = LIMITS.perBotPromptTokens; + const completionBudget = LIMITS.perBotCompletionTokens; const promptTokens = sum(bucket, 'promptTokens'); const completionTokens = sum(bucket, 'completionTokens'); const quotaPromptTokens = budgetSum(bucket, 'promptTokens'); @@ -298,7 +297,7 @@ function status(session, now = Date.now()) { const quotaRequests = budgetCount(bucket); const oldest = bucket.entries[0]; return { - enabled: cfg.hotBotBudgetEnabled !== false, + enabled: true, windowMs: WINDOW_MS, requests: bucket.entries.length, bypassedRequests: bypassedCount(bucket), diff --git a/src/GameServer/Bot/AI/LangfuseTracing.js b/src/GameServer/Bot/AI/LangfuseTracing.js index f4fdaf2a..cb9acdab 100644 --- a/src/GameServer/Bot/AI/LangfuseTracing.js +++ b/src/GameServer/Bot/AI/LangfuseTracing.js @@ -16,11 +16,6 @@ function bool(value, fallback = false) { return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase()); } -function number(value, fallback) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : fallback; -} - function text(value, max = 240) { return String(value || '').replace(/\s+/g, ' ').trim().slice(0, max); } @@ -59,19 +54,19 @@ function parseEnvFile(filename) { function config(overrides = {}) { const option = options.default.Langfuse || {}; const fileEnv = parseEnvFile(option.envFile); - const value = (key, fallback = '') => process.env[key] || fileEnv[key] || option[key] || fallback; + const value = (key, fallback = '') => process.env[key] || fileEnv[key] || fallback; const source = { enabled: bool(process.env.LANGFUSE_ENABLED || option.enabled, false), - baseUrl: value('LANGFUSE_BASE_URL', 'http://localhost:3000'), + envFile: String(option.envFile || ''), + baseUrl: value('LANGFUSE_BASE_URL', option.baseUrl || 'http://localhost:3000'), publicKey: value('LANGFUSE_PUBLIC_KEY') || value('LANGFUSE_INIT_PROJECT_PUBLIC_KEY'), secretKey: value('LANGFUSE_SECRET_KEY') || value('LANGFUSE_INIT_PROJECT_SECRET_KEY'), environment: value('LANGFUSE_TRACING_ENVIRONMENT', 'development'), release: value('LANGFUSE_RELEASE', utils.buildNumber?.() || 'nodel2'), - serviceName: text(option.serviceName || process.env.OTEL_SERVICE_NAME || 'nodel2', 80), - flushAt: Math.max(1, number(option.flushAt, 10)), - flushInterval: Math.max(1, number(option.flushInterval, 1)), - captureInput: bool(option.captureInput, true), - captureOutput: bool(option.captureOutput, true), + serviceName: text(process.env.OTEL_SERVICE_NAME || fileEnv.OTEL_SERVICE_NAME || 'nodel2', 80), + flushAt: 1, + flushInterval: 1, + capturePayloads: bool(process.env.LANGFUSE_CAPTURE_PAYLOADS || option.capturePayloads, true), debug: bool(option.debug, false) }; return { @@ -81,10 +76,7 @@ function config(overrides = {}) { baseUrl: overrides.baseUrl || source.baseUrl, publicKey: overrides.publicKey || source.publicKey, secretKey: overrides.secretKey || source.secretKey, - captureInput: bool(overrides.captureInput, source.captureInput), - captureOutput: bool(overrides.captureOutput, source.captureOutput), - flushAt: Math.max(1, number(overrides.flushAt, source.flushAt)), - flushInterval: Math.max(1, number(overrides.flushInterval, source.flushInterval)) + capturePayloads: bool(overrides.capturePayloads, source.capturePayloads) }; } @@ -98,12 +90,12 @@ function serializable(value, limit = 24000) { } function observationInput(value, cfg) { - if (cfg.captureInput) return serializable(value); + if (cfg.capturePayloads) return serializable(value); return { captured: false, type: typeof value }; } function observationOutput(value, cfg) { - if (cfg.captureOutput) return serializable(value); + if (cfg.capturePayloads) return serializable(value); return { captured: false, type: typeof value }; } diff --git a/src/GameServer/Bot/AI/OpenRouterGateway.js b/src/GameServer/Bot/AI/OpenRouterGateway.js index dd8bd1a5..5656f564 100644 --- a/src/GameServer/Bot/AI/OpenRouterGateway.js +++ b/src/GameServer/Bot/AI/OpenRouterGateway.js @@ -1,39 +1,36 @@ const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); +const REASONING_EFFORTS = new Set(['off', 'low', 'medium', 'high']); +const LUNA_MODEL = 'openai/gpt-5.6-luna'; +const MODEL_PROFILES = Object.freeze({ + [LUNA_MODEL]: Object.freeze({ + supportsTemperature: false, + completionLimitParam: 'max_tokens', + openAiStrictSchema: true, + provider: Object.freeze({ + order: Object.freeze(['OpenAI']), + sort: 'price', + allow_fallbacks: false + }) + }) +}); const DEFAULTS = Object.freeze({ enabled: false, apiKey: '', - model: 'google/gemini-2.5-flash-lite', + model: LUNA_MODEL, temperature: 0.35, + reasoningEffort: 'low', + maxConcurrentRequests: 32, + debug: false, + + // Runtime safety policy. These are deliberately not user-facing config + // knobs; callers may override them explicitly for focused tests/workflows. maxTokens: 320, - maxCompletionTokens: null, - interactiveMaxCompletionTokens: 0, - reasoningEnabled: true, - reasoningEffort: 'high', - reasoningExclude: true, timeoutMs: 3500, - cooldownMs: 45000, - chatCooldownMs: 0, - backgroundInferenceEnabled: false, - remoteChatCooldownMs: 10000, visibilityRadius: 6000, - maxPromptPrice: 0, - maxCompletionPrice: 0, - usageInclude: true, - requireProviderParameters: true, circuitBreakerFailureThreshold: 3, - circuitBreakerOpenMs: 30000, - hotBotBudgetEnabled: true, - hotBotMaxRequestsPerMinute: 6, - hotBotPromptTokenBudgetPerMinute: 12000, - hotBotCompletionTokenBudgetPerMinute: 2400, - hotBotGlobalBudgetEnabled: true, - hotBotGlobalMaxInFlight: 32, - hotBotGlobalMaxRequestsPerMinute: 240, - hotBotGlobalPromptTokenBudgetPerMinute: 300000, - hotBotGlobalCompletionTokenBudgetPerMinute: 64000, - debug: false + circuitBreakerOpenMs: 30000 }); let transport = null; @@ -66,10 +63,9 @@ function num(value, fallback) { return Number.isFinite(parsed) ? parsed : fallback; } -function nullableNum(value, fallback = null) { - if (value === undefined || value === null || value === '') return fallback; - const parsed = Number(value); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +function reasoningEffort(value, fallback = DEFAULTS.reasoningEffort) { + const normalized = String(value || fallback).trim().toLowerCase(); + return REASONING_EFFORTS.has(normalized) ? normalized : fallback; } function config(overrides = {}) { @@ -80,39 +76,11 @@ function config(overrides = {}) { apiKey: process.env.OPENROUTER_API_KEY || optn.apiKey || DEFAULTS.apiKey, model: process.env.OPENROUTER_MODEL || optn.model || DEFAULTS.model, temperature: num(optn.temperature, DEFAULTS.temperature), - maxTokens: num(optn.maxTokens, DEFAULTS.maxTokens), - maxCompletionTokens: nullableNum(optn.maxCompletionTokens, DEFAULTS.maxCompletionTokens), - interactiveMaxCompletionTokens: nullableNum( - optn.interactiveMaxCompletionTokens, - DEFAULTS.interactiveMaxCompletionTokens - ), - reasoningEnabled: bool(optn.reasoningEnabled, DEFAULTS.reasoningEnabled), - reasoningEffort: String(optn.reasoningEffort || DEFAULTS.reasoningEffort), - reasoningExclude: bool(optn.reasoningExclude, DEFAULTS.reasoningExclude), - timeoutMs: num(optn.timeoutMs, DEFAULTS.timeoutMs), - cooldownMs: num(optn.cooldownMs, DEFAULTS.cooldownMs), - chatCooldownMs: num(optn.chatCooldownMs, DEFAULTS.chatCooldownMs), - backgroundInferenceEnabled: bool(optn.backgroundInferenceEnabled, DEFAULTS.backgroundInferenceEnabled), - remoteChatCooldownMs: num(optn.remoteChatCooldownMs, DEFAULTS.remoteChatCooldownMs), - visibilityRadius: num(optn.visibilityRadius, DEFAULTS.visibilityRadius), - maxPromptPrice: num(optn.maxPromptPrice, DEFAULTS.maxPromptPrice), - maxCompletionPrice: num(optn.maxCompletionPrice, DEFAULTS.maxCompletionPrice), - usageInclude: bool(optn.usageInclude, DEFAULTS.usageInclude), - requireProviderParameters: bool(optn.requireProviderParameters, DEFAULTS.requireProviderParameters), - circuitBreakerFailureThreshold: num( - optn.circuitBreakerFailureThreshold, - DEFAULTS.circuitBreakerFailureThreshold - ), - circuitBreakerOpenMs: num(optn.circuitBreakerOpenMs, DEFAULTS.circuitBreakerOpenMs), - hotBotBudgetEnabled: bool(optn.hotBotBudgetEnabled, DEFAULTS.hotBotBudgetEnabled), - hotBotMaxRequestsPerMinute: num(optn.hotBotMaxRequestsPerMinute, DEFAULTS.hotBotMaxRequestsPerMinute), - hotBotPromptTokenBudgetPerMinute: num(optn.hotBotPromptTokenBudgetPerMinute, DEFAULTS.hotBotPromptTokenBudgetPerMinute), - hotBotCompletionTokenBudgetPerMinute: num(optn.hotBotCompletionTokenBudgetPerMinute, DEFAULTS.hotBotCompletionTokenBudgetPerMinute), - hotBotGlobalBudgetEnabled: bool(optn.hotBotGlobalBudgetEnabled, DEFAULTS.hotBotGlobalBudgetEnabled), - hotBotGlobalMaxInFlight: num(optn.hotBotGlobalMaxInFlight, DEFAULTS.hotBotGlobalMaxInFlight), - hotBotGlobalMaxRequestsPerMinute: num(optn.hotBotGlobalMaxRequestsPerMinute, DEFAULTS.hotBotGlobalMaxRequestsPerMinute), - hotBotGlobalPromptTokenBudgetPerMinute: num(optn.hotBotGlobalPromptTokenBudgetPerMinute, DEFAULTS.hotBotGlobalPromptTokenBudgetPerMinute), - hotBotGlobalCompletionTokenBudgetPerMinute: num(optn.hotBotGlobalCompletionTokenBudgetPerMinute, DEFAULTS.hotBotGlobalCompletionTokenBudgetPerMinute), + reasoningEffort: reasoningEffort(optn.reasoningEffort), + maxConcurrentRequests: Math.max(1, Math.floor(num( + optn.maxConcurrentRequests, + DEFAULTS.maxConcurrentRequests + ))), debug: bool(optn.debug, DEFAULTS.debug) }; @@ -123,41 +91,19 @@ function config(overrides = {}) { apiKey: overrides.apiKey !== undefined ? String(overrides.apiKey || '') : source.apiKey, model: overrides.model || source.model, temperature: num(overrides.temperature, source.temperature), + reasoningEffort: reasoningEffort(overrides.reasoningEffort, source.reasoningEffort), + maxConcurrentRequests: Math.max(1, Math.floor(num( + overrides.maxConcurrentRequests, + source.maxConcurrentRequests + ))), maxTokens: num(overrides.maxTokens, source.maxTokens), - maxCompletionTokens: nullableNum(overrides.maxCompletionTokens, source.maxCompletionTokens), - interactiveMaxCompletionTokens: nullableNum( - overrides.interactiveMaxCompletionTokens, - source.interactiveMaxCompletionTokens - ), - reasoningEnabled: bool(overrides.reasoningEnabled, source.reasoningEnabled), - reasoningEffort: String(overrides.reasoningEffort || source.reasoningEffort), - reasoningExclude: bool(overrides.reasoningExclude, source.reasoningExclude), timeoutMs: num(overrides.timeoutMs, source.timeoutMs), - maxPromptPrice: num(overrides.maxPromptPrice, source.maxPromptPrice), - maxCompletionPrice: num(overrides.maxCompletionPrice, source.maxCompletionPrice), - backgroundInferenceEnabled: bool( - overrides.backgroundInferenceEnabled, - source.backgroundInferenceEnabled - ), - usageInclude: bool(overrides.usageInclude, source.usageInclude), - requireProviderParameters: bool( - overrides.requireProviderParameters, - source.requireProviderParameters - ), + visibilityRadius: Math.max(1, num(overrides.visibilityRadius, source.visibilityRadius)), circuitBreakerFailureThreshold: Math.max( 1, num(overrides.circuitBreakerFailureThreshold, source.circuitBreakerFailureThreshold) ), circuitBreakerOpenMs: Math.max(0, num(overrides.circuitBreakerOpenMs, source.circuitBreakerOpenMs)), - hotBotBudgetEnabled: bool(overrides.hotBotBudgetEnabled, source.hotBotBudgetEnabled), - hotBotMaxRequestsPerMinute: Math.max(1, num(overrides.hotBotMaxRequestsPerMinute, source.hotBotMaxRequestsPerMinute)), - hotBotPromptTokenBudgetPerMinute: Math.max(240, num(overrides.hotBotPromptTokenBudgetPerMinute, source.hotBotPromptTokenBudgetPerMinute)), - hotBotCompletionTokenBudgetPerMinute: Math.max(64, num(overrides.hotBotCompletionTokenBudgetPerMinute, source.hotBotCompletionTokenBudgetPerMinute)), - hotBotGlobalBudgetEnabled: bool(overrides.hotBotGlobalBudgetEnabled, source.hotBotGlobalBudgetEnabled), - hotBotGlobalMaxInFlight: Math.max(1, num(overrides.hotBotGlobalMaxInFlight, source.hotBotGlobalMaxInFlight)), - hotBotGlobalMaxRequestsPerMinute: Math.max(1, num(overrides.hotBotGlobalMaxRequestsPerMinute, source.hotBotGlobalMaxRequestsPerMinute)), - hotBotGlobalPromptTokenBudgetPerMinute: Math.max(240, num(overrides.hotBotGlobalPromptTokenBudgetPerMinute, source.hotBotGlobalPromptTokenBudgetPerMinute)), - hotBotGlobalCompletionTokenBudgetPerMinute: Math.max(64, num(overrides.hotBotGlobalCompletionTokenBudgetPerMinute, source.hotBotGlobalCompletionTokenBudgetPerMinute)), debug: bool(overrides.debug, source.debug) }; } @@ -173,19 +119,74 @@ function sessionId(value) { return String(value).slice(0, 256); } -function providerOptions(cfg, extra = {}) { - const provider = { ...extra }; - if (cfg.requireProviderParameters) provider.require_parameters = true; +function modelProfile(model) { + return MODEL_PROFILES[String(model || '').trim()] || null; +} - if (cfg.maxPromptPrice > 0 || cfg.maxCompletionPrice > 0) { - provider.max_price = { - ...(provider.max_price || {}) - }; - if (cfg.maxPromptPrice > 0) provider.max_price.prompt = cfg.maxPromptPrice; - if (cfg.maxCompletionPrice > 0) provider.max_price.completion = cfg.maxCompletionPrice; +function supportsTemperature(model) { + return modelProfile(model)?.supportsTemperature !== false; +} + +function completionLimitParam(model) { + return modelProfile(model)?.completionLimitParam || 'max_completion_tokens'; +} + +function nullableSchema(schema) { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema; + const result = { ...schema }; + if (Array.isArray(result.type)) { + if (!result.type.includes('null')) result.type = [...result.type, 'null']; + } else if (result.type) { + result.type = [result.type, 'null']; + } else if (Array.isArray(result.anyOf)) { + result.anyOf = [...result.anyOf, { type: 'null' }]; + } else { + return { anyOf: [result, { type: 'null' }] }; + } + if (Array.isArray(result.enum) && !result.enum.includes(null)) { + result.enum = [...result.enum, null]; } + return result; +} + +function openAiStrictSchema(schema) { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema; + const result = { ...schema }; + + ['anyOf', 'oneOf', 'allOf'].forEach((keyword) => { + if (Array.isArray(result[keyword])) { + result[keyword] = result[keyword].map((entry) => openAiStrictSchema(entry)); + } + }); + if (result.items) result.items = openAiStrictSchema(result.items); + + if (result.properties && typeof result.properties === 'object') { + const originalRequired = new Set(Array.isArray(result.required) ? result.required : []); + result.properties = Object.fromEntries(Object.entries(result.properties).map(([key, property]) => { + const transformed = openAiStrictSchema(property); + return [key, originalRequired.has(key) ? transformed : nullableSchema(transformed)]; + })); + result.required = Object.keys(result.properties); + result.additionalProperties = false; + } + + return result; +} - return Object.keys(provider).length > 0 ? provider : null; +function responseSchemaForModel(responseSchema, model) { + if (!responseSchema?.schema || modelProfile(model)?.openAiStrictSchema !== true) return responseSchema; + return { + ...responseSchema, + schema: openAiStrictSchema(responseSchema.schema) + }; +} + +function providerOptions(model, extra = {}) { + return { + ...(modelProfile(model)?.provider || {}), + ...extra, + require_parameters: true + }; } function circuitState(key = 'default') { @@ -280,8 +281,8 @@ function completionLimit(cfg, request = {}) { const configured = request.maxCompletionTokens !== undefined ? request.maxCompletionTokens : request.interactive === true - ? cfg.interactiveMaxCompletionTokens - : (cfg.maxCompletionTokens ?? cfg.maxTokens); + ? null + : cfg.maxTokens; const value = Number(configured); return Number.isFinite(value) && value > 0 ? Math.floor(value) : null; } @@ -290,18 +291,12 @@ function repairConfig(spec) { const source = config(spec.config || {}); const current = completionLimit(source, spec); if (current === null) { - return { - ...(spec.config || {}), - maxCompletionTokens: null, - interactiveMaxCompletionTokens: 0 - }; + return { ...(spec.config || {}) }; } const rescue = Math.max(2048, current * 2); return { ...(spec.config || {}), - maxTokens: rescue, - maxCompletionTokens: rescue, - interactiveMaxCompletionTokens: rescue + maxTokens: rescue }; } @@ -452,35 +447,36 @@ async function requestUntraced(spec = {}) { : null; const body = { model: cfg.model, - messages: Array.isArray(requestData.messages) ? requestData.messages : [], - temperature: cfg.temperature + messages: Array.isArray(requestData.messages) ? requestData.messages : [] }; + if (supportsTemperature(cfg.model)) body.temperature = cfg.temperature; const maxCompletionTokens = completionLimit(cfg, requestData); - if (maxCompletionTokens !== null) body.max_completion_tokens = maxCompletionTokens; + if (maxCompletionTokens !== null) body[completionLimitParam(cfg.model)] = maxCompletionTokens; - if (cfg.reasoningEnabled) { + if (cfg.reasoningEffort !== 'off') { body.reasoning = { effort: cfg.reasoningEffort, - exclude: cfg.reasoningExclude + exclude: true }; } - if (requestData.responseSchema) { + const effectiveResponseSchema = responseSchemaForModel(requestData.responseSchema, cfg.model); + if (effectiveResponseSchema) { body.response_format = { type: 'json_schema', json_schema: { - name: requestData.responseSchema.name, + name: effectiveResponseSchema.name, strict: true, - schema: requestData.responseSchema.schema + schema: effectiveResponseSchema.schema } }; } if (requestData.sessionId) body.session_id = requestData.sessionId; - if (cfg.usageInclude) body.usage = { include: true }; + body.usage = { include: true }; - const provider = providerOptions(cfg, requestData.provider); + const provider = providerOptions(cfg.model, requestData.provider); if (provider) body.provider = provider; try { @@ -542,7 +538,7 @@ async function requestUntraced(spec = {}) { }); } - const validationError = validateContent(data, requestData.responseSchema); + const validationError = validateContent(data, effectiveResponseSchema); if (validationError) { markFailure(cfg, requestData.circuitKey); return complete(requestData, cfg, 'schema_error', startedAt, { @@ -623,22 +619,28 @@ async function request(spec = {}) { interactive: spec.interactive === true }); const modelParameters = { - temperature: Number((spec.config || {}).temperature ?? config().temperature), reasoning: { - enabled: effectiveConfig.reasoningEnabled, + enabled: effectiveConfig.reasoningEffort !== 'off', effort: effectiveConfig.reasoningEffort, - exclude: effectiveConfig.reasoningExclude + exclude: true } }; - if (effectiveMaxTokens !== null) modelParameters.max_completion_tokens = effectiveMaxTokens; + if (supportsTemperature(effectiveConfig.model)) { + modelParameters.temperature = effectiveConfig.temperature; + } + if (effectiveMaxTokens !== null) { + modelParameters[completionLimitParam(effectiveConfig.model)] = effectiveMaxTokens; + } observation.update({ model: result.telemetry.model || null, modelParameters, usageDetails: { input: Number(usage.promptTokens || 0), - output: Number(usage.completionTokens || 0), + // Langfuse usage buckets are additive. Reasoning tokens + // are a separate bucket, so visible output must exclude + // them; raw provider totals remain in metadata below. + output: Number(usage.visibleCompletionTokens || 0), reasoning: Number(usage.reasoningTokens || 0), - visible_output: Number(usage.visibleCompletionTokens || 0), total: Number(usage.totalTokens || 0) }, costDetails: Number.isFinite(Number(usage.cost)) ? { total: Number(usage.cost) } : undefined, @@ -648,8 +650,11 @@ async function request(spec = {}) { finishReason: result.telemetry.finishReason, interactive: spec.interactive === true, repairType: result.telemetry.repairType || null, + completionTokens: Number(usage.completionTokens || 0), reasoningTokens: Number(usage.reasoningTokens || 0), visibleCompletionTokens: Number(usage.visibleCompletionTokens || 0), + cachedPromptTokens: Number(usage.cachedPromptTokens || 0), + cacheWriteTokens: Number(usage.cacheWriteTokens || 0), maxCompletionTokens: effectiveMaxTokens } }); diff --git a/tests/test_ai_config_surface.js b/tests/test_ai_config_surface.js new file mode 100644 index 00000000..73c65b28 --- /dev/null +++ b/tests/test_ai_config_surface.js @@ -0,0 +1,64 @@ +const assert = require('assert'); + +require('../src/Global'); + +const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); + +const originalOpenRouter = options.default.OpenRouter; +const originalLangfuse = options.default.Langfuse; + +try { + assert.strictEqual(OpenRouterGateway.DEFAULTS.model, 'openai/gpt-5.6-luna'); + assert.strictEqual(OpenRouterGateway.DEFAULTS.reasoningEffort, 'low'); + assert.strictEqual(OpenRouterGateway.DEFAULTS.temperature, 0.35); + + options.default.OpenRouter = { + enabled: true, + apiKey: 'config-test-key', + model: 'test/config-model', + temperature: 0.7, + reasoningEffort: 'off', + maxConcurrentRequests: 7, + debug: true, + + // Removed keys must not silently remain user-facing overrides. + maxTokens: 1, + timeoutMs: 1, + backgroundInferenceEnabled: true, + hotBotGlobalMaxInFlight: 1 + }; + const openRouter = OpenRouterGateway.config(); + assert.strictEqual(openRouter.enabled, true); + assert.strictEqual(openRouter.model, 'test/config-model'); + assert.strictEqual(openRouter.temperature, 0.7); + assert.strictEqual(openRouter.reasoningEffort, 'off'); + assert.strictEqual(openRouter.maxConcurrentRequests, 7); + assert.strictEqual(openRouter.maxTokens, 320, 'completion safety belongs to internal policy'); + assert.strictEqual(openRouter.timeoutMs, 3500, 'provider timeout belongs to internal policy'); + assert.strictEqual(openRouter.backgroundInferenceEnabled, undefined); + assert.strictEqual(openRouter.hotBotGlobalMaxInFlight, undefined); + + options.default.Langfuse = { + enabled: true, + envFile: '', + baseUrl: 'http://127.0.0.1:3333', + capturePayloads: false, + debug: true, + captureInput: true, + captureOutput: true, + flushAt: 99 + }; + const langfuse = LangfuseTracing.config(); + assert.strictEqual(langfuse.enabled, true); + assert.strictEqual(langfuse.baseUrl, 'http://127.0.0.1:3333'); + assert.strictEqual(langfuse.capturePayloads, false); + assert.strictEqual(langfuse.captureInput, undefined); + assert.strictEqual(langfuse.captureOutput, undefined); + assert.strictEqual(langfuse.flushAt, 1, 'dev trace flushing belongs to internal policy'); + + console.log('AI config surface checks passed'); +} finally { + options.default.OpenRouter = originalOpenRouter; + options.default.Langfuse = originalLangfuse; +} diff --git a/tests/test_bot_conversation_summary.js b/tests/test_bot_conversation_summary.js index 8a62e421..af35a738 100644 --- a/tests/test_bot_conversation_summary.js +++ b/tests/test_bot_conversation_summary.js @@ -75,9 +75,16 @@ async function main() { text: `Uncompacted message ${i}` }); } - OpenRouterGateway.setTransport(async () => ({ ok: false, status: 503, async json() { return {}; } })); + let failedRequests = 0; + OpenRouterGateway.setTransport(async () => { + failedRequests += 1; + return { ok: false, status: 503, async json() { return {}; } }; + }); const failed = await BotConversationSummarizer.summarize({ playerId: 30, botId: 40, threshold: 24 }); assert.strictEqual(failed.ok, false); + const backedOff = await BotConversationSummarizer.summarize({ playerId: 30, botId: 40, threshold: 24 }); + assert.strictEqual(backedOff.reason, 'summary_backoff', 'a provider failure must not retry on every chat turn'); + assert.strictEqual(failedRequests, 1, 'summary backoff must suppress duplicate provider calls'); const raw = await BotConversationStore.context(30, 40, { limit: 40 }); assert.strictEqual(raw.summary, null); assert.strictEqual(raw.recentTurns.length, 30); diff --git a/tests/test_bot_inference_budget.js b/tests/test_bot_inference_budget.js index ce22fb80..ac28e6bb 100644 --- a/tests/test_bot_inference_budget.js +++ b/tests/test_bot_inference_budget.js @@ -14,15 +14,7 @@ const bot = session(2000201); try { options.default.OpenRouter = { ...originalConfig, - hotBotBudgetEnabled: true, - hotBotMaxRequestsPerMinute: 2, - hotBotPromptTokenBudgetPerMinute: 300, - hotBotCompletionTokenBudgetPerMinute: 100, - hotBotGlobalMaxInFlight: 8, - hotBotGlobalMaxRequestsPerMinute: 20, - hotBotGlobalPromptTokenBudgetPerMinute: 5000, - hotBotGlobalCompletionTokenBudgetPerMinute: 5000, - maxTokens: 40 + maxConcurrentRequests: 8 }; BotInferenceBudget.reset(); @@ -30,6 +22,9 @@ try { event: 'player_chat', estimatedPromptTokens: 40, maxCompletionTokens: 40, + maxRequests: 2, + promptBudget: 300, + completionBudget: 100, now: 1000 }); assert.strictEqual(first.ok, true, 'the first hot decision should reserve within budget'); @@ -39,6 +34,9 @@ try { event: 'player_chat', estimatedPromptTokens: 260, maxCompletionTokens: 40, + maxRequests: 2, + promptBudget: 300, + completionBudget: 100, now: 2000 }); assert.strictEqual(second.ok, true, 'the remaining prompt budget should admit a second decision'); @@ -46,7 +44,6 @@ try { assert.strictEqual(mid.requests, 2); assert.strictEqual(mid.promptTokens, 280, 'settlement must replace reservation with actual prompt usage'); assert.strictEqual(mid.completionTokens, 50); - assert.strictEqual(mid.remainingRequests, 0); const mandatoryChat = BotInferenceBudget.reserve(bot, { event: 'player_chat', @@ -63,6 +60,9 @@ try { const requestDenied = BotInferenceBudget.reserve(bot, { estimatedPromptTokens: 1, maxCompletionTokens: 1, + maxRequests: 2, + promptBudget: 300, + completionBudget: 100, now: 3000 }); assert.strictEqual(requestDenied.ok, false); @@ -70,10 +70,12 @@ try { assert(requestDenied.retryAfterMs > 0, 'a budget rejection should tell the caller when to retry'); BotInferenceBudget.settle(second.reservation, { promptTokens: 10, completionTokens: 5 }); - options.default.OpenRouter.hotBotMaxRequestsPerMinute = 10; const promptDenied = BotInferenceBudget.reserve(bot, { estimatedPromptTokens: 295, maxCompletionTokens: 1, + maxRequests: 10, + promptBudget: 300, + completionBudget: 100, now: 4000 }); assert.strictEqual(promptDenied.ok, false, 'prompt reservation should be bounded independently'); @@ -82,12 +84,15 @@ try { const afterWindow = BotInferenceBudget.reserve(bot, { estimatedPromptTokens: 100, maxCompletionTokens: 20, + maxRequests: 2, + promptBudget: 300, + completionBudget: 100, now: 62001 }); assert.strictEqual(afterWindow.ok, true, 'entries should expire from the sliding window'); BotInferenceBudget.reset(); - options.default.OpenRouter.hotBotGlobalMaxInFlight = 1; + options.default.OpenRouter.maxConcurrentRequests = 1; const globalFirst = BotInferenceBudget.reserve(session(2000202), { event: 'state_change', estimatedPromptTokens: 10, maxCompletionTokens: 10, now: 1000 }); @@ -102,10 +107,7 @@ try { assert.strictEqual(BotInferenceBudget.globalStatus(1001).inFlight, 0); BotInferenceBudget.reset(); - options.default.OpenRouter.hotBotGlobalMaxInFlight = 8; - options.default.OpenRouter.hotBotGlobalMaxRequestsPerMinute = 1; - options.default.OpenRouter.hotBotGlobalPromptTokenBudgetPerMinute = 10; - options.default.OpenRouter.hotBotGlobalCompletionTokenBudgetPerMinute = 10; + options.default.OpenRouter.maxConcurrentRequests = 8; const interactive = BotInferenceBudget.reserve(session(2000206), { event: 'player_chat', bypass: true, @@ -121,7 +123,7 @@ try { assert.strictEqual(BotInferenceBudget.globalStatus(1500).promptTokens, 9999); assert.strictEqual( BotInferenceBudget.globalStatus(1500).remainingPromptTokens, - 240, + 300000, 'interactive usage is observable but excluded from the global soft quota' ); const normalAfterInteractive = BotInferenceBudget.reserve(session(2000207), { @@ -133,25 +135,20 @@ try { assert.strictEqual(normalAfterInteractive.ok, true, 'interactive usage must not starve background admission'); BotInferenceBudget.settle(normalAfterInteractive.reservation, { promptTokens: 1, completionTokens: 1 }); - options.default.OpenRouter.hotBotGlobalMaxRequestsPerMinute = 1; BotInferenceBudget.reset(); - const globalRequest = BotInferenceBudget.reserve(session(2000204), { - event: 'state_change', estimatedPromptTokens: 10, maxCompletionTokens: 10, now: 2000 - }); - assert.strictEqual(globalRequest.ok, true); - BotInferenceBudget.settle(globalRequest.reservation, { promptTokens: 5, completionTokens: 5 }); + for (let index = 0; index < 240; index += 1) { + const globalRequest = BotInferenceBudget.reserve(session(2100000 + index), { + event: 'conversation_summary', estimatedPromptTokens: 10, maxCompletionTokens: 10, now: 2000 + index + }); + assert.strictEqual(globalRequest.ok, true); + BotInferenceBudget.settle(globalRequest.reservation, { promptTokens: 5, completionTokens: 5 }); + } const globalRequestDenied = BotInferenceBudget.reserve(session(2000205), { - event: 'state_change', estimatedPromptTokens: 10, maxCompletionTokens: 10, now: 2001 + event: 'conversation_summary', estimatedPromptTokens: 10, maxCompletionTokens: 10, now: 2241 }); assert.strictEqual(globalRequestDenied.ok, false); assert.strictEqual(globalRequestDenied.reason, 'inference_budget_global_requests'); - options.default.OpenRouter.hotBotBudgetEnabled = false; - BotInferenceBudget.reset(); - const bypassed = BotInferenceBudget.reserve(bot, { estimatedPromptTokens: 999999, maxCompletionTokens: 999999 }); - assert.strictEqual(bypassed.ok, true); - assert.strictEqual(bypassed.bypassed, true, 'the explicit server disable should bypass admission without fake usage'); - assert.strictEqual(BotInferenceBudget.reserve({ actor: null }).reason, 'missing_bot'); console.log('Bot inference budget checks passed'); } finally { diff --git a/tests/test_bot_inference_interactive_queue.js b/tests/test_bot_inference_interactive_queue.js index 7de6afff..cf5c94b2 100644 --- a/tests/test_bot_inference_interactive_queue.js +++ b/tests/test_bot_inference_interactive_queue.js @@ -17,12 +17,7 @@ async function main() { try { options.default.OpenRouter = { ...originalConfig, - hotBotBudgetEnabled: true, - hotBotGlobalBudgetEnabled: true, - hotBotGlobalMaxInFlight: 1, - hotBotGlobalMaxRequestsPerMinute: 1, - hotBotGlobalPromptTokenBudgetPerMinute: 240, - hotBotGlobalCompletionTokenBudgetPerMinute: 64 + maxConcurrentRequests: 1 }; BotInferenceBudget.reset(); diff --git a/tests/test_openrouter_gateway.js b/tests/test_openrouter_gateway.js index 948aaa0d..c56f3b43 100644 --- a/tests/test_openrouter_gateway.js +++ b/tests/test_openrouter_gateway.js @@ -81,12 +81,78 @@ async function main() { assert.strictEqual(captured.body.session_id, 'hot-bot:1:player:2'); assert.deepStrictEqual(captured.body.usage, { include: true }); assert.strictEqual(captured.body.max_completion_tokens, 320); - assert.deepStrictEqual(captured.body.reasoning, { effort: 'high', exclude: true }); + assert.strictEqual(captured.body.temperature, 0.35); + assert.deepStrictEqual(captured.body.reasoning, { effort: 'low', exclude: true }); assert.strictEqual(captured.body.provider.require_parameters, true); assert.strictEqual(captured.body.response_format.type, 'json_schema'); assert.strictEqual(captured.body.response_format.json_schema.strict, true); assert.deepStrictEqual(captured.body.response_format.json_schema.schema, schema.schema); + const lunaSchema = { + name: 'luna_gateway_test', + schema: { + type: 'object', + properties: { + action: { type: 'string', enum: ['say', 'offer_resources'] }, + reply: { type: 'string' }, + tradeItemId: { type: 'number', minimum: 0 }, + context: { + type: 'object', + properties: { + label: { type: 'string' }, + itemId: { type: 'number', minimum: 0 } + }, + required: ['label'], + additionalProperties: false + } + }, + required: ['action', 'reply'], + additionalProperties: false + } + }; + let lunaBody; + OpenRouterGateway.setTransport(async (_url, init) => { + lunaBody = JSON.parse(init.body); + return response({ + choices: [{ + message: { + content: JSON.stringify({ + action: 'say', + reply: 'Привет.', + tradeItemId: null, + context: null + }) + } + }] + }); + }); + const luna = await OpenRouterGateway.request({ + config: { ...baseConfig, model: 'openai/gpt-5.6-luna', reasoningEffort: 'low' }, + requestId: 'luna-success', + interactive: true, + messages: [{ role: 'user', content: 'Привет' }], + responseSchema: lunaSchema + }); + assert.strictEqual(luna.ok, true); + assert.strictEqual(lunaBody.max_tokens, undefined, 'interactive requests do not carry a completion limit'); + assert.strictEqual(lunaBody.temperature, undefined, 'Luna does not accept temperature'); + assert.deepStrictEqual(lunaBody.reasoning, { effort: 'low', exclude: true }); + assert.deepStrictEqual(lunaBody.provider, { + order: ['OpenAI'], + sort: 'price', + allow_fallbacks: false, + require_parameters: true + }); + const effectiveSchema = lunaBody.response_format.json_schema.schema; + assert.deepStrictEqual(effectiveSchema.required, ['action', 'reply', 'tradeItemId', 'context']); + assert.deepStrictEqual(effectiveSchema.properties.action.type, 'string'); + assert.deepStrictEqual(effectiveSchema.properties.tradeItemId.type, ['number', 'null']); + assert.deepStrictEqual(effectiveSchema.properties.context.type, ['object', 'null']); + assert.deepStrictEqual(effectiveSchema.properties.context.required, ['label', 'itemId']); + assert.deepStrictEqual(effectiveSchema.properties.context.properties.itemId.type, ['number', 'null']); + assert.deepStrictEqual(lunaSchema.schema.required, ['action', 'reply'], 'model adaptation must not mutate caller schemas'); + assert.deepStrictEqual(lunaSchema.schema.properties.tradeItemId.type, 'number'); + OpenRouterGateway.resetCircuit(); const repairBodies = []; OpenRouterGateway.setTransport(async (_url, init) => { @@ -118,6 +184,20 @@ async function main() { assert.strictEqual(repaired.telemetry.initialRawContent, '{"reply":'); assert.strictEqual(repaired.usage.totalTokens, 24, 'repair usage must include both provider attempts'); + OpenRouterGateway.resetCircuit(); + let lunaSummaryBody; + OpenRouterGateway.setTransport(async (_url, init) => { + lunaSummaryBody = JSON.parse(init.body); + return response({ choices: [{ message: { content: JSON.stringify({ reply: 'summary' }) } }] }); + }); + await OpenRouterGateway.request({ + config: { ...baseConfig, model: 'openai/gpt-5.6-luna', maxTokens: 220 }, + requestId: 'luna-summary-limit', + messages: [{ role: 'user', content: 'summary' }] + }); + assert.strictEqual(lunaSummaryBody.max_tokens, 220, 'Luna summary requests must use OpenAI max_tokens'); + assert.strictEqual(lunaSummaryBody.max_completion_tokens, undefined); + OpenRouterGateway.resetCircuit(); OpenRouterGateway.setTransport(async () => response({ error: { message: 'unavailable' } }, 503)); for (let index = 0; index < 3; index += 1) { From ae185fced81256a9ddd3cd8a14b6941e9b3abb4d Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:17:34 -0400 Subject: [PATCH 45/59] Make hot bot tools truthful and atomic --- src/GameServer/Bot/AI/BotAgentTools.js | 117 +++++++++++++--- src/GameServer/Bot/AI/BotBrain.js | 134 ++++++++----------- src/GameServer/Bot/AI/BotToolRegistry.js | 4 +- src/GameServer/Bot/BotAI.js | 39 ------ src/GameServer/Bot/BotTradeService.js | 12 ++ tests/test_bot_brain_state_change.js | 57 ++++---- tests/test_cold_bot_chat.js | 7 +- tests/test_hot_bot_conversation_flow.js | 4 +- tests/test_hot_bot_queue_failure.js | 4 +- tests/test_hot_bot_schema_repair.js | 5 +- tests/test_hot_conversation_history_queue.js | 3 +- tests/test_llm_pull_policy_tools.js | 9 +- tests/test_llm_trade_tools.js | 10 ++ 13 files changed, 221 insertions(+), 184 deletions(-) diff --git a/src/GameServer/Bot/AI/BotAgentTools.js b/src/GameServer/Bot/AI/BotAgentTools.js index 70723991..31f47469 100644 --- a/src/GameServer/Bot/AI/BotAgentTools.js +++ b/src/GameServer/Bot/AI/BotAgentTools.js @@ -11,6 +11,7 @@ const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); const Attack = invoke('GameServer/Actor/Attack'); const BotTradeService = invoke('GameServer/Bot/BotTradeService'); const BotNegotiationService = invoke('GameServer/Bot/Economy/BotNegotiationService'); +const BotAvailability = invoke('GameServer/Bot/AI/BotAvailability'); const ACTIONS = [ 'none', @@ -24,6 +25,7 @@ const ACTIONS = [ 'buff_target', 'heal_target', 'set_pull_policy', + 'stop_pulling_and_return', 'assign_puller', 'unassign_puller', 'set_skill_priority', @@ -32,7 +34,9 @@ const ACTIONS = [ 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment', + 'list_party_candidates', 'propose_trade', + 'give_resources', 'offer_resources', 'update_trade_offer', 'cancel_trade', @@ -46,9 +50,10 @@ const ACTIONS = [ const PK_LOCKED_ACTIONS = new Set([ 'follow_player', 'stay_here', 'hunt', 'rest', 'shop', 'move_to_spot', 'set_pull_policy', 'assign_puller', 'unassign_puller', + 'stop_pulling_and_return', 'set_skill_priority', 'clear_skill_priority', 'set_combat_stance', - 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment', - 'propose_trade', 'offer_resources', 'update_trade_offer', 'cancel_trade', + 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment', 'list_party_candidates', + 'propose_trade', 'give_resources', 'offer_resources', 'update_trade_offer', 'cancel_trade', 'quote_item', 'counter_offer', 'accept_price', 'decline_price', 'open_negotiated_trade' ]); @@ -325,21 +330,18 @@ function executeLegacy(session, decision, visiblePlayers) { return { applied: true, reason: 'rest', ...reply }; } if (action === 'shop') { - clearChatArrival(session, 'shop'); - if (startShopping(session, bot)) { - const reply = replyOutcome(session, decision.reply, targetSession); - return { applied: true, reason: 'shop', ...reply }; - } else { - const reply = replyOutcome(session, decision.reply || 'I will stay with the party and sell later.', targetSession); - return { applied: true, reason: 'shop', ...reply }; + if (!startShopping(session, bot)) { + return { applied: false, reason: 'party_companion_cannot_shop_now' }; } + clearChatArrival(session, 'shop'); + const reply = replyOutcome(session, decision.reply, targetSession); + return { applied: true, reason: 'shop', ...reply }; } if (action === 'move_to_spot') { - clearChatArrival(session, 'move_to_spot'); if (session.partyCompanion === true && session.followPlayerSession) { - const reply = replyOutcome(session, decision.reply || 'I will stay with the party.', targetSession); - return { applied: true, reason: 'party_companion_stays_with_party', ...reply }; + return { applied: false, reason: 'party_companion_stays_with_party' }; } + clearChatArrival(session, 'move_to_spot'); const applied = applyMoveToSpot(session, bot, decision.spotId); const reply = applied ? replyOutcome(session, decision.reply, targetSession) : { replyDelivered: false, playerVisibleReply: null }; @@ -476,6 +478,32 @@ function unassignPuller(context) { }); } +function stopPullingAndReturn(context) { + const session = context.session; + const leader = partyLeaderFor(session); + if (!leader) return { applied: false, reason: 'not_a_party_companion' }; + + const policy = applyPullPolicy({ + ...context, + decision: { + ...context.decision, + pullPermission: 'deny', + pullMode: 'off' + } + }); + if (!policy.applied) return policy; + + clearChatArrival(session, 'leader_requested_stop_pulling_and_return'); + session.botStay = false; + session.plan = 'following'; + const approached = approachPlayer(session, session.actor, leader); + return { + ...policy, + reason: 'pulling_stopped_returning', + effect: approached ? 'pull_disabled_and_return_started' : 'pull_disabled_return_pending' + }; +} + function learnedSkill(session, skillId) { const actor = session?.actor; const skill = actor?.skillset?.fetchSkill?.(Number(skillId)) || @@ -559,6 +587,22 @@ function proposeTrade(context) { }; } +function giveResources(context) { + const player = context?.requestContext?.playerSession; + const itemId = context.decision.tradeItemId || context.decision.itemId; + const amount = context.decision.tradeAmount || context.decision.amount; + const result = BotTradeService.startBotTradeWithOffer(context.session, player, itemId, amount); + if (!result.ok) return { applied: false, reason: result.reason }; + return { + applied: true, + outcome: 'pending', + reason: 'resources_offered_pending_confirmation', + effect: 'native_trade_open_and_offer_displayed', + trade: BotTradeService.activeTradeSummary(context.session), + line: { objectId: result.line.objectId, selfId: result.line.selfId, name: result.line.name, count: result.line.count } + }; +} + function offerResources(context) { const itemId = context.decision.tradeItemId || context.decision.itemId; const amount = context.decision.tradeAmount || context.decision.amount; @@ -646,6 +690,33 @@ function openNegotiatedTrade(context) { return { applied: true, reason: 'native_trade_open', trade: result.trade, negotiation: result.negotiation }; } +function partyCandidates(playerSession, currentSession = null) { + if (!playerSession?.actor) return []; + const World = invoke('GameServer/World/World'); + const sessions = (World.user?.sessions || []) + .filter((candidate) => candidate && candidate !== currentSession && + candidate.accountId && String(candidate.accountId).startsWith('bot_') && candidate.actor); + return BotAvailability.listForPlayer(playerSession, sessions) + .slice(0, 5) + .map(({ bot, availability }) => ({ + id: Number(bot.fetchId?.() || 0), + name: bot.fetchName?.() || 'unknown', + level: Number(bot.fetchLevel?.() || 0), + distance: availability.distance === null ? null : Math.round(availability.distance), + available: availability.available === true, + reason: availability.reason || null, + reasonText: availability.reasonText || null + })); +} + +function listPartyCandidates(context) { + return { + applied: true, + reason: 'party_candidates_listed', + candidates: partyCandidates(context?.requestContext?.playerSession, context.session) + }; +} + function isAuthorizedNegotiationParticipant(context) { const session = context?.session; const player = negotiationPlayer(context); @@ -674,6 +745,7 @@ function registerTools() { buff_target: 'Apply a supported buff to a visible player if class, MP, and range allow it.', heal_target: 'Heal a visible player if class, MP, and range allow it.', set_pull_policy: 'Set the party pull permission and mode for this companion, with a bounded hot-session expiry.', + stop_pulling_and_return: 'Disable this companion pull and start returning to the current party leader as one bounded workflow.', assign_puller: 'Assign this party companion as the dedicated puller without issuing combat commands.', unassign_puller: 'Release this companion from dedicated pulling and return to the existing automatic policy.', set_skill_priority: 'Adjust one learned offensive skill preference within a bounded combat score range.', @@ -682,7 +754,9 @@ function registerTools() { list_safe_loadouts: 'List inventory equipment candidates that are compatible and strictly improve a slot.', equip_candidate: 'Equip one validated inventory upgrade through the native backpack persistence path.', optimize_equipment: 'Equip all currently safe inventory upgrades through the native backpack path.', + list_party_candidates: 'List up to five real bot candidates with server-owned availability, level, and distance.', propose_trade: 'Open a native trade window with the authorized party leader before offering any resources.', + give_resources: 'Open native trade and display one validated resource line in the same action; player confirmation is still required.', offer_resources: 'Reserve and display safe bot inventory resources in the open native trade window.', update_trade_offer: 'Change one reserved bot trade line after revalidating inventory truth.', cancel_trade: 'Cancel the open native trade and release every reservation.', @@ -694,16 +768,17 @@ function registerTools() { }; const controlActions = new Set([ - 'set_pull_policy', 'assign_puller', 'unassign_puller', + 'set_pull_policy', 'stop_pulling_and_return', 'assign_puller', 'unassign_puller', 'set_skill_priority', 'clear_skill_priority', 'set_combat_stance', 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment', - 'propose_trade', 'offer_resources', 'update_trade_offer', 'cancel_trade' + 'propose_trade', 'give_resources', 'offer_resources', 'update_trade_offer', 'cancel_trade' ]); const economyActions = new Set([ 'quote_item', 'counter_offer', 'accept_price', 'decline_price', 'open_negotiated_trade' ]); const executors = { set_pull_policy: applyPullPolicy, + stop_pulling_and_return: stopPullingAndReturn, assign_puller: assignPuller, unassign_puller: unassignPuller, set_skill_priority: setSkillPriority, @@ -712,7 +787,9 @@ function registerTools() { list_safe_loadouts: listSafeLoadouts, equip_candidate: equipCandidate, optimize_equipment: optimizeEquipment, + list_party_candidates: listPartyCandidates, propose_trade: proposeTrade, + give_resources: giveResources, offer_resources: offerResources, update_trade_offer: updateTradeOffer, cancel_trade: cancelBotTrade, @@ -727,9 +804,11 @@ function registerTools() { BotToolRegistry.register({ name, description: descriptions[name], - kind: controlActions.has(name) && name === 'list_safe_loadouts' ? 'read' : controlActions.has(name) ? 'mutation' : 'intent', + kind: ['list_safe_loadouts', 'list_party_candidates'].includes(name) + ? 'read' + : controlActions.has(name) ? 'mutation' : 'intent', risk: controlActions.has(name) ? 'medium' : 'low', - mutating: !['none', 'say', 'list_safe_loadouts'].includes(name), + mutating: !['none', 'say', 'list_safe_loadouts', 'list_party_candidates'].includes(name), available(session) { if (!session) return true; if (session.plan === 'merchant') { @@ -742,6 +821,7 @@ function registerTools() { if (controlActions.has(name) && !(session.partyCompanion === true && session.followPlayerSession)) return false; if (economyActions.has(name) && !economyActionAvailable(session, name)) return false; if (name === 'propose_trade' && session.activeTrade) return false; + if (name === 'give_resources' && session.activeTrade) return false; if (['offer_resources', 'update_trade_offer', 'cancel_trade'].includes(name) && !session.activeTrade) return false; if (name === 'buff_target') { try { if (!BotRoles.canBuff(session.actor)) return false; } catch (_) { return true; } @@ -815,6 +895,8 @@ function rejectionReply(result = {}) { case 'pk_hunting_autonomous': return 'I am staying focused on my current fight.'; case 'tool_unavailable': return 'That action is not available to me right now.'; case 'not_a_party_companion': return 'I can only change hot party policy while I am your companion.'; + case 'party_companion_cannot_shop_now': return 'I need to stay with the party; I cannot leave for town right now.'; + case 'party_companion_stays_with_party': return 'I need to stay with the party instead of leaving for another spot.'; case 'incompatible_item': case 'not_an_upgrade': case 'no_safe_upgrade': return 'I could not find a safe equipment upgrade for this situation.'; @@ -861,5 +943,6 @@ module.exports = { remember, rejectionReply, toolDescriptions, - worldRevision + worldRevision, + partyCandidates }; diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index b09f6139..22332e46 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -10,8 +10,7 @@ const BotLLMTurnStore = invoke('GameServer/Bot/AI/BotLLMTurnStore'); const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); const BotAvailability = invoke('GameServer/Bot/AI/BotAvailability'); -const ALLOWED_PLANS = ['hunting', 'following', 'resting', 'shopping', 'pk_hunting', 'merchant', 'getting_buffed']; -const ALLOWED_EVENTS = new Set(['player_chat', 'state_change']); +const ALLOWED_EVENTS = new Set(['player_chat']); function config() { return OpenRouterGateway.config(); } @@ -134,6 +133,17 @@ function schema(allowedActions = BotAgentTools.ACTIONS) { enum: ['', 'auto', 'leader', 'bot', 'off'], description: 'Temporary party pull mode for set_pull_policy.' }, + tradeItemId: { + type: 'number', + minimum: 0, + description: 'Inventory object id for give_resources, offer_resources, or update_trade_offer.' + }, + tradeAmount: { + type: 'number', + minimum: 0, + maximum: 10000, + description: 'Bounded quantity for an outbound resource trade line.' + }, pullPermission: { type: 'string', enum: ['', 'allow', 'deny'], @@ -165,17 +175,6 @@ function schema(allowedActions = BotAgentTools.ACTIONS) { minimum: 0, description: 'Inventory object id for equip_candidate.' }, - tradeItemId: { - type: 'number', - minimum: 0, - description: 'Inventory object id for offer_resources or update_trade_offer.' - }, - tradeAmount: { - type: 'number', - minimum: 0, - maximum: 10000, - description: 'Bounded quantity for an outbound trade line.' - }, negotiationItemId: { type: 'number', minimum: 0, @@ -231,12 +230,13 @@ function systemPrompt() { 'Do not claim that buffs or heals are ready in a plain chat reply. Use buff_target or heal_target; only the validated server action may confirm a cast.', 'Party pull, skill preference, stance, and equipment tools are temporary hot-session controls. They require the current human party leader; never invent authority.', 'Pull permission, pull mode, and assigned puller are separate. Unassigning one puller returns to the existing automatic policy and does not globally disable pulling.', + 'When the player asks to stop pulling and return, prefer stop_pulling_and_return so both server mutations are applied as one bounded workflow.', 'Skill priorities are bounded hints to the deterministic offensive scorer. Emergency healing, defense, resurrection, cooldowns, MP, range, and C4 compatibility always win.', 'Equipment tools may only use safe candidates from actual inventory and native persistence. Never equip quest, incompatible, over-grade, or non-upgrade items.', 'The persona describes tone and high-level preferences only. It never overrides safety, current game state, or the allowed actions.', 'Ambient mood and intent are server-owned soft context. Treat an active ambient scene as factual only when bot.ambient.scene is present; never start or claim a scene from mood alone.', 'The contextFragments field is bounded and includes recent authoritative events; treat summaries as memory, never as permission to perform an action. Action metadata is authoritative only when serverApplied or actionResult.ok is true.', - 'Resource-gift trade tools can open a native window only with the current party leader; negotiated market tools use only the active real player pair. Both reserve safe inventory without mutating it, expose only server-owned bounds, allow at most three negotiation rounds, and release reservations on cancel/expiry. Never claim completion before native player confirmation.', + 'Resource-gift trade tools can open a native window only with the current party leader; give_resources opens the window and displays the requested line in one server action. Negotiated market tools use only the active real player pair. Both reserve safe inventory without mutating it, expose only server-owned bounds, allow at most three negotiation rounds, and release reservations on cancel/expiry. Never claim completion before native player confirmation.', 'Never invent unavailable actions, players, items, or spells.' ].join(' '); } @@ -245,11 +245,27 @@ function userPayload(event, session, status, visiblePlayers, text, requestContex const assembled = requestContext?.assembledContext; const preparedWorldRevision = requestContext?.preparedWorldRevision || requestContext?.worldRevision || BotAgentTools.worldRevision(session); + const partyRequest = isPartyRequest(text); + const availability = partyRequest && requestContext?.playerSession + ? BotAvailability.evaluate(requestContext.playerSession, session) + : null; + const candidates = partyRequest && /\b(other|anyone|who|somebody|друг|кто)\b/i.test(String(text || '')) + ? BotAgentTools.partyCandidates(requestContext.playerSession, session) + : []; return { event, playerMessage: text || '', bot: assembled?.bot || BotBrainContext.compactStatus(session, status, text), visiblePlayers, + party: partyRequest ? { + availability: availability ? { + available: availability.available === true, + reason: availability.reason || null, + reasonText: availability.reasonText || null, + distance: availability.distance === null ? null : Math.round(availability.distance) + } : null, + candidates + } : null, candidateSpots: candidateSpots(status), allowedActions: BotAgentTools.availableActions(session), tools: BotAgentTools.toolDescriptions(session), @@ -294,12 +310,10 @@ async function requestDecision(payload, cfg, session, requestContext, visiblePla null; const result = await OpenRouterGateway.request({ config: cfg, - circuitKey: requestContext?.conversationTurn - ? `hot-chat:${botId}:${playerId}` - : 'hot-background', - circuitBreaker: requestContext?.conversationTurn ? false : true, - interactive: !!requestContext?.conversationTurn, - timeoutMs: requestContext?.conversationTurn ? 0 : cfg.timeoutMs, + circuitKey: `hot-chat:${botId}:${playerId}`, + circuitBreaker: false, + interactive: true, + timeoutMs: 0, requestId: requestContext?.requestId || `hot-${botId}-${Date.now()}`, sessionId: `hot-bot:${botId}:player:${playerId || 'none'}`, source: requestContext?.source || requestContext?.channel || 'hot_brain', @@ -334,12 +348,15 @@ function conversationSessionId(session, requestContext) { function compactActionResult(result) { if (!result) return null; - return { + const compact = { ok: result.applied === true, reason: result.reason || null, idempotent: result.idempotent === true, replyDelivered: result.replyDelivered === true }; + if (result.outcome) compact.outcome = result.outcome; + if (result.effect) compact.effect = result.effect; + return compact; } function orderedConversation(conversation) { @@ -429,7 +446,12 @@ function recordConversationReply(session, decision, result, requestContext) { channel: turn.channel, text: reply, requestId: requestContext.requestId, - meta: { action, reason: result.reason || null, serverApplied: result.applied === true } + meta: { + action, + reason: result.reason || null, + serverApplied: result.applied === true, + actionResult: compactActionResult(result) + } })); } @@ -652,10 +674,6 @@ const BotBrain = { debugSkip(session, cfg, `event_not_chat:${event}`); return false; } - if (event !== 'player_chat' && cfg.backgroundInferenceEnabled !== true) { - debugSkip(session, cfg, 'background_inference_disabled'); - return false; - } if (!cfg.enabled) { debugSkip(session, cfg, 'disabled'); return false; @@ -665,37 +683,17 @@ const BotBrain = { return false; } if (session.brainInFlight) { - if (requestContext?.conversationTurn) { - const pending = { - event, - status, - text, - requestContext - }; - const queue = session.pendingBrainTurns || (session.pendingBrainTurns = []); - queue.push(pending); - session.pendingBrainTurn = queue[0]; - debugSkip(session, cfg, 'request_queued'); - return true; - } - debugSkip(session, cfg, 'request_in_flight'); - return false; - } - if (event !== 'player_chat' && bot.isDead && bot.isDead()) { - debugSkip(session, cfg, 'dead'); - return false; - } - if (session.plan === 'merchant' && event !== 'player_chat') { - debugSkip(session, cfg, 'merchant_plan'); - return false; - } - if (event !== 'player_chat' && session.plan === 'getting_buffed') { - debugSkip(session, cfg, 'refreshing_buffs'); - return false; - } - if (event !== 'player_chat' && !ALLOWED_PLANS.includes(session.plan || 'hunting')) { - debugSkip(session, cfg, `plan_not_allowed:${session.plan}`); - return false; + const pending = { + event, + status, + text, + requestContext + }; + const queue = session.pendingBrainTurns || (session.pendingBrainTurns = []); + queue.push(pending); + session.pendingBrainTurn = queue[0]; + debugSkip(session, cfg, 'request_queued'); + return true; } const visiblePlayers = visibleRealPlayers(session, bot, cfg, requestContext); @@ -706,22 +704,6 @@ const BotBrain = { if (requestContext && !requestContext.enqueuedAt) requestContext.enqueuedAt = Date.now(); - const cooldown = cfg.cooldownMs; - const lastAt = session.lastBrainThinkAt; - if (event !== 'player_chat' && lastAt && requestContext?.queued !== true && Date.now() - lastAt < cooldown) { - debugSkip(session, cfg, `cooldown:${event}`); - return false; - } - - if (event === 'state_change' && Math.random() > 0.12) { - debugSkip(session, cfg, 'ambient_sample_skip'); - return false; - } - - if (event !== 'player_chat') { - session.lastBrainThinkAt = Date.now(); - } - if (requestContext) { requestContext.preparedWorldRevision = BotAgentTools.worldRevision(session); requestContext.worldRevision = requestContext.preparedWorldRevision; @@ -730,10 +712,10 @@ const BotBrain = { const estimatedPromptTokens = estimateRequestPromptTokens(payload, session); const admission = BotInferenceBudget.reserve(session, { event, - bypass: event === 'player_chat', - priority: event === 'player_chat' ? 'interactive' : 'normal', + bypass: true, + priority: 'interactive', estimatedPromptTokens, - maxCompletionTokens: event === 'player_chat' ? 0 : cfg.maxTokens + maxCompletionTokens: 0 }); if (!admission.ok) { LangfuseTracing.withObservation( diff --git a/src/GameServer/Bot/AI/BotToolRegistry.js b/src/GameServer/Bot/AI/BotToolRegistry.js index 63f0b93d..d38d3a9c 100644 --- a/src/GameServer/Bot/AI/BotToolRegistry.js +++ b/src/GameServer/Bot/AI/BotToolRegistry.js @@ -68,8 +68,8 @@ function isPkLocked(session, action) { 'follow_player', 'stay_here', 'hunt', 'rest', 'shop', 'move_to_spot', 'set_pull_policy', 'assign_puller', 'unassign_puller', 'set_skill_priority', 'clear_skill_priority', 'set_combat_stance', - 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment', - 'propose_trade', 'offer_resources', 'update_trade_offer', 'cancel_trade', + 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment', 'list_party_candidates', + 'propose_trade', 'give_resources', 'offer_resources', 'update_trade_offer', 'cancel_trade', 'quote_item', 'counter_offer', 'accept_price', 'decline_price', 'open_negotiated_trade' ]).has(action); } diff --git a/src/GameServer/Bot/BotAI.js b/src/GameServer/Bot/BotAI.js index a5edeb7f..072e7498 100644 --- a/src/GameServer/Bot/BotAI.js +++ b/src/GameServer/Bot/BotAI.js @@ -10,7 +10,6 @@ const PartyRevivalService = invoke('GameServer/Bot/AI/PartyRevivalService'); const TownRespawn = invoke('GameServer/World/TownRespawn'); const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); const BotTradeService = invoke('GameServer/Bot/BotTradeService'); -const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); const ChatArrivalState = invoke('GameServer/Bot/AI/ChatArrivalState'); const CHAT_PHRASES = { @@ -116,44 +115,6 @@ function clearTacticalState(session) { session.lastPvpDecision = undefined; } -function highLevelStateFingerprint(status) { - if (!status?.available) return 'unavailable'; - const party = status.party; - const trade = status.trade; - return [ - status.mode, - party?.leader?.id || 0, - party?.stance || '', - status.ambient?.mood || '', - (status.blockers || []).filter((blocker) => ['dead', 'low_hp', 'low_mp', 'too_far_from_leader', 'no_targets_nearby'].includes(blocker)).join(','), - trade?.active?.id || '', - trade?.negotiation?.id || '', - trade?.negotiation?.state || '' - ].join('|'); -} - -function maybeTriggerStateChange(session, status) { - const fingerprint = highLevelStateFingerprint(status); - const previous = session.lastBrainStateFingerprint; - session.lastBrainStateFingerprint = fingerprint; - if (!previous || previous === fingerprint) return false; - - const mode = status?.mode || session.plan || 'hunting'; - const mood = status?.ambient?.mood || 'unknown'; - try { - return BotBrain.maybeThink( - session, - 'state_change', - status, - `High-level state changed: mode=${mode}, mood=${mood}. Choose at most one safe high-level response.`, - null - ); - } catch (error) { - utils.infoWarn('BotBrain', 'state-change trigger failed for %s: %s', session.actor?.fetchName?.() || 'bot', error.message); - return false; - } -} - const BotAI = { clearTacticalState, diff --git a/src/GameServer/Bot/BotTradeService.js b/src/GameServer/Bot/BotTradeService.js index 96f3eb5a..f3c74bd3 100644 --- a/src/GameServer/Bot/BotTradeService.js +++ b/src/GameServer/Bot/BotTradeService.js @@ -229,6 +229,17 @@ function startBotTrade(botSession, playerSession) { return openBotTrade(botSession, playerSession); } +function startBotTradeWithOffer(botSession, playerSession, objectId, amount) { + const opened = openBotTrade(botSession, playerSession); + if (!opened.ok) return opened; + const offered = offerBotItem(botSession, objectId, amount); + if (!offered.ok) { + cancelTrade(opened.trade, offered.reason, true); + return offered; + } + return { ok: true, trade: opened.trade, line: offered.line }; +} + function startNegotiatedTrade(botSession, playerSession, negotiation) { return openBotTrade(botSession, playerSession, negotiation); } @@ -488,6 +499,7 @@ module.exports = { offerBotItem, startNegotiatedTrade, startBotTrade, + startBotTradeWithOffer, startPlayerTrade, updateOffer }; diff --git a/tests/test_bot_brain_state_change.js b/tests/test_bot_brain_state_change.js index 193d61a2..84c4948c 100644 --- a/tests/test_bot_brain_state_change.js +++ b/tests/test_bot_brain_state_change.js @@ -35,7 +35,6 @@ async function main() { const originalConfig = options.default.OpenRouter; const originalWorldUser = World.user; const originalFetchVisibleUsers = World.fetchVisibleUsers; - const originalRandom = Math.random; const requests = []; const botSession = { accountId: 'bot_state_change', actor: actor(2000301, 'StateBot'), plan: 'resting' }; const playerSession = { accountId: 'player_state_change', actor: actor(2000302, 'NearbyPlayer', 100) }; @@ -46,12 +45,7 @@ async function main() { enabled: true, apiKey: 'state-change-test-key', model: 'test/state-change', - cooldownMs: 0, - chatCooldownMs: 0, - backgroundInferenceEnabled: true, - hotBotMaxRequestsPerMinute: 5, - hotBotPromptTokenBudgetPerMinute: 12000, - hotBotCompletionTokenBudgetPerMinute: 2400 + maxConcurrentRequests: 5 }; World.user = { sessions: [playerSession, botSession] }; World.fetchVisibleUsers = () => [playerSession]; @@ -73,7 +67,6 @@ async function main() { }); }); - Math.random = () => 0.01; const started = BotBrain.maybeThink(botSession, 'state_change', { available: true, name: 'StateBot', @@ -91,37 +84,19 @@ async function main() { persona: null, social: null }, 'High-level state changed: test'); - assert.strictEqual(started, true, 'a meaningful state-change event should enter the bounded brain path'); - - await new Promise((resolve) => setTimeout(resolve, 30)); - assert.strictEqual(requests.length, 1, 'state-change routing should produce one provider request'); - const payload = JSON.parse(requests[0].messages[1].content); - assert.strictEqual(payload.event, 'state_change'); - assert.strictEqual(BotInferenceBudget.status(botSession).requests, 1); - const recent = await BotEventJournal.recent({ botId: botSession.actor.fetchId(), limit: 10 }); - const decisionEvent = recent.find((event) => event.eventType === 'llm_decision'); - assert(decisionEvent, 'provider decisions should be available in the bounded activity journal'); - assert.strictEqual(decisionEvent.meta.event, 'state_change'); - assert.strictEqual(decisionEvent.meta.action, 'none'); - assert(!JSON.stringify(decisionEvent).includes('High-level state changed: test'), 'raw prompt text must not enter the journal'); + assert.strictEqual(started, false, 'background state changes must stay on the deterministic bot brain'); + assert.strictEqual(requests.length, 0, 'background state changes must not consume provider tokens'); options.default.OpenRouter.enabled = false; assert.strictEqual( - BotBrain.maybeThink(botSession, 'state_change', { available: true }, 'disabled probe'), + BotBrain.maybeThink(botSession, 'player_chat', { available: true }, 'disabled probe', { playerSession }), false, - 'disabled OpenRouter must keep state-change routing inert' + 'disabled OpenRouter must keep chat routing inert' ); options.default.OpenRouter.enabled = true; - options.default.OpenRouter.backgroundInferenceEnabled = false; - assert.strictEqual( - BotBrain.maybeThink(botSession, 'state_change', { available: true }, 'background-disabled probe'), - false, - 'background inference must be opt-in even when OpenRouter is enabled' - ); // An explicit tell is still an interactive LLM turn while the hot bot // is dead or in a transient deterministic plan. - options.default.OpenRouter.backgroundInferenceEnabled = false; botSession.plan = 'fleeing'; botSession.actor.isDead = () => true; const beforeChat = requests.length; @@ -138,11 +113,27 @@ async function main() { true, 'player chat must enter the LLM path even for a dead/transient hot bot' ); - await new Promise((resolve) => setTimeout(resolve, 30)); + for (let attempt = 0; attempt < 50 && botSession.brainInFlight; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } assert.strictEqual(requests.length, beforeChat + 1); - console.log('Bot brain state-change checks passed'); + const payload = JSON.parse(requests[0].messages[1].content); + assert.strictEqual(payload.event, 'player_chat'); + let decisionEvent = null; + for (let attempt = 0; attempt < 50 && !decisionEvent; attempt += 1) { + const recent = await BotEventJournal.recent({ + botId: botSession.actor.fetchId(), + playerId: playerSession.actor.fetchId(), + limit: 10 + }); + decisionEvent = recent.find((entry) => entry.eventType === 'llm_decision') || null; + if (!decisionEvent) await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert(decisionEvent, 'interactive decisions should remain visible in the bounded activity journal'); + assert.strictEqual(decisionEvent.meta.event, 'player_chat'); + assert(!JSON.stringify(decisionEvent).includes('Are you alive?'), 'raw prompt text must not enter the journal'); + console.log('Bot brain communication-only routing checks passed'); } finally { - Math.random = originalRandom; options.default.OpenRouter = originalConfig; World.user = originalWorldUser; World.fetchVisibleUsers = originalFetchVisibleUsers; diff --git a/tests/test_cold_bot_chat.js b/tests/test_cold_bot_chat.js index e1a4e364..067a123f 100644 --- a/tests/test_cold_bot_chat.js +++ b/tests/test_cold_bot_chat.js @@ -43,7 +43,7 @@ async function main() { enabled: true, apiKey: 'cold-chat-test-key', model: 'test/cold-chat', - remoteChatCooldownMs: 0 + maxConcurrentRequests: 1 }; BotConversationStore.resetMemory(); LangfuseTracing.withObservation = (name, input, metadata, work) => { @@ -130,10 +130,7 @@ async function main() { } BotInferenceBudget.reset(); - options.default.OpenRouter.hotBotGlobalMaxInFlight = 1; - options.default.OpenRouter.hotBotGlobalMaxRequestsPerMinute = 50; - options.default.OpenRouter.hotBotGlobalPromptTokenBudgetPerMinute = 50000; - options.default.OpenRouter.hotBotGlobalCompletionTokenBudgetPerMinute = 5000; + options.default.OpenRouter.maxConcurrentRequests = 1; const beforeAdmissionRequests = requests.length; let releaseAdmissionRequest; OpenRouterGateway.setTransport(async (_url, init) => { diff --git a/tests/test_hot_bot_conversation_flow.js b/tests/test_hot_bot_conversation_flow.js index 72d609ef..c5dedc3b 100644 --- a/tests/test_hot_bot_conversation_flow.js +++ b/tests/test_hot_bot_conversation_flow.js @@ -46,9 +46,7 @@ async function main() { apiKey: 'conversation-test-key', model: 'test/conversation', negotiationEnabled: true, - chatCooldownMs: 0, - timeoutMs: 1000, - circuitBreakerFailureThreshold: 5 + maxConcurrentRequests: 32 }; const playerSession = { accountId: 'player_flow', actor: actor(301, 'FlowPlayer') }; const botSession = { diff --git a/tests/test_hot_bot_queue_failure.js b/tests/test_hot_bot_queue_failure.js index a3017310..2541c76d 100644 --- a/tests/test_hot_bot_queue_failure.js +++ b/tests/test_hot_bot_queue_failure.js @@ -67,9 +67,7 @@ async function main() { enabled: true, apiKey: 'queue-failure-test-key', model: 'test/queue-failure', - timeoutMs: 1000, - cooldownMs: 0, - hotBotGlobalBudgetEnabled: false + maxConcurrentRequests: 32 }; const playerSession = { accountId: 'player_queue_failure', diff --git a/tests/test_hot_bot_schema_repair.js b/tests/test_hot_bot_schema_repair.js index 301cc899..42bbedf2 100644 --- a/tests/test_hot_bot_schema_repair.js +++ b/tests/test_hot_bot_schema_repair.js @@ -49,8 +49,7 @@ async function main() { enabled: true, apiKey: 'hot-schema-repair-test-key', model: 'test/hot-schema-repair', - hotBotBudgetEnabled: false, - chatCooldownMs: 0 + maxConcurrentRequests: 32 }; const playerSession = { @@ -102,7 +101,7 @@ async function main() { assert.strictEqual(requests.length, 2, 'hot dialogue must repair one malformed structured response'); assert.strictEqual(requests[0].max_completion_tokens, undefined); assert.strictEqual(requests[1].max_completion_tokens, undefined); - assert.deepStrictEqual(requests[0].reasoning, { effort: 'high', exclude: true }); + assert.deepStrictEqual(requests[0].reasoning, { effort: 'low', exclude: true }); assert.strictEqual(botSession.brainInFlight, false, 'repaired turn must settle'); console.log('Hot bot schema repair checks passed'); } finally { diff --git a/tests/test_hot_conversation_history_queue.js b/tests/test_hot_conversation_history_queue.js index 3c79b9c8..3a250bdc 100644 --- a/tests/test_hot_conversation_history_queue.js +++ b/tests/test_hot_conversation_history_queue.js @@ -48,8 +48,7 @@ async function main() { enabled: true, apiKey: 'hot-history-test-key', model: 'test/hot-history', - cooldownMs: 0, - chatCooldownMs: 0 + maxConcurrentRequests: 32 }; BotConversationStore.resetMemory(); const playerSession = { diff --git a/tests/test_llm_pull_policy_tools.js b/tests/test_llm_pull_policy_tools.js index 5eb99512..f8c5c4b9 100644 --- a/tests/test_llm_pull_policy_tools.js +++ b/tests/test_llm_pull_policy_tools.js @@ -16,7 +16,8 @@ function actor(id, name) { fetchDestId: () => 0, isDead: () => false, state: { fetchSeated: () => false, setSeated() {} }, - unselect() {} + unselect() {}, + moveTo() {} }; } @@ -81,6 +82,12 @@ try { Date.now = () => 106000; assert.strictEqual(HotBotPolicyOverlay.status(bot), null, 'expired policy overlay should be removed'); assert.strictEqual(PartyCompanionService.getSettings(leader).pullMode, 'off', 'expired pull policy must restore the previous party setting'); + + const stopped = BotAgentTools.execute(bot, decision('stop_pulling_and_return', 'pull-6'), [], context('pull-6')); + assert.strictEqual(stopped.applied, true); + assert.strictEqual(stopped.reason, 'pulling_stopped_returning'); + assert.strictEqual(PartyCompanionService.getSettings(leader).pullMode, 'off'); + assert.strictEqual(bot.plan, 'following', 'composite stop workflow must also start returning to the leader'); console.log('LLM pull policy tool checks passed'); } finally { Date.now = originalNow; diff --git a/tests/test_llm_trade_tools.js b/tests/test_llm_trade_tools.js index bc120d85..66f34302 100644 --- a/tests/test_llm_trade_tools.js +++ b/tests/test_llm_trade_tools.js @@ -83,6 +83,16 @@ try { const cancelled = BotAgentTools.execute(bot, decision('cancel_trade', 'trade-tool-4'), [], context('trade-tool-4')); assert.deepStrictEqual(cancelled, { applied: true, reason: 'trade_cancelled' }); assert.strictEqual(bot.activeTrade, null); + + const atomic = BotAgentTools.execute(bot, decision('give_resources', 'trade-tool-5', { + tradeItemId: 6012, + tradeAmount: 1 + }), [], context('trade-tool-5')); + assert.strictEqual(atomic.applied, true); + assert.strictEqual(atomic.outcome, 'pending', 'resource delivery must remain pending until native player confirmation'); + assert.strictEqual(atomic.line.count, 1); + assert.strictEqual(packets[3][0], 0x1e, 'give_resources must open native trade'); + assert.strictEqual(packets[4][0], 0x21, 'give_resources must display the native resource line atomically'); console.log('LLM trade tool checks passed'); } catch (error) { console.error(error); From c2f0631328a844de2571633a8b5a7bc863062db9 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:14:27 -0400 Subject: [PATCH 46/59] Fix hot bot dialogue truthfulness gaps --- scripts/run-tests.js | 2 + src/GameServer/Bot/AI/BotAgentTools.js | 27 ++++++++- src/GameServer/Bot/AI/BotBrain.js | 35 +++++++++--- .../Bot/AI/BotConversationSummarizer.js | 3 +- src/GameServer/Bot/AI/BotToolRegistry.js | 10 +++- src/GameServer/Network/Request/Speak.js | 3 +- src/GameServer/World/World.js | 45 ++++++++++++++- tests/test_bot_conversation_summary.js | 54 ++++++++++++++++++ tests/test_bot_llm_party_policy.js | 22 ++++++++ tests/test_bot_name_suggestion.js | 49 +++++++++++++++++ tests/test_bot_tool_pending_audit.js | 55 +++++++++++++++++++ tests/test_llm_trade_tools.js | 2 + 12 files changed, 291 insertions(+), 16 deletions(-) create mode 100644 tests/test_bot_name_suggestion.js create mode 100644 tests/test_bot_tool_pending_audit.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 44be65ab..f47d1f91 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -14,6 +14,7 @@ const tests = [ 'tests/test_chat_arrival_state.js', 'tests/test_bot_brain_state_change.js', 'tests/test_bot_llm_party_policy.js', + 'tests/test_bot_tool_pending_audit.js', 'tests/test_bot_conversation_store.js', 'tests/test_bot_dialogue_arbiter.js', 'tests/test_hot_bot_conversation_flow.js', @@ -41,6 +42,7 @@ const tests = [ 'tests/test_bot_availability.js', 'tests/test_bot_chat_commands.js', 'tests/test_bot_chat_text.js', + 'tests/test_bot_name_suggestion.js', 'tests/test_bot_party_chat.js', 'tests/test_bot_agent_support_confirmation.js', 'tests/test_bot_combat_skill_selection.js', diff --git a/src/GameServer/Bot/AI/BotAgentTools.js b/src/GameServer/Bot/AI/BotAgentTools.js index 31f47469..ed293502 100644 --- a/src/GameServer/Bot/AI/BotAgentTools.js +++ b/src/GameServer/Bot/AI/BotAgentTools.js @@ -593,13 +593,15 @@ function giveResources(context) { const amount = context.decision.tradeAmount || context.decision.amount; const result = BotTradeService.startBotTradeWithOffer(context.session, player, itemId, amount); if (!result.ok) return { applied: false, reason: result.reason }; + const line = { objectId: result.line.objectId, selfId: result.line.selfId, name: result.line.name, count: result.line.count }; return { applied: true, outcome: 'pending', reason: 'resources_offered_pending_confirmation', effect: 'native_trade_open_and_offer_displayed', trade: BotTradeService.activeTradeSummary(context.session), - line: { objectId: result.line.objectId, selfId: result.line.selfId, name: result.line.name, count: result.line.count } + line, + playerVisibleReply: `I put ${line.count} ${line.name} in the trade window. Please confirm the trade when you are ready.` }; } @@ -710,10 +712,18 @@ function partyCandidates(playerSession, currentSession = null) { } function listPartyCandidates(context) { + const candidates = partyCandidates(context?.requestContext?.playerSession, context.session); + const reply = candidates.length + ? `I can see ${candidates.map((candidate) => { + const distance = candidate.distance === null ? 'unknown distance' : `${candidate.distance} away`; + return `${candidate.name} (level ${candidate.level}, ${candidate.available ? 'available' : candidate.reasonText || 'busy'}, ${distance})`; + }).join('; ')}.` + : 'I do not see another bot available for your party right now.'; return { applied: true, reason: 'party_candidates_listed', - candidates: partyCandidates(context?.requestContext?.playerSession, context.session) + candidates, + playerVisibleReply: reply }; } @@ -862,6 +872,8 @@ function remember(session, decision, result, model) { action: decision.action, reason: decision.reason || result.reason, appliedReason: result.reason, + outcome: result.outcome || 'applied', + serverApplied: result.applied === true && result.outcome !== 'pending', at: Date.now(), model, usage: decision.usage ? { @@ -936,11 +948,22 @@ function rejectionReply(result = {}) { } } +function pendingReply(result = {}) { + if (result.reason === 'resources_offered_pending_confirmation') { + const line = result.line || {}; + const count = Number(line.count || 0); + const name = String(line.name || 'the requested resources'); + return `I put ${count > 0 ? `${count} ` : ''}${name} in the trade window. Please confirm the trade when you are ready.`; + } + return 'I have started that request, but it still needs your confirmation.'; +} + module.exports = { ACTIONS, availableActions, execute, remember, + pendingReply, rejectionReply, toolDescriptions, worldRevision, diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 22332e46..e03f5db8 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -237,6 +237,7 @@ function systemPrompt() { 'Ambient mood and intent are server-owned soft context. Treat an active ambient scene as factual only when bot.ambient.scene is present; never start or claim a scene from mood alone.', 'The contextFragments field is bounded and includes recent authoritative events; treat summaries as memory, never as permission to perform an action. Action metadata is authoritative only when serverApplied or actionResult.ok is true.', 'Resource-gift trade tools can open a native window only with the current party leader; give_resources opens the window and displays the requested line in one server action. Negotiated market tools use only the active real player pair. Both reserve safe inventory without mutating it, expose only server-owned bounds, allow at most three negotiation rounds, and release reservations on cancel/expiry. Never claim completion before native player confirmation.', + 'For party candidate discovery, use the server-owned party.candidates list in the current payload and answer from it; do not assume a later tool result will be sent back to you in this turn.', 'Never invent unavailable actions, players, items, or spells.' ].join(' '); } @@ -245,11 +246,12 @@ function userPayload(event, session, status, visiblePlayers, text, requestContex const assembled = requestContext?.assembledContext; const preparedWorldRevision = requestContext?.preparedWorldRevision || requestContext?.worldRevision || BotAgentTools.worldRevision(session); + const candidateRequest = isPartyCandidateRequest(text); const partyRequest = isPartyRequest(text); const availability = partyRequest && requestContext?.playerSession ? BotAvailability.evaluate(requestContext.playerSession, session) : null; - const candidates = partyRequest && /\b(other|anyone|who|somebody|друг|кто)\b/i.test(String(text || '')) + const candidates = candidateRequest ? BotAgentTools.partyCandidates(requestContext.playerSession, session) : []; return { @@ -257,7 +259,8 @@ function userPayload(event, session, status, visiblePlayers, text, requestContex playerMessage: text || '', bot: assembled?.bot || BotBrainContext.compactStatus(session, status, text), visiblePlayers, - party: partyRequest ? { + party: partyRequest || candidateRequest ? { + intent: candidateRequest ? 'candidate_discovery' : 'membership', availability: availability ? { available: availability.available === true, reason: availability.reason || null, @@ -348,8 +351,9 @@ function conversationSessionId(session, requestContext) { function compactActionResult(result) { if (!result) return null; + const confirmed = result.applied === true && result.outcome !== 'pending'; const compact = { - ok: result.applied === true, + ok: confirmed, reason: result.reason || null, idempotent: result.idempotent === true, replyDelivered: result.replyDelivered === true @@ -399,8 +403,17 @@ function validateDecisionResult(result, session) { return result; } +function isPartyCandidateRequest(text) { + const value = String(text || ''); + const candidateMarker = /\b(?:other|another|anyone|anybody|somebody|someone|who|other\s+bots?)\b|кто|друг(?:ие|их)?\s+(?:бот|игрок)|друг(?:ие|их)?\s+боты/i; + const partyMarker = /\b(?:party|group|team|join|invite|member|bot|player)s?\b|пати|групп|команд|присоедин|игрок/i; + return candidateMarker.test(value) && partyMarker.test(value); +} + function isPartyRequest(text) { - return /\b(party|group|team|join|invite)\b|пати|групп|команд|присоедин/i.test(String(text || '')); + const value = String(text || ''); + if (isPartyCandidateRequest(value)) return false; + return /\b(party|group|team|join|invite)\b|пати|групп|команд|присоедин/i.test(value); } function applyPartyPolicy(session, decision, requestContext, text) { @@ -449,7 +462,7 @@ function recordConversationReply(session, decision, result, requestContext) { meta: { action, reason: result.reason || null, - serverApplied: result.applied === true, + serverApplied: result.applied === true && result.outcome !== 'pending', actionResult: compactActionResult(result) } })); @@ -482,7 +495,9 @@ function applyDecision(session, decision, visiblePlayers, requestContext) { // Skill requests are confirmed by the native cast/effect path. Do not // persist or claim the model's speculative reply before that happens. if (!['buff_target', 'heal_target'].includes(decision.action)) { - playerVisibleReply = result.playerVisibleReply || decision.reply || null; + playerVisibleReply = result.outcome === 'pending' + ? (result.playerVisibleReply || BotAgentTools.pendingReply(result)) + : (result.playerVisibleReply || decision.reply || null); if (result.replyDelivered !== true && playerVisibleReply && playerSession?.actor) { invoke('GameServer/Bot/BotManager').botTell(session, playerSession, playerVisibleReply); result = { ...result, replyDelivered: true, playerVisibleReply }; @@ -663,6 +678,8 @@ const BotBrain = { }, applyPartyPolicy, + isPartyRequest, + isPartyCandidateRequest, visibleRealPlayers, @@ -967,13 +984,15 @@ const BotBrain = { ); providerResult = { ...providerResult, - applied: actionResult.applied === true, + applied: actionResult.applied === true && actionResult.outcome !== 'pending', + toolApplied: actionResult.applied === true, actionResult: compactActionResult(actionResult), traceOutput: { providerOutcome: providerResult.llmTelemetry?.outcome || providerResult.telemetry?.outcome || 'success', requestedAction: providerResult.action || null, toolOutcome: compactActionResult(actionResult), - applied: actionResult.applied === true, + applied: actionResult.applied === true && actionResult.outcome !== 'pending', + toolApplied: actionResult.applied === true, playerVisibleReply, replyDelivered: actionResult.replyDelivered === true } diff --git a/src/GameServer/Bot/AI/BotConversationSummarizer.js b/src/GameServer/Bot/AI/BotConversationSummarizer.js index bf223e1d..3ae04410 100644 --- a/src/GameServer/Bot/AI/BotConversationSummarizer.js +++ b/src/GameServer/Bot/AI/BotConversationSummarizer.js @@ -170,7 +170,8 @@ async function summarize(input = {}) { botId, playerId, messages, - responseSchema: schema() + responseSchema: schema(), + repairSchema: true }); } catch (_) { recordFailure(key, 'summary_provider_error'); diff --git a/src/GameServer/Bot/AI/BotToolRegistry.js b/src/GameServer/Bot/AI/BotToolRegistry.js index d38d3a9c..102af5a6 100644 --- a/src/GameServer/Bot/AI/BotToolRegistry.js +++ b/src/GameServer/Bot/AI/BotToolRegistry.js @@ -154,6 +154,12 @@ function result(applied, reason, extra = {}) { return { applied: !!applied, reason: text(reason, 160) || 'unknown', ...extra }; } +function auditOutcome(value) { + const explicit = String(value?.outcome || '').toLowerCase(); + if (['applied', 'pending', 'rejected', 'noop'].includes(explicit)) return explicit; + return value?.applied === true ? 'applied' : 'rejected'; +} + function execute(context = {}) { const session = context.session; const decision = context.decision || {}; @@ -203,7 +209,7 @@ function execute(context = {}) { if (mutationStore && mutationKey && mutationStore.has(mutationKey)) { const previous = mutationStore.get(mutationKey); - audit({ ...context, decision: { ...decision, action } }, previous.applied ? 'applied' : 'rejected', 'idempotent_replay'); + audit({ ...context, decision: { ...decision, action } }, auditOutcome(previous), 'idempotent_replay'); return { ...previous, idempotent: true }; } if (mutationStore && currentTurn && definition.mutating) { @@ -238,7 +244,7 @@ function execute(context = {}) { } const normalized = result(outcome?.applied, outcome?.reason, outcome); if (mutationStore && mutationKey) mutationStore.set(mutationKey, normalized); - audit({ ...context, decision: { ...decision, action } }, normalized.applied ? 'applied' : 'rejected', normalized.reason, { + audit({ ...context, decision: { ...decision, action } }, auditOutcome(normalized), normalized.reason, { idempotent: false, currentRevision: worldRevision(session) }); diff --git a/src/GameServer/Network/Request/Speak.js b/src/GameServer/Network/Request/Speak.js index 93694ad7..ae7a9d93 100644 --- a/src/GameServer/Network/Request/Speak.js +++ b/src/GameServer/Network/Request/Speak.js @@ -74,8 +74,7 @@ function handlePrivateTell(session, data) { const LifeState = invoke('GameServer/Bot/Population/BotLifeState'); return LifeState.findByName(target).then((state) => { if (!state) { - session.dataSendToMe(ServerResponse.actionFailed()); - return false; + return World.messageBotByName(session, session.actor, target, text, 'client_tell'); } session.dataSendToMe(ServerResponse.speak(session.actor, data)); diff --git a/src/GameServer/World/World.js b/src/GameServer/World/World.js index 4a74621c..1d6ff0b0 100644 --- a/src/GameServer/World/World.js +++ b/src/GameServer/World/World.js @@ -30,6 +30,49 @@ function coldBotTell(playerSession, state, text) { }); } +function nameDistance(left, right) { + const a = String(left || '').toLowerCase(); + const b = String(right || '').toLowerCase(); + const previous = Array.from({ length: b.length + 1 }, (_, index) => index); + for (let row = 1; row <= a.length; row += 1) { + const current = [row]; + for (let column = 1; column <= b.length; column += 1) { + current[column] = Math.min( + current[column - 1] + 1, + previous[column] + 1, + previous[column - 1] + (a[row - 1] === b[column - 1] ? 0 : 1) + ); + } + for (let column = 0; column <= b.length; column += 1) previous[column] = current[column]; + } + return previous[b.length]; +} + +function nearestBotName(lookup, BotManager, LifeState) { + const hotNames = (BotManager.sessions || []) + .map((session) => session?.actor?.fetchName?.()) + .filter(Boolean); + const coldNames = typeof LifeState.allStates === 'function' + ? LifeState.allStates(2000).map((state) => state?.name).filter(Boolean) + : []; + const names = [...new Set([...hotNames, ...coldNames].map((name) => String(name)))]; + const ranked = names + .map((name) => ({ name, distance: nameDistance(lookup, name) })) + .sort((left, right) => left.distance - right.distance || left.name.localeCompare(right.name)); + const best = ranked[0]; + if (!best) return null; + const maxDistance = Math.max(2, Math.floor(Math.max(String(lookup).length, best.name.length) * 0.3)); + return best.distance <= maxDistance ? best.name : null; +} + +function unknownBotReply(session, lookup, BotManager, LifeState) { + const suggestion = nearestBotName(lookup, BotManager, LifeState); + const text = suggestion + ? `I couldn't find a bot named "${lookup}". Did you mean "${suggestion}"?` + : `I couldn't find a bot named "${lookup}".`; + session.dataSendToMe(ServerResponse.speak(session.actor, { kind: 0, text })); +} + function waitForBotSession(BotManager, name, attempts = 40) { const target = String(name || '').toLowerCase(); return new Promise((resolve) => { @@ -293,7 +336,7 @@ const World = { return LifeState.findByName(lookup).then((state) => { if (!state) { - session.dataSendToMe(ServerResponse.actionFailed()); + unknownBotReply(session, lookup, BotManager, LifeState); return false; } diff --git a/tests/test_bot_conversation_summary.js b/tests/test_bot_conversation_summary.js index af35a738..f5e7c947 100644 --- a/tests/test_bot_conversation_summary.js +++ b/tests/test_bot_conversation_summary.js @@ -62,6 +62,60 @@ async function main() { assert.strictEqual(stale.ok, false); assert.strictEqual(stale.reason, 'version_conflict'); + // A reasoning-heavy summary may hit the compact first budget. The + // gateway must use its schema recovery attempt instead of entering + // backoff immediately. + BotConversationStore.resetMemory(); + BotConversationSummarizer.reset(); + OpenRouterGateway.resetCircuit(); + for (let i = 1; i <= 30; i += 1) { + await BotConversationStore.appendTurn({ + playerId: 50, + botId: 60, + turnId: `truncated-${i}`, + role: i % 2 ? 'player' : 'bot', + channel: 'tell', + text: `Summary recovery message ${i}` + }); + } + const recoveryBodies = []; + let recoveryCalls = 0; + OpenRouterGateway.setTransport(async (_url, init) => { + recoveryCalls += 1; + recoveryBodies.push(JSON.parse(init.body)); + if (recoveryCalls === 1) { + return { + ok: true, + status: 200, + async json() { + return { + choices: [{ finish_reason: 'length', message: { content: '{"summary":' } }], + usage: { prompt_tokens: 80, completion_tokens: 220, total_tokens: 300 } + }; + } + }; + } + return { + ok: true, + status: 200, + async json() { + return { + choices: [{ message: { content: JSON.stringify({ + summary: 'Recovered compact summary.', + openTopics: [], + promises: [] + }) } }], + usage: { prompt_tokens: 90, completion_tokens: 40, total_tokens: 130 } + }; + } + }; + }); + const recovered = await BotConversationSummarizer.summarize({ playerId: 50, botId: 60, threshold: 24 }); + assert.strictEqual(recovered.ok, true, 'summary truncation should be recovered once'); + assert.strictEqual(recoveryCalls, 2); + assert.strictEqual(recoveryBodies[0].max_completion_tokens, 220); + assert.strictEqual(recoveryBodies[1].max_completion_tokens, 2048); + // A provider failure must leave the uncompacted messages usable. BotConversationStore.resetMemory(); OpenRouterGateway.resetCircuit(); diff --git a/tests/test_bot_llm_party_policy.js b/tests/test_bot_llm_party_policy.js index 17e56a70..c8eb0170 100644 --- a/tests/test_bot_llm_party_policy.js +++ b/tests/test_bot_llm_party_policy.js @@ -27,6 +27,14 @@ function decision() { } function main() { + assert.strictEqual(BotBrain.isPartyCandidateRequest('do you know anybody to join our party?'), true); + assert.strictEqual(BotBrain.isPartyRequest('do you know anybody to join our party?'), false); + assert.strictEqual(BotBrain.isPartyCandidateRequest('I meant other bots'), true); + assert.strictEqual(BotBrain.isPartyRequest('I meant other bots'), false); + assert.strictEqual(BotBrain.isPartyCandidateRequest('who can join our group?'), true); + assert.strictEqual(BotBrain.isPartyCandidateRequest('кто может вступить в пати?'), true); + assert.strictEqual(BotBrain.isPartyRequest('can I join your party?'), true); + const playerSession = { accountId: 'party_player', actor: actor(9101, 'PartyPlayer') }; const soloSession = { accountId: 'bot_solo_party', @@ -52,6 +60,20 @@ function main() { const social = BotBrain.applyPartyPolicy(socialSession, decision(), { playerSession }, 'join our group'); assert.strictEqual(social.reply, decision().reply, 'available party policy must preserve the model personality'); assert.strictEqual(social.reason, 'party_policy:available'); + + const companionSession = { + ...socialSession, + partyCompanion: true, + followPlayerSession: playerSession + }; + const candidate = BotBrain.applyPartyPolicy( + companionSession, + decision(), + { playerSession }, + 'do you know anybody to join our party?' + ); + assert.strictEqual(candidate.reply, decision().reply, 'candidate discovery must remain an LLM reply'); + assert.notStrictEqual(candidate.reason, 'party_policy:already_grouped'); console.log('LLM party policy checks passed'); } diff --git a/tests/test_bot_name_suggestion.js b/tests/test_bot_name_suggestion.js new file mode 100644 index 00000000..71bf8d77 --- /dev/null +++ b/tests/test_bot_name_suggestion.js @@ -0,0 +1,49 @@ +const assert = require('assert'); + +require('../src/Global'); + +const World = invoke('GameServer/World/World'); +const BotManager = invoke('GameServer/Bot/BotManager'); +const LifeState = invoke('GameServer/Bot/Population/BotLifeState'); +const ReceivePacket = invoke('Packet/Receive'); + +function decode(packet) { + const received = new ReceivePacket(packet); + received.readD().readD().readS().readS(); + return { kind: received.data[1], text: received.data[3] }; +} + +async function main() { + const originalFindSessionByName = BotManager.findSessionByName; + const originalSessions = BotManager.sessions; + const originalFindByName = LifeState.findByName; + const originalAllStates = LifeState.allStates; + const packets = []; + const player = { + actor: { fetchId: () => 9910, fetchName: () => 'NameTester' }, + dataSendToMe(packet) { packets.push(packet); } + }; + try { + BotManager.sessions = [{ actor: { fetchName: () => 'FennaHaven' } }]; + BotManager.findSessionByName = () => null; + LifeState.findByName = async () => null; + LifeState.allStates = () => [{ name: 'EloraHaven' }]; + + const handled = await World.messageBotByName(player, player.actor, 'FenaHaven', 'hello', 'client_tell'); + assert.strictEqual(handled, false); + const reply = decode(packets[0]); + assert.strictEqual(reply.kind, 0); + assert.match(reply.text, /Did you mean "FennaHaven"/i); + } finally { + BotManager.findSessionByName = originalFindSessionByName; + BotManager.sessions = originalSessions; + LifeState.findByName = originalFindByName; + LifeState.allStates = originalAllStates; + } + console.log('Bot name suggestion checks passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_bot_tool_pending_audit.js b/tests/test_bot_tool_pending_audit.js new file mode 100644 index 00000000..1296605e --- /dev/null +++ b/tests/test_bot_tool_pending_audit.js @@ -0,0 +1,55 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotToolRegistry = invoke('GameServer/Bot/AI/BotToolRegistry'); +const BotToolAudit = invoke('GameServer/Bot/AI/BotToolAudit'); + +const bot = { + accountId: 'bot_pending_audit', + actor: { + fetchId: () => 9901, + fetchLocX: () => 0, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchDestId: () => 0, + isDead: () => false, + backpack: { fetchItems: () => [] } + }, + plan: 'following', + partyCompanion: true, + followPlayerSession: { accountId: 'pending_audit_player', actor: { fetchId: () => 9902 } } +}; + +BotToolAudit.resetMemory(); +BotToolRegistry.register({ + name: 'test_pending_audit', + kind: 'mutation', + mutating: true, + available: () => true, + execute: () => ({ applied: true, outcome: 'pending', reason: 'awaiting_native_confirmation' }) +}); + +async function main() { + const result = BotToolRegistry.execute({ + session: bot, + decision: { + action: 'test_pending_audit', + confidence: 0.99, + turnId: 'pending-audit-turn' + }, + requestContext: { playerSession: bot.followPlayerSession }, + expectedWorldRevision: BotToolRegistry.worldRevision(bot) + }); + assert.strictEqual(result.outcome, 'pending'); + + await new Promise((resolve) => setImmediate(resolve)); + const events = BotToolAudit.recent({ botId: 9901, limit: 10 }); + assert(events.some((event) => event.toolName === 'test_pending_audit' && event.outcome === 'pending'), 'pending tool result must stay pending in the audit'); + console.log('Pending bot tool audit checks passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_llm_trade_tools.js b/tests/test_llm_trade_tools.js index 66f34302..a1898b9f 100644 --- a/tests/test_llm_trade_tools.js +++ b/tests/test_llm_trade_tools.js @@ -91,6 +91,8 @@ try { assert.strictEqual(atomic.applied, true); assert.strictEqual(atomic.outcome, 'pending', 'resource delivery must remain pending until native player confirmation'); assert.strictEqual(atomic.line.count, 1); + assert.match(atomic.playerVisibleReply, /trade window/i, 'pending resource delivery must provide a server-owned truthful reply'); + assert.match(atomic.playerVisibleReply, /confirm/i); assert.strictEqual(packets[3][0], 0x1e, 'give_resources must open native trade'); assert.strictEqual(packets[4][0], 0x21, 'give_resources must display the native resource line atomically'); console.log('LLM trade tool checks passed'); From 8909861ad6a300d9d9d5030a847ded4029e9a3f2 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:40:48 -0400 Subject: [PATCH 47/59] Add deterministic party chat address resolver --- scripts/run-tests.js | 1 + src/GameServer/Bot/AI/PartyAddressResolver.js | 137 ++++++++++++++++++ tests/test_party_address_resolver.js | 52 +++++++ 3 files changed, 190 insertions(+) create mode 100644 src/GameServer/Bot/AI/PartyAddressResolver.js create mode 100644 tests/test_party_address_resolver.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index f47d1f91..c59b9921 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -14,6 +14,7 @@ const tests = [ 'tests/test_chat_arrival_state.js', 'tests/test_bot_brain_state_change.js', 'tests/test_bot_llm_party_policy.js', + 'tests/test_party_address_resolver.js', 'tests/test_bot_tool_pending_audit.js', 'tests/test_bot_conversation_store.js', 'tests/test_bot_dialogue_arbiter.js', diff --git a/src/GameServer/Bot/AI/PartyAddressResolver.js b/src/GameServer/Bot/AI/PartyAddressResolver.js new file mode 100644 index 00000000..10eeff88 --- /dev/null +++ b/src/GameServer/Bot/AI/PartyAddressResolver.js @@ -0,0 +1,137 @@ +const MIN_PREFIX_LENGTH = 4; + +const VOCATIVE_PREFIXES = new Set(['hey', 'hi', 'yo', 'ok', 'okay', 'please']); + +function textTokens(text) { + const source = String(text || ''); + const tokens = []; + const expression = /[A-Za-z0-9_]+/g; + let match; + while ((match = expression.exec(source)) !== null) { + tokens.push({ + value: match[0].toLowerCase(), + start: match.index, + end: match.index + match[0].length + }); + } + return tokens; +} + +function nameVariants(name) { + const full = String(name || '').trim().toLowerCase(); + if (!full) return []; + + const variants = [full]; + const withoutBotPrefix = full.replace(/^bot_/, ''); + if (withoutBotPrefix && withoutBotPrefix !== full) variants.push(withoutBotPrefix); + return [...new Set(variants)]; +} + +function candidateName(candidate) { + if (candidate?.name) return String(candidate.name); + const actor = candidate?.session?.actor || candidate?.actor; + return typeof actor?.fetchName === 'function' ? actor.fetchName() : ''; +} + +function candidateId(candidate) { + if (candidate?.id !== undefined && candidate?.id !== null) return candidate.id; + const actor = candidate?.session?.actor || candidate?.actor; + return typeof actor?.fetchId === 'function' ? actor.fetchId() : candidateName(candidate); +} + +function hasExactVariant(tokens, variantTokens) { + if (variantTokens.length === 0) return false; + for (let index = 0; index <= tokens.length - variantTokens.length; index += 1) { + if (variantTokens.every((value, offset) => tokens[index + offset].value === value)) return true; + } + return false; +} + +function isVocativeToken(tokens, index, source) { + if (index === 0) return true; + const previous = tokens[index - 1]?.value; + if (VOCATIVE_PREFIXES.has(previous)) return true; + + const token = tokens[index]; + const after = source.slice(token.end); + return /^[\s]*[,!:]/.test(after); +} + +function resolve(text, candidates = [], options = {}) { + const source = String(text || ''); + const tokens = textTokens(source); + const normalizedCandidates = candidates + .map((candidate) => ({ + candidate, + id: candidateId(candidate), + name: candidateName(candidate), + variants: nameVariants(candidateName(candidate)) + })) + .filter((entry) => entry.variants.length > 0); + + const exactMatches = normalizedCandidates.filter((entry) => ( + entry.variants.some((variant) => hasExactVariant(tokens, variant.split(/\s+/g))) + )); + if (exactMatches.length === 1) { + return { + status: 'matched', + candidate: exactMatches[0].candidate, + matches: [exactMatches[0].candidate], + alias: exactMatches[0].name, + matchType: 'full_name' + }; + } + if (exactMatches.length > 1) { + return { + status: 'ambiguous', + candidate: null, + matches: exactMatches.map((entry) => entry.candidate), + alias: null, + matchType: 'full_name' + }; + } + + const minPrefixLength = Math.max( + MIN_PREFIX_LENGTH, + Number(options.minPrefixLength || MIN_PREFIX_LENGTH) + ); + const prefixMatches = []; + tokens.forEach((token, index) => { + if (token.value.length < minPrefixLength || !isVocativeToken(tokens, index, source)) return; + normalizedCandidates.forEach((entry) => { + if (entry.variants.some((variant) => { + const variantToken = variant.split(/\s+/g)[0]; + return variantToken.length > token.value.length && variantToken.startsWith(token.value); + })) { + if (!prefixMatches.some((match) => match.id === entry.id)) prefixMatches.push(entry); + } + }); + }); + + if (prefixMatches.length === 1) { + return { + status: 'matched', + candidate: prefixMatches[0].candidate, + matches: [prefixMatches[0].candidate], + alias: prefixMatches[0].name, + matchType: 'unique_prefix' + }; + } + if (prefixMatches.length > 1) { + return { + status: 'ambiguous', + candidate: null, + matches: prefixMatches.map((entry) => entry.candidate), + alias: null, + matchType: 'ambiguous_prefix' + }; + } + + return { status: 'none', candidate: null, matches: [], alias: null, matchType: null }; +} + +module.exports = { + MIN_PREFIX_LENGTH, + nameVariants, + resolve +}; diff --git a/tests/test_party_address_resolver.js b/tests/test_party_address_resolver.js new file mode 100644 index 00000000..4e010682 --- /dev/null +++ b/tests/test_party_address_resolver.js @@ -0,0 +1,52 @@ +const assert = require('assert'); + +require('../src/Global'); + +const PartyAddressResolver = invoke('GameServer/Bot/AI/PartyAddressResolver'); + +function candidate(id, name) { + return { id, name }; +} + +async function main() { + const nice = candidate(1, 'NiceBot'); + const nico = candidate(2, 'Nicolas'); + + let result = PartyAddressResolver.resolve('NiceBot, start pulling.', [nice, nico]); + assert.strictEqual(result.status, 'matched'); + assert.strictEqual(result.candidate, nice); + assert.strictEqual(result.matchType, 'full_name'); + + result = PartyAddressResolver.resolve('Nice, now you are on pull.', [nice, nico]); + assert.strictEqual(result.status, 'matched'); + assert.strictEqual(result.candidate, nice); + assert.strictEqual(result.matchType, 'unique_prefix'); + + result = PartyAddressResolver.resolve('hey Nico, hold here.', [nice, nico]); + assert.strictEqual(result.status, 'matched'); + assert.strictEqual(result.candidate, nico); + + result = PartyAddressResolver.resolve('Nic, hold here.', [nice, nico]); + assert.strictEqual(result.status, 'none', 'prefixes shorter than four characters must not auto-route'); + + const nimbus = candidate(3, 'Nimbus'); + result = PartyAddressResolver.resolve('Nice, check the spot.', [nice, nimbus]); + assert.strictEqual(result.status, 'matched'); + assert.strictEqual(result.candidate, nice); + + const arina = candidate(4, 'Arina'); + const arion = candidate(5, 'Arinor'); + result = PartyAddressResolver.resolve('Arin, regroup.', [arina, arion]); + assert.strictEqual(result.status, 'ambiguous'); + assert.deepStrictEqual(result.matches, [arina, arion]); + + result = PartyAddressResolver.resolve('the weather is nice today', [nice]); + assert.strictEqual(result.status, 'none', 'common words must not become a bot address'); + + console.log('Party address resolver checks passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From be84649a9d166658224b44c23461e8061d2ad226 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:44:38 -0400 Subject: [PATCH 48/59] Route party chat through deterministic roster --- scripts/run-tests.js | 1 + src/GameServer/Bot/AI/PartyDialogueRouter.js | 135 +++++++++++++++++++ src/GameServer/Bot/BotManager.js | 41 ++---- tests/test_party_dialogue_router.js | 92 +++++++++++++ 4 files changed, 241 insertions(+), 28 deletions(-) create mode 100644 src/GameServer/Bot/AI/PartyDialogueRouter.js create mode 100644 tests/test_party_dialogue_router.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index c59b9921..b76e6713 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -15,6 +15,7 @@ const tests = [ 'tests/test_bot_brain_state_change.js', 'tests/test_bot_llm_party_policy.js', 'tests/test_party_address_resolver.js', + 'tests/test_party_dialogue_router.js', 'tests/test_bot_tool_pending_audit.js', 'tests/test_bot_conversation_store.js', 'tests/test_bot_dialogue_arbiter.js', diff --git a/src/GameServer/Bot/AI/PartyDialogueRouter.js b/src/GameServer/Bot/AI/PartyDialogueRouter.js new file mode 100644 index 00000000..ff8d5879 --- /dev/null +++ b/src/GameServer/Bot/AI/PartyDialogueRouter.js @@ -0,0 +1,135 @@ +const PartyAddressResolver = invoke('GameServer/Bot/AI/PartyAddressResolver'); + +const PARTY_CHANNEL_KIND = 3; +const ACTIVE_RESPONDER_TTL_MS = 5 * 60 * 1000; +const HEARING_RADIUS = 1500; + +function actorId(actor) { + return actor && typeof actor.fetchId === 'function' ? actor.fetchId() : null; +} + +function actorName(actor) { + return actor && typeof actor.fetchName === 'function' ? actor.fetchName() : ''; +} + +function distanceBetween(a, b) { + if (!a || !b) return Infinity; + const dx = Number(a.fetchLocX?.() || 0) - Number(b.fetchLocX?.() || 0); + const dy = Number(a.fetchLocY?.() || 0) - Number(b.fetchLocY?.() || 0); + const dz = Number(a.fetchLocZ?.() || 0) - Number(b.fetchLocZ?.() || 0); + return Math.sqrt((dx * dx) + (dy * dy) + (dz * dz)); +} + +function isOnline(session) { + const actor = session?.actor; + if (!actor) return false; + if (typeof actor.fetchIsOnline === 'function' && !actor.fetchIsOnline()) return false; + if (typeof actor.isDead === 'function' && actor.isDead()) return false; + return true; +} + +function isCompanion(session, playerSession) { + return session?.partyCompanion === true && session.followPlayerSession === playerSession; +} + +function isGroupAddress(text) { + return /\b(?:bot|bots|guys|party|team|help|everyone|anyone|somebody|someone)\b/i.test(String(text || '')); +} + +function buildCandidates({ sessions = [], playerSession, partyChannel = false, hearingRadius = HEARING_RADIUS } = {}) { + const player = playerSession?.actor; + return sessions + .filter((session) => isOnline(session)) + .map((session) => { + const actor = session.actor; + const companion = isCompanion(session, playerSession); + const distance = distanceBetween(actor, player); + return { + session, + actor, + id: actorId(actor), + name: actorName(actor), + companion, + selected: typeof player?.fetchDestId === 'function' && player.fetchDestId() === actorId(actor), + distance, + role: session.partyRole || session.role || actor.partyRole || actor.role || null, + eligible: partyChannel ? companion : distance <= hearingRadius + }; + }) + .filter((candidate) => candidate.eligible); +} + +function findById(candidates, id) { + if (id === undefined || id === null || id === '') return null; + return candidates.find((candidate) => String(candidate.id) === String(id)) || null; +} + +function select({ + text, + playerSession, + sessions, + kind, + now = Date.now(), + activeResponderId, + activeResponderAt, + hearingRadius = HEARING_RADIUS +} = {}) { + const partyChannel = Number(kind) === PARTY_CHANNEL_KIND; + const candidates = buildCandidates({ sessions, playerSession, partyChannel, hearingRadius }); + const explicit = PartyAddressResolver.resolve(text, candidates); + + if (explicit.status === 'matched') { + return { + candidate: explicit.candidate, + candidates, + status: 'matched', + reason: `explicit_${explicit.matchType}`, + matchType: explicit.matchType + }; + } + if (explicit.status === 'ambiguous') { + return { + candidate: null, + candidates, + status: 'ambiguous', + reason: 'explicit_ambiguous', + matchType: explicit.matchType, + matches: explicit.matches + }; + } + + const selected = candidates.find((candidate) => candidate.selected); + if (selected) { + return { candidate: selected, candidates, status: 'matched', reason: 'selected', matchType: null }; + } + + const activeAge = Number.isFinite(Number(activeResponderAt)) + ? now - Number(activeResponderAt) + : Infinity; + const active = activeAge >= 0 && activeAge <= ACTIVE_RESPONDER_TTL_MS + ? findById(candidates, activeResponderId) + : null; + if (active) { + return { candidate: active, candidates, status: 'matched', reason: 'active_responder', matchType: null }; + } + + const spokesperson = candidates.find((candidate) => candidate.companion); + if (partyChannel && spokesperson) { + return { candidate: spokesperson, candidates, status: 'matched', reason: 'party_spokesperson', matchType: null }; + } + + if (isGroupAddress(text) && candidates[0]) { + return { candidate: candidates[0], candidates, status: 'matched', reason: 'group_spokesperson', matchType: null }; + } + + return { candidate: null, candidates, status: 'none', reason: 'unresolved', matchType: null }; +} + +module.exports = { + ACTIVE_RESPONDER_TTL_MS, + HEARING_RADIUS, + PARTY_CHANNEL_KIND, + buildCandidates, + isGroupAddress, + select +}; diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js index b6d6d283..0baa6e5c 100644 --- a/src/GameServer/Bot/BotManager.js +++ b/src/GameServer/Bot/BotManager.js @@ -736,40 +736,24 @@ const BotManager = { const SpeckMath = invoke('GameServer/SpeckMath'); const playerPt = new SpeckMath.Point3D(player.fetchLocX(), player.fetchLocY(), player.fetchLocZ()); const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); + const PartyDialogueRouter = invoke('GameServer/Bot/AI/PartyDialogueRouter'); const llmEnabled = BotBrain.isEnabled(); const groupAddress = /\b(bot|bots|guys|party|team|help)\b/.test(text) || /(бот|боты|ребят|народ|пати|команда|кто-нибудь)/.test(text); const partyChannel = Number(data.kind) === 3; - const candidates = this.sessions - .filter((session) => session.actor) - .map((session) => { - const bot = session.actor; - const distance = new SpeckMath.Point3D(bot.fetchLocX(), bot.fetchLocY(), bot.fetchLocZ()).distance(playerPt); - const botName = bot.fetchName().toLowerCase(); - const shortBotName = botName.replace(/^bot_/, ''); - return { - session, - distance, - addressed: text.includes(botName) || text.includes(shortBotName), - selected: typeof player.fetchDestId === 'function' && player.fetchDestId() === bot.fetchId(), - companion: session.followPlayerSession === playerSession && session.partyCompanion === true - }; - }) - .filter((candidate) => candidate.distance <= 1500); let llmResponder = null; if (llmEnabled) { - const explicit = candidates.find((candidate) => candidate.selected) || - candidates.find((candidate) => candidate.addressed); - const activeId = Number(playerSession.botDialogueResponderId || 0); - const activeAt = Number(playerSession.botDialogueResponderAt || 0); - const active = activeId && Date.now() - activeAt <= 60000 - ? candidates.find((candidate) => candidate.session.actor.fetchId() === activeId) - : null; - llmResponder = explicit || active || - (partyChannel ? candidates.find((candidate) => candidate.companion) : null) || - (groupAddress ? candidates[0] : null); + const route = PartyDialogueRouter.select({ + text: rawText, + playerSession, + sessions: this.sessions, + kind: data.kind, + activeResponderId: playerSession.botDialogueResponderId, + activeResponderAt: playerSession.botDialogueResponderAt + }); + llmResponder = route.candidate; if (llmResponder) { - playerSession.botDialogueResponderId = llmResponder.session.actor.fetchId(); + playerSession.botDialogueResponderId = llmResponder.id; playerSession.botDialogueResponderAt = Date.now(); } } @@ -780,7 +764,8 @@ const BotManager = { if (!bot) return; const distance = new SpeckMath.Point3D(bot.fetchLocX(), bot.fetchLocY(), bot.fetchLocZ()).distance(playerPt); - if (distance > 1500) return; // Too far to hear + const partyCompanion = session.followPlayerSession === playerSession && session.partyCompanion === true; + if (distance > 1500 && !(partyChannel && partyCompanion)) return; // Party chat is roster-scoped, not proximity-scoped let handledByRule = false; const botName = bot.fetchName().toLowerCase(); diff --git a/tests/test_party_dialogue_router.js b/tests/test_party_dialogue_router.js new file mode 100644 index 00000000..14002a23 --- /dev/null +++ b/tests/test_party_dialogue_router.js @@ -0,0 +1,92 @@ +const assert = require('assert'); + +require('../src/Global'); + +const PartyDialogueRouter = invoke('GameServer/Bot/AI/PartyDialogueRouter'); + +function actor(id, name, x = 0) { + return { + fetchId: () => id, + fetchName: () => name, + fetchLocX: () => x, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchIsOnline: () => true, + fetchDestId: () => 0 + }; +} + +function session(id, name, x = 0, companion = true) { + const value = { + actor: actor(id, name, x), + partyCompanion: companion, + followPlayerSession: null + }; + return value; +} + +async function main() { + const player = { actor: actor(100, 'Slava', 0) }; + const nice = session(1, 'NiceBot', 5000); + const healer = session(2, 'Healer', 100); + const unrelated = session(3, 'FarBot', 0, false); + nice.followPlayerSession = player; + healer.followPlayerSession = player; + + let result = PartyDialogueRouter.select({ + text: 'Nice, now you are on pull.', + playerSession: player, + sessions: [nice, healer, unrelated], + kind: 3 + }); + assert.strictEqual(result.reason, 'explicit_unique_prefix'); + assert.strictEqual(result.candidate.session, nice); + assert.deepStrictEqual(result.candidates.map((candidate) => candidate.session), [nice, healer]); + + result = PartyDialogueRouter.select({ + text: 'NiceBot, open the trade.', + playerSession: player, + sessions: [nice, healer], + kind: 3, + activeResponderId: healer.actor.fetchId(), + activeResponderAt: Date.now() + }); + assert.strictEqual(result.reason, 'explicit_full_name', 'explicit name must beat the previous responder'); + assert.strictEqual(result.candidate.session, nice); + + result = PartyDialogueRouter.select({ + text: 'yes, continue.', + playerSession: player, + sessions: [nice, healer], + kind: 3, + activeResponderId: healer.actor.fetchId(), + activeResponderAt: Date.now() + }); + assert.strictEqual(result.reason, 'active_responder'); + assert.strictEqual(result.candidate.session, healer); + + result = PartyDialogueRouter.select({ + text: 'party, regroup.', + playerSession: player, + sessions: [nice, healer], + kind: 3, + activeResponderAt: Date.now() - PartyDialogueRouter.ACTIVE_RESPONDER_TTL_MS - 1 + }); + assert.strictEqual(result.reason, 'party_spokesperson'); + assert.strictEqual(result.candidate.session, nice); + + result = PartyDialogueRouter.select({ + text: 'the weather is nice today', + playerSession: player, + sessions: [nice, healer], + kind: 0 + }); + assert.strictEqual(result.status, 'none'); + + console.log('Party dialogue router checks passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From e6e8b2ce111ab54e492d0647a44660704d491e32 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:50:11 -0400 Subject: [PATCH 49/59] Track party dialogue focus and in-flight ownership --- scripts/run-tests.js | 1 + src/GameServer/Bot/AI/BotBrain.js | 17 +++ src/GameServer/Bot/AI/BotDialogueArbiter.js | 11 ++ src/GameServer/Bot/AI/PartyDialogueRouter.js | 23 ++- src/GameServer/Bot/AI/PartyDialogueState.js | 148 +++++++++++++++++++ src/GameServer/Bot/BotManager.js | 14 +- tests/test_bot_dialogue_arbiter.js | 3 + tests/test_party_dialogue_state.js | 81 ++++++++++ 8 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 src/GameServer/Bot/AI/PartyDialogueState.js create mode 100644 tests/test_party_dialogue_state.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index b76e6713..d013bf5b 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -16,6 +16,7 @@ const tests = [ 'tests/test_bot_llm_party_policy.js', 'tests/test_party_address_resolver.js', 'tests/test_party_dialogue_router.js', + 'tests/test_party_dialogue_state.js', 'tests/test_bot_tool_pending_audit.js', 'tests/test_bot_conversation_store.js', 'tests/test_bot_dialogue_arbiter.js', diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index e03f5db8..0210e787 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -9,6 +9,7 @@ const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); const BotLLMTurnStore = invoke('GameServer/Bot/AI/BotLLMTurnStore'); const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); const BotAvailability = invoke('GameServer/Bot/AI/BotAvailability'); +const PartyDialogueState = invoke('GameServer/Bot/AI/PartyDialogueState'); const ALLOWED_EVENTS = new Set(['player_chat']); function config() { @@ -468,6 +469,19 @@ function recordConversationReply(session, decision, result, requestContext) { })); } +function recordDialogueDelivery(session, reply, requestContext) { + if (!reply || !requestContext?.playerSession || !requestContext?.conversationTurn) return; + PartyDialogueState.recordDeliveredReply( + requestContext.playerSession, + session, + reply, + { + turnId: requestContext.conversationTurn.turnId, + channel: requestContext.conversationTurn.channel + } + ); +} + function queueConversationWrite(session, work, metadata = {}) { const previous = session.lastConversationWrite || Promise.resolve(); const persist = () => LangfuseTracing.withObservation( @@ -502,6 +516,7 @@ function applyDecision(session, decision, visiblePlayers, requestContext) { invoke('GameServer/Bot/BotManager').botTell(session, playerSession, playerVisibleReply); result = { ...result, replyDelivered: true, playerVisibleReply }; } + if (result.replyDelivered === true) recordDialogueDelivery(session, playerVisibleReply, requestContext); recordConversationReply(session, decision, result, requestContext); } return { ...result, playerVisibleReply }; @@ -511,6 +526,7 @@ function applyDecision(session, decision, visiblePlayers, requestContext) { const reply = BotAgentTools.rejectionReply(result); BotManager.botTell(session, requestContext.playerSession, reply); playerVisibleReply = reply; + recordDialogueDelivery(session, reply, requestContext); if (requestContext.conversationTurn) { queueConversationWrite(session, () => BotConversationService.recordFallback({ playerSession: requestContext.playerSession, @@ -588,6 +604,7 @@ function fallbackReply(session, requestContext, outcome, persistMetadata = {}) { : `I am ${plan} right now.`; BotManager.botTell(session, playerSession, reply); + recordDialogueDelivery(session, reply, requestContext); if (requestContext?.conversationTurn) { queueConversationWrite( session, diff --git a/src/GameServer/Bot/AI/BotDialogueArbiter.js b/src/GameServer/Bot/AI/BotDialogueArbiter.js index 8b0808b2..aec0c81d 100644 --- a/src/GameServer/Bot/AI/BotDialogueArbiter.js +++ b/src/GameServer/Bot/AI/BotDialogueArbiter.js @@ -2,6 +2,15 @@ const BotConversationService = invoke('GameServer/Bot/AI/BotConversationService' const BotContextAssembler = invoke('GameServer/Bot/AI/BotContextAssembler'); const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); +const PartyDialogueState = invoke('GameServer/Bot/AI/PartyDialogueState'); + +function recordDeliveredReply(input, turn, reply) { + if (!reply || !input?.playerSession || !input?.botSession) return; + PartyDialogueState.recordDeliveredReply(input.playerSession, input.botSession, reply, { + turnId: turn?.turnId || input.turnId || input.requestId || null, + channel: turn?.channel || input.channel || input.source || 'hot_dialogue' + }); +} function fallbackText(botSession, reason) { const plan = botSession?.plan || 'hunting'; @@ -66,6 +75,7 @@ function deliverFallback(input, turn, reason) { async () => { BotManager.botTell(input.botSession, input.playerSession, reply); delivered = true; + recordDeliveredReply(input, turn, reply); return { ok: true, reply, delivered: true }; }, 'chain' @@ -97,6 +107,7 @@ function deliverFallback(input, turn, reason) { try { BotManager.botTell(input.botSession, input.playerSession, reply); delivered = true; + recordDeliveredReply(input, turn, reply); } catch (_) { delivered = false; } diff --git a/src/GameServer/Bot/AI/PartyDialogueRouter.js b/src/GameServer/Bot/AI/PartyDialogueRouter.js index ff8d5879..2fb8a0c2 100644 --- a/src/GameServer/Bot/AI/PartyDialogueRouter.js +++ b/src/GameServer/Bot/AI/PartyDialogueRouter.js @@ -36,6 +36,11 @@ function isGroupAddress(text) { return /\b(?:bot|bots|guys|party|team|help|everyone|anyone|somebody|someone)\b/i.test(String(text || '')); } +function isContinuationMessage(text) { + const value = String(text || '').trim().toLowerCase(); + return /^(?:yes|yeah|yep|sure|no|nope|ok|okay|alright|right|exactly|do it|go ahead|continue|sounds good|got it|thanks|thank you|and then|what about)\b/.test(value); +} + function buildCandidates({ sessions = [], playerSession, partyChannel = false, hearingRadius = HEARING_RADIUS } = {}) { const player = playerSession?.actor; return sessions @@ -70,6 +75,7 @@ function select({ sessions, kind, now = Date.now(), + dialogueState = null, activeResponderId, activeResponderAt, hearingRadius = HEARING_RADIUS @@ -103,11 +109,19 @@ function select({ return { candidate: selected, candidates, status: 'matched', reason: 'selected', matchType: null }; } - const activeAge = Number.isFinite(Number(activeResponderAt)) - ? now - Number(activeResponderAt) + const state = dialogueState || {}; + const inFlight = findById(candidates, state.inFlightBotId); + if (inFlight) { + return { candidate: inFlight, candidates, status: 'matched', reason: 'in_flight', matchType: null }; + } + + const previousId = state.lastDeliveredBotId ?? state.activeBotId ?? activeResponderId; + const previousAt = state.lastDeliveredAt || state.activeSince || activeResponderAt; + const activeAge = Number.isFinite(Number(previousAt)) + ? now - Number(previousAt) : Infinity; - const active = activeAge >= 0 && activeAge <= ACTIVE_RESPONDER_TTL_MS - ? findById(candidates, activeResponderId) + const active = (partyChannel || isContinuationMessage(text)) && activeAge >= 0 && activeAge <= ACTIVE_RESPONDER_TTL_MS + ? findById(candidates, previousId) : null; if (active) { return { candidate: active, candidates, status: 'matched', reason: 'active_responder', matchType: null }; @@ -131,5 +145,6 @@ module.exports = { PARTY_CHANNEL_KIND, buildCandidates, isGroupAddress, + isContinuationMessage, select }; diff --git a/src/GameServer/Bot/AI/PartyDialogueState.js b/src/GameServer/Bot/AI/PartyDialogueState.js new file mode 100644 index 00000000..a141e0bd --- /dev/null +++ b/src/GameServer/Bot/AI/PartyDialogueState.js @@ -0,0 +1,148 @@ +const MAX_RECENT_TURNS = 8; + +function actorId(session) { + return session?.actor?.fetchId?.() || session?.accountId || null; +} + +function ensure(playerSession) { + if (!playerSession) return null; + if (!playerSession.partyDialogueState || typeof playerSession.partyDialogueState !== 'object') { + playerSession.partyDialogueState = { + version: 1, + activeBotId: null, + activeSince: 0, + inFlightBotId: null, + inFlightSince: 0, + lastExplicitBotId: null, + lastExplicitAt: 0, + lastDeliveredBotId: null, + lastDeliveredAt: 0, + lastDeliveredTurnId: null, + spokespersonId: null, + recentTurns: [] + }; + } + return playerSession.partyDialogueState; +} + +function compactTurn(turn = {}) { + return { + role: turn.role === 'bot' ? 'bot' : 'player', + botId: turn.botId ?? null, + text: String(turn.text || '').replace(/\s+/g, ' ').trim().slice(0, 240), + channel: String(turn.channel || 'party_chat').slice(0, 32), + at: Number(turn.at || Date.now()) + }; +} + +function pushTurn(state, turn) { + const compact = compactTurn(turn); + if (!compact.text) return; + state.recentTurns = [...(state.recentTurns || []), compact].slice(-MAX_RECENT_TURNS); +} + +function beginRequest(playerSession, botSession, details = {}) { + const state = ensure(playerSession); + if (!state) return null; + const botId = actorId(botSession); + if (!botId) return state; + + const at = Number(details.at || Date.now()); + state.inFlightBotId = botId; + state.inFlightSince = at; + state.spokespersonId = details.spokespersonId ?? state.spokespersonId ?? null; + if (String(details.reason || '').startsWith('explicit_')) { + state.lastExplicitBotId = botId; + state.lastExplicitAt = at; + } + pushTurn(state, { + role: 'player', + botId, + text: details.text, + channel: details.channel, + at + }); + + // Keep the legacy fields in sync while callers migrate to the bounded + // state object. They are not used to claim a reply was delivered. + playerSession.botDialogueResponderId = botId; + playerSession.botDialogueResponderAt = at; + return state; +} + +function clearInFlight(playerSession, botSession = null) { + const state = ensure(playerSession); + if (!state) return null; + const botId = botSession ? actorId(botSession) : null; + if (!botId || String(state.inFlightBotId) === String(botId)) { + state.inFlightBotId = null; + state.inFlightSince = 0; + } + return state; +} + +function recordDeliveredReply(playerSession, botSession, text, details = {}) { + const state = ensure(playerSession); + if (!state) return null; + const botId = actorId(botSession); + if (!botId || !String(text || '').trim()) return state; + const turnId = details.turnId || null; + const deliveryKey = `${botId}:${turnId || ''}:${String(text).trim()}`; + if (state.lastDeliveredTurnId === deliveryKey) return state; + + const at = Number(details.at || Date.now()); + if (!state.inFlightBotId || String(state.inFlightBotId) === String(botId)) { + state.inFlightBotId = null; + state.inFlightSince = 0; + } + state.activeBotId = botId; + state.activeSince = at; + state.lastDeliveredBotId = botId; + state.lastDeliveredAt = at; + state.lastDeliveredTurnId = deliveryKey; + pushTurn(state, { + role: 'bot', + botId, + text, + channel: details.channel, + at + }); + playerSession.botDialogueResponderId = botId; + playerSession.botDialogueResponderAt = at; + return state; +} + +function snapshot(playerSession) { + const state = playerSession?.partyDialogueState; + if (!state) return null; + return { + version: state.version, + activeBotId: state.activeBotId, + activeSince: state.activeSince, + inFlightBotId: state.inFlightBotId, + inFlightSince: state.inFlightSince, + lastExplicitBotId: state.lastExplicitBotId, + lastExplicitAt: state.lastExplicitAt, + lastDeliveredBotId: state.lastDeliveredBotId, + lastDeliveredAt: state.lastDeliveredAt, + spokespersonId: state.spokespersonId, + recentTurns: [...(state.recentTurns || [])] + }; +} + +function reset(playerSession) { + if (!playerSession) return; + delete playerSession.partyDialogueState; + delete playerSession.botDialogueResponderId; + delete playerSession.botDialogueResponderAt; +} + +module.exports = { + MAX_RECENT_TURNS, + beginRequest, + clearInFlight, + ensure, + recordDeliveredReply, + reset, + snapshot +}; diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js index 0baa6e5c..2b06b741 100644 --- a/src/GameServer/Bot/BotManager.js +++ b/src/GameServer/Bot/BotManager.js @@ -737,6 +737,7 @@ const BotManager = { const playerPt = new SpeckMath.Point3D(player.fetchLocX(), player.fetchLocY(), player.fetchLocZ()); const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); const PartyDialogueRouter = invoke('GameServer/Bot/AI/PartyDialogueRouter'); + const PartyDialogueState = invoke('GameServer/Bot/AI/PartyDialogueState'); const llmEnabled = BotBrain.isEnabled(); const groupAddress = /\b(bot|bots|guys|party|team|help)\b/.test(text) || /(бот|боты|ребят|народ|пати|команда|кто-нибудь)/.test(text); const partyChannel = Number(data.kind) === 3; @@ -748,13 +749,17 @@ const BotManager = { playerSession, sessions: this.sessions, kind: data.kind, + dialogueState: PartyDialogueState.snapshot(playerSession), activeResponderId: playerSession.botDialogueResponderId, activeResponderAt: playerSession.botDialogueResponderAt }); llmResponder = route.candidate; if (llmResponder) { - playerSession.botDialogueResponderId = llmResponder.id; - playerSession.botDialogueResponderAt = Date.now(); + PartyDialogueState.beginRequest(playerSession, llmResponder.session, { + reason: route.reason, + text: rawText, + channel: partyChannel ? 'party_chat' : 'local_chat' + }); } } @@ -790,7 +795,12 @@ const BotManager = { channel: partyChannel ? 'party_chat' : 'local_chat', source: partyChannel ? 'party_chat' : 'local_chat', allowFallback: true + }).then((result) => { + if (result?.started === true || result?.queued === true || result?.delivered === true) return result; + PartyDialogueState.clearInFlight(playerSession, session); + return result; }).catch((error) => { + PartyDialogueState.clearInFlight(playerSession, session); utils.infoWarn('BotDialogue', 'local LLM chat route failed: %s', error.message); }); return; diff --git a/tests/test_bot_dialogue_arbiter.js b/tests/test_bot_dialogue_arbiter.js index 6071e95c..d0b981d7 100644 --- a/tests/test_bot_dialogue_arbiter.js +++ b/tests/test_bot_dialogue_arbiter.js @@ -11,6 +11,7 @@ const BotAI = invoke('GameServer/Bot/BotAI'); const BotManager = invoke('GameServer/Bot/BotManager'); const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); +const PartyDialogueState = invoke('GameServer/Bot/AI/PartyDialogueState'); function actor(id, name, x = 0) { return { @@ -188,6 +189,7 @@ async function main() { delete player.botDialogueResponderId; delete player.botDialogueResponderAt; + PartyDialogueState.reset(player); routes.length = 0; BotManager.handlePlayerSpeak(player, { text: 'nice weather today' }); assert.deepStrictEqual(routes, [], 'unaddressed local chat must not fan out to hot bots'); @@ -198,6 +200,7 @@ async function main() { delete player.botDialogueResponderId; delete player.botDialogueResponderAt; + PartyDialogueState.reset(player); routes.length = 0; BotManager.handlePlayerSpeak(player, { kind: 3, text: 'party, regroup' }); assert.deepStrictEqual(routes, ['Aria'], 'party chat must select one companion responder'); diff --git a/tests/test_party_dialogue_state.js b/tests/test_party_dialogue_state.js new file mode 100644 index 00000000..e261691c --- /dev/null +++ b/tests/test_party_dialogue_state.js @@ -0,0 +1,81 @@ +const assert = require('assert'); + +require('../src/Global'); + +const PartyDialogueState = invoke('GameServer/Bot/AI/PartyDialogueState'); + +function session(id, name) { + return { actor: { fetchId: () => id, fetchName: () => name } }; +} + +async function main() { + const player = { actor: { fetchId: () => 100, fetchName: () => 'Slava' } }; + const nice = session(1, 'NiceBot'); + const healer = session(2, 'Healer'); + + PartyDialogueState.beginRequest(player, nice, { + reason: 'explicit_unique_prefix', + text: 'Nice, pull now.', + channel: 'party_chat', + at: 1000 + }); + let state = PartyDialogueState.snapshot(player); + assert.strictEqual(state.inFlightBotId, 1); + assert.strictEqual(state.activeBotId, null, 'request admission must not claim a delivered reply'); + assert.strictEqual(state.recentTurns.length, 1); + + PartyDialogueState.beginRequest(player, healer, { + reason: 'active_responder', + text: 'yes', + channel: 'party_chat', + at: 1100 + }); + state = PartyDialogueState.snapshot(player); + assert.strictEqual(state.inFlightBotId, 2, 'rapid continuation must move the in-flight owner immediately'); + assert.strictEqual(state.activeBotId, null); + + PartyDialogueState.recordDeliveredReply(player, nice, 'I am on pull.', { + turnId: 'turn-1', + channel: 'party_chat', + at: 1150 + }); + state = PartyDialogueState.snapshot(player); + assert.strictEqual(state.inFlightBotId, 2, 'an older bot reply must not steal a newer in-flight owner'); + + PartyDialogueState.clearInFlight(player, healer); + state = PartyDialogueState.snapshot(player); + assert.strictEqual(state.inFlightBotId, null); + + PartyDialogueState.recordDeliveredReply(player, nice, 'I am on pull.', { + turnId: 'turn-1', + channel: 'party_chat', + at: 1200 + }); + state = PartyDialogueState.snapshot(player); + assert.strictEqual(state.activeBotId, 1); + assert.strictEqual(state.lastDeliveredBotId, 1); + assert.strictEqual(state.inFlightBotId, null); + assert.strictEqual(state.recentTurns.at(-1).role, 'bot'); + + PartyDialogueState.recordDeliveredReply(player, nice, 'I am on pull.', { + turnId: 'turn-1', + channel: 'party_chat', + at: 1300 + }); + assert.strictEqual(PartyDialogueState.snapshot(player).recentTurns.length, 3, 'same delivery must be idempotent'); + + const bounded = PartyDialogueState.ensure(player); + for (let index = 0; index < 20; index += 1) { + PartyDialogueState.beginRequest(player, nice, { text: `message ${index}`, at: 2000 + index }); + } + assert.ok(bounded.recentTurns.length <= PartyDialogueState.MAX_RECENT_TURNS); + + PartyDialogueState.reset(player); + assert.strictEqual(PartyDialogueState.snapshot(player), null); + console.log('Party dialogue state checks passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From 0cc18285ba6148743d4d63e5753a63a54189d111 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:52:41 -0400 Subject: [PATCH 50/59] Route party messages to role and interaction owners --- src/GameServer/Bot/AI/PartyDialogueRouter.js | 86 +++++++++++++++++++- src/GameServer/Bot/BotManager.js | 3 +- tests/test_party_dialogue_router.js | 27 +++++- 3 files changed, 110 insertions(+), 6 deletions(-) diff --git a/src/GameServer/Bot/AI/PartyDialogueRouter.js b/src/GameServer/Bot/AI/PartyDialogueRouter.js index 2fb8a0c2..746ac1cd 100644 --- a/src/GameServer/Bot/AI/PartyDialogueRouter.js +++ b/src/GameServer/Bot/AI/PartyDialogueRouter.js @@ -1,9 +1,21 @@ const PartyAddressResolver = invoke('GameServer/Bot/AI/PartyAddressResolver'); +const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); const PARTY_CHANNEL_KIND = 3; const ACTIVE_RESPONDER_TTL_MS = 5 * 60 * 1000; const HEARING_RADIUS = 1500; +const ROLE_ALIASES = { + tank: ['tank', 'frontline', 'guard'], + healer: ['healer', 'medic', 'healing'], + buffer: ['buffer', 'support'], + puller: ['puller', 'pull'], + dps: ['dps', 'damage', 'dd'], + mage: ['mage', 'caster'], + archer: ['archer', 'ranged'], + dagger: ['dagger', 'rogue'] +}; + function actorId(actor) { return actor && typeof actor.fetchId === 'function' ? actor.fetchId() : null; } @@ -41,14 +53,52 @@ function isContinuationMessage(text) { return /^(?:yes|yeah|yep|sure|no|nope|ok|okay|alright|right|exactly|do it|go ahead|continue|sounds good|got it|thanks|thank you|and then|what about)\b/.test(value); } +function roleFor(session, actor) { + return String( + session?.partyRole || + session?.role || + session?.botStatus?.role || + session?.roleDecision?.role || + actor?.partyRole || + actor?.role || + BotRoles.inferRole(actor) + ).toLowerCase(); +} + +function isPuller(session, role) { + return session?.partyPuller === true || + session?.isPuller === true || + session?.roleDecision?.decision === 'party_pull' || + role === 'puller'; +} + +function roleAddressMatches(text, candidates) { + const value = String(text || '').toLowerCase(); + const requestedRoles = Object.entries(ROLE_ALIASES) + .filter(([, aliases]) => aliases.some((alias) => new RegExp(`\\b${alias}\\b`, 'i').test(value))) + .map(([role]) => role); + if (requestedRoles.length !== 1) return { status: requestedRoles.length > 1 ? 'ambiguous' : 'none', matches: [] }; + + const requested = requestedRoles[0]; + const matches = candidates.filter((candidate) => ( + requested === 'puller' + ? candidate.puller + : candidate.role === requested + )); + if (matches.length === 1) return { status: 'matched', matches, role: requested }; + return { status: matches.length > 1 ? 'ambiguous' : 'none', matches, role: requested }; +} + function buildCandidates({ sessions = [], playerSession, partyChannel = false, hearingRadius = HEARING_RADIUS } = {}) { const player = playerSession?.actor; + const configuredPullerId = Number(playerSession?.partyCompanionSettings?.pullerId || 0); return sessions .filter((session) => isOnline(session)) .map((session) => { const actor = session.actor; const companion = isCompanion(session, playerSession); const distance = distanceBetween(actor, player); + const role = roleFor(session, actor); return { session, actor, @@ -57,7 +107,12 @@ function buildCandidates({ sessions = [], playerSession, partyChannel = false, h companion, selected: typeof player?.fetchDestId === 'function' && player.fetchDestId() === actorId(actor), distance, - role: session.partyRole || session.role || actor.partyRole || actor.role || null, + role, + puller: isPuller(session, role) || Number(actorId(actor)) === configuredPullerId, + pendingInteraction: !!( + session.activeTrade?.playerSession === playerSession || + session.activeNegotiation?.playerSession === playerSession + ), eligible: partyChannel ? companion : distance <= hearingRadius }; }) @@ -109,6 +164,11 @@ function select({ return { candidate: selected, candidates, status: 'matched', reason: 'selected', matchType: null }; } + const pending = candidates.find((candidate) => candidate.pendingInteraction); + if (pending) { + return { candidate: pending, candidates, status: 'matched', reason: 'pending_interaction', matchType: null }; + } + const state = dialogueState || {}; const inFlight = findById(candidates, state.inFlightBotId); if (inFlight) { @@ -127,7 +187,28 @@ function select({ return { candidate: active, candidates, status: 'matched', reason: 'active_responder', matchType: null }; } - const spokesperson = candidates.find((candidate) => candidate.companion); + const roleOwner = roleAddressMatches(text, candidates); + if (roleOwner.status === 'matched') { + return { + candidate: roleOwner.matches[0], + candidates, + status: 'matched', + reason: `role_${roleOwner.role}`, + matchType: 'role' + }; + } + if (roleOwner.status === 'ambiguous') { + return { + candidate: null, + candidates, + status: 'ambiguous', + reason: 'role_ambiguous', + matchType: 'role', + matches: roleOwner.matches + }; + } + + const spokesperson = findById(candidates, state.spokespersonId) || candidates.find((candidate) => candidate.companion); if (partyChannel && spokesperson) { return { candidate: spokesperson, candidates, status: 'matched', reason: 'party_spokesperson', matchType: null }; } @@ -146,5 +227,6 @@ module.exports = { buildCandidates, isGroupAddress, isContinuationMessage, + roleAddressMatches, select }; diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js index 2b06b741..a9d6f68a 100644 --- a/src/GameServer/Bot/BotManager.js +++ b/src/GameServer/Bot/BotManager.js @@ -758,7 +758,8 @@ const BotManager = { PartyDialogueState.beginRequest(playerSession, llmResponder.session, { reason: route.reason, text: rawText, - channel: partyChannel ? 'party_chat' : 'local_chat' + channel: partyChannel ? 'party_chat' : 'local_chat', + spokespersonId: route.reason === 'party_spokesperson' ? llmResponder.id : undefined }); } } diff --git a/tests/test_party_dialogue_router.js b/tests/test_party_dialogue_router.js index 14002a23..4c73c779 100644 --- a/tests/test_party_dialogue_router.js +++ b/tests/test_party_dialogue_router.js @@ -16,11 +16,12 @@ function actor(id, name, x = 0) { }; } -function session(id, name, x = 0, companion = true) { +function session(id, name, x = 0, companion = true, role = null) { const value = { actor: actor(id, name, x), partyCompanion: companion, - followPlayerSession: null + followPlayerSession: null, + role }; return value; } @@ -28,7 +29,7 @@ function session(id, name, x = 0, companion = true) { async function main() { const player = { actor: actor(100, 'Slava', 0) }; const nice = session(1, 'NiceBot', 5000); - const healer = session(2, 'Healer', 100); + const healer = session(2, 'Mira', 100, true, 'healer'); const unrelated = session(3, 'FarBot', 0, false); nice.followPlayerSession = player; healer.followPlayerSession = player; @@ -65,6 +66,26 @@ async function main() { assert.strictEqual(result.reason, 'active_responder'); assert.strictEqual(result.candidate.session, healer); + nice.activeTrade = { playerSession: player }; + result = PartyDialogueRouter.select({ + text: 'is the price ready?', + playerSession: player, + sessions: [nice, healer], + kind: 3 + }); + assert.strictEqual(result.reason, 'pending_interaction'); + assert.strictEqual(result.candidate.session, nice); + delete nice.activeTrade; + + result = PartyDialogueRouter.select({ + text: 'healer, keep us alive.', + playerSession: player, + sessions: [nice, healer], + kind: 3 + }); + assert.strictEqual(result.reason, 'role_healer'); + assert.strictEqual(result.candidate.session, healer); + result = PartyDialogueRouter.select({ text: 'party, regroup.', playerSession: player, From 35da1ef3bc049c7a06b67ff140230599c9a8573b Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:01:42 -0400 Subject: [PATCH 51/59] Add bounded LLM router for ambiguous party chat --- config/default.ini | 1 + config/local.example.ini | 2 + scripts/run-tests.js | 2 + src/GameServer/Bot/AI/OpenRouterGateway.js | 5 + src/GameServer/Bot/AI/PartyDialogueRouter.js | 20 +- src/GameServer/Bot/AI/PartyDialogueState.js | 19 ++ src/GameServer/Bot/AI/PartyLLMRouter.js | 184 +++++++++++++++++++ src/GameServer/Bot/BotManager.js | 108 ++++++++--- tests/test_ai_config_surface.js | 2 + tests/test_party_chat_routing_integration.js | 88 +++++++++ tests/test_party_dialogue_router.js | 14 ++ tests/test_party_llm_router.js | 93 ++++++++++ 12 files changed, 516 insertions(+), 22 deletions(-) create mode 100644 src/GameServer/Bot/AI/PartyLLMRouter.js create mode 100644 tests/test_party_chat_routing_integration.js create mode 100644 tests/test_party_llm_router.js diff --git a/config/default.ini b/config/default.ini index 5b195335..431ab506 100644 --- a/config/default.ini +++ b/config/default.ini @@ -28,6 +28,7 @@ showMonsterLevel = true enabled = false apiKey = model = openai/gpt-5.6-luna +partyRouterModel = temperature = 0.35 reasoningEffort = low maxConcurrentRequests = 32 diff --git a/config/local.example.ini b/config/local.example.ini index 161eaca0..551ef94d 100644 --- a/config/local.example.ini +++ b/config/local.example.ini @@ -2,6 +2,8 @@ enabled = true apiKey = sk-or-v1-your-key-here model = openai/gpt-5.6-luna +# Optional cheap model for ambiguous party-chat routing; empty keeps deterministic routing only. +partyRouterModel = debug = true [Langfuse] diff --git a/scripts/run-tests.js b/scripts/run-tests.js index d013bf5b..44f5b6d8 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -17,6 +17,8 @@ const tests = [ 'tests/test_party_address_resolver.js', 'tests/test_party_dialogue_router.js', 'tests/test_party_dialogue_state.js', + 'tests/test_party_llm_router.js', + 'tests/test_party_chat_routing_integration.js', 'tests/test_bot_tool_pending_audit.js', 'tests/test_bot_conversation_store.js', 'tests/test_bot_dialogue_arbiter.js', diff --git a/src/GameServer/Bot/AI/OpenRouterGateway.js b/src/GameServer/Bot/AI/OpenRouterGateway.js index 5656f564..d56e1ddd 100644 --- a/src/GameServer/Bot/AI/OpenRouterGateway.js +++ b/src/GameServer/Bot/AI/OpenRouterGateway.js @@ -19,6 +19,7 @@ const DEFAULTS = Object.freeze({ enabled: false, apiKey: '', model: LUNA_MODEL, + partyRouterModel: '', temperature: 0.35, reasoningEffort: 'low', maxConcurrentRequests: 32, @@ -75,6 +76,7 @@ function config(overrides = {}) { enabled: bool(optn.enabled, DEFAULTS.enabled), apiKey: process.env.OPENROUTER_API_KEY || optn.apiKey || DEFAULTS.apiKey, model: process.env.OPENROUTER_MODEL || optn.model || DEFAULTS.model, + partyRouterModel: process.env.OPENROUTER_PARTY_ROUTER_MODEL || optn.partyRouterModel || DEFAULTS.partyRouterModel, temperature: num(optn.temperature, DEFAULTS.temperature), reasoningEffort: reasoningEffort(optn.reasoningEffort), maxConcurrentRequests: Math.max(1, Math.floor(num( @@ -90,6 +92,9 @@ function config(overrides = {}) { enabled: bool(overrides.enabled, source.enabled), apiKey: overrides.apiKey !== undefined ? String(overrides.apiKey || '') : source.apiKey, model: overrides.model || source.model, + partyRouterModel: overrides.partyRouterModel !== undefined + ? String(overrides.partyRouterModel || '') + : String(source.partyRouterModel || ''), temperature: num(overrides.temperature, source.temperature), reasoningEffort: reasoningEffort(overrides.reasoningEffort, source.reasoningEffort), maxConcurrentRequests: Math.max(1, Math.floor(num( diff --git a/src/GameServer/Bot/AI/PartyDialogueRouter.js b/src/GameServer/Bot/AI/PartyDialogueRouter.js index 746ac1cd..6d19dadc 100644 --- a/src/GameServer/Bot/AI/PartyDialogueRouter.js +++ b/src/GameServer/Bot/AI/PartyDialogueRouter.js @@ -133,6 +133,7 @@ function select({ dialogueState = null, activeResponderId, activeResponderAt, + allowSpokespersonFallback = true, hearingRadius = HEARING_RADIUS } = {}) { const partyChannel = Number(kind) === PARTY_CHANNEL_KIND; @@ -149,6 +150,19 @@ function select({ }; } if (explicit.status === 'ambiguous') { + if (partyChannel && allowSpokespersonFallback !== false) { + const fallback = findById(candidates, dialogueState?.spokespersonId) || candidates.find((candidate) => candidate.companion); + if (fallback) { + return { + candidate: fallback, + candidates, + status: 'matched', + reason: 'party_spokesperson_ambiguous', + matchType: explicit.matchType, + matches: explicit.matches + }; + } + } return { candidate: null, candidates, @@ -209,10 +223,14 @@ function select({ } const spokesperson = findById(candidates, state.spokespersonId) || candidates.find((candidate) => candidate.companion); - if (partyChannel && spokesperson) { + if (partyChannel && spokesperson && allowSpokespersonFallback !== false) { return { candidate: spokesperson, candidates, status: 'matched', reason: 'party_spokesperson', matchType: null }; } + if (partyChannel && allowSpokespersonFallback === false) { + return { candidate: null, candidates, status: 'needs_router', reason: 'party_ambiguous', matchType: null }; + } + if (isGroupAddress(text) && candidates[0]) { return { candidate: candidates[0], candidates, status: 'matched', reason: 'group_spokesperson', matchType: null }; } diff --git a/src/GameServer/Bot/AI/PartyDialogueState.js b/src/GameServer/Bot/AI/PartyDialogueState.js index a141e0bd..9eb1fb9e 100644 --- a/src/GameServer/Bot/AI/PartyDialogueState.js +++ b/src/GameServer/Bot/AI/PartyDialogueState.js @@ -19,6 +19,7 @@ function ensure(playerSession) { lastDeliveredAt: 0, lastDeliveredTurnId: null, spokespersonId: null, + routerInFlightAt: 0, recentTurns: [] }; } @@ -81,6 +82,21 @@ function clearInFlight(playerSession, botSession = null) { return state; } +function beginRouter(playerSession, at = Date.now()) { + const state = ensure(playerSession); + if (!state) return false; + if (state.routerInFlightAt) return false; + state.routerInFlightAt = Number(at || Date.now()); + return true; +} + +function clearRouter(playerSession) { + const state = ensure(playerSession); + if (!state) return null; + state.routerInFlightAt = 0; + return state; +} + function recordDeliveredReply(playerSession, botSession, text, details = {}) { const state = ensure(playerSession); if (!state) return null; @@ -126,6 +142,7 @@ function snapshot(playerSession) { lastDeliveredBotId: state.lastDeliveredBotId, lastDeliveredAt: state.lastDeliveredAt, spokespersonId: state.spokespersonId, + routerInFlightAt: state.routerInFlightAt, recentTurns: [...(state.recentTurns || [])] }; } @@ -140,7 +157,9 @@ function reset(playerSession) { module.exports = { MAX_RECENT_TURNS, beginRequest, + beginRouter, clearInFlight, + clearRouter, ensure, recordDeliveredReply, reset, diff --git a/src/GameServer/Bot/AI/PartyLLMRouter.js b/src/GameServer/Bot/AI/PartyLLMRouter.js new file mode 100644 index 00000000..52c0219b --- /dev/null +++ b/src/GameServer/Bot/AI/PartyLLMRouter.js @@ -0,0 +1,184 @@ +const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); + +const ROUTER_SCHEMA = { + name: 'party_chat_route', + schema: { + type: 'object', + properties: { + route: { type: 'string', enum: ['bot', 'party', 'clarify', 'none'] }, + botId: { type: ['number', 'null'] }, + intent: { type: 'string' }, + confidence: { type: 'number', minimum: 0, maximum: 1 }, + reason: { type: 'string' } + }, + required: ['route', 'botId', 'intent', 'confidence', 'reason'], + additionalProperties: false + } +}; + +const ROUTER_TEMPERATURE = 0.1; +const ROUTER_MAX_TOKENS = 96; + +function config() { + const gateway = OpenRouterGateway.config(); + return { + ...gateway, + model: String(gateway.partyRouterModel || '').trim() + }; +} + +function enabled() { + const cfg = config(); + return cfg.enabled === true && !!cfg.apiKey && !!cfg.model; +} + +function playerId(session) { + return session?.actor?.fetchId?.() || null; +} + +function candidateCard(candidate, state) { + const id = candidate?.id ?? null; + const lastTurn = [...(state?.recentTurns || [])] + .reverse() + .find((turn) => turn.role === 'bot' && String(turn.botId) === String(id)); + return { + id, + name: String(candidate?.name || '').slice(0, 48), + role: String(candidate?.role || 'dps').slice(0, 24), + companion: candidate?.companion === true, + lastSpoke: lastTurn?.text ? String(lastTurn.text).slice(0, 120) : null + }; +} + +function normalizeData(data, candidates) { + const route = ['bot', 'party', 'clarify', 'none'].includes(data?.route) ? data.route : 'none'; + const requestedId = data?.botId === null || data?.botId === undefined ? null : Number(data.botId); + const candidate = route === 'bot' + ? candidates.find((entry) => Number(entry.id) === requestedId) || null + : null; + if (route === 'bot' && !candidate) { + return { + ok: false, + route: 'clarify', + candidate: null, + intent: 'unknown', + confidence: 0, + reason: 'invalid_bot_id', + data + }; + } + return { + ok: true, + route, + candidate, + intent: String(data?.intent || 'conversation').slice(0, 80), + confidence: Math.max(0, Math.min(1, Number(data?.confidence || 0))), + reason: String(data?.reason || 'router_decision').slice(0, 240), + data + }; +} + +function prompt(input, cards) { + return { + message: String(input.text || '').slice(0, 500), + channel: 'party_chat', + selectedBotId: input.selectedBotId ?? null, + inFlightBotId: input.dialogueState?.inFlightBotId ?? null, + lastDeliveredBotId: input.dialogueState?.lastDeliveredBotId ?? null, + recentPartyTurns: (input.dialogueState?.recentTurns || []).slice(-6).map((turn) => ({ + role: turn.role, + botId: turn.botId, + text: String(turn.text || '').slice(0, 160) + })), + candidates: cards + }; +} + +async function route(input = {}) { + const cfg = config(); + if (!cfg.enabled || !cfg.apiKey || !cfg.model) { + return { ok: false, route: 'none', candidate: null, reason: 'disabled', telemetry: null }; + } + + const candidates = Array.isArray(input.candidates) ? input.candidates : []; + if (candidates.length === 0) { + return { ok: false, route: 'none', candidate: null, reason: 'no_candidates', telemetry: null }; + } + + const userPayload = prompt(input, candidates.map((candidate) => candidateCard(candidate, input.dialogueState))); + const botId = playerId(input.playerSession); + const metadata = { + event: 'party_chat_route', + source: 'party_router', + channel: 'party_chat', + playerId: botId, + candidateCount: candidates.length, + model: cfg.model + }; + + return LangfuseTracing.withObservation( + 'party.router.generation', + userPayload, + metadata, + async () => { + const result = await OpenRouterGateway.request({ + config: { + ...cfg, + model: cfg.model, + reasoningEffort: 'off', + temperature: ROUTER_TEMPERATURE, + maxTokens: ROUTER_MAX_TOKENS, + timeoutMs: 0 + }, + requestId: `party-router:${botId || 'unknown'}:${Date.now()}`, + sessionId: `party-router:${botId || 'unknown'}`, + circuitKey: 'party-router', + source: 'party_router', + playerId: botId, + interactive: false, + messages: [ + { + role: 'system', + content: [ + 'You are a strict party-chat router for an online game.', + 'Choose at most one current candidate bot; never invent IDs.', + 'Use route=party only when the message is genuinely for the whole party.', + 'Use clarify when a human should clarify an ambiguous addressee.', + 'Use none only when no bot should answer.', + 'Return only the required JSON object. Do not call tools and do not roleplay.' + ].join(' ') + }, + { role: 'user', content: JSON.stringify(userPayload) } + ], + responseSchema: ROUTER_SCHEMA + }); + + if (!result?.ok) { + return { + ok: false, + route: 'none', + candidate: null, + reason: result?.reason || 'router_provider_error', + telemetry: result?.telemetry || null + }; + } + + return { + ...normalizeData(result.data, candidates), + telemetry: result.telemetry || null, + usage: result.usage || null + }; + }, + 'chain' + ); +} + +module.exports = { + ROUTER_MAX_TOKENS, + ROUTER_SCHEMA, + ROUTER_TEMPERATURE, + config, + enabled, + route +}; diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js index a9d6f68a..db99f51c 100644 --- a/src/GameServer/Bot/BotManager.js +++ b/src/GameServer/Bot/BotManager.js @@ -738,28 +738,108 @@ const BotManager = { const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); const PartyDialogueRouter = invoke('GameServer/Bot/AI/PartyDialogueRouter'); const PartyDialogueState = invoke('GameServer/Bot/AI/PartyDialogueState'); + const PartyLLMRouter = invoke('GameServer/Bot/AI/PartyLLMRouter'); const llmEnabled = BotBrain.isEnabled(); const groupAddress = /\b(bot|bots|guys|party|team|help)\b/.test(text) || /(бот|боты|ребят|народ|пати|команда|кто-нибудь)/.test(text); const partyChannel = Number(data.kind) === 3; let llmResponder = null; + let deterministicRoute = null; + const dialogueState = PartyDialogueState.snapshot(playerSession); + const cheapRouterAvailable = llmEnabled && partyChannel && PartyLLMRouter.enabled() && !dialogueState?.routerInFlightAt; if (llmEnabled) { - const route = PartyDialogueRouter.select({ + deterministicRoute = PartyDialogueRouter.select({ text: rawText, playerSession, sessions: this.sessions, kind: data.kind, - dialogueState: PartyDialogueState.snapshot(playerSession), + dialogueState, activeResponderId: playerSession.botDialogueResponderId, - activeResponderAt: playerSession.botDialogueResponderAt + activeResponderAt: playerSession.botDialogueResponderAt, + allowSpokespersonFallback: !cheapRouterAvailable }); - llmResponder = route.candidate; + llmResponder = deterministicRoute.candidate; if (llmResponder) { PartyDialogueState.beginRequest(playerSession, llmResponder.session, { - reason: route.reason, + reason: deterministicRoute.reason, text: rawText, channel: partyChannel ? 'party_chat' : 'local_chat', - spokespersonId: route.reason === 'party_spokesperson' ? llmResponder.id : undefined + spokespersonId: deterministicRoute.reason === 'party_spokesperson' ? llmResponder.id : undefined + }); + } + } + + const dispatchLLM = (session, reason = deterministicRoute?.reason || 'deterministic_route') => { + if (!session?.actor) return Promise.resolve({ ok: false, reason: 'missing_bot' }); + const currentState = PartyDialogueState.snapshot(playerSession); + if (String(currentState?.inFlightBotId || '') !== String(session.actor.fetchId())) { + PartyDialogueState.beginRequest(playerSession, session, { + reason, + text: rawText, + channel: partyChannel ? 'party_chat' : 'local_chat', + spokespersonId: reason === 'party_spokesperson' ? session.actor.fetchId() : undefined + }); + } + return BotDialogueArbiter.route({ + playerSession, + botSession: session, + text: rawText, + channel: partyChannel ? 'party_chat' : 'local_chat', + source: partyChannel ? 'party_chat' : 'local_chat', + allowFallback: true + }).then((result) => { + if (result?.started === true || result?.queued === true || result?.delivered === true) return result; + PartyDialogueState.clearInFlight(playerSession, session); + return result; + }).catch((error) => { + PartyDialogueState.clearInFlight(playerSession, session); + utils.infoWarn('BotDialogue', 'local LLM chat route failed: %s', error.message); + return { ok: false, reason: 'route_error', error: error.message }; + }); + }; + + if (llmEnabled && partyChannel && cheapRouterAvailable && !llmResponder && deterministicRoute && ( + deterministicRoute.status === 'needs_router' || deterministicRoute.status === 'ambiguous' + )) { + const fallbackRoute = PartyDialogueRouter.select({ + text: rawText, + playerSession, + sessions: this.sessions, + kind: data.kind, + dialogueState, + activeResponderId: playerSession.botDialogueResponderId, + activeResponderAt: playerSession.botDialogueResponderAt, + allowSpokespersonFallback: true + }); + if (PartyDialogueState.beginRouter(playerSession)) { + return PartyLLMRouter.route({ + text: rawText, + playerSession, + candidates: deterministicRoute.candidates, + selectedBotId: deterministicRoute.candidates.find((candidate) => candidate.selected)?.id || null, + dialogueState: PartyDialogueState.snapshot(playerSession) + }).then((routerResult) => { + PartyDialogueState.clearRouter(playerSession); + let chosen = routerResult?.candidate || null; + let reason = routerResult?.route === 'bot' ? 'router_bot' : `router_${routerResult?.route || 'fallback'}`; + if (!chosen && ['party', 'clarify'].includes(routerResult?.route)) chosen = fallbackRoute.candidate; + if (!chosen && routerResult?.ok !== true) chosen = fallbackRoute.candidate; + return chosen ? dispatchLLM(chosen, reason) : routerResult; + }).catch((error) => { + PartyDialogueState.clearRouter(playerSession); + return fallbackRoute.candidate + ? dispatchLLM(fallbackRoute.candidate, 'router_error_fallback') + : { ok: false, reason: 'router_error', error: error.message }; + }); + } + llmResponder = fallbackRoute.candidate; + deterministicRoute = fallbackRoute; + if (llmResponder) { + PartyDialogueState.beginRequest(playerSession, llmResponder.session, { + reason: fallbackRoute.reason, + text: rawText, + channel: 'party_chat', + spokespersonId: fallbackRoute.reason === 'party_spokesperson' ? llmResponder.id : undefined }); } } @@ -789,21 +869,7 @@ const BotManager = { const llmAddressed = llmEnabled && llmResponder?.session === session; if (llmEnabled) { if (!llmAddressed) return; - BotDialogueArbiter.route({ - playerSession, - botSession: session, - text: rawText, - channel: partyChannel ? 'party_chat' : 'local_chat', - source: partyChannel ? 'party_chat' : 'local_chat', - allowFallback: true - }).then((result) => { - if (result?.started === true || result?.queued === true || result?.delivered === true) return result; - PartyDialogueState.clearInFlight(playerSession, session); - return result; - }).catch((error) => { - PartyDialogueState.clearInFlight(playerSession, session); - utils.infoWarn('BotDialogue', 'local LLM chat route failed: %s', error.message); - }); + dispatchLLM(session, deterministicRoute?.reason); return; } diff --git a/tests/test_ai_config_surface.js b/tests/test_ai_config_surface.js index 73c65b28..d1423462 100644 --- a/tests/test_ai_config_surface.js +++ b/tests/test_ai_config_surface.js @@ -12,6 +12,7 @@ try { assert.strictEqual(OpenRouterGateway.DEFAULTS.model, 'openai/gpt-5.6-luna'); assert.strictEqual(OpenRouterGateway.DEFAULTS.reasoningEffort, 'low'); assert.strictEqual(OpenRouterGateway.DEFAULTS.temperature, 0.35); + assert.strictEqual(OpenRouterGateway.DEFAULTS.partyRouterModel, ''); options.default.OpenRouter = { enabled: true, @@ -34,6 +35,7 @@ try { assert.strictEqual(openRouter.temperature, 0.7); assert.strictEqual(openRouter.reasoningEffort, 'off'); assert.strictEqual(openRouter.maxConcurrentRequests, 7); + assert.strictEqual(openRouter.partyRouterModel, '', 'party routing remains deterministic unless explicitly configured'); assert.strictEqual(openRouter.maxTokens, 320, 'completion safety belongs to internal policy'); assert.strictEqual(openRouter.timeoutMs, 3500, 'provider timeout belongs to internal policy'); assert.strictEqual(openRouter.backgroundInferenceEnabled, undefined); diff --git a/tests/test_party_chat_routing_integration.js b/tests/test_party_chat_routing_integration.js new file mode 100644 index 00000000..1a50c979 --- /dev/null +++ b/tests/test_party_chat_routing_integration.js @@ -0,0 +1,88 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotManager = invoke('GameServer/Bot/BotManager'); +const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); +const BotDialogueArbiter = invoke('GameServer/Bot/AI/BotDialogueArbiter'); +const PartyLLMRouter = invoke('GameServer/Bot/AI/PartyLLMRouter'); +const PartyDialogueState = invoke('GameServer/Bot/AI/PartyDialogueState'); +const World = invoke('GameServer/World/World'); + +function actor(id, name, x) { + return { + fetchId: () => id, + fetchName: () => name, + fetchLocX: () => x, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchDestId: () => 0, + fetchIsOnline: () => true, + isDead: () => false + }; +} + +function session(id, name, x) { + return { accountId: `bot_${id}`, actor: actor(id, name, x), partyCompanion: true }; +} + +async function main() { + const originalSessions = BotManager.sessions; + const originalWorldUser = World.user; + const originalEnabled = BotBrain.isEnabled; + const originalRouterEnabled = PartyLLMRouter.enabled; + const originalRouterRoute = PartyLLMRouter.route; + const originalArbiterRoute = BotDialogueArbiter.route; + const player = { accountId: 'player_party_route', actor: actor(100, 'Slava', 0) }; + const nice = session(1, 'NiceBot', 5000); + const mira = session(2, 'Mira', 7000); + nice.followPlayerSession = player; + mira.followPlayerSession = player; + const routed = []; + let routerCalls = 0; + try { + BotManager.sessions = [nice, mira]; + World.user = { sessions: [player, nice, mira] }; + BotBrain.isEnabled = () => true; + PartyLLMRouter.enabled = () => true; + PartyLLMRouter.route = async (input) => { + routerCalls += 1; + assert.strictEqual(input.candidates.length, 2, 'router must receive only the party roster'); + return { + ok: true, + route: 'bot', + candidate: input.candidates[1], + reason: 'router chose the healer', + intent: 'support', + confidence: 0.95 + }; + }; + BotDialogueArbiter.route = async (input) => { + routed.push(input.botSession.actor.fetchName()); + return { ok: true, started: true }; + }; + + const result = await BotManager.handlePlayerSpeak(player, { + kind: 3, + text: 'who should handle this?' + }); + assert.strictEqual(routerCalls, 1, 'one party message must cause at most one router call'); + assert.deepStrictEqual(routed, ['Mira']); + assert.strictEqual(result.started, true); + } finally { + PartyDialogueState.reset(player); + BotManager.sessions = originalSessions; + World.user = originalWorldUser; + BotBrain.isEnabled = originalEnabled; + PartyLLMRouter.enabled = originalRouterEnabled; + PartyLLMRouter.route = originalRouterRoute; + BotDialogueArbiter.route = originalArbiterRoute; + } + + console.log('Party chat routing integration checks passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_party_dialogue_router.js b/tests/test_party_dialogue_router.js index 4c73c779..a0994e6d 100644 --- a/tests/test_party_dialogue_router.js +++ b/tests/test_party_dialogue_router.js @@ -86,6 +86,20 @@ async function main() { assert.strictEqual(result.reason, 'role_healer'); assert.strictEqual(result.candidate.session, healer); + const arina = session(4, 'Arina'); + const arinor = session(5, 'Arinor'); + arina.followPlayerSession = player; + arinor.followPlayerSession = player; + result = PartyDialogueRouter.select({ + text: 'Arin, regroup.', + playerSession: player, + sessions: [arina, arinor], + kind: 3, + allowSpokespersonFallback: true + }); + assert.strictEqual(result.reason, 'party_spokesperson_ambiguous'); + assert.strictEqual(result.candidate.actor.fetchName(), 'Arina'); + result = PartyDialogueRouter.select({ text: 'party, regroup.', playerSession: player, diff --git a/tests/test_party_llm_router.js b/tests/test_party_llm_router.js new file mode 100644 index 00000000..7e62b364 --- /dev/null +++ b/tests/test_party_llm_router.js @@ -0,0 +1,93 @@ +const assert = require('assert'); + +require('../src/Global'); + +const OpenRouterGateway = invoke('GameServer/Bot/AI/OpenRouterGateway'); +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); +const PartyLLMRouter = invoke('GameServer/Bot/AI/PartyLLMRouter'); + +function response(body) { + return { ok: true, status: 200, json: async () => body }; +} + +function candidate(id, name, role = 'dps') { + return { id, name, role, companion: true, session: { actor: { fetchId: () => id } } }; +} + +async function main() { + const originalConfig = options.default.OpenRouter; + const originalTransport = OpenRouterGateway.setTransport; + const originalWithObservation = LangfuseTracing.withObservation; + const observations = []; + let captured = null; + try { + options.default.OpenRouter = { + enabled: true, + apiKey: 'party-router-test', + model: 'test/main', + partyRouterModel: 'test/router' + }; + OpenRouterGateway.setTransport(async (_url, init) => { + captured = JSON.parse(init.body); + return response({ + choices: [{ message: { content: JSON.stringify({ + route: 'bot', + botId: 2, + intent: 'continue_pull', + confidence: 0.91, + reason: 'recent pull discussion' + }) } }], + usage: { prompt_tokens: 40, completion_tokens: 18, total_tokens: 58, cost: 0.0001 } + }); + }); + LangfuseTracing.withObservation = (name, input, metadata, work) => { + observations.push({ name, input, metadata }); + return Promise.resolve(work(null)); + }; + + const playerSession = { actor: { fetchId: () => 100, fetchName: () => 'Slava' } }; + const candidates = [candidate(1, 'NiceBot', 'tank'), candidate(2, 'Mira', 'healer')]; + const result = await PartyLLMRouter.route({ + text: 'keep it going', + playerSession, + candidates, + selectedBotId: null, + dialogueState: { + inFlightBotId: null, + lastDeliveredBotId: 1, + recentTurns: [{ role: 'bot', botId: 1, text: 'I am pulling.', channel: 'party_chat' }] + } + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.candidate, candidates[1]); + assert.strictEqual(result.route, 'bot'); + assert.strictEqual(observations[0].name, 'party.router.generation'); + assert.strictEqual(captured.model, 'test/router'); + assert.strictEqual(captured.max_completion_tokens, PartyLLMRouter.ROUTER_MAX_TOKENS); + assert.strictEqual(captured.temperature, PartyLLMRouter.ROUTER_TEMPERATURE); + assert.strictEqual(captured.reasoning, undefined); + assert.ok(!JSON.stringify(captured.messages).includes('persona')); + + captured = null; + OpenRouterGateway.setTransport(async () => response({ + choices: [{ message: { content: JSON.stringify({ + route: 'bot', botId: 999, intent: 'bad', confidence: 0.9, reason: 'invalid' + }) } }] + })); + const invalid = await PartyLLMRouter.route({ text: 'who?', playerSession, candidates }); + assert.strictEqual(invalid.ok, false); + assert.strictEqual(invalid.reason, 'invalid_bot_id'); + } finally { + options.default.OpenRouter = originalConfig; + OpenRouterGateway.resetTransport(); + OpenRouterGateway.setTransport = originalTransport; + LangfuseTracing.withObservation = originalWithObservation; + } + + console.log('Party LLM router checks passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From 1bdc783076e643cc436474eb1dc1607de31660a1 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:06:40 -0400 Subject: [PATCH 52/59] Add party routing telemetry and dispatch guardrails --- src/GameServer/Bot/AI/PartyDialogueRouter.js | 38 +++++++++++ src/GameServer/Bot/BotManager.js | 70 ++++++++++++++++++++ tests/test_party_chat_routing_integration.js | 20 ++++++ 3 files changed, 128 insertions(+) diff --git a/src/GameServer/Bot/AI/PartyDialogueRouter.js b/src/GameServer/Bot/AI/PartyDialogueRouter.js index 6d19dadc..85b09c0a 100644 --- a/src/GameServer/Bot/AI/PartyDialogueRouter.js +++ b/src/GameServer/Bot/AI/PartyDialogueRouter.js @@ -4,6 +4,16 @@ const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); const PARTY_CHANNEL_KIND = 3; const ACTIVE_RESPONDER_TTL_MS = 5 * 60 * 1000; const HEARING_RADIUS = 1500; +const routeMetrics = { + messages: 0, + deterministicRoutes: 0, + routerInvocations: 0, + routerBotRoutes: 0, + clarified: 0, + unresolved: 0, + dispatches: 0, + multiDispatchViolations: 0 +}; const ROLE_ALIASES = { tank: ['tank', 'frontline', 'guard'], @@ -89,6 +99,31 @@ function roleAddressMatches(text, candidates) { return { status: matches.length > 1 ? 'ambiguous' : 'none', matches, role: requested }; } +function recordMetric(result, options = {}) { + const source = options.source || 'deterministic'; + const dispatchCount = Math.max(0, Number(options.dispatchCount || 0)); + if (source === 'deterministic') routeMetrics.messages += 1; + if (source === 'deterministic' && result?.candidate) routeMetrics.deterministicRoutes += 1; + if (source === 'llm_router') { + routeMetrics.routerInvocations += 1; + if (result?.route === 'bot' && result?.candidate) routeMetrics.routerBotRoutes += 1; + } + if (result?.route === 'clarify' || result?.reason === 'explicit_ambiguous' || result?.reason === 'role_ambiguous') { + routeMetrics.clarified += 1; + } + if (result?.status === 'none' || result?.reason === 'unresolved' || result?.route === 'none') routeMetrics.unresolved += 1; + routeMetrics.dispatches += dispatchCount; + if (dispatchCount > 1) routeMetrics.multiDispatchViolations += 1; +} + +function metrics() { + return { ...routeMetrics }; +} + +function resetMetrics() { + Object.keys(routeMetrics).forEach((key) => { routeMetrics[key] = 0; }); +} + function buildCandidates({ sessions = [], playerSession, partyChannel = false, hearingRadius = HEARING_RADIUS } = {}) { const player = playerSession?.actor; const configuredPullerId = Number(playerSession?.partyCompanionSettings?.pullerId || 0); @@ -245,6 +280,9 @@ module.exports = { buildCandidates, isGroupAddress, isContinuationMessage, + metrics, + recordMetric, + resetMetrics, roleAddressMatches, select }; diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js index db99f51c..0431f0ae 100644 --- a/src/GameServer/Bot/BotManager.js +++ b/src/GameServer/Bot/BotManager.js @@ -22,6 +22,7 @@ const GoalService = invoke('GameServer/Bot/Goals/GoalService'); const BotConversation = invoke('GameServer/Bot/AI/BotConversation'); const BotAmbientDirector = invoke('GameServer/Bot/AI/BotAmbientDirector'); const BotDialogueArbiter = invoke('GameServer/Bot/AI/BotDialogueArbiter'); +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); const BotSupportPlanner = invoke('GameServer/Bot/AI/BotSupportPlanner'); const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); const BotClassProgression = invoke('GameServer/Bot/BotClassProgression'); @@ -747,6 +748,12 @@ const BotManager = { let deterministicRoute = null; const dialogueState = PartyDialogueState.snapshot(playerSession); const cheapRouterAvailable = llmEnabled && partyChannel && PartyLLMRouter.enabled() && !dialogueState?.routerInFlightAt; + const routeMetadata = { + event: 'party_chat_route', + channel: partyChannel ? 'party_chat' : 'local_chat', + playerId: player.fetchId?.() || null, + sessionId: `party-chat:${player.fetchId?.() || 'unknown'}` + }; if (llmEnabled) { deterministicRoute = PartyDialogueRouter.select({ text: rawText, @@ -767,6 +774,37 @@ const BotManager = { spokespersonId: deterministicRoute.reason === 'party_spokesperson' ? llmResponder.id : undefined }); } + if (partyChannel) { + const addressObservation = LangfuseTracing.startObservation( + 'party.address.resolve', + { message: rawText }, + { ...routeMetadata, status: deterministicRoute.status, reason: deterministicRoute.reason }, + 'span' + ); + addressObservation?.end({ + status: deterministicRoute.status, + reason: deterministicRoute.reason, + candidateId: deterministicRoute.candidate?.id || null, + candidateCount: deterministicRoute.candidates?.length || 0 + }, LangfuseTracing.observationStatus(deterministicRoute)); + PartyDialogueRouter.recordMetric(deterministicRoute, { + source: 'deterministic', + dispatchCount: llmResponder ? 1 : 0 + }); + if (llmResponder) { + const routeObservation = LangfuseTracing.startObservation( + 'party.dialogue.route', + { message: rawText }, + { ...routeMetadata, source: 'deterministic', reason: deterministicRoute.reason }, + 'span' + ); + routeObservation?.end({ + route: 'bot', + candidateId: llmResponder.id, + reason: deterministicRoute.reason + }, LangfuseTracing.observationStatus(deterministicRoute)); + } + } } const dispatchLLM = (session, reason = deterministicRoute?.reason || 'deterministic_route') => { @@ -780,6 +818,14 @@ const BotManager = { spokespersonId: reason === 'party_spokesperson' ? session.actor.fetchId() : undefined }); } + const dispatchObservation = partyChannel + ? LangfuseTracing.startObservation( + 'party.dispatch', + { botId: session.actor.fetchId?.(), message: rawText }, + { ...routeMetadata, botId: session.actor.fetchId?.() || null, reason }, + 'span' + ) + : null; return BotDialogueArbiter.route({ playerSession, botSession: session, @@ -788,10 +834,15 @@ const BotManager = { source: partyChannel ? 'party_chat' : 'local_chat', allowFallback: true }).then((result) => { + dispatchObservation?.end(result, LangfuseTracing.observationStatus(result)); if (result?.started === true || result?.queued === true || result?.delivered === true) return result; PartyDialogueState.clearInFlight(playerSession, session); return result; }).catch((error) => { + dispatchObservation?.end({ ok: false, reason: 'route_error', error: error.message }, { + level: 'ERROR', + statusMessage: error.message + }); PartyDialogueState.clearInFlight(playerSession, session); utils.infoWarn('BotDialogue', 'local LLM chat route failed: %s', error.message); return { ok: false, reason: 'route_error', error: error.message }; @@ -824,9 +875,28 @@ const BotManager = { let reason = routerResult?.route === 'bot' ? 'router_bot' : `router_${routerResult?.route || 'fallback'}`; if (!chosen && ['party', 'clarify'].includes(routerResult?.route)) chosen = fallbackRoute.candidate; if (!chosen && routerResult?.ok !== true) chosen = fallbackRoute.candidate; + PartyDialogueRouter.recordMetric(routerResult, { + source: 'llm_router', + dispatchCount: chosen ? 1 : 0 + }); + const routeObservation = LangfuseTracing.startObservation( + 'party.dialogue.route', + { message: rawText }, + { ...routeMetadata, source: 'llm_router', reason }, + 'span' + ); + routeObservation?.end({ + route: routerResult?.route || 'fallback', + candidateId: chosen?.id || null, + reason: routerResult?.reason || reason + }, LangfuseTracing.observationStatus(routerResult)); return chosen ? dispatchLLM(chosen, reason) : routerResult; }).catch((error) => { PartyDialogueState.clearRouter(playerSession); + PartyDialogueRouter.recordMetric({ route: 'none', reason: 'router_error' }, { + source: 'llm_router', + dispatchCount: fallbackRoute.candidate ? 1 : 0 + }); return fallbackRoute.candidate ? dispatchLLM(fallbackRoute.candidate, 'router_error_fallback') : { ok: false, reason: 'router_error', error: error.message }; diff --git a/tests/test_party_chat_routing_integration.js b/tests/test_party_chat_routing_integration.js index 1a50c979..699c1036 100644 --- a/tests/test_party_chat_routing_integration.js +++ b/tests/test_party_chat_routing_integration.js @@ -7,6 +7,8 @@ const BotBrain = invoke('GameServer/Bot/AI/BotBrain'); const BotDialogueArbiter = invoke('GameServer/Bot/AI/BotDialogueArbiter'); const PartyLLMRouter = invoke('GameServer/Bot/AI/PartyLLMRouter'); const PartyDialogueState = invoke('GameServer/Bot/AI/PartyDialogueState'); +const PartyDialogueRouter = invoke('GameServer/Bot/AI/PartyDialogueRouter'); +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); const World = invoke('GameServer/World/World'); function actor(id, name, x) { @@ -33,6 +35,7 @@ async function main() { const originalRouterEnabled = PartyLLMRouter.enabled; const originalRouterRoute = PartyLLMRouter.route; const originalArbiterRoute = BotDialogueArbiter.route; + const originalStartObservation = LangfuseTracing.startObservation; const player = { accountId: 'player_party_route', actor: actor(100, 'Slava', 0) }; const nice = session(1, 'NiceBot', 5000); const mira = session(2, 'Mira', 7000); @@ -40,7 +43,13 @@ async function main() { mira.followPlayerSession = player; const routed = []; let routerCalls = 0; + const spans = []; try { + PartyDialogueRouter.resetMetrics(); + LangfuseTracing.startObservation = (name) => ({ + end(value, status) { spans.push({ name, value, status }); }, + update() {} + }); BotManager.sessions = [nice, mira]; World.user = { sessions: [player, nice, mira] }; BotBrain.isEnabled = () => true; @@ -69,7 +78,17 @@ async function main() { assert.strictEqual(routerCalls, 1, 'one party message must cause at most one router call'); assert.deepStrictEqual(routed, ['Mira']); assert.strictEqual(result.started, true); + const metrics = PartyDialogueRouter.metrics(); + assert.strictEqual(metrics.messages, 1); + assert.strictEqual(metrics.routerInvocations, 1); + assert.strictEqual(metrics.dispatches, 1); + assert.strictEqual(metrics.multiDispatchViolations, 0); + assert.deepStrictEqual( + spans.map((span) => span.name), + ['party.address.resolve', 'party.dialogue.route', 'party.dispatch'] + ); } finally { + PartyDialogueRouter.resetMetrics(); PartyDialogueState.reset(player); BotManager.sessions = originalSessions; World.user = originalWorldUser; @@ -77,6 +96,7 @@ async function main() { PartyLLMRouter.enabled = originalRouterEnabled; PartyLLMRouter.route = originalRouterRoute; BotDialogueArbiter.route = originalArbiterRoute; + LangfuseTracing.startObservation = originalStartObservation; } console.log('Party chat routing integration checks passed'); From 6978229e6dde91b5f776a2835563fe7817987e3e Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:12:27 -0400 Subject: [PATCH 53/59] Fix party LLM routing and companion errands --- scripts/run-tests.js | 2 + src/GameServer/Bot/AI/BotAgentTools.js | 57 +++++++- src/GameServer/Bot/AI/BotBrain.js | 37 ++++- src/GameServer/Bot/AI/BotBrainContext.js | 4 +- src/GameServer/Bot/AI/BotContextAssembler.js | 8 +- src/GameServer/Bot/AI/BotSupplyErrand.js | 93 +++++++++++++ src/GameServer/Bot/AI/BotToolRegistry.js | 8 +- src/GameServer/Bot/AI/BotTownTravel.js | 10 +- src/GameServer/Bot/AI/OpenRouterGateway.js | 5 + src/GameServer/Bot/AI/PartyAddressResolver.js | 58 ++++++++ .../Bot/AI/PartyCompanionService.js | 102 ++++++++++++++ src/GameServer/Bot/AI/PartyDialogueRouter.js | 59 ++++---- src/GameServer/Bot/AI/PartyLLMRouter.js | 49 +++++-- src/GameServer/Bot/AI/PartyPulling.js | 1 + .../Bot/AI/States/FollowingState.js | 45 ++++++- src/GameServer/Bot/AI/States/ShoppingState.js | 76 +++++++++++ src/GameServer/Bot/BotManager.js | 127 +++++++++++++++++- tests/test_bot_context_assembler.js | 17 +++ tests/test_bot_dialogue_arbiter.js | 6 +- tests/test_bot_tool_authorization.js | 7 +- tests/test_llm_party_regroup.js | 65 +++++++++ tests/test_llm_supply_errand.js | 110 +++++++++++++++ tests/test_party_address_resolver.js | 10 ++ tests/test_party_chat_routing_integration.js | 71 +++++++++- tests/test_party_dialogue_router.js | 47 ++++++- tests/test_party_llm_router.js | 29 +++- 26 files changed, 1032 insertions(+), 71 deletions(-) create mode 100644 src/GameServer/Bot/AI/BotSupplyErrand.js create mode 100644 tests/test_llm_party_regroup.js create mode 100644 tests/test_llm_supply_errand.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 44f5b6d8..0da004c2 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -33,6 +33,8 @@ const tests = [ 'tests/test_bot_tool_authorization.js', 'tests/test_hot_bot_policy_overlay.js', 'tests/test_llm_pull_policy_tools.js', + 'tests/test_llm_party_regroup.js', + 'tests/test_llm_supply_errand.js', 'tests/test_llm_skill_priority_tools.js', 'tests/test_llm_equipment_tools.js', 'tests/test_llm_trade_tools.js', diff --git a/src/GameServer/Bot/AI/BotAgentTools.js b/src/GameServer/Bot/AI/BotAgentTools.js index ed293502..65a8d57d 100644 --- a/src/GameServer/Bot/AI/BotAgentTools.js +++ b/src/GameServer/Bot/AI/BotAgentTools.js @@ -17,10 +17,12 @@ const ACTIONS = [ 'none', 'say', 'follow_player', + 'regroup_party', 'stay_here', 'hunt', 'rest', 'shop', + 'fetch_resources', 'move_to_spot', 'buff_target', 'heal_target', @@ -48,7 +50,7 @@ const ACTIONS = [ ]; const PK_LOCKED_ACTIONS = new Set([ - 'follow_player', 'stay_here', 'hunt', 'rest', 'shop', 'move_to_spot', + 'follow_player', 'regroup_party', 'stay_here', 'hunt', 'rest', 'shop', 'fetch_resources', 'move_to_spot', 'set_pull_policy', 'assign_puller', 'unassign_puller', 'stop_pulling_and_return', 'set_skill_priority', 'clear_skill_priority', 'set_combat_stance', @@ -504,6 +506,24 @@ function stopPullingAndReturn(context) { }; } +function regroupParty(context) { + const leader = partyLeaderFor(context.session); + if (!leader) return { applied: false, reason: 'not_a_party_companion' }; + const result = PartyCompanionService.beginRegroup(leader, { + radius: context.decision.regroupRadius, + requestedBy: context.requestContext?.playerSession?.actor?.fetchId?.() + }); + if (!result.ok) return { applied: false, reason: result.reason }; + return { + applied: true, + reason: 'party_regroup_started', + effect: 'pull_paused_and_party_approaching_compact_formation', + radius: result.radius, + affected: result.affected, + playerVisibleReply: `Regrouping all ${result.affected} companions around you.` + }; +} + function learnedSkill(session, skillId) { const actor = session?.actor; const skill = actor?.skillset?.fetchSkill?.(Number(skillId)) || @@ -605,6 +625,28 @@ function giveResources(context) { }; } +function fetchResources(context) { + const player = context?.requestContext?.playerSession; + const result = invoke('GameServer/Bot/AI/BotSupplyErrand').request( + context.session, + player, + context.decision.supplyItemId, + context.decision.supplyAmount + ); + if (!result.ok) return { applied: false, reason: result.reason, ...result }; + return { + applied: true, + outcome: 'pending', + reason: result.reason, + effect: 'travel_purchase_return_and_native_trade_pending', + itemSelfId: result.itemSelfId, + amount: result.amount, + cost: result.cost, + town: result.town, + playerVisibleReply: `I will buy ${result.amount} new ${result.itemName} in ${result.town}, return, and open trade with exactly that amount.` + }; +} + function offerResources(context) { const itemId = context.decision.tradeItemId || context.decision.itemId; const amount = context.decision.tradeAmount || context.decision.amount; @@ -747,10 +789,12 @@ function registerTools() { none: 'Do nothing when no useful response is needed.', say: 'Send a short in-character reply to the target visible player.', follow_player: 'Persistently approach a visible player until arrival. Real party follow still requires an invite.', + regroup_party: 'Make every current companion approach compact distinct slots around the human leader and pause new pulls until regrouped.', stay_here: 'Hold the current position.', hunt: 'Return to independent hunting.', rest: 'Sit and recover.', shop: 'Go to town for normal restock behavior.', + fetch_resources: 'For a party leader request, buy an exact new quantity of supported shots in town, return, and offer that purchased quantity in native trade.', move_to_spot: 'Move to one of the provided candidate spot ids.', buff_target: 'Apply a supported buff to a visible player if class, MP, and range allow it.', heal_target: 'Heal a visible player if class, MP, and range allow it.', @@ -778,15 +822,17 @@ function registerTools() { }; const controlActions = new Set([ + 'regroup_party', 'set_pull_policy', 'stop_pulling_and_return', 'assign_puller', 'unassign_puller', 'set_skill_priority', 'clear_skill_priority', 'set_combat_stance', 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment', - 'propose_trade', 'give_resources', 'offer_resources', 'update_trade_offer', 'cancel_trade' + 'propose_trade', 'give_resources', 'fetch_resources', 'offer_resources', 'update_trade_offer', 'cancel_trade' ]); const economyActions = new Set([ 'quote_item', 'counter_offer', 'accept_price', 'decline_price', 'open_negotiated_trade' ]); const executors = { + regroup_party: regroupParty, set_pull_policy: applyPullPolicy, stop_pulling_and_return: stopPullingAndReturn, assign_puller: assignPuller, @@ -800,6 +846,7 @@ function registerTools() { list_party_candidates: listPartyCandidates, propose_trade: proposeTrade, give_resources: giveResources, + fetch_resources: fetchResources, offer_resources: offerResources, update_trade_offer: updateTradeOffer, cancel_trade: cancelBotTrade, @@ -828,6 +875,7 @@ function registerTools() { return ['none', 'say'].includes(name); } if (session.plan === 'pk_hunting' && PK_LOCKED_ACTIONS.has(name)) return false; + if (name === 'shop' && session.partyCompanion === true && session.followPlayerSession) return false; if (controlActions.has(name) && !(session.partyCompanion === true && session.followPlayerSession)) return false; if (economyActions.has(name) && !economyActionAvailable(session, name)) return false; if (name === 'propose_trade' && session.activeTrade) return false; @@ -908,6 +956,11 @@ function rejectionReply(result = {}) { case 'tool_unavailable': return 'That action is not available to me right now.'; case 'not_a_party_companion': return 'I can only change hot party policy while I am your companion.'; case 'party_companion_cannot_shop_now': return 'I need to stay with the party; I cannot leave for town right now.'; + case 'unsupported_supply_item': return 'I can currently fetch shop-bought soulshots and spiritshots, but not that item.'; + case 'invalid_supply_amount': return 'Tell me a supply amount between 1 and 5,000.'; + case 'supply_errand_active': return 'I already have a shopping or delivery errand in progress.'; + case 'supply_not_sold_in_town': return 'The nearest town does not sell that supply.'; + case 'not_enough_adena': return 'I do not have enough Adena for that purchase.'; case 'party_companion_stays_with_party': return 'I need to stay with the party instead of leaving for another spot.'; case 'incompatible_item': case 'not_an_upgrade': diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 0210e787..ef429568 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -129,6 +129,12 @@ function schema(allowedActions = BotAgentTools.ACTIONS) { enum: ['', 'might', 'shield', 'haste', 'windwalk'], description: 'Buff type for buff_target, or empty string.' }, + regroupRadius: { + type: 'number', + minimum: 40, + maximum: 150, + description: 'Compact radius around the party leader for regroup_party; use 50 unless the player specifies another value.' + }, pullMode: { type: 'string', enum: ['', 'auto', 'leader', 'bot', 'off'], @@ -145,6 +151,17 @@ function schema(allowedActions = BotAgentTools.ACTIONS) { maximum: 10000, description: 'Bounded quantity for an outbound resource trade line.' }, + supplyItemId: { + type: 'number', + minimum: 0, + description: 'Item template self id from bot.inventory.shots for fetch_resources.' + }, + supplyAmount: { + type: 'number', + minimum: 1, + maximum: 5000, + description: 'Exact new quantity to buy and deliver for fetch_resources.' + }, pullPermission: { type: 'string', enum: ['', 'allow', 'deny'], @@ -226,6 +243,7 @@ function systemPrompt() { 'Never invent a background request, ambient prompt, player intent, or private internal event.', 'A player-facing reply must be grounded in the authoritative bot state and conversation context.', 'follow_player only means approach a visible player unless the bot is already an invited party companion.', + 'For a whole-party request such as everybody come closer or regroup, use regroup_party once. It controls all current companions server-side; never answer as if only this bot moved.', 'For a non-party follow request, say that you are on your way unless the authoritative distance is already near the player; never claim to be beside them before arrival.', 'For buff_target and heal_target, choose a visible player and let the server validate class, learned skill, MP, range, and safety.', 'Do not claim that buffs or heals are ready in a plain chat reply. Use buff_target or heal_target; only the validated server action may confirm a cast.', @@ -238,6 +256,7 @@ function systemPrompt() { 'Ambient mood and intent are server-owned soft context. Treat an active ambient scene as factual only when bot.ambient.scene is present; never start or claim a scene from mood alone.', 'The contextFragments field is bounded and includes recent authoritative events; treat summaries as memory, never as permission to perform an action. Action metadata is authoritative only when serverApplied or actionResult.ok is true.', 'Resource-gift trade tools can open a native window only with the current party leader; give_resources opens the window and displays the requested line in one server action. Negotiated market tools use only the active real player pair. Both reserve safe inventory without mutating it, expose only server-owned bounds, allow at most three negotiation rounds, and release reservations on cancel/expiry. Never claim completion before native player confirmation.', + 'When the party leader explicitly asks the bot to go to town, buy new shots, bring them back, or specifies a quantity to purchase, use fetch_resources with bot.inventory.shots.selfId. This buys the requested new quantity even if the bot already owns some; do not substitute give_resources from existing stock. The server will return and open native trade later, so describe it as pending.', 'For party candidate discovery, use the server-owned party.candidates list in the current payload and answer from it; do not assume a later tool result will be sent back to you in this turn.', 'Never invent unavailable actions, players, items, or spells.' ].join(' '); @@ -822,10 +841,20 @@ const BotBrain = { const finishTurn = () => { BotInferenceBudget.settle(reservation, providerResult?.usage); const finalTelemetry = providerResult?.llmTelemetry || providerResult?.telemetry || {}; + const actionResult = providerResult?.actionResult || null; + const turnOutcome = providerResult?.ok === false + ? providerResult.reason + : actionResult + ? providerResult.toolApplied === false + ? `tool_rejected:${actionResult.reason || 'unknown'}` + : actionResult.outcome === 'pending' + ? `tool_pending:${actionResult.reason || providerResult.action || 'action'}` + : `tool_applied:${actionResult.reason || providerResult.action || 'action'}` + : finalTelemetry.outcome || 'success'; turnPersistence.then(() => BotLLMTurnStore.finish({ turnId, - ok: providerResult?.ok !== false, - outcome: providerResult?.ok === false ? providerResult.reason : finalTelemetry.outcome, + ok: providerResult?.ok !== false && providerResult?.toolApplied !== false, + outcome: turnOutcome, model: finalTelemetry.model || cfg.model, traceId: finalTelemetry.traceId || null, usage: providerResult?.usage || finalTelemetry.usage, @@ -836,7 +865,9 @@ const BotBrain = { traceId: finalTelemetry.traceId || null, observationId: finalTelemetry.observationId || null, finishReason: finalTelemetry.finishReason || null, - status: finalTelemetry.status || null + status: finalTelemetry.status || null, + toolOutcome: actionResult?.outcome || null, + toolReason: actionResult?.reason || null } })).catch(() => {}); session.lastBrainBudget = BotInferenceBudget.status(session); diff --git a/src/GameServer/Bot/AI/BotBrainContext.js b/src/GameServer/Bot/AI/BotBrainContext.js index a9976bef..5d3d69f4 100644 --- a/src/GameServer/Bot/AI/BotBrainContext.js +++ b/src/GameServer/Bot/AI/BotBrainContext.js @@ -34,8 +34,8 @@ function safeNumber(read, fallback = 0) { function textRequestsInventory(text = '') { const lower = String(text || '').toLowerCase(); - return /\b(item|items|inventory|gear|weapon|armor|adena|shot|shots|soulshot|soulshots|spiritshot|spiritshots|trade|loot|give|bring|spare|need|have|sell|buy)\b/.test(lower) || - /(инвент|вещ|шмот|оруж|брон|аден|сос|шоты|шот|трейд|лут|дай|принес|принести|запас|нужн|есть|прод)/.test(lower); + return /\b(item|items|inventory|gear|weapon|armor|adena|shot|shots|soulshot|soulshots|spiritshot|spiritshots|trade|loot|give|bring|spare|need|have|sell|buy|equip|equipped|upgrade)\b/.test(lower) || + /(инвент|вещ|шмот|оруж|брон|аден|сос|шоты|шот|трейд|лут|дай|принес|принести|запас|нужн|есть|прод|экип|надет|улучш)/.test(lower); } function compactTarget(target) { diff --git a/src/GameServer/Bot/AI/BotContextAssembler.js b/src/GameServer/Bot/AI/BotContextAssembler.js index 68b67e4f..afc2731f 100644 --- a/src/GameServer/Bot/AI/BotContextAssembler.js +++ b/src/GameServer/Bot/AI/BotContextAssembler.js @@ -82,7 +82,11 @@ async function assemble(input = {}) { const requestContext = input.requestContext || {}; const budget = Math.max(240, Number(input.budget || DEFAULT_BUDGET)); const hardMaxTokens = Math.max(budget, Number(input.hardMaxTokens || HARD_MAX_TOKENS)); - const itemIntent = BotBrainContext.textRequestsInventory(text); + const conversation = requestContext.conversation || null; + const recentTurns = conversation?.recentTurns || []; + const itemFollowup = /^(?:is (?:it|that)|are (?:they|those)|what about (?:it|that|them)|and (?:it|that|them)|which one)\b/i.test(String(text || '').trim()) && + recentTurns.slice(-4).some((turn) => BotBrainContext.textRequestsInventory(turn?.text)); + const itemIntent = BotBrainContext.textRequestsInventory(text) || itemFollowup; const skillIntent = textWants(text, /\b(skill|skills|heal|buff|haste|shield|might|wind walk|windwalk|spoil|sweep)\b|скилл|хил|баф|хаст|щит|майт|винд|спойл|свип/); let bot; try { @@ -104,7 +108,6 @@ async function assemble(input = {}) { }); } - const conversation = requestContext.conversation || null; const fragments = [ { id: 'conversation_summary', @@ -171,6 +174,7 @@ async function assemble(input = {}) { fragmentCount: selected.length, included: selected.map((fragment) => fragment.id), itemIntent, + itemFollowup, skillIntent, journalCount: journal.length, estimatedTokens: Math.min(hardMaxTokens, serializedCost) diff --git a/src/GameServer/Bot/AI/BotSupplyErrand.js b/src/GameServer/Bot/AI/BotSupplyErrand.js new file mode 100644 index 00000000..5aa564c6 --- /dev/null +++ b/src/GameServer/Bot/AI/BotSupplyErrand.js @@ -0,0 +1,93 @@ +const MarketOpportunity = invoke('GameServer/Bot/Economy/MarketOpportunity'); +const ShotStock = invoke('GameServer/Inventory/ShotStock'); + +const MAX_REQUEST_AMOUNT = 5000; + +function actorAdena(actor) { + return Number(actor?.backpack?.fetchItemFromSelfId?.(57)?.fetchAmount?.() || 0); +} + +function request(session, playerSession, itemSelfId, requestedAmount) { + const bot = session?.actor; + const player = playerSession?.actor; + const selfId = Number(itemSelfId || 0); + const amount = Math.floor(Number(requestedAmount || 0)); + if (!bot || !player || session.partyCompanion !== true || session.followPlayerSession !== playerSession) { + return { ok: false, reason: 'not_a_party_companion' }; + } + if (!ShotStock.kindForSelfId(selfId)) return { ok: false, reason: 'unsupported_supply_item' }; + if (amount < 1 || amount > MAX_REQUEST_AMOUNT) return { ok: false, reason: 'invalid_supply_amount' }; + if (session.companionShopping || session.pendingResourceDelivery || session.activeTrade) { + return { ok: false, reason: 'supply_errand_active' }; + } + + const BotAI = invoke('GameServer/Bot/BotAI'); + const town = BotAI.getClosestTown(bot.fetchLocX(), bot.fetchLocY()); + const offer = MarketOpportunity.npcOffers(selfId, town.name) + .sort((left, right) => Number(left.price) - Number(right.price))[0]; + if (!offer) return { ok: false, reason: 'supply_not_sold_in_town', town: town.name }; + + const cost = Number(offer.price) * amount; + const adena = actorAdena(bot); + if (cost <= 0 || adena < cost) return { ok: false, reason: 'not_enough_adena', cost, adena }; + + session.resumeAfterShopping = { + plan: 'following', + followPlayerSession: playerSession, + partyCompanion: true, + botStay: false, + stayLocation: null + }; + session.companionShopping = { + kind: 'player_resource_purchase', + playerSession, + playerId: player.fetchId(), + itemId: selfId, + itemName: offer.itemName, + amount, + unitPrice: Number(offer.price), + totalCost: cost, + target: { + actorId: null, + name: `${town.name} general shop`, + locX: town.x, + locY: town.y, + locZ: town.z, + town: town.name + } + }; + session.shoppingTarget = session.companionShopping.target; + session.shoppingDoneAnnounced = false; + session.currentTargetId = undefined; + session.botStay = false; + bot.unselect?.(); + bot.automation?.abortAll?.(bot); + + const travel = invoke('GameServer/Bot/AI/BotTownTravel').request( + session, + bot, + BotAI, + null, + { allowCompanion: true, preserveShoppingTarget: true, announce: false } + ); + if (travel === 'deferred') { + invoke('GameServer/Bot/AI/BotTownTravel').clearCombatTrip(session); + session.companionShopping = undefined; + session.shoppingTarget = undefined; + session.resumeAfterShopping = undefined; + return { ok: false, reason: 'unsafe_combat_state' }; + } + return { + ok: true, + outcome: 'pending', + reason: 'supply_errand_started', + itemSelfId: selfId, + itemName: offer.itemName, + amount, + cost, + town: town.name, + travel + }; +} + +module.exports = { MAX_REQUEST_AMOUNT, request }; diff --git a/src/GameServer/Bot/AI/BotToolRegistry.js b/src/GameServer/Bot/AI/BotToolRegistry.js index 102af5a6..1bd04eee 100644 --- a/src/GameServer/Bot/AI/BotToolRegistry.js +++ b/src/GameServer/Bot/AI/BotToolRegistry.js @@ -4,7 +4,9 @@ const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); const definitions = new Map(); const SOFT_FRESHNESS_ACTIONS = new Set([ - 'none', 'say', 'follow_player', 'hunt', 'rest' + 'none', 'say', 'follow_player', 'regroup_party', 'stay_here', 'hunt', 'rest', + 'set_pull_policy', 'stop_pulling_and_return', 'assign_puller', 'unassign_puller', + 'set_combat_stance' ]); function text(value, max = 160) { @@ -65,11 +67,11 @@ function worldRevision(session) { function isPkLocked(session, action) { return session?.plan === 'pk_hunting' && new Set([ - 'follow_player', 'stay_here', 'hunt', 'rest', 'shop', 'move_to_spot', + 'follow_player', 'regroup_party', 'stay_here', 'hunt', 'rest', 'shop', 'move_to_spot', 'set_pull_policy', 'assign_puller', 'unassign_puller', 'set_skill_priority', 'clear_skill_priority', 'set_combat_stance', 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment', 'list_party_candidates', - 'propose_trade', 'give_resources', 'offer_resources', 'update_trade_offer', 'cancel_trade', + 'propose_trade', 'give_resources', 'fetch_resources', 'offer_resources', 'update_trade_offer', 'cancel_trade', 'quote_item', 'counter_offer', 'accept_price', 'decline_price', 'open_negotiated_trade' ]).has(action); } diff --git a/src/GameServer/Bot/AI/BotTownTravel.js b/src/GameServer/Bot/AI/BotTownTravel.js index ba7d0a99..3356da4d 100644 --- a/src/GameServer/Bot/AI/BotTownTravel.js +++ b/src/GameServer/Bot/AI/BotTownTravel.js @@ -72,8 +72,8 @@ function beginEscape(session, bot, town) { }, SOE_CAST_MS); } -function request(session, bot, BotAI, reason) { - if (session.partyCompanion === true && session.followPlayerSession) return 'companion'; +function request(session, bot, BotAI, reason, options = {}) { + if (session.partyCompanion === true && session.followPlayerSession && options.allowCompanion !== true) return 'companion'; const pending = session.pendingTownTrip || {}; session.pendingTownTrip = { reason: reason || pending.reason || null, requestedAt: pending.requestedAt || Date.now() }; @@ -83,8 +83,10 @@ function request(session, bot, BotAI, reason) { session.preShopLocation = { locX: bot.fetchLocX(), locY: bot.fetchLocY(), locZ: bot.fetchLocZ() }; session.plan = 'shopping'; session.shopTimer = Date.now(); - session.shoppingTarget = undefined; - BotAI.say(session, session.pendingTownTrip.reason || `Heading to ${town.name} to sell and restock.`); + if (options.preserveShoppingTarget !== true) session.shoppingTarget = undefined; + if (options.announce !== false) { + BotAI.say(session, session.pendingTownTrip.reason || `Heading to ${town.name} to sell and restock.`); + } session.pendingTownTrip = undefined; if (distance2d(bot, town) > SOE_DISTANCE) { diff --git a/src/GameServer/Bot/AI/OpenRouterGateway.js b/src/GameServer/Bot/AI/OpenRouterGateway.js index d56e1ddd..d59b031a 100644 --- a/src/GameServer/Bot/AI/OpenRouterGateway.js +++ b/src/GameServer/Bot/AI/OpenRouterGateway.js @@ -2,6 +2,7 @@ const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); const REASONING_EFFORTS = new Set(['off', 'low', 'medium', 'high']); const LUNA_MODEL = 'openai/gpt-5.6-luna'; +const GPT_OSS_120B_MODEL = 'openai/gpt-oss-120b'; const MODEL_PROFILES = Object.freeze({ [LUNA_MODEL]: Object.freeze({ supportsTemperature: false, @@ -12,6 +13,10 @@ const MODEL_PROFILES = Object.freeze({ sort: 'price', allow_fallbacks: false }) + }), + [GPT_OSS_120B_MODEL]: Object.freeze({ + supportsTemperature: true, + completionLimitParam: 'max_tokens' }) }); diff --git a/src/GameServer/Bot/AI/PartyAddressResolver.js b/src/GameServer/Bot/AI/PartyAddressResolver.js index 10eeff88..80e4c2b2 100644 --- a/src/GameServer/Bot/AI/PartyAddressResolver.js +++ b/src/GameServer/Bot/AI/PartyAddressResolver.js @@ -57,6 +57,32 @@ function isVocativeToken(tokens, index, source) { return /^[\s]*[,!:]/.test(after); } +function editDistanceAtMostOne(left, right) { + if (left === right) return true; + if (Math.abs(left.length - right.length) > 1) return false; + + let leftIndex = 0; + let rightIndex = 0; + let edits = 0; + while (leftIndex < left.length && rightIndex < right.length) { + if (left[leftIndex] === right[rightIndex]) { + leftIndex += 1; + rightIndex += 1; + continue; + } + edits += 1; + if (edits > 1) return false; + if (left.length > right.length) leftIndex += 1; + else if (right.length > left.length) rightIndex += 1; + else { + leftIndex += 1; + rightIndex += 1; + } + } + if (leftIndex < left.length || rightIndex < right.length) edits += 1; + return edits <= 1; +} + function resolve(text, candidates = [], options = {}) { const source = String(text || ''); const tokens = textTokens(source); @@ -127,11 +153,43 @@ function resolve(text, candidates = [], options = {}) { }; } + // Party chat commonly contains a one-character typo in a name. Only accept + // it in a vocative position and only when it identifies one roster member; + // ordinary words elsewhere in the sentence must never become addresses. + const fuzzyMatches = []; + tokens.forEach((token, index) => { + if (token.value.length < minPrefixLength || !isVocativeToken(tokens, index, source)) return; + normalizedCandidates.forEach((entry) => { + if (entry.variants.some((variant) => editDistanceAtMostOne(token.value, variant.split(/\s+/g)[0]))) { + if (!fuzzyMatches.some((match) => match.id === entry.id)) fuzzyMatches.push(entry); + } + }); + }); + if (fuzzyMatches.length === 1) { + return { + status: 'matched', + candidate: fuzzyMatches[0].candidate, + matches: [fuzzyMatches[0].candidate], + alias: fuzzyMatches[0].name, + matchType: 'fuzzy_name' + }; + } + if (fuzzyMatches.length > 1) { + return { + status: 'ambiguous', + candidate: null, + matches: fuzzyMatches.map((entry) => entry.candidate), + alias: null, + matchType: 'ambiguous_fuzzy_name' + }; + } + return { status: 'none', candidate: null, matches: [], alias: null, matchType: null }; } module.exports = { MIN_PREFIX_LENGTH, + editDistanceAtMostOne, nameVariants, resolve }; diff --git a/src/GameServer/Bot/AI/PartyCompanionService.js b/src/GameServer/Bot/AI/PartyCompanionService.js index b3e7bdb5..428bfbe7 100644 --- a/src/GameServer/Bot/AI/PartyCompanionService.js +++ b/src/GameServer/Bot/AI/PartyCompanionService.js @@ -23,6 +23,10 @@ const MAX_PARTY_MEMBERS = 9; const MAX_COMPANIONS = MAX_PARTY_MEMBERS - 1; const PARTY_POSITION_UPDATE_DISTANCE = 150; const PARTY_MEMBER_UPDATE_INTERVAL_MS = 1000; +const DEFAULT_REGROUP_RADIUS = 50; +const MIN_REGROUP_RADIUS = 40; +const MAX_REGROUP_RADIUS = 150; +const REGROUP_TTL_MS = 20000; const FORMATION_OFFSETS = [ { locX: -90, locY: -70 }, { locX: -90, locY: 70 }, @@ -488,6 +492,21 @@ function formationTargetFor(companionSession) { if (!leader) return null; const slot = formationSlotFor(companionSession); + const regroup = regroupDirective(companionSession.followPlayerSession); + if (regroup && regroup.memberIds.includes(Number(companionSession.actor?.fetchId?.()))) { + const members = membersForLeader(companionSession.followPlayerSession) + .filter((member) => regroup.memberIds.includes(Number(member.actor?.fetchId?.()))); + const count = Math.max(1, members.length); + const angle = ((Math.PI * 2) * slot.index / count) + + ((Number(leader.fetchHead?.() || 0) / 65536) * Math.PI * 2); + return { + locX: Math.round(leader.fetchLocX() + Math.cos(angle) * regroup.radius), + locY: Math.round(leader.fetchLocY() + Math.sin(angle) * regroup.radius), + locZ: leader.fetchLocZ(), + slot: slot.index, + regroup: true + }; + } // C4 heading is a 16-bit turn where zero faces +X. Formation offsets are // authored in leader-local space, so the group stays behind/beside the // leader as they change direction instead of forming against world north. @@ -504,6 +523,85 @@ function formationTargetFor(companionSession) { }; } +function regroupDirective(leaderSession, now = Date.now()) { + const directive = leaderSession?.partyRegroupDirective; + if (!directive) return null; + if (Number(directive.expiresAt || 0) <= now) { + delete leaderSession.partyRegroupDirective; + return null; + } + return directive; +} + +function regroupActive(leaderSession, now = Date.now()) { + const directive = regroupDirective(leaderSession, now); + if (!directive?.active) return false; + const members = membersForLeader(leaderSession) + .filter((member) => directive.memberIds.includes(Number(member.actor?.fetchId?.()))); + const complete = members.length === 0 || members.every((member) => { + const target = formationTargetFor(member); + return target && distance2d(member.actor, { + fetchLocX: () => target.locX, + fetchLocY: () => target.locY + }) <= 55; + }); + if (complete) { + delete leaderSession.partyRegroupDirective; + return false; + } + return true; +} + +function beginRegroup(leaderSession, options = {}) { + const leader = leaderSession?.actor; + if (!leader) return { ok: false, reason: 'missing_party_leader' }; + const allMembers = membersForLeader(leaderSession); + const members = allMembers.filter((member) => !['shopping', 'getting_buffed', 'merchant'].includes(member.plan)); + if (members.length === 0) return { ok: false, reason: 'no_party_companions' }; + const requestedRadius = Number(options.radius); + const radius = Math.max(MIN_REGROUP_RADIUS, Math.min( + MAX_REGROUP_RADIUS, + Number.isFinite(requestedRadius) ? Math.round(requestedRadius) : DEFAULT_REGROUP_RADIUS + )); + + leaderSession.partyRegroupDirective = { + active: true, + radius, + memberIds: members.map((member) => Number(member.actor.fetchId())), + startedAt: Date.now(), + expiresAt: Date.now() + REGROUP_TTL_MS, + requestedBy: options.requestedBy || leader.fetchId?.() || null + }; + // Cancel the current delivery, not the configured pull policy. Once the + // compact formation is reached (or expires), normal pulling may resume. + leaderSession.partyPullState = {}; + members.forEach((member) => { + cancelCompanionAction(member); + member.botStay = false; + member.stayLocation = null; + member.plan = 'following'; + member.currentTargetId = undefined; + member.lastFollowMoveTarget = null; + member.actor?.unselect?.(); + Promise.resolve(BotEventJournal.record({ + playerId: leader.fetchId?.(), + botId: member.actor?.fetchId?.(), + eventType: 'party_regroup', + summary: `${leader.fetchName?.() || 'Leader'} called the party into a compact formation.`, + weight: 3, + dedupeKey: `regroup:${leader.fetchId?.()}:${leaderSession.partyRegroupDirective.startedAt}`, + meta: { radius, affected: members.length } + })).catch(() => {}); + }); + return { + ok: true, + radius, + affected: members.length, + deferred: allMembers.length - members.length, + expiresAt: leaderSession.partyRegroupDirective.expiresAt + }; +} + function partyActorsForLeader(leaderSession) { return [leaderSession?.actor, ...membersForLeader(leaderSession).map((memberSession) => memberSession.actor)] .filter((actor) => actor?.fetchIsOnline?.() !== false); @@ -643,6 +741,10 @@ const PartyCompanionService = { formationTargetFor, + beginRegroup, + + regroupActive, + sendPartyPositions, updatePosition(session, actor) { diff --git a/src/GameServer/Bot/AI/PartyDialogueRouter.js b/src/GameServer/Bot/AI/PartyDialogueRouter.js index 85b09c0a..e3ab8a4b 100644 --- a/src/GameServer/Bot/AI/PartyDialogueRouter.js +++ b/src/GameServer/Bot/AI/PartyDialogueRouter.js @@ -60,7 +60,7 @@ function isGroupAddress(text) { function isContinuationMessage(text) { const value = String(text || '').trim().toLowerCase(); - return /^(?:yes|yeah|yep|sure|no|nope|ok|okay|alright|right|exactly|do it|go ahead|continue|sounds good|got it|thanks|thank you|and then|what about)\b/.test(value); + return /^(?:yes|yeah|yep|sure|no|nope|ok|okay|alright|right|exactly|do it|go ahead|continue|sounds good|got it|thanks|thank you|and then|what about|is (?:it|that)|are (?:they|those)|(?:what|which) (?:weapon|armor|item|skill|buff|price)|but\b|and\b)/.test(value); } function roleFor(session, actor) { @@ -82,10 +82,17 @@ function isPuller(session, role) { role === 'puller'; } +function roleAliasIsAddressed(text, alias) { + const value = String(text || '').trim(); + const escaped = String(alias).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const atStart = new RegExp(`^(?:(?:hey|hi|yo|ok|okay|please)\\s+)?${escaped}\\b`, 'i'); + const delimited = new RegExp(`(?:^|[,!:]\\s*)${escaped}\\s*[,!:]`, 'i'); + return atStart.test(value) || delimited.test(value); +} + function roleAddressMatches(text, candidates) { - const value = String(text || '').toLowerCase(); const requestedRoles = Object.entries(ROLE_ALIASES) - .filter(([, aliases]) => aliases.some((alias) => new RegExp(`\\b${alias}\\b`, 'i').test(value))) + .filter(([, aliases]) => aliases.some((alias) => roleAliasIsAddressed(text, alias))) .map(([role]) => role); if (requestedRoles.length !== 1) return { status: requestedRoles.length > 1 ? 'ambiguous' : 'none', matches: [] }; @@ -208,9 +215,25 @@ function select({ }; } - const selected = candidates.find((candidate) => candidate.selected); - if (selected) { - return { candidate: selected, candidates, status: 'matched', reason: 'selected', matchType: null }; + const roleOwner = roleAddressMatches(text, candidates); + if (roleOwner.status === 'matched') { + return { + candidate: roleOwner.matches[0], + candidates, + status: 'matched', + reason: `role_${roleOwner.role}`, + matchType: 'role' + }; + } + if (roleOwner.status === 'ambiguous') { + return { + candidate: null, + candidates, + status: 'ambiguous', + reason: 'role_ambiguous', + matchType: 'role', + matches: roleOwner.matches + }; } const pending = candidates.find((candidate) => candidate.pendingInteraction); @@ -229,32 +252,16 @@ function select({ const activeAge = Number.isFinite(Number(previousAt)) ? now - Number(previousAt) : Infinity; - const active = (partyChannel || isContinuationMessage(text)) && activeAge >= 0 && activeAge <= ACTIVE_RESPONDER_TTL_MS + const active = isContinuationMessage(text) && activeAge >= 0 && activeAge <= ACTIVE_RESPONDER_TTL_MS ? findById(candidates, previousId) : null; if (active) { return { candidate: active, candidates, status: 'matched', reason: 'active_responder', matchType: null }; } - const roleOwner = roleAddressMatches(text, candidates); - if (roleOwner.status === 'matched') { - return { - candidate: roleOwner.matches[0], - candidates, - status: 'matched', - reason: `role_${roleOwner.role}`, - matchType: 'role' - }; - } - if (roleOwner.status === 'ambiguous') { - return { - candidate: null, - candidates, - status: 'ambiguous', - reason: 'role_ambiguous', - matchType: 'role', - matches: roleOwner.matches - }; + const selected = candidates.find((candidate) => candidate.selected); + if (selected) { + return { candidate: selected, candidates, status: 'matched', reason: 'selected', matchType: null }; } const spokesperson = findById(candidates, state.spokespersonId) || candidates.find((candidate) => candidate.companion); diff --git a/src/GameServer/Bot/AI/PartyLLMRouter.js b/src/GameServer/Bot/AI/PartyLLMRouter.js index 52c0219b..a93bc4b9 100644 --- a/src/GameServer/Bot/AI/PartyLLMRouter.js +++ b/src/GameServer/Bot/AI/PartyLLMRouter.js @@ -18,7 +18,7 @@ const ROUTER_SCHEMA = { }; const ROUTER_TEMPERATURE = 0.1; -const ROUTER_MAX_TOKENS = 96; +const ROUTER_MAX_TOKENS = 512; function config() { const gateway = OpenRouterGateway.config(); @@ -37,9 +37,13 @@ function playerId(session) { return session?.actor?.fetchId?.() || null; } +function partyTurns(state) { + return (state?.recentTurns || []).filter((turn) => turn.channel === 'party_chat'); +} + function candidateCard(candidate, state) { const id = candidate?.id ?? null; - const lastTurn = [...(state?.recentTurns || [])] + const lastTurn = [...partyTurns(state)] .reverse() .find((turn) => turn.role === 'bot' && String(turn.botId) === String(id)); return { @@ -52,13 +56,20 @@ function candidateCard(candidate, state) { } function normalizeData(data, candidates) { - const route = ['bot', 'party', 'clarify', 'none'].includes(data?.route) ? data.route : 'none'; + let route = ['bot', 'party', 'clarify', 'none'].includes(data?.route) ? data.route : 'none'; + const reason = String(data?.reason || 'router_decision').slice(0, 240); + const ambiguitySignal = !/\bnot ambiguous\b/i.test(reason) && + /\b(?:ambiguous|ambiguity|clarif(?:y|ication)|specify|which (?:bot|party member)|addressee)\b/i.test(reason); + // Small routers occasionally emit route=none while explicitly explaining + // that the addressee is ambiguous. Preserve the semantic decision rather + // than silently dropping a turn that the model itself says needs clarity. + if (route === 'none' && ambiguitySignal) route = 'clarify'; const requestedId = data?.botId === null || data?.botId === undefined ? null : Number(data.botId); const candidate = route === 'bot' ? candidates.find((entry) => Number(entry.id) === requestedId) || null : null; if (route === 'bot' && !candidate) { - return { + const invalid = { ok: false, route: 'clarify', candidate: null, @@ -67,16 +78,28 @@ function normalizeData(data, candidates) { reason: 'invalid_bot_id', data }; + invalid.traceOutput = { ...invalid, candidate: null }; + return invalid; } - return { + const normalized = { ok: true, route, candidate, intent: String(data?.intent || 'conversation').slice(0, 80), confidence: Math.max(0, Math.min(1, Number(data?.confidence || 0))), - reason: String(data?.reason || 'router_decision').slice(0, 240), + reason, data }; + normalized.traceOutput = { + ok: normalized.ok, + route: normalized.route, + candidateId: candidate?.id ?? null, + intent: normalized.intent, + confidence: normalized.confidence, + reason: normalized.reason, + data: normalized.data + }; + return normalized; } function prompt(input, cards) { @@ -86,7 +109,7 @@ function prompt(input, cards) { selectedBotId: input.selectedBotId ?? null, inFlightBotId: input.dialogueState?.inFlightBotId ?? null, lastDeliveredBotId: input.dialogueState?.lastDeliveredBotId ?? null, - recentPartyTurns: (input.dialogueState?.recentTurns || []).slice(-6).map((turn) => ({ + recentPartyTurns: partyTurns(input.dialogueState).slice(-6).map((turn) => ({ role: turn.role, botId: turn.botId, text: String(turn.text || '').slice(0, 160) @@ -114,7 +137,8 @@ async function route(input = {}) { channel: 'party_chat', playerId: botId, candidateCount: candidates.length, - model: cfg.model + model: cfg.model, + sessionId: `party-chat:${botId || 'unknown'}` }; return LangfuseTracing.withObservation( @@ -126,7 +150,7 @@ async function route(input = {}) { config: { ...cfg, model: cfg.model, - reasoningEffort: 'off', + reasoningEffort: 'low', temperature: ROUTER_TEMPERATURE, maxTokens: ROUTER_MAX_TOKENS, timeoutMs: 0 @@ -145,13 +169,16 @@ async function route(input = {}) { 'Choose at most one current candidate bot; never invent IDs.', 'Use route=party only when the message is genuinely for the whole party.', 'Use clarify when a human should clarify an ambiguous addressee.', - 'Use none only when no bot should answer.', + 'Never use none merely because the addressee is ambiguous; use clarify.', + 'Use none only for messages that genuinely need no bot response.', + 'For example, "who should answer this?" is clarify, not none.', 'Return only the required JSON object. Do not call tools and do not roleplay.' ].join(' ') }, { role: 'user', content: JSON.stringify(userPayload) } ], - responseSchema: ROUTER_SCHEMA + responseSchema: ROUTER_SCHEMA, + repairSchema: true }); if (!result?.ok) { diff --git a/src/GameServer/Bot/AI/PartyPulling.js b/src/GameServer/Bot/AI/PartyPulling.js index 591f8ac3..3b92a287 100644 --- a/src/GameServer/Bot/AI/PartyPulling.js +++ b/src/GameServer/Bot/AI/PartyPulling.js @@ -148,6 +148,7 @@ function supportProviders(leaderSession) { function pauseReason(leaderSession, puller) { const state = pullState(leaderSession); + if (invoke('GameServer/Bot/AI/PartyCompanionService').regroupActive(leaderSession)) return 'party_regrouping'; if (leaderSession?.actor?.isDead?.()) return 'party_revival'; const recovery = leaderSession?.partyRecoveryCast; if (Number(recovery?.expiresAt || 0) > Date.now()) return 'party_recharging'; diff --git a/src/GameServer/Bot/AI/States/FollowingState.js b/src/GameServer/Bot/AI/States/FollowingState.js index 2e5de354..b17aa3d1 100644 --- a/src/GameServer/Bot/AI/States/FollowingState.js +++ b/src/GameServer/Bot/AI/States/FollowingState.js @@ -697,7 +697,49 @@ function followTargetFor(session, player) { }; } +function deliverPurchasedResources(session, bot, playerSession) { + const delivery = session.pendingResourceDelivery; + if (!delivery) return false; + if (delivery.playerSession !== playerSession || Number(delivery.playerId) !== Number(playerSession.actor?.fetchId?.())) { + session.pendingResourceDelivery = undefined; + return false; + } + if (point(bot).distance(point(playerSession.actor)) > 350) { + if (!bot.state?.fetchTowards?.()) { + bot.moveTo({ from: loc(bot), to: loc(playerSession.actor) }); + } + recordRoleDecision(session, bot, 'deliver_resources', 'return_to_leader', { targetId: delivery.playerId }); + return true; + } + const trade = invoke('GameServer/Bot/BotTradeService').startBotTradeWithOffer( + session, + playerSession, + delivery.objectId, + delivery.amount + ); + if (!trade.ok) { + if (trade.reason === 'too_far') return true; + BotPartyChat.announce(session, { + priority: 'informational', + key: `resource-delivery-failed:${bot.fetchId()}:${delivery.purchasedAt}`, + templates: [`I brought ${delivery.itemName}, but could not open trade (${trade.reason}).`] + }); + session.pendingResourceDelivery = undefined; + return false; + } + BotPartyChat.announce(session, { + priority: 'informational', + key: `resource-delivery:${bot.fetchId()}:${delivery.purchasedAt}`, + templates: [`I brought ${delivery.amount} ${delivery.itemName}. Please confirm the trade.`] + }); + session.pendingResourceDelivery = undefined; + recordRoleDecision(session, bot, 'deliver_resources', 'native_trade_open', { targetId: delivery.playerId }); + return true; +} + module.exports = { + deliverPurchasedResources, + tick(session, bot, Generics, BotAI) { const playerSession = session.followPlayerSession; if (session.partyCompanion !== true) { @@ -752,6 +794,7 @@ module.exports = { // immediately schedules the prioritized leader resurrection. if (!revival.blockedBy) return; } + if (!player.isDead?.() && deliverPurchasedResources(session, bot, playerSession)) return; const role = BotRoles.inferRole(bot); const distance = point(bot).distance(point(player)); const partySettings = PartyCompanionService.getSettings(playerSession); @@ -1423,7 +1466,7 @@ module.exports = { to: { locX: session.stayLocation.locX, locY: session.stayLocation.locY, locZ: session.stayLocation.locZ } }); } - } else if (distance > FOLLOW_RUN_DISTANCE) { + } else if (distance > FOLLOW_RUN_DISTANCE || PartyCompanionService.regroupActive(playerSession)) { if (impairments.rooted) { recordRoleDecision(session, bot, 'hold_position', 'rooted'); return; diff --git a/src/GameServer/Bot/AI/States/ShoppingState.js b/src/GameServer/Bot/AI/States/ShoppingState.js index 3d6a61f4..a86372ad 100644 --- a/src/GameServer/Bot/AI/States/ShoppingState.js +++ b/src/GameServer/Bot/AI/States/ShoppingState.js @@ -121,6 +121,58 @@ module.exports = { const NpcTalkResponse = invoke(path.world + 'NpcTalkResponse'); const companionErrand = session.companionShopping; + if (companionErrand?.kind === 'player_resource_purchase') { + const current = bot.backpack?.fetchItemFromSelfId?.(companionErrand.itemId); + const currentAmount = Number(current?.fetchAmount?.() || 0); + const plan = { + kind: ShotStock.kindForSelfId(companionErrand.itemId), + selfId: Number(companionErrand.itemId), + name: companionErrand.itemName, + price: Number(companionErrand.unitPrice) + }; + try { + const purchased = await ShotStock.purchaseActorRestock(bot, { + plan, + targetAmount: currentAmount + Number(companionErrand.amount) + }); + if (!purchased.ok || Number(purchased.delta) !== Number(companionErrand.amount)) { + throw new Error(purchased.reason || 'purchase_delta_mismatch'); + } + const purchasedItem = bot.backpack.fetchItemFromSelfId(companionErrand.itemId); + session.pendingResourceDelivery = { + playerSession: companionErrand.playerSession, + playerId: companionErrand.playerId, + objectId: purchasedItem.fetchId(), + itemSelfId: Number(companionErrand.itemId), + itemName: companionErrand.itemName, + amount: Number(companionErrand.amount), + purchasedAt: Date.now() + }; + session.lastTradeSummary = `bought ${purchased.delta}x ${companionErrand.itemName} for ${formatAdena(purchased.cost)}a to deliver to ${companionErrand.playerSession?.actor?.fetchName?.() || 'the leader'}`; + BotAI.say(session, `Bought ${purchased.delta}x ${companionErrand.itemName}. Returning with them now.`); + Promise.resolve(BotEventJournal.record({ + playerId: companionErrand.playerId, + botId: bot.fetchId(), + eventType: 'resource_purchase', + summary: `${bot.fetchName()} bought ${purchased.delta} ${companionErrand.itemName} to deliver to the party leader.`, + weight: 4, + dedupeKey: `resource_purchase:${bot.fetchId()}:${companionErrand.playerId}:${companionErrand.itemId}:${companionErrand.amount}:${Date.now()}`, + meta: { + itemSelfId: companionErrand.itemId, + amount: purchased.delta, + cost: purchased.cost, + requestedBy: companionErrand.playerId + } + })).catch(() => {}); + } catch (error) { + session.lastTradeSummary = `could not buy ${companionErrand.amount}x ${companionErrand.itemName}`; + BotAI.say(session, 'I could not complete that supply purchase. I am returning now.'); + utils.infoWarn('Shopping', 'requested supply purchase failed for %s: %s', bot.fetchName(), error.message); + } + this.scheduleResourceReturn(session, bot, BotAI); + return; + } + if (companionErrand?.kind === 'market_purchase') { const sellerSession = findStoreSession(companionErrand.target.actorId); const seller = sellerSession?.actor; @@ -290,5 +342,29 @@ module.exports = { }); } }, 9000); + }, + + scheduleResourceReturn(session, bot, BotAI) { + setTimeout(() => { + const resume = session.resumeAfterShopping; + const leaderSession = resume?.followPlayerSession; + session.plan = session.partyCompanion === true && leaderSession?.actor?.fetchIsOnline?.() + ? 'following' + : 'hunting'; + session.shoppingDoneAnnounced = false; + session.shoppingTarget = undefined; + session.companionShopping = undefined; + session.resumeAfterShopping = undefined; + session.preShopLocation = undefined; + if (session.plan === 'following') { + const leader = leaderSession.actor; + bot.moveTo({ + from: { locX: bot.fetchLocX(), locY: bot.fetchLocY(), locZ: bot.fetchLocZ() }, + to: { locX: leader.fetchLocX(), locY: leader.fetchLocY(), locZ: leader.fetchLocZ() } + }); + } else { + session.pendingResourceDelivery = undefined; + } + }, 1000); } }; diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js index 0431f0ae..d19ec552 100644 --- a/src/GameServer/Bot/BotManager.js +++ b/src/GameServer/Bot/BotManager.js @@ -729,6 +729,39 @@ const BotManager = { }, handlePlayerSpeak(playerSession, data) { + const partyChannel = Number(data?.kind) === 3; + const llmEnabled = invoke('GameServer/Bot/AI/BotBrain').isEnabled(); + if (!partyChannel || !llmEnabled) return this.handlePlayerSpeakNow(playerSession, data); + + // Route one party-chat turn at a time per player. The main bot request + // may continue asynchronously, but its owner is established before the + // next ingress turn is resolved, so rapid continuations cannot overtake + // an outstanding party-router decision or escape to the spokesperson. + const previous = playerSession.partyDialogueIngressPromise || Promise.resolve(); + const run = Promise.resolve(previous) + .catch(() => {}) + .then(() => this.handlePlayerSpeakNow(playerSession, data)); + let tracked; + const clear = () => { + if (playerSession.partyDialogueIngressPromise === tracked) { + delete playerSession.partyDialogueIngressPromise; + } + }; + tracked = run.then( + (result) => { + clear(); + return result; + }, + (error) => { + clear(); + throw error; + } + ); + playerSession.partyDialogueIngressPromise = tracked; + return tracked; + }, + + handlePlayerSpeakNow(playerSession, data) { const rawText = data.text.trim(); const text = rawText.toLowerCase(); const player = playerSession.actor; @@ -849,6 +882,91 @@ const BotManager = { }); }; + const deliverPartyClarification = async (candidate, matches = []) => { + if (!candidate?.session?.actor) return { ok: false, reason: 'missing_clarifier' }; + const names = [...new Set((matches || []) + .map((match) => match?.name || match?.actor?.fetchName?.() || match?.session?.actor?.fetchName?.()) + .filter(Boolean))]; + const reply = names.length > 1 + ? `Which one do you mean: ${names.join(' or ')}?` + : 'Which party member do you mean?'; + const botSession = candidate.session; + const botId = botSession.actor.fetchId?.() || candidate.id || null; + const observation = LangfuseTracing.startObservation( + 'party.dispatch', + { botId, message: rawText, action: 'clarify' }, + { ...routeMetadata, botId, reason: 'router_clarify', deterministic: true }, + 'span' + ); + + PartyDialogueState.beginRequest(playerSession, botSession, { + reason: 'router_clarify', + text: rawText, + channel: 'party_chat', + spokespersonId: botId + }); + + let turn = null; + let persistenceError = null; + let persisted = false; + try { + const BotConversationService = invoke('GameServer/Bot/AI/BotConversationService'); + turn = await BotConversationService.beginTurn({ + playerSession, + botSession, + text: rawText, + channel: 'party_chat', + source: 'party_router_clarification' + }); + } catch (error) { + persistenceError = error; + } + + try { + this.botTell(botSession, playerSession, reply); + PartyDialogueState.recordDeliveredReply(playerSession, botSession, reply, { + turnId: turn?.turnId || `party-clarify:${botId}:${Date.now()}`, + channel: 'party_chat' + }); + } catch (error) { + PartyDialogueState.clearInFlight(playerSession, botSession); + observation?.end({ ok: false, delivered: false, clarification: true, error: error.message }, { + level: 'ERROR', + statusMessage: error.message + }); + return { ok: false, started: false, delivered: false, clarification: true, reply, error: error.message }; + } + + if (turn) { + const BotConversationService = invoke('GameServer/Bot/AI/BotConversationService'); + try { + persisted = await BotConversationService.recordFallback({ + playerSession, + botSession, + turnId: turn.turnId, + channel: 'party_chat', + text: reply, + reason: 'party_route_clarification' + }); + } catch (error) { + persistenceError = persistenceError || error; + } + } + + const result = { + ok: true, + started: false, + delivered: true, + clarification: true, + reply, + persisted: persisted === true + }; + observation?.end(result, persistenceError + ? { level: 'WARNING', statusMessage: persistenceError.message } + : LangfuseTracing.observationStatus(result)); + return result; + }; + if (llmEnabled && partyChannel && cheapRouterAvailable && !llmResponder && deterministicRoute && ( deterministicRoute.status === 'needs_router' || deterministicRoute.status === 'ambiguous' )) { @@ -890,7 +1008,12 @@ const BotManager = { candidateId: chosen?.id || null, reason: routerResult?.reason || reason }, LangfuseTracing.observationStatus(routerResult)); - return chosen ? dispatchLLM(chosen, reason) : routerResult; + if (routerResult?.route === 'clarify') { + return chosen + ? deliverPartyClarification(chosen, deterministicRoute.matches) + : routerResult; + } + return chosen ? dispatchLLM(chosen.session, reason) : routerResult; }).catch((error) => { PartyDialogueState.clearRouter(playerSession); PartyDialogueRouter.recordMetric({ route: 'none', reason: 'router_error' }, { @@ -898,7 +1021,7 @@ const BotManager = { dispatchCount: fallbackRoute.candidate ? 1 : 0 }); return fallbackRoute.candidate - ? dispatchLLM(fallbackRoute.candidate, 'router_error_fallback') + ? dispatchLLM(fallbackRoute.candidate.session, 'router_error_fallback') : { ok: false, reason: 'router_error', error: error.message }; }); } diff --git a/tests/test_bot_context_assembler.js b/tests/test_bot_context_assembler.js index 1ad31722..d747bf03 100644 --- a/tests/test_bot_context_assembler.js +++ b/tests/test_bot_context_assembler.js @@ -52,6 +52,23 @@ async function main() { requestContext: { playerId: 10 } }); assert.strictEqual(compactOptions.includeInventory, true, 'soulshots/bring must include inventory context'); + const followup = await BotContextAssembler.assemble({ + session: { actor: { fetchId: () => 20 } }, + status: { available: true }, + text: 'Is it better?', + requestContext: { + playerId: 10, + conversation: { + recentTurns: [ + { role: 'player', channel: 'party_chat', text: 'Equip the Tarbar instead of the Bone Staff.' }, + { role: 'bot', channel: 'party_chat', text: 'I equipped it.' } + ] + } + } + }); + assert.strictEqual(followup.telemetry.itemFollowup, true, 'pronoun follow-up should inherit recent equipment context'); + assert.strictEqual(compactOptions.includeEquipment, true); + assert.strictEqual(compactOptions.includeInventory, true); console.log('Bot context assembler checks passed'); } finally { BotBrainContext.compactStatus = originalCompact; diff --git a/tests/test_bot_dialogue_arbiter.js b/tests/test_bot_dialogue_arbiter.js index d0b981d7..49938f20 100644 --- a/tests/test_bot_dialogue_arbiter.js +++ b/tests/test_bot_dialogue_arbiter.js @@ -12,6 +12,7 @@ const BotManager = invoke('GameServer/Bot/BotManager'); const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); const PartyDialogueState = invoke('GameServer/Bot/AI/PartyDialogueState'); +const PartyLLMRouter = invoke('GameServer/Bot/AI/PartyLLMRouter'); function actor(id, name, x = 0) { return { @@ -47,6 +48,7 @@ async function main() { const originalWithRootObservation = LangfuseTracing.withRootObservation; const originalSessions = BotManager.sessions; const originalRoute = BotDialogueArbiter.route; + const originalPartyRouterEnabled = PartyLLMRouter.enabled; const originalWorldUser = invoke('GameServer/World/World').user; const observations = []; @@ -57,6 +59,7 @@ async function main() { const captured = []; BotSocialMemory.recordEvent = () => Promise.resolve(null); BotBrain.isEnabled = () => true; + PartyLLMRouter.enabled = () => false; BotAI.getStatus = () => ({ available: true, mode: 'hunting', level: 10, name: 'Aria' }); BotBrain.maybeThink = (_bot, _event, _status, text, requestContext) => { captured.push({ text, requestContext }); @@ -202,7 +205,7 @@ async function main() { delete player.botDialogueResponderAt; PartyDialogueState.reset(player); routes.length = 0; - BotManager.handlePlayerSpeak(player, { kind: 3, text: 'party, regroup' }); + await BotManager.handlePlayerSpeak(player, { kind: 3, text: 'party, regroup' }); assert.deepStrictEqual(routes, ['Aria'], 'party chat must select one companion responder'); } finally { BotBrain.maybeThink = originalBrain; @@ -215,6 +218,7 @@ async function main() { LangfuseTracing.withRootObservation = originalWithRootObservation; BotManager.sessions = originalSessions; BotDialogueArbiter.route = originalRoute; + PartyLLMRouter.enabled = originalPartyRouterEnabled; invoke('GameServer/World/World').user = originalWorldUser; } diff --git a/tests/test_bot_tool_authorization.js b/tests/test_bot_tool_authorization.js index 085225eb..0847074c 100644 --- a/tests/test_bot_tool_authorization.js +++ b/tests/test_bot_tool_authorization.js @@ -42,7 +42,7 @@ function main() { { applied: false, reason: 'pk_hunting_autonomous' } ); - const session = { accountId: 'bot_stale_registry', plan: 'hunting', actor: actor() }; + const session = { accountId: 'bot_stale_registry', plan: 'hunting', actor: actor(), dataSendToOthers() {} }; const revision = BotAgentTools.worldRevision(session); session.plan = 'resting'; const stale = BotAgentTools.execute( @@ -51,8 +51,9 @@ function main() { [], { worldRevision: revision, conversationTurn: { turnId: 'stale-1' } } ); - assert.deepStrictEqual(stale, { applied: false, reason: 'stale_world_state' }); - assert.strictEqual(session.botStay, undefined, 'stale tool must not mutate session state'); + assert.strictEqual(stale.applied, true, 'leader control commands should tolerate unrelated volatile world changes'); + assert.strictEqual(stale.reason, 'stay_here'); + assert.strictEqual(session.botStay, true); const unknown = BotAgentTools.execute(session, decision('invented_tool', 'unknown-1'), [], null); assert.deepStrictEqual(unknown, { applied: false, reason: 'unknown_tool' }); diff --git a/tests/test_llm_party_regroup.js b/tests/test_llm_party_regroup.js new file mode 100644 index 00000000..abf5fdf6 --- /dev/null +++ b/tests/test_llm_party_regroup.js @@ -0,0 +1,65 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); +const BotManager = invoke('GameServer/Bot/BotManager'); +const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); + +function actor(id, x, y) { + return { + fetchId: () => id, + fetchName: () => `Bot${id}`, + fetchLocX: () => x, + fetchLocY: () => y, + fetchLocZ: () => 0, + fetchHead: () => 0, + fetchIsOnline: () => true, + isDead: () => false, + unselect() {}, + attack: { abortCast() {}, clearTimers() {} }, + state: { setHits() {}, setCasts() {} }, + automation: { abortAll() {} } + }; +} + +async function main() { + const originalSessions = BotManager.sessions; + const leader = { accountId: 'player', actor: actor(100, 0, 0) }; + const first = { accountId: 'bot_1', actor: actor(1, 600, 0), partyCompanion: true, followPlayerSession: leader }; + const second = { accountId: 'bot_2', actor: actor(2, -600, 0), partyCompanion: true, followPlayerSession: leader }; + BotManager.sessions = [first, second]; + try { + leader.partyCompanionSettings = { pullMode: 'bot', pullerId: 1 }; + leader.partyPullState = { phase: 'approach', targetId: 77 }; + const result = BotAgentTools.execute(first, { + action: 'regroup_party', + regroupRadius: 50, + reason: 'leader requested compact formation', + confidence: 1 + }, [], { + playerSession: leader, + requestId: 'regroup-1', + preparedWorldRevision: BotAgentTools.worldRevision(first) + }); + assert.strictEqual(result.applied, true); + assert.strictEqual(result.affected, 2); + assert.deepStrictEqual(leader.partyPullState, {}, 'the current pull should be cancelled'); + assert.strictEqual(leader.partyCompanionSettings.pullMode, 'bot', 'configured pull policy must survive regroup'); + assert.strictEqual(PartyCompanionService.regroupActive(leader), true); + const firstTarget = PartyCompanionService.formationTargetFor(first); + const secondTarget = PartyCompanionService.formationTargetFor(second); + assert.strictEqual(firstTarget.regroup, true); + assert.notDeepStrictEqual(firstTarget, secondTarget, 'companions need distinct compact slots'); + assert(Math.hypot(firstTarget.locX, firstTarget.locY) <= 51); + assert(Math.hypot(secondTarget.locX, secondTarget.locY) <= 51); + } finally { + BotManager.sessions = originalSessions; + } + console.log('LLM party regroup checks passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_llm_supply_errand.js b/tests/test_llm_supply_errand.js new file mode 100644 index 00000000..b685a2bb --- /dev/null +++ b/tests/test_llm_supply_errand.js @@ -0,0 +1,110 @@ +const assert = require('assert'); + +require('../src/Global'); + +const BotSupplyErrand = invoke('GameServer/Bot/AI/BotSupplyErrand'); +const BotTownTravel = invoke('GameServer/Bot/AI/BotTownTravel'); +const MarketOpportunity = invoke('GameServer/Bot/Economy/MarketOpportunity'); +const ShoppingState = invoke('GameServer/Bot/AI/States/ShoppingState'); +const FollowingState = invoke('GameServer/Bot/AI/States/FollowingState'); +const ShotStock = invoke('GameServer/Inventory/ShotStock'); +const BotTradeService = invoke('GameServer/Bot/BotTradeService'); +const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); +const BotPartyChat = invoke('GameServer/Bot/AI/BotPartyChat'); + +function item(id, selfId, amount, name = 'Soulshot: D-grade') { + let count = amount; + return { + fetchId: () => id, + fetchSelfId: () => selfId, + fetchAmount: () => count, + setAmount: (value) => { count = value; }, + fetchName: () => name + }; +} + +async function main() { + const originals = { + npcOffers: MarketOpportunity.npcOffers, + request: BotTownTravel.request, + purchase: ShotStock.purchaseActorRestock, + scheduleReturn: ShoppingState.scheduleResourceReturn, + trade: BotTradeService.startBotTradeWithOffer, + inferRole: BotRoles.inferRole, + announce: BotPartyChat.announce + }; + const adena = item(10, 57, 100000, 'Adena'); + const shots = item(11, 1463, 991); + const bySelfId = new Map([[57, adena], [1463, shots]]); + const player = { + accountId: 'player', + actor: { + fetchId: () => 100, + fetchName: () => 'Slava', + fetchIsOnline: () => true, + fetchLocX: () => 0, + fetchLocY: () => 0, + fetchLocZ: () => 0 + } + }; + const bot = { + fetchId: () => 1, + fetchName: () => 'Caelan', + fetchIsOnline: () => true, + fetchLocX: () => 0, + fetchLocY: () => 0, + fetchLocZ: () => 0, + unselect() {}, + moveTo() {}, + backpack: { fetchItemFromSelfId: (selfId) => bySelfId.get(Number(selfId)) }, + automation: { abortAll() {} }, + state: { fetchTowards: () => false } + }; + const session = { accountId: 'bot_1', actor: bot, partyCompanion: true, followPlayerSession: player }; + try { + MarketOpportunity.npcOffers = () => [{ price: 17, itemName: 'Soulshot: D-grade' }]; + BotTownTravel.request = (_session, _bot, _ai, _reason, options) => { + assert.strictEqual(options.allowCompanion, true); + return 'walk'; + }; + const requested = BotSupplyErrand.request(session, player, 1463, 200); + assert.strictEqual(requested.ok, true); + assert.strictEqual(requested.amount, 200); + assert.strictEqual(session.companionShopping.amount, 200); + + ShotStock.purchaseActorRestock = async (_actor, options) => { + assert.strictEqual(options.targetAmount, 1191, 'purchase target must add the requested amount to existing stock'); + shots.setAmount(options.targetAmount); + return { ok: true, delta: 200, cost: 3400 }; + }; + ShoppingState.scheduleResourceReturn = () => {}; + await ShoppingState.sellAndRestock(session, bot, null, { say() {} }); + assert.strictEqual(session.pendingResourceDelivery.amount, 200); + assert.strictEqual(session.pendingResourceDelivery.objectId, 11); + + let offered = null; + BotTradeService.startBotTradeWithOffer = (_session, _player, objectId, amount) => { + offered = { objectId, amount }; + return { ok: true, line: { objectId, count: amount } }; + }; + BotRoles.inferRole = () => 'dps'; + BotPartyChat.announce = () => true; + assert.strictEqual(FollowingState.deliverPurchasedResources(session, bot, player), true); + assert.deepStrictEqual(offered, { objectId: 11, amount: 200 }); + assert.strictEqual(session.pendingResourceDelivery, undefined); + } finally { + MarketOpportunity.npcOffers = originals.npcOffers; + BotTownTravel.request = originals.request; + ShotStock.purchaseActorRestock = originals.purchase; + ShoppingState.scheduleResourceReturn = originals.scheduleReturn; + BotTradeService.startBotTradeWithOffer = originals.trade; + BotRoles.inferRole = originals.inferRole; + BotPartyChat.announce = originals.announce; + } + console.log('LLM supply errand checks passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_party_address_resolver.js b/tests/test_party_address_resolver.js index 4e010682..02016ec8 100644 --- a/tests/test_party_address_resolver.js +++ b/tests/test_party_address_resolver.js @@ -43,6 +43,16 @@ async function main() { result = PartyAddressResolver.resolve('the weather is nice today', [nice]); assert.strictEqual(result.status, 'none', 'common words must not become a bot address'); + const caelan = candidate(6, 'Caelan'); + result = PartyAddressResolver.resolve('Caelar, open trade.', [caelan, nice]); + assert.strictEqual(result.status, 'matched', 'a unique one-character name typo should still route'); + assert.strictEqual(result.candidate, caelan); + assert.strictEqual(result.matchType, 'fuzzy_name'); + + const emrys = candidate(7, 'Emrys'); + result = PartyAddressResolver.resolve('Emris stop pull and come here', [emrys, nice]); + assert.strictEqual(result.candidate, emrys); + console.log('Party address resolver checks passed'); } diff --git a/tests/test_party_chat_routing_integration.js b/tests/test_party_chat_routing_integration.js index 699c1036..9321d80d 100644 --- a/tests/test_party_chat_routing_integration.js +++ b/tests/test_party_chat_routing_integration.js @@ -25,7 +25,12 @@ function actor(id, name, x) { } function session(id, name, x) { - return { accountId: `bot_${id}`, actor: actor(id, name, x), partyCompanion: true }; + return { + accountId: `bot_${id}`, + actor: actor(id, name, x), + partyCompanion: true, + dataSendToMe() {} + }; } async function main() { @@ -36,7 +41,7 @@ async function main() { const originalRouterRoute = PartyLLMRouter.route; const originalArbiterRoute = BotDialogueArbiter.route; const originalStartObservation = LangfuseTracing.startObservation; - const player = { accountId: 'player_party_route', actor: actor(100, 'Slava', 0) }; + const player = { accountId: 'player_party_route', actor: actor(100, 'Slava', 0), dataSendToMe() {} }; const nice = session(1, 'NiceBot', 5000); const mira = session(2, 'Mira', 7000); nice.followPlayerSession = player; @@ -67,7 +72,7 @@ async function main() { }; }; BotDialogueArbiter.route = async (input) => { - routed.push(input.botSession.actor.fetchName()); + routed.push({ bot: input.botSession.actor.fetchName(), text: input.text }); return { ok: true, started: true }; }; @@ -76,7 +81,7 @@ async function main() { text: 'who should handle this?' }); assert.strictEqual(routerCalls, 1, 'one party message must cause at most one router call'); - assert.deepStrictEqual(routed, ['Mira']); + assert.deepStrictEqual(routed.map((entry) => entry.bot), ['Mira']); assert.strictEqual(result.started, true); const metrics = PartyDialogueRouter.metrics(); assert.strictEqual(metrics.messages, 1); @@ -87,6 +92,64 @@ async function main() { spans.map((span) => span.name), ['party.address.resolve', 'party.dialogue.route', 'party.dispatch'] ); + + PartyDialogueState.reset(player); + routed.length = 0; + routerCalls = 0; + let resolveRouter; + PartyLLMRouter.route = (input) => { + routerCalls += 1; + return new Promise((resolve) => { + resolveRouter = () => resolve({ + ok: true, + route: 'bot', + candidate: input.candidates[1], + reason: 'router chose Mira', + intent: 'conversation', + confidence: 0.9 + }); + }); + }; + + const first = BotManager.handlePlayerSpeak(player, { kind: 3, text: 'who should do it?' }); + for (let attempt = 0; attempt < 20 && !resolveRouter; attempt += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } + assert.ok(resolveRouter, 'the first party turn must reach the LLM router'); + const second = BotManager.handlePlayerSpeak(player, { kind: 3, text: 'yes, do it' }); + assert.deepStrictEqual(routed, [], 'a later turn must not overtake the pending router decision'); + resolveRouter(); + await Promise.all([first, second]); + assert.strictEqual(routerCalls, 1, 'the continuation must reuse the established in-flight owner'); + assert.deepStrictEqual(routed, [ + { bot: 'Mira', text: 'who should do it?' }, + { bot: 'Mira', text: 'yes, do it' } + ]); + + PartyDialogueState.reset(player); + routed.length = 0; + const arina = session(3, 'Arina', 5000); + const arinor = session(4, 'Arinor', 5000); + arina.followPlayerSession = player; + arinor.followPlayerSession = player; + BotManager.sessions = [arina, arinor]; + World.user = { sessions: [player, arina, arinor] }; + PartyLLMRouter.route = async () => ({ + ok: true, + route: 'clarify', + candidate: null, + reason: 'ambiguous name', + intent: 'clarify_addressee', + confidence: 0.95 + }); + const clarification = await BotManager.handlePlayerSpeak(player, { + kind: 3, + text: 'Arin, take pull.' + }); + assert.strictEqual(clarification.clarification, true); + assert.strictEqual(clarification.reply, 'Which one do you mean: Arina or Arinor?'); + assert.deepStrictEqual(routed, [], 'clarification must not enter the tool-capable main BotBrain'); + assert.strictEqual(PartyDialogueState.snapshot(player).recentTurns.at(-1).text, clarification.reply); } finally { PartyDialogueRouter.resetMetrics(); PartyDialogueState.reset(player); diff --git a/tests/test_party_dialogue_router.js b/tests/test_party_dialogue_router.js index a0994e6d..d5b431d0 100644 --- a/tests/test_party_dialogue_router.js +++ b/tests/test_party_dialogue_router.js @@ -81,9 +81,52 @@ async function main() { text: 'healer, keep us alive.', playerSession: player, sessions: [nice, healer], - kind: 3 + kind: 3, + dialogueState: { + lastDeliveredBotId: nice.actor.fetchId(), + lastDeliveredAt: Date.now() + } + }); + assert.strictEqual(result.reason, 'role_healer', 'a textual role address must beat the active responder'); + assert.strictEqual(result.candidate.session, healer); + + result = PartyDialogueRouter.select({ + text: 'we may need a healer, later in the dungeon', + playerSession: player, + sessions: [nice, healer], + kind: 3, + dialogueState: { + lastDeliveredBotId: nice.actor.fetchId(), + lastDeliveredAt: Date.now() + }, + allowSpokespersonFallback: false + }); + assert.strictEqual(result.status, 'needs_router', 'mentioning a role must not be treated as addressing that role'); + + result = PartyDialogueRouter.select({ + text: 'what should we do at the next room?', + playerSession: player, + sessions: [nice, healer], + kind: 3, + dialogueState: { + lastDeliveredBotId: healer.actor.fetchId(), + lastDeliveredAt: Date.now() + }, + allowSpokespersonFallback: false + }); + assert.strictEqual(result.status, 'needs_router', 'fresh party topics must not stay pinned to the active responder'); + + result = PartyDialogueRouter.select({ + text: 'is it better?', + playerSession: player, + sessions: [nice, healer], + kind: 3, + dialogueState: { + lastDeliveredBotId: healer.actor.fetchId(), + lastDeliveredAt: Date.now() + } }); - assert.strictEqual(result.reason, 'role_healer'); + assert.strictEqual(result.reason, 'active_responder', 'a pronoun follow-up must beat a stale client selection'); assert.strictEqual(result.candidate.session, healer); const arina = session(4, 'Arina'); diff --git a/tests/test_party_llm_router.js b/tests/test_party_llm_router.js index 7e62b364..d90fee2d 100644 --- a/tests/test_party_llm_router.js +++ b/tests/test_party_llm_router.js @@ -25,7 +25,7 @@ async function main() { enabled: true, apiKey: 'party-router-test', model: 'test/main', - partyRouterModel: 'test/router' + partyRouterModel: 'openai/gpt-oss-120b' }; OpenRouterGateway.setTransport(async (_url, init) => { captured = JSON.parse(init.body); @@ -55,18 +55,24 @@ async function main() { dialogueState: { inFlightBotId: null, lastDeliveredBotId: 1, - recentTurns: [{ role: 'bot', botId: 1, text: 'I am pulling.', channel: 'party_chat' }] + recentTurns: [ + { role: 'bot', botId: 2, text: 'local-only detail', channel: 'local_chat' }, + { role: 'bot', botId: 1, text: 'I am pulling.', channel: 'party_chat' } + ] } }); assert.strictEqual(result.ok, true); assert.strictEqual(result.candidate, candidates[1]); assert.strictEqual(result.route, 'bot'); assert.strictEqual(observations[0].name, 'party.router.generation'); - assert.strictEqual(captured.model, 'test/router'); - assert.strictEqual(captured.max_completion_tokens, PartyLLMRouter.ROUTER_MAX_TOKENS); + assert.strictEqual(captured.model, 'openai/gpt-oss-120b'); + assert.strictEqual(captured.max_tokens, PartyLLMRouter.ROUTER_MAX_TOKENS); + assert.strictEqual(captured.max_completion_tokens, undefined); assert.strictEqual(captured.temperature, PartyLLMRouter.ROUTER_TEMPERATURE); - assert.strictEqual(captured.reasoning, undefined); + assert.deepStrictEqual(captured.reasoning, { effort: 'low', exclude: true }); + assert.strictEqual(captured.response_format.type, 'json_schema'); assert.ok(!JSON.stringify(captured.messages).includes('persona')); + assert.ok(!JSON.stringify(captured.messages).includes('local-only detail'), 'local chat must not leak into party routing context'); captured = null; OpenRouterGateway.setTransport(async () => response({ @@ -77,6 +83,19 @@ async function main() { const invalid = await PartyLLMRouter.route({ text: 'who?', playerSession, candidates }); assert.strictEqual(invalid.ok, false); assert.strictEqual(invalid.reason, 'invalid_bot_id'); + + OpenRouterGateway.setTransport(async () => response({ + choices: [{ message: { content: JSON.stringify({ + route: 'none', + botId: null, + intent: 'clarify_addressee', + confidence: 0.8, + reason: 'The addressee is ambiguous; the player should specify which bot.' + }) } }] + })); + const selfCorrected = await PartyLLMRouter.route({ text: 'who should answer?', playerSession, candidates }); + assert.strictEqual(selfCorrected.ok, true); + assert.strictEqual(selfCorrected.route, 'clarify', 'self-described ambiguity must not become a silent none route'); } finally { options.default.OpenRouter = originalConfig; OpenRouterGateway.resetTransport(); From 96f51969c8dc6aa73c8d513ef301e8f5bae2d925 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:03:18 -0400 Subject: [PATCH 54/59] Implement hot companion supply and party AI fixes --- config/default.ini | 2 +- src/GameServer/Bot/AI/BotAgentTools.js | 84 +++++++++-- src/GameServer/Bot/AI/BotBrain.js | 66 +++++++-- src/GameServer/Bot/AI/BotBrainContext.js | 18 ++- .../Bot/AI/BotConversationSummarizer.js | 18 ++- src/GameServer/Bot/AI/BotSkillCapabilities.js | 21 ++- src/GameServer/Bot/AI/BotSupplyErrand.js | 134 ++++++++++++++--- src/GameServer/Bot/AI/BotSupportPlanner.js | 7 + src/GameServer/Bot/AI/BotToolRegistry.js | 3 +- src/GameServer/Bot/AI/BotTownTravel.js | 43 +++++- src/GameServer/Bot/AI/HotBotPolicyOverlay.js | 24 +++ src/GameServer/Bot/AI/OpenRouterGateway.js | 2 +- .../Bot/AI/PartyCompanionService.js | 29 ++++ src/GameServer/Bot/AI/PartyDialogueRouter.js | 4 +- .../Bot/AI/States/FollowingState.js | 20 +++ src/GameServer/Bot/AI/States/ShoppingState.js | 48 +++--- src/GameServer/Bot/BotManager.js | 2 +- .../Bot/Economy/MarketOpportunity.js | 137 +++++++++++++++++- .../World/Generics/NpcShopBuyLists.js | 4 + src/GameServer/World/TownRespawn.js | 1 + tests/test_ai_config_surface.js | 4 +- tests/test_llm_supply_errand.js | 63 ++++++-- 22 files changed, 645 insertions(+), 89 deletions(-) diff --git a/config/default.ini b/config/default.ini index 431ab506..ccfee0a3 100644 --- a/config/default.ini +++ b/config/default.ini @@ -28,7 +28,7 @@ showMonsterLevel = true enabled = false apiKey = model = openai/gpt-5.6-luna -partyRouterModel = +partyRouterModel = openai/gpt-5.6-luna temperature = 0.35 reasoningEffort = low maxConcurrentRequests = 32 diff --git a/src/GameServer/Bot/AI/BotAgentTools.js b/src/GameServer/Bot/AI/BotAgentTools.js index 65a8d57d..f79009a5 100644 --- a/src/GameServer/Bot/AI/BotAgentTools.js +++ b/src/GameServer/Bot/AI/BotAgentTools.js @@ -18,6 +18,7 @@ const ACTIONS = [ 'say', 'follow_player', 'regroup_party', + 'stay_party', 'stay_here', 'hunt', 'rest', @@ -25,6 +26,7 @@ const ACTIONS = [ 'fetch_resources', 'move_to_spot', 'buff_target', + 'set_buff_policy', 'heal_target', 'set_pull_policy', 'stop_pulling_and_return', @@ -50,7 +52,7 @@ const ACTIONS = [ ]; const PK_LOCKED_ACTIONS = new Set([ - 'follow_player', 'regroup_party', 'stay_here', 'hunt', 'rest', 'shop', 'fetch_resources', 'move_to_spot', + 'follow_player', 'regroup_party', 'stay_party', 'stay_here', 'hunt', 'rest', 'shop', 'fetch_resources', 'move_to_spot', 'set_pull_policy', 'assign_puller', 'unassign_puller', 'stop_pulling_and_return', 'set_skill_priority', 'clear_skill_priority', 'set_combat_stance', @@ -191,10 +193,14 @@ function distance2d(a, b) { function applyBuffTarget(session, bot, decision, targetSession) { const target = targetSession?.actor; const buffType = String(decision.buffType || '').toLowerCase(); - if (!target || !BotBuffs.SUPPORT_BUFFS[buffType]) return { applied: false, reason: 'invalid_buff_target' }; + if (!target) return { applied: false, reason: 'invalid_buff_target' }; if (!BotRoles.canBuff(bot)) return { applied: false, reason: 'bot_cannot_buff' }; const skill = BotSkillCapabilities.buffSkill(bot, buffType); - if (!skill) return { applied: false, reason: 'buff_not_learned' }; + const semantic = skill?.fetchSemantic?.() || {}; + const targetKind = semantic.target || skill?.fetchTargetKind?.(); + if (!skill || (targetKind && !['friendly', 'ally', 'party'].includes(targetKind))) { + return { applied: false, reason: 'buff_not_learned' }; + } if (bot.fetchMp() < skill.fetchConsumedMp()) return { applied: false, reason: 'low_mp_for_buff' }; if (distance2d(bot, target) > 900) return { applied: false, reason: 'target_too_far' }; @@ -524,6 +530,20 @@ function regroupParty(context) { }; } +function stayParty(context) { + const leader = partyLeaderFor(context.session); + if (!leader) return { applied: false, reason: 'not_a_party_companion' }; + const result = PartyCompanionService.holdParty(leader); + if (!result.ok) return { applied: false, reason: result.reason }; + return { + applied: true, + reason: 'party_hold_started', + effect: 'pull_paused_and_party_holding_current_positions', + affected: result.affected, + playerVisibleReply: `Holding all ${result.affected} companions here.` + }; +} + function learnedSkill(session, skillId) { const actor = session?.actor; const skill = actor?.skillset?.fetchSkill?.(Number(skillId)) || @@ -570,6 +590,27 @@ function setCombatStance(context) { return policyActionResult(context.session, { combatStance: stance }, context, `combat_stance:${stance}`); } +function setBuffPolicy(context) { + const session = context.session; + const requested = String(context.decision.buffPolicyType || '').trim().toLowerCase().replace(/\s+/g, '_'); + const mode = String(context.decision.buffPolicyMode || '').trim().toLowerCase(); + const capability = BotSkillCapabilities.supportBuffs(session.actor) + .find((entry) => entry.type === requested || entry.key === requested || entry.name.toLowerCase() === String(context.decision.buffPolicyType || '').trim().toLowerCase()); + if (!capability) return { applied: false, reason: 'buff_policy_unknown' }; + if (!['allow', 'deny', 'clear'].includes(mode)) return { applied: false, reason: 'invalid_buff_policy' }; + + const current = HotBotPolicyOverlay.get(session)?.buffPolicy || { excluded: [], allowed: [] }; + const excluded = new Set(current.excluded || []); + const allowed = new Set(current.allowed || []); + excluded.delete(capability.type); + allowed.delete(capability.type); + if (mode === 'deny') excluded.add(capability.type); + if (mode === 'allow') allowed.add(capability.type); + return policyActionResult(session, { + buffPolicy: { excluded: [...excluded], allowed: [...allowed] } + }, context, `buff_policy:${mode}:${capability.type}`); +} + function listSafeLoadouts(context) { const loadouts = BotEquipmentUpgrade.listSafeLoadouts(context.session); return { applied: true, reason: 'safe_loadouts', loadouts }; @@ -627,10 +668,14 @@ function giveResources(context) { function fetchResources(context) { const player = context?.requestContext?.playerSession; + const requestedName = context.decision.supplyItemName; + const resolved = !context.decision.supplyItemId && requestedName + ? invoke('GameServer/Bot/Economy/MarketOpportunity').resolveSupplyItem(requestedName) + : null; const result = invoke('GameServer/Bot/AI/BotSupplyErrand').request( context.session, player, - context.decision.supplyItemId, + context.decision.supplyItemId || resolved?.selfId, context.decision.supplyAmount ); if (!result.ok) return { applied: false, reason: result.reason, ...result }; @@ -640,6 +685,7 @@ function fetchResources(context) { reason: result.reason, effect: 'travel_purchase_return_and_native_trade_pending', itemSelfId: result.itemSelfId, + itemName: result.itemName, amount: result.amount, cost: result.cost, town: result.town, @@ -790,13 +836,15 @@ function registerTools() { say: 'Send a short in-character reply to the target visible player.', follow_player: 'Persistently approach a visible player until arrival. Real party follow still requires an invite.', regroup_party: 'Make every current companion approach compact distinct slots around the human leader and pause new pulls until regrouped.', + stay_party: 'Hold every current companion at its current position and pause new pulls until the leader asks the party to follow again.', stay_here: 'Hold the current position.', hunt: 'Return to independent hunting.', rest: 'Sit and recover.', shop: 'Go to town for normal restock behavior.', - fetch_resources: 'For a party leader request, buy an exact new quantity of supported shots in town, return, and offer that purchased quantity in native trade.', + fetch_resources: 'For a party leader request, buy an exact new quantity of an item from the server-owned city shop catalog, return beside the leader, and offer that purchased quantity in native trade.', move_to_spot: 'Move to one of the provided candidate spot ids.', buff_target: 'Apply a supported buff to a visible player if class, MP, and range allow it.', + set_buff_policy: 'Temporarily allow, deny, or clear one learned friendly buff in the support rotation.', heal_target: 'Heal a visible player if class, MP, and range allow it.', set_pull_policy: 'Set the party pull permission and mode for this companion, with a bounded hot-session expiry.', stop_pulling_and_return: 'Disable this companion pull and start returning to the current party leader as one bounded workflow.', @@ -823,6 +871,7 @@ function registerTools() { const controlActions = new Set([ 'regroup_party', + 'stay_party', 'set_buff_policy', 'set_pull_policy', 'stop_pulling_and_return', 'assign_puller', 'unassign_puller', 'set_skill_priority', 'clear_skill_priority', 'set_combat_stance', 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment', @@ -833,6 +882,7 @@ function registerTools() { ]); const executors = { regroup_party: regroupParty, + stay_party: stayParty, set_pull_policy: applyPullPolicy, stop_pulling_and_return: stopPullingAndReturn, assign_puller: assignPuller, @@ -840,6 +890,7 @@ function registerTools() { set_skill_priority: setSkillPriority, clear_skill_priority: clearSkillPriority, set_combat_stance: setCombatStance, + set_buff_policy: setBuffPolicy, list_safe_loadouts: listSafeLoadouts, equip_candidate: equipCandidate, optimize_equipment: optimizeEquipment, @@ -956,16 +1007,31 @@ function rejectionReply(result = {}) { case 'tool_unavailable': return 'That action is not available to me right now.'; case 'not_a_party_companion': return 'I can only change hot party policy while I am your companion.'; case 'party_companion_cannot_shop_now': return 'I need to stay with the party; I cannot leave for town right now.'; - case 'unsupported_supply_item': return 'I can currently fetch shop-bought soulshots and spiritshots, but not that item.'; + case 'unsupported_supply_item': return 'I cannot identify that item in the server shop catalog.'; case 'invalid_supply_amount': return 'Tell me a supply amount between 1 and 5,000.'; + case 'non_stackable_supply_amount': return 'That item is not stackable; request one at a time.'; case 'supply_errand_active': return 'I already have a shopping or delivery errand in progress.'; - case 'supply_not_sold_in_town': return 'The nearest town does not sell that supply.'; - case 'not_enough_adena': return 'I do not have enough Adena for that purchase.'; + case 'supply_not_available': return 'I cannot find a city shop that sells that item right now.'; + case 'supply_destination_missing': return 'I found the item, but not a valid city destination for it.'; + case 'supply_price_invalid': return 'The shop returned an invalid price for that item.'; + case 'not_enough_adena': { + const cost = Number(result.cost || 0); + const adena = Number(result.adena || 0); + const format = (value) => Number.isFinite(value) && value > 0 ? value.toLocaleString('en-US') : null; + const item = result.itemName ? ` for ${result.itemName}` : ''; + const amount = format(cost); + const have = format(adena); + return amount && have + ? `I need ${amount} Adena${item}, but I only have ${have}. Transfer Adena to me and I will retry.` + : `I need more Adena${item}. Transfer it to me and I will retry.`; + } + case 'buff_policy_unknown': return 'I have not learned that buff, so I cannot change its rotation.'; + case 'invalid_buff_policy': return 'Choose allow, deny, or clear for the buff policy.'; case 'party_companion_stays_with_party': return 'I need to stay with the party instead of leaving for another spot.'; case 'incompatible_item': case 'not_an_upgrade': case 'no_safe_upgrade': return 'I could not find a safe equipment upgrade for this situation.'; - case 'unsafe_combat_state': return 'I will finish the current action before changing equipment.'; + case 'unsafe_combat_state': return 'I am in combat. I will finish this fight before starting that request.'; case 'skill_not_learned': case 'skill_not_combat_eligible': case 'skill_incompatible': return 'That skill cannot be used as a combat preference.'; diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index ef429568..56c891b9 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -84,9 +84,19 @@ function visibleRealPlayers(session, bot, cfg = config(), requestContext = null) } function candidateSpots(status) { - if (!status || !status.available || status.mode !== 'hunting') return []; + if (!status || !status.available) return []; - return SpotService.ensureIndexed() + let indexed; + try { + indexed = SpotService.ensureIndexed(); + } catch (_) { + // Lightweight chat fixtures and startup windows may not have the + // world spawn catalog loaded yet. An empty candidate list is safer + // than making the whole dialogue turn fail. + return []; + } + + return indexed .map((spot) => ({ id: spot.id, name: spot.name, @@ -126,8 +136,16 @@ function schema(allowedActions = BotAgentTools.ACTIONS) { }, buffType: { type: 'string', - enum: ['', 'might', 'shield', 'haste', 'windwalk'], - description: 'Buff type for buff_target, or empty string.' + description: 'Exact learned friendly buff effect or name from bot.skills.support.availableBuffs for buff_target.' + }, + buffPolicyType: { + type: 'string', + description: 'Exact learned friendly buff effect or name for set_buff_policy.' + }, + buffPolicyMode: { + type: 'string', + enum: ['', 'allow', 'deny', 'clear'], + description: 'Temporary support rotation policy for set_buff_policy.' }, regroupRadius: { type: 'number', @@ -154,7 +172,11 @@ function schema(allowedActions = BotAgentTools.ACTIONS) { supplyItemId: { type: 'number', minimum: 0, - description: 'Item template self id from bot.inventory.shots for fetch_resources.' + description: 'Exact item template self id from the server-owned supply catalog for fetch_resources.' + }, + supplyItemName: { + type: 'string', + description: 'Exact item name from the player request when the compact catalog does not contain the item; the server resolves it against all NPC-listed items.' }, supplyAmount: { type: 'number', @@ -243,10 +265,11 @@ function systemPrompt() { 'Never invent a background request, ambient prompt, player intent, or private internal event.', 'A player-facing reply must be grounded in the authoritative bot state and conversation context.', 'follow_player only means approach a visible player unless the bot is already an invited party companion.', - 'For a whole-party request such as everybody come closer or regroup, use regroup_party once. It controls all current companions server-side; never answer as if only this bot moved.', + 'For a whole-party request such as everybody come closer or regroup, use regroup_party once. For everyone stay here, use stay_party once. Both control all current companions server-side; never answer as if only this bot moved.', 'For a non-party follow request, say that you are on your way unless the authoritative distance is already near the player; never claim to be beside them before arrival.', 'For buff_target and heal_target, choose a visible player and let the server validate class, learned skill, MP, range, and safety.', 'Do not claim that buffs or heals are ready in a plain chat reply. Use buff_target or heal_target; only the validated server action may confirm a cast.', + 'When the player asks to stop, allow, or exclude one learned buff from the support rotation, use set_buff_policy with the exact effect/name from bot.skills.support.availableBuffs and mode deny, allow, or clear. Do not claim a rotation changed after a plain say.', 'Party pull, skill preference, stance, and equipment tools are temporary hot-session controls. They require the current human party leader; never invent authority.', 'Pull permission, pull mode, and assigned puller are separate. Unassigning one puller returns to the existing automatic policy and does not globally disable pulling.', 'When the player asks to stop pulling and return, prefer stop_pulling_and_return so both server mutations are applied as one bounded workflow.', @@ -256,7 +279,7 @@ function systemPrompt() { 'Ambient mood and intent are server-owned soft context. Treat an active ambient scene as factual only when bot.ambient.scene is present; never start or claim a scene from mood alone.', 'The contextFragments field is bounded and includes recent authoritative events; treat summaries as memory, never as permission to perform an action. Action metadata is authoritative only when serverApplied or actionResult.ok is true.', 'Resource-gift trade tools can open a native window only with the current party leader; give_resources opens the window and displays the requested line in one server action. Negotiated market tools use only the active real player pair. Both reserve safe inventory without mutating it, expose only server-owned bounds, allow at most three negotiation rounds, and release reservations on cancel/expiry. Never claim completion before native player confirmation.', - 'When the party leader explicitly asks the bot to go to town, buy new shots, bring them back, or specifies a quantity to purchase, use fetch_resources with bot.inventory.shots.selfId. This buys the requested new quantity even if the bot already owns some; do not substitute give_resources from existing stock. The server will return and open native trade later, so describe it as pending.', + 'When the party leader explicitly asks the bot to go to town and buy a new item, use fetch_resources with the exact selfId from the compact server-owned supply catalog (entries are [selfId, name, price, town]) and the requested quantity. If the compact catalog does not show the item, pass its exact requested name in supplyItemName; the server resolves it against the full NPC catalog. This buys a new quantity even if the bot already owns some; do not substitute give_resources from existing stock. If the server reports insufficient Adena, say how much is needed and wait for the player to transfer Adena before retrying. The server returns beside the leader and opens native trade only when the party is safe, so describe it as pending.', 'For party candidate discovery, use the server-owned party.candidates list in the current payload and answer from it; do not assume a later tool result will be sent back to you in this turn.', 'Never invent unavailable actions, players, items, or spells.' ].join(' '); @@ -433,11 +456,36 @@ function isPartyCandidateRequest(text) { function isPartyRequest(text) { const value = String(text || ''); if (isPartyCandidateRequest(value)) return false; - return /\b(party|group|team|join|invite)\b|пати|групп|команд|присоедин/i.test(value); + // Membership requests are distinct from ordinary party context such as + // “find a better hunting spot for this party” or “the party is in town”. + // Applying membership policy to those messages silently replaced the LLM + // answer with “I am already with you”. + return /\b(?:join|invite|join\s+(?:our|the)\s+(?:party|group|team)|party\s+up|group\s+up|add\s+me|take\s+me|let\s+me\s+join|(?:wanna|want(?:\s+to)?|can\s+i|could\s+i|need)\s+(?:join|party|group|team))\b|пати\s*(?:вступ|присоедин|инвайт)|присоедин/i.test(value); } function applyPartyPolicy(session, decision, requestContext, text) { - if (!isPartyRequest(text) || !requestContext?.playerSession) return decision; + if (!requestContext?.playerSession) return decision; + const value = String(text || '').toLowerCase(); + const group = /\b(?:everyone|everybody|all|guys|bots|companions|party|team)\b/.test(value); + if (group && /\b(?:stay|wait|hold|stop)\b/.test(value)) { + return { + ...decision, + action: 'stay_party', + reply: String(decision.reply || '').trim() || 'Everyone hold here.', + reason: 'party_policy:whole_party_hold', + confidence: Math.max(0.95, Number(decision.confidence || 0)) + }; + } + if (group && /\b(?:come|closer|regroup|follow)\b/.test(value)) { + return { + ...decision, + action: 'regroup_party', + reply: String(decision.reply || '').trim() || 'Regrouping around you.', + reason: 'party_policy:whole_party_regroup', + confidence: Math.max(0.95, Number(decision.confidence || 0)) + }; + } + if (!isPartyRequest(text)) return decision; if (session.partyCompanion === true && session.followPlayerSession === requestContext.playerSession) { return { ...decision, diff --git a/src/GameServer/Bot/AI/BotBrainContext.js b/src/GameServer/Bot/AI/BotBrainContext.js index 5d3d69f4..37e493de 100644 --- a/src/GameServer/Bot/AI/BotBrainContext.js +++ b/src/GameServer/Bot/AI/BotBrainContext.js @@ -1,6 +1,8 @@ const BotBuffs = invoke('GameServer/Bot/AI/BotBuffs'); const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); const ShotStock = invoke('GameServer/Inventory/ShotStock'); +const BotSkillCapabilities = invoke('GameServer/Bot/AI/BotSkillCapabilities'); +const MarketOpportunity = invoke('GameServer/Bot/Economy/MarketOpportunity'); const SLOT_NAMES = { 1: 'right_ear', @@ -209,6 +211,13 @@ function inventorySnapshot(actor, text = '') { amount: Number(shotItem?.fetchAmount?.() || 0), loaded: shotPlan.kind === 'spiritshot' ? !!actor.spiritshotLoaded : !!actor.soulshotLoaded }, + // The compact catalog keeps common supplies visible without blowing + // the bounded hot-dialogue prompt. The server resolver still accepts + // every NPC-listed item by exact name, even when it is not in this + // compact view. + supplyCatalog: wantsItems + ? MarketOpportunity.supplyCatalog(48).map((entry) => [entry.selfId, entry.name, entry.price, entry.town]) + : null, notable, truncated: notable.length < items.length }; @@ -246,7 +255,14 @@ function skillsSnapshot(actor, text = '') { support: { canHeal: BotRoles.isHealer(actor), canBuff: BotRoles.canBuff(actor), - availableBuffs: Object.keys(BotBuffs.SUPPORT_BUFFS) + // Advertise only buffs backed by a learned, executable friendly + // skill. The old global list made the LLM request Might/Shield on + // classes that only had native chants or resistance buffs. + availableBuffs: BotSkillCapabilities.supportBuffs(actor).map((buff) => ({ + type: buff.type, + name: buff.name, + skillId: buff.skill.fetchSelfId() + })) }, truncated: active.length < skills.filter((skill) => !skill.fetchPassive()).length }; diff --git a/src/GameServer/Bot/AI/BotConversationSummarizer.js b/src/GameServer/Bot/AI/BotConversationSummarizer.js index 3ae04410..0e96a47b 100644 --- a/src/GameServer/Bot/AI/BotConversationSummarizer.js +++ b/src/GameServer/Bot/AI/BotConversationSummarizer.js @@ -88,13 +88,23 @@ function schema() { }; } -function normalizeSummary(data) { +function hasAuthoritativePromise(turns = []) { + return turns.some((turn) => { + const meta = turn?.meta || {}; + const action = String(meta.action || '').toLowerCase(); + if (!action || ['say', 'none'].includes(action)) return false; + if (meta.serverApplied === true) return true; + return meta.actionResult?.outcome === 'pending' || meta.actionResult?.ok === true; + }); +} + +function normalizeSummary(data, turns = []) { if (!data || typeof data !== 'object') return ''; const summary = String(data.summary || '').replace(/\s+/g, ' ').trim(); const openTopics = Array.isArray(data.openTopics) ? data.openTopics.map((value) => String(value || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 4) : []; - const promises = Array.isArray(data.promises) + const promises = hasAuthoritativePromise(turns) && Array.isArray(data.promises) ? data.promises.map((value) => String(value || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 4) : []; if (!summary && openTopics.length === 0 && promises.length === 0) return ''; @@ -146,7 +156,7 @@ async function summarize(input = {}) { const messages = [ { role: 'system', - content: 'Summarize a game conversation for the same bot and player. Keep durable facts, preferences, unresolved requests, and explicit promises. Treat action metadata as authoritative: only an action with serverApplied=true or actionResult.ok=true happened; an LLM proposal, refusal, fallback, or failed action did not happen. A pending action means a server-side request or native window is active, not that the final transfer or effect completed. Never create permissions, tool authorizations, or facts not stated in the dialogue.' + content: 'Summarize a game conversation for the same bot and player. Keep only durable facts, explicit player preferences, unresolved requests, and promises backed by a successful or pending mutating server action. Treat action metadata as authoritative: only an action with serverApplied=true or actionResult.ok=true happened; an LLM proposal, plain say reply, refusal, fallback, or failed action did not happen. A pending action means a server-side request or native window is active, not that the final transfer or effect completed. Do not turn “I will”, “I am going”, “I will check”, transient movement, an unconfirmed cast, a plain acknowledgement, a roleplay sentence, or a guessed name/alias into a durable promise. Never create permissions, tool authorizations, preferences, or facts not stated in the dialogue.' }, { role: 'user', content: JSON.stringify({ previousSummary: current.summary || '', turns: compactTurns(compacted) }) } ]; @@ -185,7 +195,7 @@ async function summarize(input = {}) { return { ok: false, reason }; } - const summary = normalizeSummary(result.data); + const summary = normalizeSummary(result.data, compacted); if (!summary) { recordFailure(key, 'empty_summary'); return { ok: false, reason: 'empty_summary' }; diff --git a/src/GameServer/Bot/AI/BotSkillCapabilities.js b/src/GameServer/Bot/AI/BotSkillCapabilities.js index a3490a9b..4411c23f 100644 --- a/src/GameServer/Bot/AI/BotSkillCapabilities.js +++ b/src/GameServer/Bot/AI/BotSkillCapabilities.js @@ -33,8 +33,24 @@ function healSkill(actor) { } function buffSkill(actor, buffType) { - const buff = BuffCatalog.byTypeOrKey(buffType); - return buff ? learnedSkill(actor, buff.id) : null; + const requested = String(buffType || '').trim().toLowerCase().replace(/\s+/g, '_'); + const buff = BuffCatalog.byTypeOrKey(requested); + const direct = buff ? learnedSkill(actor, buff.id) : null; + if (direct) return direct; + return supportBuffs(actor).find((entry) => entry.type === requested || entry.key === requested || entry.name.toLowerCase() === String(buffType || '').trim().toLowerCase())?.skill || null; +} + +function supportBuffs(actor) { + return activeSkills(actor) + .map((skill) => ({ skill, semantic: skill.fetchSemantic?.() || {} })) + .filter(({ skill, semantic }) => semantic.effectType === 'buff' && ['friendly', 'ally', 'party'].includes(semantic.target || skill.fetchTargetKind?.())) + .map(({ skill, semantic }) => ({ + type: String(semantic.effect || '').toLowerCase(), + key: String(semantic.effect || '').toLowerCase(), + name: skill.model?.name || semantic.effect || `Skill ${skill.fetchSelfId?.()}`, + skill + })) + .filter((entry) => entry.type); } function manaRechargeSkill(actor) { @@ -49,6 +65,7 @@ function manaRechargeSkill(actor) { module.exports = { aggressionSkill: (actor) => learnedSkill(actor, 28), buffSkill, + supportBuffs, healSkill, manaRechargeSkill, learnedSkill diff --git a/src/GameServer/Bot/AI/BotSupplyErrand.js b/src/GameServer/Bot/AI/BotSupplyErrand.js index 5aa564c6..a340e218 100644 --- a/src/GameServer/Bot/AI/BotSupplyErrand.js +++ b/src/GameServer/Bot/AI/BotSupplyErrand.js @@ -1,5 +1,9 @@ +const DataCache = invoke('GameServer/DataCache'); const MarketOpportunity = invoke('GameServer/Bot/Economy/MarketOpportunity'); -const ShotStock = invoke('GameServer/Inventory/ShotStock'); +const TradeService = invoke('GameServer/Bot/TradeService'); +const ServerResponse = invoke('GameServer/Network/Response'); +const TownPathfinder = invoke('GameServer/Bot/AI/TownPathfinder'); +const TownRespawn = invoke('GameServer/World/TownRespawn'); const MAX_REQUEST_AMOUNT = 5000; @@ -7,6 +11,29 @@ function actorAdena(actor) { return Number(actor?.backpack?.fetchItemFromSelfId?.(57)?.fetchAmount?.() || 0); } +function itemTemplate(selfId) { + return (DataCache.items || []).find((item) => Number(item.selfId) === Number(selfId)) || null; +} + +function townDestination(offer, bot, BotAI) { + const town = TownPathfinder.towns?.find((candidate) => candidate.name === offer?.town); + if (town?.center) { + return { name: town.name, x: town.center.locX, y: town.center.locY, z: town.center.locZ }; + } + const respawn = Object.values(TownRespawn.towns || {}).find((candidate) => candidate.name === offer?.town); + if (respawn) return { name: respawn.name, x: respawn.locX, y: respawn.locY, z: respawn.locZ }; + // Never silently replace “the city that sells this” with the nearest + // restart town. An incomplete atlas entry is safer as an explicit + // destination error than a bot arriving at a city without the item. + return null; +} + +function hideForSupply(session, bot) { + if (session.supplyErrandHidden === true) return; + session.supplyErrandHidden = true; + session.dataSendToOthers?.(ServerResponse.deleteOb(bot.fetchId()), bot); +} + function request(session, playerSession, itemSelfId, requestedAmount) { const bot = session?.actor; const player = playerSession?.actor; @@ -15,21 +42,38 @@ function request(session, playerSession, itemSelfId, requestedAmount) { if (!bot || !player || session.partyCompanion !== true || session.followPlayerSession !== playerSession) { return { ok: false, reason: 'not_a_party_companion' }; } - if (!ShotStock.kindForSelfId(selfId)) return { ok: false, reason: 'unsupported_supply_item' }; + if (!itemTemplate(selfId)) return { ok: false, reason: 'unsupported_supply_item' }; if (amount < 1 || amount > MAX_REQUEST_AMOUNT) return { ok: false, reason: 'invalid_supply_amount' }; if (session.companionShopping || session.pendingResourceDelivery || session.activeTrade) { return { ok: false, reason: 'supply_errand_active' }; } - const BotAI = invoke('GameServer/Bot/BotAI'); - const town = BotAI.getClosestTown(bot.fetchLocX(), bot.fetchLocY()); - const offer = MarketOpportunity.npcOffers(selfId, town.name) - .sort((left, right) => Number(left.price) - Number(right.price))[0]; - if (!offer) return { ok: false, reason: 'supply_not_sold_in_town', town: town.name }; + const Market = MarketOpportunity.bestSupplyOffer(selfId); + if (!Market) return { ok: false, reason: 'supply_not_available' }; + + const template = itemTemplate(selfId); + // Unknown metadata is treated as non-stackable. That prevents an armor, + // weapon, or quest object from being requested in a quantity that the + // native inventory path cannot represent. + const stackable = template?.etc?.stackable === true; + if (!stackable && amount !== 1) return { ok: false, reason: 'non_stackable_supply_amount' }; - const cost = Number(offer.price) * amount; + const cost = Number(Market.price) * amount; const adena = actorAdena(bot); - if (cost <= 0 || adena < cost) return { ok: false, reason: 'not_enough_adena', cost, adena }; + if (cost <= 0) return { ok: false, reason: 'supply_price_invalid' }; + if (adena < cost) return { ok: false, reason: 'not_enough_adena', cost, adena, itemName: Market.itemName }; + + const BotAI = invoke('GameServer/Bot/BotAI'); + const town = townDestination(Market, bot, BotAI); + if (!town) return { ok: false, reason: 'supply_destination_missing' }; + + // Do this check before touching the combat target, automation, visibility, + // or shopping state. BotTownTravel performs the same guard, but by that + // point the errand must not have disturbed an active fight. + const TownTravel = invoke('GameServer/Bot/AI/BotTownTravel'); + if (TownTravel.inCombat(session, bot)) { + return { ok: false, reason: 'unsafe_combat_state' }; + } session.resumeAfterShopping = { plan: 'following', @@ -43,13 +87,15 @@ function request(session, playerSession, itemSelfId, requestedAmount) { playerSession, playerId: player.fetchId(), itemId: selfId, - itemName: offer.itemName, + itemName: Market.itemName, amount, - unitPrice: Number(offer.price), + unitPrice: Number(Market.price), totalCost: cost, + sourceType: Market.sourceType, + sourceId: Market.sourceId, target: { - actorId: null, - name: `${town.name} general shop`, + actorId: Market.sourceType === 'private_store' ? Number(Market.sourceId) : null, + name: Market.sourceName || `${town.name} general shop`, locX: town.x, locY: town.y, locZ: town.z, @@ -63,18 +109,30 @@ function request(session, playerSession, itemSelfId, requestedAmount) { bot.unselect?.(); bot.automation?.abortAll?.(bot); - const travel = invoke('GameServer/Bot/AI/BotTownTravel').request( + // Hide the actor before the escape cast. The actual movement and purchase + // remain server-side; only the final return and native trade are visible. + hideForSupply(session, bot); + const travel = TownTravel.request( session, bot, BotAI, null, - { allowCompanion: true, preserveShoppingTarget: true, announce: false } + { + allowCompanion: true, + preserveShoppingTarget: true, + destinationTown: town, + forceScrollOfEscape: true, + announce: false + } ); if (travel === 'deferred') { - invoke('GameServer/Bot/AI/BotTownTravel').clearCombatTrip(session); + TownTravel.clearCombatTrip(session); session.companionShopping = undefined; session.shoppingTarget = undefined; session.resumeAfterShopping = undefined; + session.supplyErrandHidden = false; + session.dataSendToOthers?.(ServerResponse.charInfo(bot), bot); + session.dataSendToOthers?.(ServerResponse.relationChanged(bot), bot); return { ok: false, reason: 'unsafe_combat_state' }; } return { @@ -82,12 +140,52 @@ function request(session, playerSession, itemSelfId, requestedAmount) { outcome: 'pending', reason: 'supply_errand_started', itemSelfId: selfId, - itemName: offer.itemName, + itemName: Market.itemName, amount, cost, town: town.name, + sourceType: Market.sourceType, travel }; } -module.exports = { MAX_REQUEST_AMOUNT, request }; +async function purchaseAtDestination(bot, errand) { + if (!errand || !bot) return { ok: false, reason: 'missing_supply_errand' }; + if (!['npc', 'configured_store'].includes(errand.sourceType)) { + return { ok: false, reason: 'supply_source_unavailable' }; + } + + const store = { + storeType: 1, + items: [{ + selfId: Number(errand.itemId), + price: Number(errand.unitPrice), + count: Number(errand.amount) + }] + }; + try { + const bought = await TradeService.buyFromStore(bot, store, Number(errand.itemId), Number(errand.amount)); + if (Number(bought.qty) !== Number(errand.amount)) { + return { ok: false, reason: 'purchase_quantity_mismatch', bought }; + } + const item = bot.backpack?.fetchItemFromSelfId?.(Number(errand.itemId)); + if (!item) return { ok: false, reason: 'purchase_inventory_sync_failed' }; + return { + ok: true, + delta: Number(bought.qty), + cost: Number(bought.totalAdena), + item + }; + } catch (error) { + const message = String(error?.message || error || 'purchase_failed'); + return { ok: false, reason: /not enough adena/i.test(message) ? 'not_enough_adena' : message }; + } +} + +module.exports = { + MAX_REQUEST_AMOUNT, + actorAdena, + itemTemplate, + purchaseAtDestination, + request +}; diff --git a/src/GameServer/Bot/AI/BotSupportPlanner.js b/src/GameServer/Bot/AI/BotSupportPlanner.js index 72391aa6..15fe4c07 100644 --- a/src/GameServer/Bot/AI/BotSupportPlanner.js +++ b/src/GameServer/Bot/AI/BotSupportPlanner.js @@ -1,5 +1,6 @@ const EffectStore = invoke('GameServer/Effects/EffectStore'); const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); +const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); const REFRESH_THRESHOLD_MS = 2 * 60 * 1000; const CAST_RESERVATION_MS = 5000; @@ -41,6 +42,9 @@ const INDIVIDUAL_BUFF_TARGET_ROLES = { function supportSkills(actor) { const skills = actor?.skillset?.fetchSkills?.() || actor?.skillset?.skills || []; + const overlay = HotBotPolicyOverlay.get(actor?.session); + const excluded = new Set(overlay?.buffPolicy?.excluded || []); + const allowed = new Set(overlay?.buffPolicy?.allowed || []); return skills .filter((skill) => skill && !skill.fetchPassive?.()) .filter((skill) => { @@ -51,9 +55,12 @@ function supportSkills(actor) { // the support planner request it continuously and pauses pulling. const skillType = skill.fetchSkillType?.(); const periodicHeal = skillType === 'hot' || skillType === 'healHot' || skillType === 'manaHot'; + const effect = String(semantic?.effect || '').toLowerCase(); return !periodicHeal && semantic?.effectType === 'buff' && !EXCLUDED_PARTY_BUFF_EFFECTS.has(semantic.effect) && + !excluded.has(effect) && + (allowed.size === 0 || allowed.has(effect)) && ['friendly', 'ally', 'party'].includes(semantic.target); }); } diff --git a/src/GameServer/Bot/AI/BotToolRegistry.js b/src/GameServer/Bot/AI/BotToolRegistry.js index 1bd04eee..58ddc3b4 100644 --- a/src/GameServer/Bot/AI/BotToolRegistry.js +++ b/src/GameServer/Bot/AI/BotToolRegistry.js @@ -67,7 +67,8 @@ function worldRevision(session) { function isPkLocked(session, action) { return session?.plan === 'pk_hunting' && new Set([ - 'follow_player', 'regroup_party', 'stay_here', 'hunt', 'rest', 'shop', 'move_to_spot', + 'follow_player', 'regroup_party', 'stay_party', 'stay_here', 'hunt', 'rest', 'shop', 'move_to_spot', + 'set_buff_policy', 'set_pull_policy', 'assign_puller', 'unassign_puller', 'set_skill_priority', 'clear_skill_priority', 'set_combat_stance', 'list_safe_loadouts', 'equip_candidate', 'optimize_equipment', 'list_party_candidates', diff --git a/src/GameServer/Bot/AI/BotTownTravel.js b/src/GameServer/Bot/AI/BotTownTravel.js index 3356da4d..235f814f 100644 --- a/src/GameServer/Bot/AI/BotTownTravel.js +++ b/src/GameServer/Bot/AI/BotTownTravel.js @@ -26,12 +26,29 @@ function clearCombatTrip(session) { session.townEscape = undefined; } +function revealInterruptedSupplyErrand(session, bot) { + if (session?.supplyErrandHidden !== true) return; + session.supplyErrandHidden = false; + session.dataSendToOthers?.(ServerResponse.charInfo(bot), bot); + session.dataSendToOthers?.(ServerResponse.relationChanged(bot), bot); + // Do not leave a half-started errand blocking the next player request. + // The combat state remains authoritative; the player can ask again once + // the party is safe. + if (session.companionShopping?.kind === 'player_resource_purchase') { + session.companionShopping = undefined; + session.shoppingTarget = undefined; + session.resumeAfterShopping = undefined; + session.preShopLocation = undefined; + } +} + function interruptEscape(session, bot) { if (!session.townEscape) return false; session.townEscape = undefined; session.pendingTownTrip = session.pendingTownTrip || { reason: 'Finishing the fight before going to town.', requestedAt: Date.now() }; session.plan = 'hunting'; bot.state.setCasts(false); + revealInterruptedSupplyErrand(session, bot); return true; } @@ -45,7 +62,9 @@ function beginEscape(session, bot, town) { session.townEscape = { token, town: town.name, startedAt: Date.now(), completesAt: Date.now() + SOE_CAST_MS }; bot.state.setCasts(true); - session.dataSendToMeAndOthers?.(ServerResponse.skillStarted(bot, bot.fetchId(), skill), bot); + if (session.supplyErrandHidden !== true) { + session.dataSendToMeAndOthers?.(ServerResponse.skillStarted(bot, bot.fetchId(), skill), bot); + } setTimeout(() => { if (session.townEscape?.token !== token) return; @@ -53,13 +72,23 @@ function beginEscape(session, bot, town) { bot.state.setCasts(false); session.townEscape = undefined; session.plan = 'hunting'; + revealInterruptedSupplyErrand(session, bot); return; } bot.state.setCasts(false); session.townEscape = undefined; - const TeleportTo = invoke('GameServer/Actor/Generics/TeleportTo'); - TeleportTo(session, bot, { locX: town.x, locY: town.y, locZ: town.z }); + const destination = { locX: town.x, locY: town.y, locZ: town.z }; + if (session.supplyErrandHidden === true) { + // A companion supply run is a cold-style server operation. The + // player sees the request and the eventual return, never a bot + // walking across the world or teleporting through intermediate + // locations. + bot.setLocXYZ?.(destination); + } else { + const TeleportTo = invoke('GameServer/Actor/Generics/TeleportTo'); + TeleportTo(session, bot, destination); + } Promise.resolve(BotEventJournal.record({ botId: bot.fetchId(), eventType: 'travel_complete', @@ -79,7 +108,7 @@ function request(session, bot, BotAI, reason, options = {}) { session.pendingTownTrip = { reason: reason || pending.reason || null, requestedAt: pending.requestedAt || Date.now() }; if (inCombat(session, bot)) return 'deferred'; - const town = BotAI.getClosestTown(bot.fetchLocX(), bot.fetchLocY()); + const town = options.destinationTown || BotAI.getClosestTown(bot.fetchLocX(), bot.fetchLocY()); session.preShopLocation = { locX: bot.fetchLocX(), locY: bot.fetchLocY(), locZ: bot.fetchLocZ() }; session.plan = 'shopping'; session.shopTimer = Date.now(); @@ -89,8 +118,10 @@ function request(session, bot, BotAI, reason, options = {}) { } session.pendingTownTrip = undefined; - if (distance2d(bot, town) > SOE_DISTANCE) { - BotAI.say(session, `${town.name} is far away. Using a Scroll of Escape.`); + if (options.forceScrollOfEscape === true || distance2d(bot, town) > SOE_DISTANCE) { + BotAI.say(session, options.forceScrollOfEscape === true + ? `Using a Scroll of Escape to reach ${town.name}.` + : `${town.name} is far away. Using a Scroll of Escape.`); beginEscape(session, bot, town); return 'escape'; } diff --git a/src/GameServer/Bot/AI/HotBotPolicyOverlay.js b/src/GameServer/Bot/AI/HotBotPolicyOverlay.js index db13d74c..04c781f5 100644 --- a/src/GameServer/Bot/AI/HotBotPolicyOverlay.js +++ b/src/GameServer/Bot/AI/HotBotPolicyOverlay.js @@ -8,6 +8,7 @@ const MIN_TTL_MS = 5 * 1000; const MAX_TTL_MS = 30 * 60 * 1000; const MAX_SKILL_PRIORITIES = 12; const MAX_SKILL_WEIGHT = 50; +const MAX_BUFF_POLICIES = 24; const STANCES = new Set(['balanced', 'aggressive', 'defensive', 'ranged']); function now() { @@ -80,6 +81,21 @@ function normalizeStance(value) { return STANCES.has(stance) ? stance : null; } +function normalizeBuffTypes(value) { + if (!Array.isArray(value)) return []; + return [...new Set(value.map((entry) => String(entry || '').trim().toLowerCase().replace(/\s+/g, '_')))] + .filter(Boolean) + .slice(0, MAX_BUFF_POLICIES); +} + +function normalizeBuffPolicy(value) { + if (!value || typeof value !== 'object') return { excluded: [], allowed: [] }; + return { + excluded: normalizeBuffTypes(value.excluded), + allowed: normalizeBuffTypes(value.allowed) + }; +} + function normalizePull(value) { if (!value || typeof value !== 'object') return null; const permission = ['allow', 'deny'].includes(value.permission) ? value.permission : null; @@ -119,6 +135,7 @@ function set(session, patch = {}, context = {}) { expiresAt: now() + ttl(patch.ttlMs ?? context.ttlMs ?? (previous.expiresAt ? previous.expiresAt - now() : DEFAULT_TTL_MS)), skillPriorities: normalizePriorities(patch.skillPriorities ?? previous.skillPriorities), combatStance: normalizeStance(patch.combatStance ?? previous.combatStance), + buffPolicy: normalizeBuffPolicy(patch.buffPolicy ?? previous.buffPolicy), pull: normalizePull(patch.pull ?? previous.pull) }; @@ -133,6 +150,7 @@ function set(session, patch = {}, context = {}) { // An explicitly cleared field must not be resurrected by the old object. if (patch.skillPriorities === null) updated.skillPriorities = {}; if (patch.combatStance === null) updated.combatStance = null; + if (patch.buffPolicy === null) updated.buffPolicy = { excluded: [], allowed: [] }; if (patch.pull === null) updated.pull = null; session.hotPolicyOverlay = updated; @@ -180,6 +198,10 @@ function status(session) { expiresInSec: Math.max(0, Math.ceil((overlay.expiresAt - now()) / 1000)), pull: overlay.pull ? { ...overlay.pull } : null, combatStance: overlay.combatStance || null, + buffPolicy: { + excluded: [...(overlay.buffPolicy?.excluded || [])], + allowed: [...(overlay.buffPolicy?.allowed || [])] + }, skillPriorities: { ...(overlay.skillPriorities || {}) } }; } @@ -187,6 +209,7 @@ function status(session) { module.exports = { DEFAULT_TTL_MS, MAX_SKILL_WEIGHT, + MAX_BUFF_POLICIES, STANCES: [...STANCES], clear, clearForCold, @@ -196,6 +219,7 @@ module.exports = { get, normalizePriorities, normalizeStance, + normalizeBuffPolicy, set, status }; diff --git a/src/GameServer/Bot/AI/OpenRouterGateway.js b/src/GameServer/Bot/AI/OpenRouterGateway.js index d59b031a..9ef08dea 100644 --- a/src/GameServer/Bot/AI/OpenRouterGateway.js +++ b/src/GameServer/Bot/AI/OpenRouterGateway.js @@ -24,7 +24,7 @@ const DEFAULTS = Object.freeze({ enabled: false, apiKey: '', model: LUNA_MODEL, - partyRouterModel: '', + partyRouterModel: LUNA_MODEL, temperature: 0.35, reasoningEffort: 'low', maxConcurrentRequests: 32, diff --git a/src/GameServer/Bot/AI/PartyCompanionService.js b/src/GameServer/Bot/AI/PartyCompanionService.js index 428bfbe7..56017448 100644 --- a/src/GameServer/Bot/AI/PartyCompanionService.js +++ b/src/GameServer/Bot/AI/PartyCompanionService.js @@ -602,6 +602,34 @@ function beginRegroup(leaderSession, options = {}) { }; } +function holdParty(leaderSession) { + const leader = leaderSession?.actor; + if (!leader) return { ok: false, reason: 'missing_party_leader' }; + const members = membersForLeader(leaderSession) + .filter((member) => !['shopping', 'getting_buffed', 'merchant'].includes(member.plan)); + if (!members.length) return { ok: false, reason: 'no_party_companions' }; + + // A hold order is a per-member position anchor. It intentionally does not + // change the configured pull policy permanently; regroup/follow can clear + // the temporary order later. + delete leaderSession.partyRegroupDirective; + leaderSession.partyPullState = {}; + members.forEach((member) => { + cancelCompanionAction(member); + member.botStay = true; + member.stayLocation = { + locX: member.actor.fetchLocX(), + locY: member.actor.fetchLocY(), + locZ: member.actor.fetchLocZ() + }; + member.currentTargetId = undefined; + member.lastFollowMoveTarget = null; + member.plan = 'following'; + member.actor.unselect?.(); + }); + return { ok: true, affected: members.length }; +} + function partyActorsForLeader(leaderSession) { return [leaderSession?.actor, ...membersForLeader(leaderSession).map((memberSession) => memberSession.actor)] .filter((actor) => actor?.fetchIsOnline?.() !== false); @@ -742,6 +770,7 @@ const PartyCompanionService = { formationTargetFor, beginRegroup, + holdParty, regroupActive, diff --git a/src/GameServer/Bot/AI/PartyDialogueRouter.js b/src/GameServer/Bot/AI/PartyDialogueRouter.js index e3ab8a4b..b608e1be 100644 --- a/src/GameServer/Bot/AI/PartyDialogueRouter.js +++ b/src/GameServer/Bot/AI/PartyDialogueRouter.js @@ -55,12 +55,12 @@ function isCompanion(session, playerSession) { } function isGroupAddress(text) { - return /\b(?:bot|bots|guys|party|team|help|everyone|anyone|somebody|someone)\b/i.test(String(text || '')); + return /\b(?:bot|bots|guys|party|team|help|everyone|everybody|anyone|somebody|someone|companions|members|all|each)\b/i.test(String(text || '')); } function isContinuationMessage(text) { const value = String(text || '').trim().toLowerCase(); - return /^(?:yes|yeah|yep|sure|no|nope|ok|okay|alright|right|exactly|do it|go ahead|continue|sounds good|got it|thanks|thank you|and then|what about|is (?:it|that)|are (?:they|those)|(?:what|which) (?:weapon|armor|item|skill|buff|price)|but\b|and\b)/.test(value); + return /^(?:yes|yeah|yep|sure|no|nope|ok|okay|alright|right|exactly|do it|go ahead|continue|sounds good|got it|thanks|thank you|and then|what about|is (?:it|that)|are (?:they|those)|(?:what|which) (?:weapon|armor|item|skill|buff|price)|(?:can|could|would) you\b|but\b|and\b)/.test(value); } function roleFor(session, actor) { diff --git a/src/GameServer/Bot/AI/States/FollowingState.js b/src/GameServer/Bot/AI/States/FollowingState.js index b17aa3d1..77d333c5 100644 --- a/src/GameServer/Bot/AI/States/FollowingState.js +++ b/src/GameServer/Bot/AI/States/FollowingState.js @@ -711,6 +711,26 @@ function deliverPurchasedResources(session, bot, playerSession) { recordRoleDecision(session, bot, 'deliver_resources', 'return_to_leader', { targetId: delivery.playerId }); return true; } + let partyThreat = null; + try { partyThreat = PartyAwareness.findThreatTargetingParty(playerSession); } catch (_) { /* lightweight test/session */ } + const leaderBusy = !!( + playerSession.actor.state?.fetchHits?.() || + playerSession.actor.state?.fetchCasts?.() || + playerSession.actor.fetchDestId?.() + ); + if (partyThreat || leaderBusy) { + const now = Date.now(); + if (now - Number(session.resourceTradeWaitAnnouncedAt || 0) > 15000) { + session.resourceTradeWaitAnnouncedAt = now; + BotPartyChat.announce(session, { + priority: 'informational', + key: `resource-delivery-wait:${bot.fetchId()}:${delivery.purchasedAt}`, + templates: ['I have the supplies. I will open trade as soon as the party is safe.'] + }); + } + recordRoleDecision(session, bot, 'deliver_resources', 'wait_for_safe_trade', { targetId: delivery.playerId }); + return true; + } const trade = invoke('GameServer/Bot/BotTradeService').startBotTradeWithOffer( session, playerSession, diff --git a/src/GameServer/Bot/AI/States/ShoppingState.js b/src/GameServer/Bot/AI/States/ShoppingState.js index a86372ad..d842ae46 100644 --- a/src/GameServer/Bot/AI/States/ShoppingState.js +++ b/src/GameServer/Bot/AI/States/ShoppingState.js @@ -122,23 +122,14 @@ module.exports = { const companionErrand = session.companionShopping; if (companionErrand?.kind === 'player_resource_purchase') { - const current = bot.backpack?.fetchItemFromSelfId?.(companionErrand.itemId); - const currentAmount = Number(current?.fetchAmount?.() || 0); - const plan = { - kind: ShotStock.kindForSelfId(companionErrand.itemId), - selfId: Number(companionErrand.itemId), - name: companionErrand.itemName, - price: Number(companionErrand.unitPrice) - }; + let deliveryReady = false; try { - const purchased = await ShotStock.purchaseActorRestock(bot, { - plan, - targetAmount: currentAmount + Number(companionErrand.amount) - }); + const BotSupplyErrand = invoke('GameServer/Bot/AI/BotSupplyErrand'); + const purchased = await BotSupplyErrand.purchaseAtDestination(bot, companionErrand); if (!purchased.ok || Number(purchased.delta) !== Number(companionErrand.amount)) { throw new Error(purchased.reason || 'purchase_delta_mismatch'); } - const purchasedItem = bot.backpack.fetchItemFromSelfId(companionErrand.itemId); + const purchasedItem = purchased.item || bot.backpack.fetchItemFromSelfId(companionErrand.itemId); session.pendingResourceDelivery = { playerSession: companionErrand.playerSession, playerId: companionErrand.playerId, @@ -148,6 +139,7 @@ module.exports = { amount: Number(companionErrand.amount), purchasedAt: Date.now() }; + deliveryReady = true; session.lastTradeSummary = `bought ${purchased.delta}x ${companionErrand.itemName} for ${formatAdena(purchased.cost)}a to deliver to ${companionErrand.playerSession?.actor?.fetchName?.() || 'the leader'}`; BotAI.say(session, `Bought ${purchased.delta}x ${companionErrand.itemName}. Returning with them now.`); Promise.resolve(BotEventJournal.record({ @@ -165,11 +157,14 @@ module.exports = { } })).catch(() => {}); } catch (error) { + session.pendingResourceDelivery = undefined; session.lastTradeSummary = `could not buy ${companionErrand.amount}x ${companionErrand.itemName}`; - BotAI.say(session, 'I could not complete that supply purchase. I am returning now.'); + BotAI.say(session, error?.message === 'not_enough_adena' + ? 'I am short on Adena for that purchase. Give me some and I will try again.' + : 'I could not complete that supply purchase. I am returning now.'); utils.infoWarn('Shopping', 'requested supply purchase failed for %s: %s', bot.fetchName(), error.message); } - this.scheduleResourceReturn(session, bot, BotAI); + this.scheduleResourceReturn(session, bot, BotAI, { deliveryReady }); return; } @@ -344,7 +339,7 @@ module.exports = { }, 9000); }, - scheduleResourceReturn(session, bot, BotAI) { + scheduleResourceReturn(session, bot, BotAI, options = {}) { setTimeout(() => { const resume = session.resumeAfterShopping; const leaderSession = resume?.followPlayerSession; @@ -358,12 +353,25 @@ module.exports = { session.preShopLocation = undefined; if (session.plan === 'following') { const leader = leaderSession.actor; - bot.moveTo({ - from: { locX: bot.fetchLocX(), locY: bot.fetchLocY(), locZ: bot.fetchLocZ() }, - to: { locX: leader.fetchLocX(), locY: leader.fetchLocY(), locZ: leader.fetchLocZ() } - }); + const destination = { + locX: leader.fetchLocX() + 80, + locY: leader.fetchLocY(), + locZ: leader.fetchLocZ() + }; + // A requested supply run is intentionally invisible while it + // is away. Reappear in a valid companion slot instead of + // making the player watch a long return route. + bot.setLocXYZ?.(destination); + session.supplyErrandHidden = false; + const Response = invoke('GameServer/Network/Response'); + session.dataSendToOthers?.(Response.charInfo(bot), bot); + session.dataSendToOthers?.(Response.relationChanged(bot), bot); + BotAI.say(session, options.deliveryReady === true + ? 'I am back with the new supplies. I will open trade when the party is safe.' + : 'I am back, but I could not complete that purchase.'); } else { session.pendingResourceDelivery = undefined; + session.supplyErrandHidden = false; } }, 1000); } diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js index d19ec552..11d4e448 100644 --- a/src/GameServer/Bot/BotManager.js +++ b/src/GameServer/Bot/BotManager.js @@ -774,7 +774,7 @@ const BotManager = { const PartyDialogueState = invoke('GameServer/Bot/AI/PartyDialogueState'); const PartyLLMRouter = invoke('GameServer/Bot/AI/PartyLLMRouter'); const llmEnabled = BotBrain.isEnabled(); - const groupAddress = /\b(bot|bots|guys|party|team|help)\b/.test(text) || /(бот|боты|ребят|народ|пати|команда|кто-нибудь)/.test(text); + const groupAddress = /\b(bot|bots|guys|party|team|help|everyone|everybody|companions|all)\b/.test(text) || /(бот|боты|ребят|народ|пати|команда|кто-нибудь)/.test(text); const partyChannel = Number(data.kind) === 3; let llmResponder = null; diff --git a/src/GameServer/Bot/Economy/MarketOpportunity.js b/src/GameServer/Bot/Economy/MarketOpportunity.js index b0b6275a..762909c4 100644 --- a/src/GameServer/Bot/Economy/MarketOpportunity.js +++ b/src/GameServer/Bot/Economy/MarketOpportunity.js @@ -1,20 +1,63 @@ const DataCache = invoke('GameServer/DataCache'); const World = invoke('GameServer/World/World'); const NpcShopBuyLists = invoke('GameServer/World/Generics/NpcShopBuyLists'); +const MerchantStoreConfigs = invoke('GameServer/Bot/MerchantStoreConfigs'); +const TradeService = invoke('GameServer/Bot/TradeService'); const TOWN_NPC_SELLERS = { Giran: [7081, 7082, 7084, 7085, 7087, 7088, 7090, 7091, 7093, 7094, 7829], Oren: [7178, 7179, 7180, 7181], Gludio: [7313, 7314, 7315], - Gludin: [7060, 7061, 7062, 7063, 7207, 7208, 7209], - 'Talking Island': [7001, 7002, 7003, 7004] + Gludin: [7060, 7061, 7062, 7063, 7207, 7208, 7209, 7321], + 'Talking Island': [7001, 7002, 7003, 7004], + Aden: [7837, 7838, 7839, 7840, 7841, 7842, 7831, 7869], + 'Hunter\'s Village': [7230, 7231, 7235, 7301, 7684], + 'Dwarven Village': [7516, 7517, 7518, 7519], + 'Elven Village': [7135, 7136, 7137, 7138], + 'Dark Elven Village': [7147, 7148, 7149, 7150], + 'Floran Village': [7078, 7436, 7437], + Cema: [7834], + Goddard: [8256], + Rune: [8300], + 'Orc Village': [7558, 7559, 7560, 7561], + 'Dion': [7253, 7254, 7294], + 'Heine': [7731, 7827, 7828, 7830] }; const coldStoreIndex = new Map(); +const SHOT_IDS = new Set([ + 1835, 1463, 1464, 1465, 1466, 1467, + 2509, 2510, 2511, 2512, 2513, 2514, + 3947, 3948, 3949, 3950, 3951, 3952 +]); function itemName(selfId) { return (DataCache.items || []).find((item) => Number(item.selfId) === Number(selfId))?.template?.name || `Item ${selfId}`; } +function normalizeItemLookup(value) { + const normalized = String(value || '') + .toLowerCase() + .replace(/soulshots?/g, 'soulshot') + .replace(/spiritshots?/g, 'spiritshot') + .replace(/blessed\s+spiritshot/g, 'blessed_spiritshot') + .replace(/no\s*grade/g, 'no_grade') + .replace(/([a-z])\s*[- ]\s*grade/g, '$1_grade') + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .replace(/_+/g, '_'); + + // Players commonly put the grade before the item family (“D-grade + // soulshots”), while the C4 item names put it after the family. Keep one + // canonical form so both phrasings resolve to the same catalog entry. + return normalized + .replace(/^no_grade_(blessed_)?spiritshot$/, '$1spiritshot_no_grade') + .replace(/^no_grade_soulshot$/, 'soulshot_no_grade') + .replace(/^([a-z])_grade_(blessed_)?spiritshot$/, '$2spiritshot_$1_grade') + .replace(/^([a-z])_grade_soulshot$/, 'soulshot_$1_grade') + .replace(/^grade_([a-z])_(blessed_)?spiritshot$/, '$2spiritshot_$1_grade') + .replace(/^grade_([a-z])_soulshot$/, 'soulshot_$1_grade'); +} + function npcOffers(selfId, town) { const offers = []; const seen = new Set(); @@ -40,6 +83,33 @@ function npcOffers(selfId, town) { return offers; } +function npcOffersAll(selfId) { + return Object.keys(TOWN_NPC_SELLERS).flatMap((town) => npcOffers(selfId, town)); +} + +function configuredStoreOffers(selfId) { + return Object.entries(MerchantStoreConfigs) + .flatMap(([storeName, store]) => { + if (store?.storeType !== 1 || !store.town) return []; + const line = (store.items || []).find((entry) => Number(entry.selfId) === Number(selfId) && Number(entry.count) > 0); + if (!line) return []; + const price = TradeService.ratedPrice(selfId, line.priceRate ?? 1); + if (price <= 0) return []; + return [{ + sourceType: 'configured_store', + sourceId: storeName, + sourceName: storeName, + town: store.town, + selfId: Number(selfId), + itemName: itemName(selfId), + price, + count: Number(line.count), + available: true, + storeConfig: store + }]; + }); +} + function privateOffers(selfId, town) { return (World.user?.sessions || []).flatMap((session) => { const actor = session?.actor; @@ -123,6 +193,64 @@ function bestOffer(selfId, options = {}) { return findOffers(selfId, options).find((offer) => offer.price <= budget) || null; } +// A companion may leave the field for the city that actually sells the +// requested item. Checking only the geographically nearest town made a +// valid item look impossible whenever its NPC list lived elsewhere. +function bestSupplyOffer(selfId, options = {}) { + const budget = Number.isFinite(Number(options.budget)) ? Number(options.budget) : Infinity; + // A companion supply errand uses a server-owned NPC or configured city + // merchant. Dynamic private/cold offers remain available to the market + // planner and are never guessed as a guaranteed supply source. + const offers = [...npcOffersAll(selfId), ...configuredStoreOffers(selfId)]; + return offers + .filter((offer) => offer.available && Number(offer.price) <= budget) + .sort((a, b) => Number(a.sourceType !== 'npc') - Number(b.sourceType !== 'npc') || a.price - b.price)[0] || null; +} + +function resolveSupplyItem(value) { + const requested = normalizeItemLookup(value); + if (!requested) return null; + const candidates = [...new Set([ + ...(NpcShopBuyLists.allEntries?.() || []).map((entry) => Number(entry.selfId)), + ...Object.values(MerchantStoreConfigs) + .filter((store) => store?.storeType === 1) + .flatMap((store) => (store.items || []).map((entry) => Number(entry.selfId))) + ].filter(Boolean))] + .map((selfId) => ({ selfId, name: itemName(selfId), normalized: normalizeItemLookup(itemName(selfId)) })) + .filter((entry) => entry.normalized); + + const exact = candidates.find((entry) => entry.normalized === requested); + if (exact) return exact; + + // Chat often omits punctuation or uses “shots” for the singular item + // family. Accept a unique token-contained match, but never guess between + // grades or unrelated items. + const matches = candidates.filter((entry) => entry.normalized.includes(requested) || requested.includes(entry.normalized)); + return matches.length === 1 ? matches[0] : null; +} + +function supplyCatalog(limit = 96) { + const ids = [...new Set([ + ...(NpcShopBuyLists.allEntries?.() || []).map((entry) => Number(entry.selfId)), + ...Object.values(MerchantStoreConfigs) + .filter((store) => store?.storeType === 1) + .flatMap((store) => (store.items || []).map((entry) => Number(entry.selfId))) + ].filter(Boolean))]; + return ids + .map((selfId) => { + const offer = [...npcOffersAll(selfId), ...configuredStoreOffers(selfId)].sort((a, b) => a.price - b.price)[0]; + return offer ? { + selfId, + name: offer.itemName, + price: Number(offer.price), + town: offer.town + } : null; + }) + .filter(Boolean) + .sort((a, b) => Number(SHOT_IDS.has(b.selfId)) - Number(SHOT_IDS.has(a.selfId)) || a.name.localeCompare(b.name)) + .slice(0, Math.max(1, Number(limit) || 96)); +} + function reserve(offer, qty = 1) { const count = Math.max(1, Number(qty) || 1); if (!offer?.available || Number(offer.price) <= 0) return false; @@ -143,13 +271,18 @@ function release(offer, qty = 1) { module.exports = { TOWN_NPC_SELLERS, bestOffer, + bestSupplyOffer, coldOffers, findOffers, indexColdStore, npcOffers, + npcOffersAll, + normalizeItemLookup, privateOffers, + resolveSupplyItem, removeColdStore, resetColdStores, + supplyCatalog, release, reserve }; diff --git a/src/GameServer/World/Generics/NpcShopBuyLists.js b/src/GameServer/World/Generics/NpcShopBuyLists.js index 97835e7c..9391ef3e 100644 --- a/src/GameServer/World/Generics/NpcShopBuyLists.js +++ b/src/GameServer/World/Generics/NpcShopBuyLists.js @@ -1137,5 +1137,9 @@ module.exports = { fetchFallback(key) { return flatten(FALLBACKS[key]); + }, + + allEntries() { + return flatten(Object.values(NPC_LISTS).flat()); } }; diff --git a/src/GameServer/World/TownRespawn.js b/src/GameServer/World/TownRespawn.js index f109a998..a1cab4cf 100644 --- a/src/GameServer/World/TownRespawn.js +++ b/src/GameServer/World/TownRespawn.js @@ -124,6 +124,7 @@ function getChaoticRespawnCoords(locX, locY, random = Math.random) { } module.exports = { + towns: TOWNS, getClosestTown, getRegionGroup, getRespawnCoords, diff --git a/tests/test_ai_config_surface.js b/tests/test_ai_config_surface.js index d1423462..4e5d8916 100644 --- a/tests/test_ai_config_surface.js +++ b/tests/test_ai_config_surface.js @@ -12,7 +12,7 @@ try { assert.strictEqual(OpenRouterGateway.DEFAULTS.model, 'openai/gpt-5.6-luna'); assert.strictEqual(OpenRouterGateway.DEFAULTS.reasoningEffort, 'low'); assert.strictEqual(OpenRouterGateway.DEFAULTS.temperature, 0.35); - assert.strictEqual(OpenRouterGateway.DEFAULTS.partyRouterModel, ''); + assert.strictEqual(OpenRouterGateway.DEFAULTS.partyRouterModel, 'openai/gpt-5.6-luna'); options.default.OpenRouter = { enabled: true, @@ -35,7 +35,7 @@ try { assert.strictEqual(openRouter.temperature, 0.7); assert.strictEqual(openRouter.reasoningEffort, 'off'); assert.strictEqual(openRouter.maxConcurrentRequests, 7); - assert.strictEqual(openRouter.partyRouterModel, '', 'party routing remains deterministic unless explicitly configured'); + assert.strictEqual(openRouter.partyRouterModel, 'openai/gpt-5.6-luna', 'party routing uses the configured fast router by default'); assert.strictEqual(openRouter.maxTokens, 320, 'completion safety belongs to internal policy'); assert.strictEqual(openRouter.timeoutMs, 3500, 'provider timeout belongs to internal policy'); assert.strictEqual(openRouter.backgroundInferenceEnabled, undefined); diff --git a/tests/test_llm_supply_errand.js b/tests/test_llm_supply_errand.js index b685a2bb..c97eaa4d 100644 --- a/tests/test_llm_supply_errand.js +++ b/tests/test_llm_supply_errand.js @@ -7,10 +7,13 @@ const BotTownTravel = invoke('GameServer/Bot/AI/BotTownTravel'); const MarketOpportunity = invoke('GameServer/Bot/Economy/MarketOpportunity'); const ShoppingState = invoke('GameServer/Bot/AI/States/ShoppingState'); const FollowingState = invoke('GameServer/Bot/AI/States/FollowingState'); -const ShotStock = invoke('GameServer/Inventory/ShotStock'); +const BotSupplyErrandModule = invoke('GameServer/Bot/AI/BotSupplyErrand'); +const TradeService = invoke('GameServer/Bot/TradeService'); +const DataCache = invoke('GameServer/DataCache'); const BotTradeService = invoke('GameServer/Bot/BotTradeService'); const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); const BotPartyChat = invoke('GameServer/Bot/AI/BotPartyChat'); +const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); function item(id, selfId, amount, name = 'Soulshot: D-grade') { let count = amount; @@ -25,9 +28,10 @@ function item(id, selfId, amount, name = 'Soulshot: D-grade') { async function main() { const originals = { - npcOffers: MarketOpportunity.npcOffers, + bestSupplyOffer: MarketOpportunity.bestSupplyOffer, request: BotTownTravel.request, - purchase: ShotStock.purchaseActorRestock, + purchase: BotSupplyErrandModule.purchaseAtDestination, + tradePurchase: TradeService.buyFromStore, scheduleReturn: ShoppingState.scheduleResourceReturn, trade: BotTradeService.startBotTradeWithOffer, inferRole: BotRoles.inferRole, @@ -55,16 +59,27 @@ async function main() { fetchLocY: () => 0, fetchLocZ: () => 0, unselect() {}, + setLocXYZ() {}, + dataSendToOthers() {}, moveTo() {}, backpack: { fetchItemFromSelfId: (selfId) => bySelfId.get(Number(selfId)) }, automation: { abortAll() {} }, state: { fetchTowards: () => false } }; const session = { accountId: 'bot_1', actor: bot, partyCompanion: true, followPlayerSession: player }; + const originalItems = DataCache.items; try { - MarketOpportunity.npcOffers = () => [{ price: 17, itemName: 'Soulshot: D-grade' }]; + DataCache.items = [{ selfId: 1463, template: { name: 'Soulshot: D-grade' }, etc: { stackable: true } }]; + assert.strictEqual(MarketOpportunity.resolveSupplyItem('Soulshots D grade').selfId, 1463); + assert.strictEqual(MarketOpportunity.normalizeItemLookup('D-grade soulshots'), 'soulshot_d_grade'); + assert.strictEqual(MarketOpportunity.normalizeItemLookup('No-grade soulshots'), 'soulshot_no_grade'); + MarketOpportunity.bestSupplyOffer = () => ({ + sourceType: 'npc', sourceId: 7004, sourceName: 'D-grade grocer', town: 'Talking Island', + selfId: 1463, price: 17, itemName: 'Soulshot: D-grade', available: true + }); BotTownTravel.request = (_session, _bot, _ai, _reason, options) => { assert.strictEqual(options.allowCompanion, true); + assert.strictEqual(options.forceScrollOfEscape, true); return 'walk'; }; const requested = BotSupplyErrand.request(session, player, 1463, 200); @@ -72,10 +87,12 @@ async function main() { assert.strictEqual(requested.amount, 200); assert.strictEqual(session.companionShopping.amount, 200); - ShotStock.purchaseActorRestock = async (_actor, options) => { - assert.strictEqual(options.targetAmount, 1191, 'purchase target must add the requested amount to existing stock'); - shots.setAmount(options.targetAmount); - return { ok: true, delta: 200, cost: 3400 }; + TradeService.buyFromStore = async (_actor, store, selfId, amount) => { + assert.strictEqual(store.items[0].selfId, 1463); + assert.strictEqual(selfId, 1463); + assert.strictEqual(amount, 200); + shots.setAmount(1191); + return { qty: 200, totalAdena: 3400, name: 'Soulshot: D-grade' }; }; ShoppingState.scheduleResourceReturn = () => {}; await ShoppingState.sellAndRestock(session, bot, null, { say() {} }); @@ -92,14 +109,40 @@ async function main() { assert.strictEqual(FollowingState.deliverPurchasedResources(session, bot, player), true); assert.deepStrictEqual(offered, { objectId: 11, amount: 200 }); assert.strictEqual(session.pendingResourceDelivery, undefined); + + // An errand request must not cancel an active fight just because the + // player asked at the wrong moment. + session.companionShopping = undefined; + session.pendingResourceDelivery = undefined; + session.currentTargetId = 777; + bot.state.fetchHits = () => true; + const duringFight = BotSupplyErrand.request(session, player, 1463, 1); + assert.strictEqual(duringFight.reason, 'unsafe_combat_state'); + assert.strictEqual(session.currentTargetId, 777); + bot.state.fetchHits = () => false; + session.currentTargetId = undefined; + + // The player-facing rejection includes the amount to transfer, so a + // failed affordability check is actionable rather than generic. + const affordability = BotAgentTools.rejectionReply({ + reason: 'not_enough_adena', + cost: 3400, + adena: 100, + itemName: 'Soulshot: D-grade' + }); + assert(affordability.includes('3,400')); + assert(affordability.includes('100')); + assert(affordability.includes('Transfer Adena')); } finally { - MarketOpportunity.npcOffers = originals.npcOffers; + MarketOpportunity.bestSupplyOffer = originals.bestSupplyOffer; BotTownTravel.request = originals.request; - ShotStock.purchaseActorRestock = originals.purchase; + BotSupplyErrandModule.purchaseAtDestination = originals.purchase; + TradeService.buyFromStore = originals.tradePurchase; ShoppingState.scheduleResourceReturn = originals.scheduleReturn; BotTradeService.startBotTradeWithOffer = originals.trade; BotRoles.inferRole = originals.inferRole; BotPartyChat.announce = originals.announce; + DataCache.items = originalItems; } console.log('LLM supply errand checks passed'); } From a6d924d37e3e7973061a27de6aaf82092245decc Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:32:55 -0400 Subject: [PATCH 55/59] Fix companion supply workflows and AI policy edges --- scripts/run-tests.js | 1 + src/GameServer/Bot/AI/BotAgentTools.js | 12 +- src/GameServer/Bot/AI/BotBrain.js | 4 +- .../Bot/AI/BotConversationSummarizer.js | 31 +++-- src/GameServer/Bot/AI/BotSupplyErrand.js | 90 +++++++++++--- src/GameServer/Bot/AI/BotSupportPlanner.js | 5 +- src/GameServer/Bot/AI/BotToolRegistry.js | 3 +- src/GameServer/Bot/AI/BotTownTravel.js | 19 ++- src/GameServer/Bot/AI/BotWorkflowTelemetry.js | 34 ++++++ .../Bot/AI/States/FollowingState.js | 14 ++- src/GameServer/Bot/AI/States/ShoppingState.js | 36 +++++- .../Bot/Economy/MarketOpportunity.js | 46 +++++-- tests/test_bot_conversation_summary.js | 12 +- tests/test_bot_llm_party_policy.js | 8 ++ tests/test_bot_support_planner.js | 12 ++ tests/test_bot_town_travel.js | 16 +++ tests/test_llm_configured_supply_store.js | 115 ++++++++++++++++++ tests/test_llm_supply_errand.js | 18 ++- 18 files changed, 427 insertions(+), 49 deletions(-) create mode 100644 src/GameServer/Bot/AI/BotWorkflowTelemetry.js create mode 100644 tests/test_llm_configured_supply_store.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 0da004c2..ff7ed79e 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -35,6 +35,7 @@ const tests = [ 'tests/test_llm_pull_policy_tools.js', 'tests/test_llm_party_regroup.js', 'tests/test_llm_supply_errand.js', + 'tests/test_llm_configured_supply_store.js', 'tests/test_llm_skill_priority_tools.js', 'tests/test_llm_equipment_tools.js', 'tests/test_llm_trade_tools.js', diff --git a/src/GameServer/Bot/AI/BotAgentTools.js b/src/GameServer/Bot/AI/BotAgentTools.js index f79009a5..3167a2b7 100644 --- a/src/GameServer/Bot/AI/BotAgentTools.js +++ b/src/GameServer/Bot/AI/BotAgentTools.js @@ -601,13 +601,13 @@ function setBuffPolicy(context) { const current = HotBotPolicyOverlay.get(session)?.buffPolicy || { excluded: [], allowed: [] }; const excluded = new Set(current.excluded || []); - const allowed = new Set(current.allowed || []); excluded.delete(capability.type); - allowed.delete(capability.type); if (mode === 'deny') excluded.add(capability.type); - if (mode === 'allow') allowed.add(capability.type); + // "allow" is an explicit removal from the deny list, not an exclusive + // allow-list. A player asking to re-enable Might must not silently disable + // every other useful party buff. return policyActionResult(session, { - buffPolicy: { excluded: [...excluded], allowed: [...allowed] } + buffPolicy: { excluded: [...excluded], allowed: [] } }, context, `buff_policy:${mode}:${capability.type}`); } @@ -1013,6 +1013,10 @@ function rejectionReply(result = {}) { case 'supply_errand_active': return 'I already have a shopping or delivery errand in progress.'; case 'supply_not_available': return 'I cannot find a city shop that sells that item right now.'; case 'supply_destination_missing': return 'I found the item, but not a valid city destination for it.'; + case 'configured_store_unavailable': + case 'configured_store_stock_changed': return 'That configured shop no longer has enough of the requested item.'; + case 'configured_store_price_changed': return 'That shop changed its price before I could buy the item.'; + case 'purchase_quantity_mismatch': return 'The shop could not provide the exact requested amount.'; case 'supply_price_invalid': return 'The shop returned an invalid price for that item.'; case 'not_enough_adena': { const cost = Number(result.cost || 0); diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 56c891b9..99690156 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -467,7 +467,9 @@ function applyPartyPolicy(session, decision, requestContext, text) { if (!requestContext?.playerSession) return decision; const value = String(text || '').toLowerCase(); const group = /\b(?:everyone|everybody|all|guys|bots|companions|party|team)\b/.test(value); - if (group && /\b(?:stay|wait|hold|stop)\b/.test(value)) { + const positionHold = /\b(?:stay|wait|hold)\b/.test(value) && + (/(?:\b(?:here|there|position|spot|together|close)\b)/.test(value) || /\bhold\s+position\b/.test(value)); + if (group && positionHold) { return { ...decision, action: 'stay_party', diff --git a/src/GameServer/Bot/AI/BotConversationSummarizer.js b/src/GameServer/Bot/AI/BotConversationSummarizer.js index 0e96a47b..51e8771d 100644 --- a/src/GameServer/Bot/AI/BotConversationSummarizer.js +++ b/src/GameServer/Bot/AI/BotConversationSummarizer.js @@ -58,6 +58,7 @@ function compactTurns(turns) { return (turns || []).map((turn) => { const compact = { id: turn.id, + turnId: turn.turnId, role: turn.role, channel: turn.channel, text: turn.text @@ -80,7 +81,18 @@ function schema() { properties: { summary: { type: 'string' }, openTopics: { type: 'array', items: { type: 'string' } }, - promises: { type: 'array', items: { type: 'string' } } + promises: { + type: 'array', + items: { + type: 'object', + properties: { + turnId: { type: 'string' }, + text: { type: 'string' } + }, + required: ['turnId', 'text'], + additionalProperties: false + } + } }, required: ['summary', 'openTopics', 'promises'], additionalProperties: false @@ -88,14 +100,14 @@ function schema() { }; } -function hasAuthoritativePromise(turns = []) { - return turns.some((turn) => { +function authoritativeTurnIds(turns = []) { + return new Set(turns.filter((turn) => { const meta = turn?.meta || {}; const action = String(meta.action || '').toLowerCase(); if (!action || ['say', 'none'].includes(action)) return false; if (meta.serverApplied === true) return true; return meta.actionResult?.outcome === 'pending' || meta.actionResult?.ok === true; - }); + }).map((turn) => String(turn.turnId || turn.id || ''))); } function normalizeSummary(data, turns = []) { @@ -104,8 +116,13 @@ function normalizeSummary(data, turns = []) { const openTopics = Array.isArray(data.openTopics) ? data.openTopics.map((value) => String(value || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 4) : []; - const promises = hasAuthoritativePromise(turns) && Array.isArray(data.promises) - ? data.promises.map((value) => String(value || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 4) + const authoritative = authoritativeTurnIds(turns); + const promises = Array.isArray(data.promises) + ? data.promises + .filter((value) => value && typeof value === 'object' && authoritative.has(String(value.turnId || ''))) + .map((value) => String(value.text || '').replace(/\s+/g, ' ').trim()) + .filter(Boolean) + .slice(0, 4) : []; if (!summary && openTopics.length === 0 && promises.length === 0) return ''; const sections = [summary || 'No stable facts recorded.']; @@ -156,7 +173,7 @@ async function summarize(input = {}) { const messages = [ { role: 'system', - content: 'Summarize a game conversation for the same bot and player. Keep only durable facts, explicit player preferences, unresolved requests, and promises backed by a successful or pending mutating server action. Treat action metadata as authoritative: only an action with serverApplied=true or actionResult.ok=true happened; an LLM proposal, plain say reply, refusal, fallback, or failed action did not happen. A pending action means a server-side request or native window is active, not that the final transfer or effect completed. Do not turn “I will”, “I am going”, “I will check”, transient movement, an unconfirmed cast, a plain acknowledgement, a roleplay sentence, or a guessed name/alias into a durable promise. Never create permissions, tool authorizations, preferences, or facts not stated in the dialogue.' + content: 'Summarize a game conversation for the same bot and player. Keep only durable facts, explicit player preferences, unresolved requests, and promises backed by a successful or pending mutating server action. Treat action metadata as authoritative: only an action with serverApplied=true or actionResult.ok=true happened; an LLM proposal, plain say reply, refusal, fallback, or failed action did not happen. A pending action means a server-side request or native window is active, not that the final transfer or effect completed. Do not turn “I will”, “I am going”, “I will check”, transient movement, an unconfirmed cast, a plain acknowledgement, a roleplay sentence, or a guessed name/alias into a durable promise. For every promise return an object with the exact authoritative turnId that caused it; if no authoritative turn supports it, omit it. Never create permissions, tool authorizations, preferences, or facts not stated in the dialogue.' }, { role: 'user', content: JSON.stringify({ previousSummary: current.summary || '', turns: compactTurns(compacted) }) } ]; diff --git a/src/GameServer/Bot/AI/BotSupplyErrand.js b/src/GameServer/Bot/AI/BotSupplyErrand.js index a340e218..b5e7f92d 100644 --- a/src/GameServer/Bot/AI/BotSupplyErrand.js +++ b/src/GameServer/Bot/AI/BotSupplyErrand.js @@ -4,6 +4,7 @@ const TradeService = invoke('GameServer/Bot/TradeService'); const ServerResponse = invoke('GameServer/Network/Response'); const TownPathfinder = invoke('GameServer/Bot/AI/TownPathfinder'); const TownRespawn = invoke('GameServer/World/TownRespawn'); +const WorkflowTelemetry = invoke('GameServer/Bot/AI/BotWorkflowTelemetry'); const MAX_REQUEST_AMOUNT = 5000; @@ -16,6 +17,9 @@ function itemTemplate(selfId) { } function townDestination(offer, bot, BotAI) { + if (Number.isFinite(Number(offer?.locX)) && Number.isFinite(Number(offer?.locY)) && Number.isFinite(Number(offer?.locZ))) { + return { name: offer.town, x: Number(offer.locX), y: Number(offer.locY), z: Number(offer.locZ) }; + } const town = TownPathfinder.towns?.find((candidate) => candidate.name === offer?.town); if (town?.center) { return { name: town.name, x: town.center.locX, y: town.center.locY, z: town.center.locZ }; @@ -48,7 +52,7 @@ function request(session, playerSession, itemSelfId, requestedAmount) { return { ok: false, reason: 'supply_errand_active' }; } - const Market = MarketOpportunity.bestSupplyOffer(selfId); + const Market = MarketOpportunity.bestSupplyOffer(selfId, { amount }); if (!Market) return { ok: false, reason: 'supply_not_available' }; const template = itemTemplate(selfId); @@ -93,12 +97,14 @@ function request(session, playerSession, itemSelfId, requestedAmount) { totalCost: cost, sourceType: Market.sourceType, sourceId: Market.sourceId, + sourceName: Market.sourceName, + workflowId: `supply-${bot.fetchId()}-${player.fetchId()}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, target: { - actorId: Market.sourceType === 'private_store' ? Number(Market.sourceId) : null, + actorId: ['private_store', 'configured_store'].includes(Market.sourceType) ? Number(Market.sourceId) || null : null, name: Market.sourceName || `${town.name} general shop`, - locX: town.x, - locY: town.y, - locZ: town.z, + locX: Number(Market.locX ?? town.x), + locY: Number(Market.locY ?? town.y), + locZ: Number(Market.locZ ?? town.z), town: town.name } }; @@ -135,6 +141,17 @@ function request(session, playerSession, itemSelfId, requestedAmount) { session.dataSendToOthers?.(ServerResponse.relationChanged(bot), bot); return { ok: false, reason: 'unsafe_combat_state' }; } + WorkflowTelemetry.recordSupply(session.companionShopping.workflowId, 'requested', { + botId: bot.fetchId(), + playerId: player.fetchId(), + itemSelfId: selfId, + amount, + cost, + sourceType: Market.sourceType, + sourceId: Market.sourceId, + town: town.name, + travel + }, 'pending', 'supply_errand_started'); return { ok: true, outcome: 'pending', @@ -145,6 +162,7 @@ function request(session, playerSession, itemSelfId, requestedAmount) { cost, town: town.name, sourceType: Market.sourceType, + workflowId: session.companionShopping.workflowId, travel }; } @@ -155,21 +173,64 @@ async function purchaseAtDestination(bot, errand) { return { ok: false, reason: 'supply_source_unavailable' }; } - const store = { - storeType: 1, - items: [{ - selfId: Number(errand.itemId), - price: Number(errand.unitPrice), - count: Number(errand.amount) - }] - }; + let store = null; + if (errand.sourceType === 'configured_store') { + const World = invoke('GameServer/World/World'); + const sessions = World.user?.sessions || []; + const source = sessions.find((candidate) => { + const actor = candidate?.actor; + const candidateStore = actor?.fetchPrivateStore?.(); + return Number(candidateStore?.storeType) === 1 && ( + Number(actor.fetchId?.() || 0) === Number(errand.sourceId) || + actor.fetchName?.() === errand.sourceName + ); + }); + store = source?.actor?.fetchPrivateStore?.() || null; + const line = store?.items?.find((entry) => Number(entry.selfId) === Number(errand.itemId)); + if (!source || !line) { + WorkflowTelemetry.recordSupply(errand.workflowId, 'purchase', { botId: bot.fetchId(), itemSelfId: errand.itemId, amount: errand.amount }, 'failed', 'configured_store_unavailable'); + return { ok: false, reason: 'configured_store_unavailable' }; + } + if (Number(line.count) < Number(errand.amount)) { + WorkflowTelemetry.recordSupply(errand.workflowId, 'purchase', { botId: bot.fetchId(), itemSelfId: errand.itemId, amount: errand.amount, available: Number(line.count) }, 'rejected', 'configured_store_stock_changed'); + return { ok: false, reason: 'configured_store_stock_changed', available: Number(line.count) }; + } + if (Number(line.price) !== Number(errand.unitPrice)) { + WorkflowTelemetry.recordSupply(errand.workflowId, 'purchase', { botId: bot.fetchId(), itemSelfId: errand.itemId, amount: errand.amount, price: Number(line.price) }, 'rejected', 'configured_store_price_changed'); + return { ok: false, reason: 'configured_store_price_changed', price: Number(line.price) }; + } + } else { + store = { + storeType: 1, + items: [{ + selfId: Number(errand.itemId), + price: Number(errand.unitPrice), + count: Number(errand.amount) + }] + }; + } try { const bought = await TradeService.buyFromStore(bot, store, Number(errand.itemId), Number(errand.amount)); if (Number(bought.qty) !== Number(errand.amount)) { + WorkflowTelemetry.recordSupply(errand.workflowId, 'purchase', { + botId: bot.fetchId(), + itemSelfId: errand.itemId, + amount: Number(bought.qty || 0), + requestedAmount: Number(errand.amount) + }, 'failed', 'purchase_quantity_mismatch'); return { ok: false, reason: 'purchase_quantity_mismatch', bought }; } const item = bot.backpack?.fetchItemFromSelfId?.(Number(errand.itemId)); - if (!item) return { ok: false, reason: 'purchase_inventory_sync_failed' }; + if (!item) { + WorkflowTelemetry.recordSupply(errand.workflowId, 'purchase', { botId: bot.fetchId(), itemSelfId: errand.itemId, amount: errand.amount }, 'failed', 'purchase_inventory_sync_failed'); + return { ok: false, reason: 'purchase_inventory_sync_failed' }; + } + WorkflowTelemetry.recordSupply(errand.workflowId, 'purchase', { + botId: bot.fetchId(), + itemSelfId: errand.itemId, + amount: Number(bought.qty), + cost: Number(bought.totalAdena) + }); return { ok: true, delta: Number(bought.qty), @@ -178,6 +239,7 @@ async function purchaseAtDestination(bot, errand) { }; } catch (error) { const message = String(error?.message || error || 'purchase_failed'); + WorkflowTelemetry.recordSupply(errand.workflowId, 'purchase', { botId: bot.fetchId(), itemSelfId: errand.itemId, amount: errand.amount }, 'failed', /not enough adena/i.test(message) ? 'not_enough_adena' : message); return { ok: false, reason: /not enough adena/i.test(message) ? 'not_enough_adena' : message }; } } diff --git a/src/GameServer/Bot/AI/BotSupportPlanner.js b/src/GameServer/Bot/AI/BotSupportPlanner.js index 15fe4c07..2d559d91 100644 --- a/src/GameServer/Bot/AI/BotSupportPlanner.js +++ b/src/GameServer/Bot/AI/BotSupportPlanner.js @@ -44,7 +44,6 @@ function supportSkills(actor) { const skills = actor?.skillset?.fetchSkills?.() || actor?.skillset?.skills || []; const overlay = HotBotPolicyOverlay.get(actor?.session); const excluded = new Set(overlay?.buffPolicy?.excluded || []); - const allowed = new Set(overlay?.buffPolicy?.allowed || []); return skills .filter((skill) => skill && !skill.fetchPassive?.()) .filter((skill) => { @@ -60,7 +59,9 @@ function supportSkills(actor) { semantic?.effectType === 'buff' && !EXCLUDED_PARTY_BUFF_EFFECTS.has(semantic.effect) && !excluded.has(effect) && - (allowed.size === 0 || allowed.has(effect)) && + // The current policy is deny-by-exception. Ignore the legacy + // `allowed` field so one old `allow` command cannot turn the + // whole support package exclusive. ['friendly', 'ally', 'party'].includes(semantic.target); }); } diff --git a/src/GameServer/Bot/AI/BotToolRegistry.js b/src/GameServer/Bot/AI/BotToolRegistry.js index 58ddc3b4..90b7146d 100644 --- a/src/GameServer/Bot/AI/BotToolRegistry.js +++ b/src/GameServer/Bot/AI/BotToolRegistry.js @@ -249,7 +249,8 @@ function execute(context = {}) { if (mutationStore && mutationKey) mutationStore.set(mutationKey, normalized); audit({ ...context, decision: { ...decision, action } }, auditOutcome(normalized), normalized.reason, { idempotent: false, - currentRevision: worldRevision(session) + currentRevision: worldRevision(session), + workflowId: normalized.workflowId || normalized.workflow?.id || null }); return normalized; } diff --git a/src/GameServer/Bot/AI/BotTownTravel.js b/src/GameServer/Bot/AI/BotTownTravel.js index 235f814f..c5a53783 100644 --- a/src/GameServer/Bot/AI/BotTownTravel.js +++ b/src/GameServer/Bot/AI/BotTownTravel.js @@ -26,7 +26,7 @@ function clearCombatTrip(session) { session.townEscape = undefined; } -function revealInterruptedSupplyErrand(session, bot) { +function revealSupplyErrand(session, bot, options = {}) { if (session?.supplyErrandHidden !== true) return; session.supplyErrandHidden = false; session.dataSendToOthers?.(ServerResponse.charInfo(bot), bot); @@ -34,7 +34,7 @@ function revealInterruptedSupplyErrand(session, bot) { // Do not leave a half-started errand blocking the next player request. // The combat state remains authoritative; the player can ask again once // the party is safe. - if (session.companionShopping?.kind === 'player_resource_purchase') { + if (options.clearErrand === true && session.companionShopping?.kind === 'player_resource_purchase') { session.companionShopping = undefined; session.shoppingTarget = undefined; session.resumeAfterShopping = undefined; @@ -42,6 +42,10 @@ function revealInterruptedSupplyErrand(session, bot) { } } +function revealInterruptedSupplyErrand(session, bot) { + revealSupplyErrand(session, bot, { clearErrand: true }); +} + function interruptEscape(session, bot) { if (!session.townEscape) return false; session.townEscape = undefined; @@ -133,4 +137,13 @@ function request(session, bot, BotAI, reason, options = {}) { return 'walk'; } -module.exports = { SOE_CAST_MS, SOE_DISTANCE, clearCombatTrip, hasCombatThreat, inCombat, interruptEscape, request }; +module.exports = { + SOE_CAST_MS, + SOE_DISTANCE, + clearCombatTrip, + hasCombatThreat, + inCombat, + interruptEscape, + request, + revealSupplyErrand +}; diff --git a/src/GameServer/Bot/AI/BotWorkflowTelemetry.js b/src/GameServer/Bot/AI/BotWorkflowTelemetry.js new file mode 100644 index 00000000..730fd18f --- /dev/null +++ b/src/GameServer/Bot/AI/BotWorkflowTelemetry.js @@ -0,0 +1,34 @@ +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); + +function text(value, max = 160) { + return String(value || '').replace(/\s+/g, ' ').trim().slice(0, max); +} + +function recordSupply(workflowId, phase, payload = {}, outcome = 'completed', reason = null) { + if (!workflowId) return null; + const safePayload = { + workflowId: text(workflowId, 128), + phase: text(phase, 64), + ...payload + }; + const observation = LangfuseTracing.startObservation( + `bot.workflow.supply.${safePayload.phase}`, + safePayload, + { + workflowId: safePayload.workflowId, + phase: safePayload.phase, + botId: payload.botId || null, + playerId: payload.playerId || null, + outcome, + reason: reason || null + }, + 'workflow' + ); + observation?.end({ ...safePayload, outcome, reason: reason || null }, + outcome === 'failed' || outcome === 'rejected' + ? LangfuseTracing.observationStatus({ applied: false, reason: reason || outcome }) + : {}); + return safePayload; +} + +module.exports = { recordSupply }; diff --git a/src/GameServer/Bot/AI/States/FollowingState.js b/src/GameServer/Bot/AI/States/FollowingState.js index 77d333c5..213fd0a6 100644 --- a/src/GameServer/Bot/AI/States/FollowingState.js +++ b/src/GameServer/Bot/AI/States/FollowingState.js @@ -7,6 +7,7 @@ const BotSkillCapabilities = invoke('GameServer/Bot/AI/BotSkillCapabilities'); const BotSupportPlanner = invoke('GameServer/Bot/AI/BotSupportPlanner'); const PartyAwareness = invoke('GameServer/Bot/AI/PartyAwareness'); const PartyCompanionService = invoke('GameServer/Bot/AI/PartyCompanionService'); +const WorkflowTelemetry = invoke('GameServer/Bot/AI/BotWorkflowTelemetry'); const PartyPulling = invoke('GameServer/Bot/AI/PartyPulling'); const PartyRevivalService = invoke('GameServer/Bot/AI/PartyRevivalService'); const BotPartyChat = invoke('GameServer/Bot/AI/BotPartyChat'); @@ -716,7 +717,7 @@ function deliverPurchasedResources(session, bot, playerSession) { const leaderBusy = !!( playerSession.actor.state?.fetchHits?.() || playerSession.actor.state?.fetchCasts?.() || - playerSession.actor.fetchDestId?.() + playerSession.actor.state?.fetchCombats?.() ); if (partyThreat || leaderBusy) { const now = Date.now(); @@ -745,6 +746,11 @@ function deliverPurchasedResources(session, bot, playerSession) { templates: [`I brought ${delivery.itemName}, but could not open trade (${trade.reason}).`] }); session.pendingResourceDelivery = undefined; + WorkflowTelemetry.recordSupply(delivery.workflowId, 'trade', { + botId: bot.fetchId(), + playerId: delivery.playerId, + amount: delivery.amount + }, 'failed', trade.reason || 'trade_open_failed'); return false; } BotPartyChat.announce(session, { @@ -753,6 +759,12 @@ function deliverPurchasedResources(session, bot, playerSession) { templates: [`I brought ${delivery.amount} ${delivery.itemName}. Please confirm the trade.`] }); session.pendingResourceDelivery = undefined; + WorkflowTelemetry.recordSupply(delivery.workflowId, 'trade', { + botId: bot.fetchId(), + playerId: delivery.playerId, + amount: delivery.amount, + objectId: delivery.objectId + }, 'completed', 'native_trade_open'); recordRoleDecision(session, bot, 'deliver_resources', 'native_trade_open', { targetId: delivery.playerId }); return true; } diff --git a/src/GameServer/Bot/AI/States/ShoppingState.js b/src/GameServer/Bot/AI/States/ShoppingState.js index d842ae46..e27d1062 100644 --- a/src/GameServer/Bot/AI/States/ShoppingState.js +++ b/src/GameServer/Bot/AI/States/ShoppingState.js @@ -9,6 +9,7 @@ const LifeState = invoke('GameServer/Bot/Population/BotLifeState'); const GoalExecutor = invoke('GameServer/Bot/Goals/GoalExecutor'); const Cooldown = invoke('GameServer/Bot/Population/Cooldown'); const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); +const WorkflowTelemetry = invoke('GameServer/Bot/AI/BotWorkflowTelemetry'); function findStoreSession(actorId) { const BotManager = invoke('GameServer/Bot/BotManager'); @@ -133,12 +134,20 @@ module.exports = { session.pendingResourceDelivery = { playerSession: companionErrand.playerSession, playerId: companionErrand.playerId, + workflowId: companionErrand.workflowId, objectId: purchasedItem.fetchId(), itemSelfId: Number(companionErrand.itemId), itemName: companionErrand.itemName, amount: Number(companionErrand.amount), purchasedAt: Date.now() }; + WorkflowTelemetry.recordSupply(companionErrand.workflowId, 'return', { + botId: bot.fetchId(), + playerId: companionErrand.playerId, + itemSelfId: companionErrand.itemId, + amount: purchased.delta, + cost: purchased.cost + }, 'pending', 'purchase_complete_returning'); deliveryReady = true; session.lastTradeSummary = `bought ${purchased.delta}x ${companionErrand.itemName} for ${formatAdena(purchased.cost)}a to deliver to ${companionErrand.playerSession?.actor?.fetchName?.() || 'the leader'}`; BotAI.say(session, `Bought ${purchased.delta}x ${companionErrand.itemName}. Returning with them now.`); @@ -163,6 +172,12 @@ module.exports = { ? 'I am short on Adena for that purchase. Give me some and I will try again.' : 'I could not complete that supply purchase. I am returning now.'); utils.infoWarn('Shopping', 'requested supply purchase failed for %s: %s', bot.fetchName(), error.message); + WorkflowTelemetry.recordSupply(companionErrand.workflowId, 'return', { + botId: bot.fetchId(), + playerId: companionErrand.playerId, + itemSelfId: companionErrand.itemId, + amount: companionErrand.amount + }, 'failed', error?.message || 'purchase_failed'); } this.scheduleResourceReturn(session, bot, BotAI, { deliveryReady }); return; @@ -342,6 +357,7 @@ module.exports = { scheduleResourceReturn(session, bot, BotAI, options = {}) { setTimeout(() => { const resume = session.resumeAfterShopping; + const workflowId = session.companionShopping?.workflowId || session.pendingResourceDelivery?.workflowId || resume?.workflowId || options.workflowId; const leaderSession = resume?.followPlayerSession; session.plan = session.partyCompanion === true && leaderSession?.actor?.fetchIsOnline?.() ? 'following' @@ -362,16 +378,26 @@ module.exports = { // is away. Reappear in a valid companion slot instead of // making the player watch a long return route. bot.setLocXYZ?.(destination); - session.supplyErrandHidden = false; - const Response = invoke('GameServer/Network/Response'); - session.dataSendToOthers?.(Response.charInfo(bot), bot); - session.dataSendToOthers?.(Response.relationChanged(bot), bot); + BotTownTravel.revealSupplyErrand(session, bot); BotAI.say(session, options.deliveryReady === true ? 'I am back with the new supplies. I will open trade when the party is safe.' : 'I am back, but I could not complete that purchase.'); + WorkflowTelemetry.recordSupply(workflowId, 'return', { + botId: bot.fetchId(), + playerId: leaderSession?.actor?.fetchId?.() || null, + deliveryReady: options.deliveryReady === true + }, options.deliveryReady === true ? 'completed' : 'failed', options.deliveryReady === true ? 'returned_to_leader' : 'purchase_failed'); } else { session.pendingResourceDelivery = undefined; - session.supplyErrandHidden = false; + // The leader may have disconnected during the errand. Reveal + // through the same packet path even when there is no return + // target; otherwise every nearby client keeps a ghost bot. + BotTownTravel.revealSupplyErrand(session, bot); + WorkflowTelemetry.recordSupply(workflowId, 'return', { + botId: bot.fetchId(), + deliveryReady: false, + leaderOnline: false + }, 'failed', 'leader_offline'); } }, 1000); } diff --git a/src/GameServer/Bot/Economy/MarketOpportunity.js b/src/GameServer/Bot/Economy/MarketOpportunity.js index 762909c4..6cd8ee75 100644 --- a/src/GameServer/Bot/Economy/MarketOpportunity.js +++ b/src/GameServer/Bot/Economy/MarketOpportunity.js @@ -87,25 +87,52 @@ function npcOffersAll(selfId) { return Object.keys(TOWN_NPC_SELLERS).flatMap((town) => npcOffers(selfId, town)); } +function configuredStoreSession(storeName) { + const sessions = World.user?.sessions; + if (!Array.isArray(sessions)) return null; + return sessions.find((session) => { + const actor = session?.actor; + const store = actor?.fetchPrivateStore?.(); + return actor?.fetchName?.() === storeName && Number(store?.storeType) === 1; + }) || null; +} + function configuredStoreOffers(selfId) { return Object.entries(MerchantStoreConfigs) .flatMap(([storeName, store]) => { if (store?.storeType !== 1 || !store.town) return []; - const line = (store.items || []).find((entry) => Number(entry.selfId) === Number(selfId) && Number(entry.count) > 0); + const liveSession = configuredStoreSession(storeName); + const liveStore = liveSession?.actor?.fetchPrivateStore?.(); + // In a running world, a configured offer is valid only when the + // actual merchant bot is spawned and still has the item. The + // config-only fallback keeps lightweight catalog/unit fixtures + // usable before World.init(), but purchase execution never trusts + // that fallback as a live source. + const line = liveStore + ? (liveStore.items || []).find((entry) => Number(entry.selfId) === Number(selfId) && Number(entry.count) > 0) + : (Array.isArray(World.user?.sessions) ? null : (store.items || []).find((entry) => Number(entry.selfId) === Number(selfId) && Number(entry.count) > 0)); if (!line) return []; - const price = TradeService.ratedPrice(selfId, line.priceRate ?? 1); + const price = liveStore ? Number(line.price) : TradeService.ratedPrice(selfId, line.priceRate ?? 1); if (price <= 0) return []; + const actor = liveSession?.actor; return [{ sourceType: 'configured_store', - sourceId: storeName, - sourceName: storeName, + sourceId: actor ? Number(actor.fetchId?.() || 0) : storeName, + sourceName: actor?.fetchName?.() || storeName, town: store.town, selfId: Number(selfId), itemName: itemName(selfId), price, count: Number(line.count), available: true, - storeConfig: store + live: !!liveStore, + locX: Number(actor?.fetchLocX?.() ?? store.locX ?? 0), + locY: Number(actor?.fetchLocY?.() ?? store.locY ?? 0), + locZ: Number(actor?.fetchLocZ?.() ?? store.locZ ?? 0), + storeConfig: store, + session: liveSession || undefined, + store: liveStore || undefined, + storeItem: liveStore ? line : undefined }]; }); } @@ -198,13 +225,15 @@ function bestOffer(selfId, options = {}) { // valid item look impossible whenever its NPC list lived elsewhere. function bestSupplyOffer(selfId, options = {}) { const budget = Number.isFinite(Number(options.budget)) ? Number(options.budget) : Infinity; + const amount = Math.max(1, Number(options.amount) || 1); // A companion supply errand uses a server-owned NPC or configured city // merchant. Dynamic private/cold offers remain available to the market // planner and are never guessed as a guaranteed supply source. const offers = [...npcOffersAll(selfId), ...configuredStoreOffers(selfId)]; return offers - .filter((offer) => offer.available && Number(offer.price) <= budget) - .sort((a, b) => Number(a.sourceType !== 'npc') - Number(b.sourceType !== 'npc') || a.price - b.price)[0] || null; + .filter((offer) => offer.available && Number(offer.price) <= budget && + (offer.sourceType === 'npc' || Number(offer.count) >= amount)) + .sort((a, b) => Number(a.price) - Number(b.price) || Number(a.sourceType !== 'npc') - Number(b.sourceType !== 'npc'))[0] || null; } function resolveSupplyItem(value) { @@ -238,7 +267,8 @@ function supplyCatalog(limit = 96) { ].filter(Boolean))]; return ids .map((selfId) => { - const offer = [...npcOffersAll(selfId), ...configuredStoreOffers(selfId)].sort((a, b) => a.price - b.price)[0]; + const offer = [...npcOffersAll(selfId), ...configuredStoreOffers(selfId)] + .sort((a, b) => Number(a.price) - Number(b.price) || Number(a.sourceType !== 'npc') - Number(b.sourceType !== 'npc'))[0]; return offer ? { selfId, name: offer.itemName, diff --git a/tests/test_bot_conversation_summary.js b/tests/test_bot_conversation_summary.js index f5e7c947..75670953 100644 --- a/tests/test_bot_conversation_summary.js +++ b/tests/test_bot_conversation_summary.js @@ -26,7 +26,10 @@ async function main() { choices: [{ message: { content: JSON.stringify({ summary: 'The player asked for support and prefers concise answers.', openTopics: ['confirm the next support action'], - promises: ['reply truthfully after a validated cast'] + promises: [ + { turnId: 'turn-4', text: 'reply truthfully after a validated cast' }, + { turnId: 'turn-5', text: 'do not preserve this plain conversational promise' } + ] }) } }], usage: { prompt_tokens: 80, completion_tokens: 30, total_tokens: 110 } }; @@ -41,13 +44,18 @@ async function main() { turnId: `turn-${i}`, role: i % 2 ? 'player' : 'bot', channel: 'tell', - text: i % 2 ? `Please help with support request ${i}.` : `I will check request ${i}.` + text: i % 2 ? `Please help with support request ${i}.` : `I will check request ${i}.`, + meta: i === 4 + ? { action: 'give_resources', serverApplied: true, actionResult: { ok: true, outcome: 'pending' } } + : null }); } const first = await BotConversationSummarizer.summarize({ playerId: 10, botId: 20, threshold: 24 }); assert.strictEqual(first.ok, true); assert.strictEqual(BotInferenceBudget.status({ actor: { fetchId: () => 20 } }).requests, 1, 'summary inference must consume the bot budget'); assert.match(first.summary, /Open topics/); + assert.match(first.summary, /reply truthfully after a validated cast/); + assert(!first.summary.includes('do not preserve this plain conversational promise'), 'plain model promises must not survive compaction'); const compacted = await BotConversationStore.context(10, 20, { limit: 20 }); assert.match(compacted.summary, /concise answers/); assert.strictEqual(compacted.recentTurns.length, 8); diff --git a/tests/test_bot_llm_party_policy.js b/tests/test_bot_llm_party_policy.js index c8eb0170..4a0e1982 100644 --- a/tests/test_bot_llm_party_policy.js +++ b/tests/test_bot_llm_party_policy.js @@ -74,6 +74,14 @@ function main() { ); assert.strictEqual(candidate.reply, decision().reply, 'candidate discovery must remain an LLM reply'); assert.notStrictEqual(candidate.reason, 'party_policy:already_grouped'); + + const groupSession = { ...socialSession, partyCompanion: true, followPlayerSession: playerSession }; + const stopPulling = BotBrain.applyPartyPolicy(groupSession, decision(), { playerSession }, 'everyone stop pulling'); + assert.strictEqual(stopPulling.action, 'say', 'a pull-policy request must reach the LLM/tool layer'); + const buffRequest = BotBrain.applyPartyPolicy(groupSession, decision(), { playerSession }, 'party stop using Might'); + assert.strictEqual(buffRequest.action, 'say', 'a buff-policy request must not become a positional hold'); + const hold = BotBrain.applyPartyPolicy(groupSession, decision(), { playerSession }, 'everybody hold position here'); + assert.strictEqual(hold.action, 'stay_party'); console.log('LLM party policy checks passed'); } diff --git a/tests/test_bot_support_planner.js b/tests/test_bot_support_planner.js index 499907ea..84c0836d 100644 --- a/tests/test_bot_support_planner.js +++ b/tests/test_bot_support_planner.js @@ -4,6 +4,7 @@ require('../src/Global'); const BotSupportPlanner = invoke('GameServer/Bot/AI/BotSupportPlanner'); const EffectStore = invoke('GameServer/Effects/EffectStore'); +const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); function skill(id, name, level, effect, stats, target = 'friendly', type = null) { return { @@ -41,10 +42,21 @@ const shieldOne = skill(1040, 'Shield', 1, 'shield', { pDefMul: 1.08 }); const chantOfLife = skill(1229, 'Chant of Life', 1, 'chant_of_life', {}, 'friendly', 'hot'); const kissOfEva = skill(1073, 'Kiss of Eva', 2, 'kiss_of_eva', { breath: 7 }); const soulShieldTwo = skill(1010, 'Soul Shield', 2, 'soul_shield', { pDefMul: 1.12 }); +const empower = skill(1059, 'Empower', 1, 'empower', { mAtkMul: 1.2 }); const shaman = actor('Noren', 49, [soulShieldTwo]); const mage = actor('Saren', 25, [shieldOne]); const target = actor('Slava', 0); +const policyProvider = actor('PolicyProvider', 25, [shieldOne, empower]); +policyProvider.session = { actor: policyProvider }; +HotBotPolicyOverlay.set(policyProvider.session, { + // This is a legacy persisted allow-list. It must not make support + // rotation exclusive after the policy semantics changed to deny-only. + buffPolicy: { allowed: ['shield'], excluded: [] } +}, { ownerId: 1 }); +assert.strictEqual(BotSupportPlanner.supportSkills(policyProvider).length, 2, 'allowing one buff must not disable the other useful party buffs'); +HotBotPolicyOverlay.clear(policyProvider.session, 'test_reset'); + assert.deepStrictEqual( BotSupportPlanner.supportSkills(actor('ChantBuffer', 49, [chantOfLife])), [], diff --git a/tests/test_bot_town_travel.js b/tests/test_bot_town_travel.js index c015b7a5..f34076fc 100644 --- a/tests/test_bot_town_travel.js +++ b/tests/test_bot_town_travel.js @@ -3,6 +3,7 @@ const assert = require('assert'); require('../src/Global'); const BotTownTravel = invoke('GameServer/Bot/AI/BotTownTravel'); +const Response = invoke('GameServer/Network/Response'); function botAt(loc) { let casts = false; @@ -36,6 +37,8 @@ function session(actor) { } const originalSetTimeout = global.setTimeout; +const originalCharInfo = Response.charInfo; +const originalRelationChanged = Response.relationChanged; try { const timers = []; @@ -43,6 +46,8 @@ try { timers.push({ fn, delay }); return 0; }; + Response.charInfo = () => Buffer.from('char-info'); + Response.relationChanged = () => Buffer.from('relation'); const farBot = botAt({ locX: 0, locY: 0, locZ: 0 }); const farSession = session(farBot); @@ -79,7 +84,18 @@ try { assert.strictEqual(resumedResult, 'escape', 'pending town trip should start after combat ends'); assert.strictEqual(fightingSession.pendingTownTrip, undefined, 'started town trip should clear its pending marker'); + const visiblePackets = []; + const interruptedBot = botAt({ locX: 10, locY: 20, locZ: 30 }); + const interruptedSession = session(interruptedBot); + interruptedSession.supplyErrandHidden = true; + interruptedSession.dataSendToOthers = (packet) => visiblePackets.push(packet); + BotTownTravel.revealSupplyErrand(interruptedSession, interruptedBot); + assert.strictEqual(interruptedSession.supplyErrandHidden, false, 'terminal supply workflow must reveal the bot'); + assert.strictEqual(visiblePackets.length, 2, 'reveal must broadcast both character and relation packets'); + console.log('Bot town travel checks passed'); } finally { global.setTimeout = originalSetTimeout; + Response.charInfo = originalCharInfo; + Response.relationChanged = originalRelationChanged; } diff --git a/tests/test_llm_configured_supply_store.js b/tests/test_llm_configured_supply_store.js new file mode 100644 index 00000000..03800dca --- /dev/null +++ b/tests/test_llm_configured_supply_store.js @@ -0,0 +1,115 @@ +const assert = require('assert'); + +require('../src/Global'); + +const DataCache = invoke('GameServer/DataCache'); +const World = invoke('GameServer/World/World'); +const MarketOpportunity = invoke('GameServer/Bot/Economy/MarketOpportunity'); +const BotSupplyErrand = invoke('GameServer/Bot/AI/BotSupplyErrand'); +const TradeService = invoke('GameServer/Bot/TradeService'); +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); + +function inventoryItem(id, selfId, amount) { + let count = amount; + return { + fetchId: () => id, + fetchSelfId: () => selfId, + fetchAmount: () => count, + setAmount: (value) => { count = value; }, + fetchName: () => 'Varnish' + }; +} + +async function main() { + const originalItems = DataCache.items; + const originalWorldUser = World.user; + const originalBuy = TradeService.buyFromStore; + const originalStartObservation = LangfuseTracing.startObservation; + const observations = []; + const botItem = inventoryItem(700, 1864, 0); + const bot = { + fetchId: () => 7100, + backpack: { fetchItemFromSelfId: (selfId) => Number(selfId) === 1864 ? botItem : null } + }; + const store = { + storeType: 1, + town: 'Talking Island', + items: [{ selfId: 1864, price: 10, count: 2 }] + }; + const merchant = { + fetchId: () => 7200, + fetchName: () => 'Mira', + fetchLocX: () => -84168, + fetchLocY: () => 244729, + fetchLocZ: () => -3730, + fetchPrivateStore: () => store + }; + const merchantSession = { actor: merchant }; + let calls = 0; + try { + DataCache.items = [{ selfId: 1864, template: { name: 'Varnish' }, etc: { stackable: true } }]; + World.user = { sessions: [merchantSession] }; + LangfuseTracing.startObservation = (name, input, metadata) => { + observations.push({ name, input, metadata }); + return { end() {} }; + }; + + const offer = MarketOpportunity.bestSupplyOffer(1864); + assert(offer, 'live configured merchant should produce a supply offer'); + assert.strictEqual(offer.sourceType, 'configured_store'); + assert.strictEqual(offer.sourceId, 7200); + assert.strictEqual(offer.count, 2); + assert.strictEqual(offer.price, 10); + assert.strictEqual(MarketOpportunity.bestSupplyOffer(1864, { amount: 3 }), null, 'a finite store must not be selected for an oversized request'); + + TradeService.buyFromStore = async (_bot, liveStore, selfId, amount) => { + calls += 1; + assert.strictEqual(liveStore, store); + assert.strictEqual(selfId, 1864); + const line = liveStore.items.find((entry) => entry.selfId === selfId); + line.count -= amount; + botItem.setAmount(botItem.fetchAmount() + amount); + return { qty: amount, totalAdena: amount * line.price, name: 'Varnish' }; + }; + + const overdraw = await BotSupplyErrand.purchaseAtDestination(bot, { + workflowId: 'workflow-stock-reject', + sourceType: 'configured_store', + sourceId: 7200, + sourceName: 'Mira', + itemId: 1864, + amount: 3, + unitPrice: 10 + }); + assert.strictEqual(overdraw.ok, false); + assert.strictEqual(overdraw.reason, 'configured_store_stock_changed'); + assert.strictEqual(calls, 0, 'finite stock must be checked before TradeService'); + assert.strictEqual(store.items[0].count, 2); + + const bought = await BotSupplyErrand.purchaseAtDestination(bot, { + workflowId: 'workflow-stock-ok', + sourceType: 'configured_store', + sourceId: 7200, + sourceName: 'Mira', + itemId: 1864, + amount: 1, + unitPrice: 10 + }); + assert.strictEqual(bought.ok, true); + assert.strictEqual(calls, 1); + assert.strictEqual(store.items[0].count, 1); + assert.strictEqual(botItem.fetchAmount(), 1); + assert(observations.some((entry) => entry.name === 'bot.workflow.supply.purchase' && entry.metadata.workflowId === 'workflow-stock-ok')); + console.log('Configured supply store checks passed'); + } finally { + DataCache.items = originalItems; + World.user = originalWorldUser; + TradeService.buyFromStore = originalBuy; + LangfuseTracing.startObservation = originalStartObservation; + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_llm_supply_errand.js b/tests/test_llm_supply_errand.js index c97eaa4d..3be9662b 100644 --- a/tests/test_llm_supply_errand.js +++ b/tests/test_llm_supply_errand.js @@ -48,7 +48,8 @@ async function main() { fetchIsOnline: () => true, fetchLocX: () => 0, fetchLocY: () => 0, - fetchLocZ: () => 0 + fetchLocZ: () => 0, + state: { fetchHits: () => false, fetchCasts: () => false, fetchCombats: () => false, fetchDestId: () => 1000 } } }; const bot = { @@ -110,6 +111,21 @@ async function main() { assert.deepStrictEqual(offered, { objectId: 11, amount: 200 }); assert.strictEqual(session.pendingResourceDelivery, undefined); + // Selecting the bot is normal before a native trade request. A + // selected target is not combat and must not keep delivery pending. + session.pendingResourceDelivery = { + playerSession: player, + playerId: 100, + objectId: 11, + itemName: 'Soulshot: D-grade', + amount: 1, + purchasedAt: Date.now() + }; + offered = null; + assert.strictEqual(FollowingState.deliverPurchasedResources(session, bot, player), true); + assert.deepStrictEqual(offered, { objectId: 11, amount: 1 }); + assert.strictEqual(session.pendingResourceDelivery, undefined); + // An errand request must not cancel an active fight just because the // player asked at the wrong moment. session.companionShopping = undefined; From 6b7ac25507c8dbe6c24fd54c489860e7245296f6 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:16:19 -0400 Subject: [PATCH 56/59] Fix supply errand atomicity and lifecycle --- scripts/run-tests.js | 2 + src/GameServer/Bot/AI/BotSupplyErrand.js | 33 ++++- src/GameServer/Bot/AI/BotToolRegistry.js | 1 + src/GameServer/Bot/AI/BotTownTravel.js | 44 ++++-- src/GameServer/Bot/AI/BotWorkflowTelemetry.js | 61 ++++++-- src/GameServer/Bot/AI/LangfuseTracing.js | 58 +++++--- .../Bot/AI/States/FollowingState.js | 19 ++- src/GameServer/Bot/AI/States/ShoppingState.js | 51 +++++-- src/GameServer/Bot/BotAI.js | 5 + src/GameServer/Bot/BotTradeService.js | 72 ++++++--- .../Bot/Population/BackgroundResolver.js | 32 ++++ src/GameServer/Bot/Population/BotLifeState.js | 10 ++ .../Bot/Population/PopulationService.js | 1 + src/GameServer/Bot/TradeService.js | 78 ++++++++-- tests/test_llm_supply_errand.js | 49 ++++++- tests/test_supply_trade_lifecycle.js | 138 ++++++++++++++++++ tests/test_trade_store_atomicity.js | 104 +++++++++++++ 17 files changed, 652 insertions(+), 106 deletions(-) create mode 100644 tests/test_supply_trade_lifecycle.js create mode 100644 tests/test_trade_store_atomicity.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index ff7ed79e..44051253 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -36,6 +36,8 @@ const tests = [ 'tests/test_llm_party_regroup.js', 'tests/test_llm_supply_errand.js', 'tests/test_llm_configured_supply_store.js', + 'tests/test_trade_store_atomicity.js', + 'tests/test_supply_trade_lifecycle.js', 'tests/test_llm_skill_priority_tools.js', 'tests/test_llm_equipment_tools.js', 'tests/test_llm_trade_tools.js', diff --git a/src/GameServer/Bot/AI/BotSupplyErrand.js b/src/GameServer/Bot/AI/BotSupplyErrand.js index b5e7f92d..ff23870c 100644 --- a/src/GameServer/Bot/AI/BotSupplyErrand.js +++ b/src/GameServer/Bot/AI/BotSupplyErrand.js @@ -5,6 +5,7 @@ const ServerResponse = invoke('GameServer/Network/Response'); const TownPathfinder = invoke('GameServer/Bot/AI/TownPathfinder'); const TownRespawn = invoke('GameServer/World/TownRespawn'); const WorkflowTelemetry = invoke('GameServer/Bot/AI/BotWorkflowTelemetry'); +const LifeState = invoke('GameServer/Bot/Population/BotLifeState'); const MAX_REQUEST_AMOUNT = 5000; @@ -86,6 +87,7 @@ function request(session, playerSession, itemSelfId, requestedAmount) { botStay: false, stayLocation: null }; + const workflowStartedAt = Date.now(); session.companionShopping = { kind: 'player_resource_purchase', playerSession, @@ -98,7 +100,9 @@ function request(session, playerSession, itemSelfId, requestedAmount) { sourceType: Market.sourceType, sourceId: Market.sourceId, sourceName: Market.sourceName, - workflowId: `supply-${bot.fetchId()}-${player.fetchId()}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + workflowId: `supply-${bot.fetchId()}-${player.fetchId()}-${workflowStartedAt}-${Math.random().toString(36).slice(2, 8)}`, + startedAt: workflowStartedAt, + expiresAt: workflowStartedAt + 10 * 60 * 1000, target: { actorId: ['private_store', 'configured_store'].includes(Market.sourceType) ? Number(Market.sourceId) || null : null, name: Market.sourceName || `${town.name} general shop`, @@ -115,8 +119,10 @@ function request(session, playerSession, itemSelfId, requestedAmount) { bot.unselect?.(); bot.automation?.abortAll?.(bot); - // Hide the actor before the escape cast. The actual movement and purchase - // remain server-side; only the final return and native trade are visible. + // Hide and park the actor before the escape cast. The supply workflow is + // persisted as cold and the normal AI loop is stopped while it is away; + // the actor is intentionally retained only as a server-side inventory + // handle until the destination purchase has completed. hideForSupply(session, bot); const travel = TownTravel.request( session, @@ -128,7 +134,12 @@ function request(session, playerSession, itemSelfId, requestedAmount) { preserveShoppingTarget: true, destinationTown: town, forceScrollOfEscape: true, - announce: false + announce: false, + onArrival: () => { + session.supplyErrandPhase = 'shopping'; + BotAI.init?.(session); + BotAI.wakeup?.(session, { urgent: true }); + } } ); if (travel === 'deferred') { @@ -141,6 +152,16 @@ function request(session, playerSession, itemSelfId, requestedAmount) { session.dataSendToOthers?.(ServerResponse.relationChanged(bot), bot); return { ok: false, reason: 'unsafe_combat_state' }; } + if (travel === 'escape') { + session.supplyErrandPhase = 'cold'; + BotAI.stop?.(session); + const workflowId = session.companionShopping.workflowId; + Promise.resolve().then(() => LifeState.markCold(session, 'supply_errand')).then((state) => { + if (state && session.companionShopping?.workflowId === workflowId) { + session.coldLifeState = state; + } + }).catch(() => {}); + } WorkflowTelemetry.recordSupply(session.companionShopping.workflowId, 'requested', { botId: bot.fetchId(), playerId: player.fetchId(), @@ -210,7 +231,9 @@ async function purchaseAtDestination(bot, errand) { }; } try { - const bought = await TradeService.buyFromStore(bot, store, Number(errand.itemId), Number(errand.amount)); + const bought = await TradeService.buyFromStore(bot, store, Number(errand.itemId), Number(errand.amount), { + expectedUnitPrice: Number(errand.unitPrice) + }); if (Number(bought.qty) !== Number(errand.amount)) { WorkflowTelemetry.recordSupply(errand.workflowId, 'purchase', { botId: bot.fetchId(), diff --git a/src/GameServer/Bot/AI/BotToolRegistry.js b/src/GameServer/Bot/AI/BotToolRegistry.js index 90b7146d..532d618a 100644 --- a/src/GameServer/Bot/AI/BotToolRegistry.js +++ b/src/GameServer/Bot/AI/BotToolRegistry.js @@ -131,6 +131,7 @@ function audit(context, outcome, reason, meta = {}) { botId: actorId(context.session), playerId: playerId(context), turnId: turnId(context), + workflowId: meta.workflowId || null, outcome, reason, phase: outcome === 'requested' ? 'request' : 'result' diff --git a/src/GameServer/Bot/AI/BotTownTravel.js b/src/GameServer/Bot/AI/BotTownTravel.js index c5a53783..1c4ea943 100644 --- a/src/GameServer/Bot/AI/BotTownTravel.js +++ b/src/GameServer/Bot/AI/BotTownTravel.js @@ -46,17 +46,38 @@ function revealInterruptedSupplyErrand(session, bot) { revealSupplyErrand(session, bot, { clearErrand: true }); } +function restoreSupplyHot(session, bot, reason = 'supply_errand_interrupted') { + if (session?.supplyErrandPhase !== 'cold') { + revealInterruptedSupplyErrand(session, bot); + return Promise.resolve({ ok: true, reason: 'not_parked_cold' }); + } + + session.supplyErrandPhase = 'returning'; + revealInterruptedSupplyErrand(session, bot); + if (session.coldLifeState) { + session.coldLifeState = { ...session.coldLifeState, activity: session.plan || 'hunting' }; + } + const BotAI = invoke('GameServer/Bot/BotAI'); + BotAI.stop?.(session); + const PopulationService = invoke('GameServer/Bot/Population/PopulationService'); + return Promise.resolve().then(() => PopulationService.markHot(session, reason)).catch(() => null).then(() => { + session.supplyErrandPhase = undefined; + BotAI.init?.(session); + return { ok: true, reason }; + }); +} + function interruptEscape(session, bot) { if (!session.townEscape) return false; session.townEscape = undefined; session.pendingTownTrip = session.pendingTownTrip || { reason: 'Finishing the fight before going to town.', requestedAt: Date.now() }; session.plan = 'hunting'; bot.state.setCasts(false); - revealInterruptedSupplyErrand(session, bot); + restoreSupplyHot(session, bot, 'supply_errand_interrupted'); return true; } -function beginEscape(session, bot, town) { +function beginEscape(session, bot, town, options = {}) { const token = Symbol('bot_town_escape'); const skill = { fetchSelfId: () => SOE_SKILL_ID, @@ -76,7 +97,7 @@ function beginEscape(session, bot, town) { bot.state.setCasts(false); session.townEscape = undefined; session.plan = 'hunting'; - revealInterruptedSupplyErrand(session, bot); + restoreSupplyHot(session, bot, 'supply_errand_interrupted'); return; } @@ -84,15 +105,19 @@ function beginEscape(session, bot, town) { session.townEscape = undefined; const destination = { locX: town.x, locY: town.y, locZ: town.z }; if (session.supplyErrandHidden === true) { - // A companion supply run is a cold-style server operation. The - // player sees the request and the eventual return, never a bot - // walking across the world or teleporting through intermediate - // locations. + // A companion supply run is a parked cold operation. The player + // sees the request and the eventual return, never a bot walking + // across the world or teleporting through intermediate locations. bot.setLocXYZ?.(destination); } else { const TeleportTo = invoke('GameServer/Actor/Generics/TeleportTo'); TeleportTo(session, bot, destination); } + if (typeof options.onArrival === 'function') { + Promise.resolve(options.onArrival(destination)).catch((error) => { + utils.infoWarn('BotTownTravel', 'arrival callback failed for %s: %s', bot.fetchName?.() || bot.fetchId?.(), error.message || error); + }); + } Promise.resolve(BotEventJournal.record({ botId: bot.fetchId(), eventType: 'travel_complete', @@ -126,7 +151,7 @@ function request(session, bot, BotAI, reason, options = {}) { BotAI.say(session, options.forceScrollOfEscape === true ? `Using a Scroll of Escape to reach ${town.name}.` : `${town.name} is far away. Using a Scroll of Escape.`); - beginEscape(session, bot, town); + beginEscape(session, bot, town, options); return 'escape'; } @@ -145,5 +170,6 @@ module.exports = { inCombat, interruptEscape, request, - revealSupplyErrand + revealSupplyErrand, + restoreSupplyHot }; diff --git a/src/GameServer/Bot/AI/BotWorkflowTelemetry.js b/src/GameServer/Bot/AI/BotWorkflowTelemetry.js index 730fd18f..ea63c69f 100644 --- a/src/GameServer/Bot/AI/BotWorkflowTelemetry.js +++ b/src/GameServer/Bot/AI/BotWorkflowTelemetry.js @@ -1,33 +1,66 @@ const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); +const activeWorkflows = new Map(); function text(value, max = 160) { return String(value || '').replace(/\s+/g, ' ').trim().slice(0, max); } -function recordSupply(workflowId, phase, payload = {}, outcome = 'completed', reason = null) { +function recordSupply(workflowId, phase, payload = {}, outcome = 'completed', reason = null, options = {}) { if (!workflowId) return null; const safePayload = { workflowId: text(workflowId, 128), phase: text(phase, 64), ...payload }; - const observation = LangfuseTracing.startObservation( - `bot.workflow.supply.${safePayload.phase}`, - safePayload, - { - workflowId: safePayload.workflowId, - phase: safePayload.phase, - botId: payload.botId || null, - playerId: payload.playerId || null, - outcome, - reason: reason || null - }, - 'workflow' - ); + const metadata = { + workflowId: safePayload.workflowId, + phase: safePayload.phase, + botId: payload.botId || null, + playerId: payload.playerId || null, + outcome, + reason: reason || null + }; + let workflow = activeWorkflows.get(safePayload.workflowId); + if (!workflow) { + const root = LangfuseTracing.startObservation( + 'bot.workflow.supply', + safePayload, + { ...metadata, workflowPhase: 'root' }, + 'chain' + ); + workflow = { root, startedAt: Date.now() }; + activeWorkflows.set(safePayload.workflowId, workflow); + } + + // A real Langfuse child observation keeps every phase under one trace. + // Test/fallback implementations may only expose startObservation; keep + // the phase observable there as well instead of dropping telemetry. + const observation = workflow.root?.child + ? workflow.root.child(`bot.workflow.supply.${safePayload.phase}`, safePayload, metadata, 'span') + : LangfuseTracing.startObservation( + `bot.workflow.supply.${safePayload.phase}`, + safePayload, + metadata, + 'span' + ); observation?.end({ ...safePayload, outcome, reason: reason || null }, outcome === 'failed' || outcome === 'rejected' ? LangfuseTracing.observationStatus({ applied: false, reason: reason || outcome }) : {}); + + const terminal = options.terminal === true; + if (terminal) { + workflow.root?.end({ + workflowId: safePayload.workflowId, + outcome, + reason: reason || null, + completedPhase: safePayload.phase, + durationMs: Math.max(0, Date.now() - workflow.startedAt) + }, outcome === 'failed' || outcome === 'rejected' || outcome === 'cancelled' + ? LangfuseTracing.observationStatus({ applied: false, reason: reason || outcome }) + : {}); + activeWorkflows.delete(safePayload.workflowId); + } return safePayload; } diff --git a/src/GameServer/Bot/AI/LangfuseTracing.js b/src/GameServer/Bot/AI/LangfuseTracing.js index cb9acdab..c27d744e 100644 --- a/src/GameServer/Bot/AI/LangfuseTracing.js +++ b/src/GameServer/Bot/AI/LangfuseTracing.js @@ -138,6 +138,45 @@ function updateObservation(observation, attributes) { if (typeof observation?.update === 'function') observation.update(attributes); } +function wrapObservation(observation, cfg) { + if (!observation) return null; + let ended = false; + const wrapped = { + update(value = {}) { + if (ended) return; + updateObservation(observation, { + ...value, + output: value.output === undefined ? undefined : observationOutput(value.output, cfg) + }); + }, + end(value, status = {}) { + if (ended) return; + ended = true; + const attributes = {}; + if (value !== undefined) attributes.output = observationOutput(value, cfg); + if (status.level) attributes.level = status.level; + if (status.statusMessage) attributes.statusMessage = text(status.statusMessage, 240); + if (Object.keys(attributes).length > 0) observation.update(attributes); + observation.end(); + }, + child(name, input, metadata, asType = 'span') { + if (typeof observation.startObservation !== 'function') return null; + try { + const childObservation = observation.startObservation(String(name), { + input: observationInput(input, cfg), + metadata: serializable(metadata || {}) + }, { asType }); + return wrapObservation(childObservation, cfg); + } catch (_) { + return null; + } + }, + traceId: observation.traceId, + id: observation.id + }; + return wrapped; +} + function init(overrides = {}) { if (initialized) return status(); const cfg = config(overrides); @@ -239,24 +278,7 @@ function startObservation(name, input, metadata, asType = 'span') { input: observationInput(input, cfg), metadata: serializable(metadata || {}) }, { asType }); - return { - update(value = {}) { - updateObservation(observation, { - ...value, - output: value.output === undefined ? undefined : observationOutput(value.output, cfg) - }); - }, - end(value, status = {}) { - const attributes = {}; - if (value !== undefined) attributes.output = observationOutput(value, cfg); - if (status.level) attributes.level = status.level; - if (status.statusMessage) attributes.statusMessage = text(status.statusMessage, 240); - if (Object.keys(attributes).length > 0) observation.update(attributes); - observation.end(); - }, - traceId: observation.traceId, - id: observation.id - }; + return wrapObservation(observation, cfg); } catch (_) { return null; } diff --git a/src/GameServer/Bot/AI/States/FollowingState.js b/src/GameServer/Bot/AI/States/FollowingState.js index 213fd0a6..a69f0e8e 100644 --- a/src/GameServer/Bot/AI/States/FollowingState.js +++ b/src/GameServer/Bot/AI/States/FollowingState.js @@ -705,6 +705,11 @@ function deliverPurchasedResources(session, bot, playerSession) { session.pendingResourceDelivery = undefined; return false; } + if (delivery.tradeId && session.activeTrade?.id === delivery.tradeId) { + recordRoleDecision(session, bot, 'deliver_resources', 'trade_pending_confirmation', { targetId: delivery.playerId }); + return true; + } + if (Number(delivery.retryAt || 0) > Date.now()) return true; if (point(bot).distance(point(playerSession.actor)) > 350) { if (!bot.state?.fetchTowards?.()) { bot.moveTo({ from: loc(bot), to: loc(playerSession.actor) }); @@ -736,35 +741,37 @@ function deliverPurchasedResources(session, bot, playerSession) { session, playerSession, delivery.objectId, - delivery.amount + delivery.amount, + { workflowId: delivery.workflowId, supplyDelivery: true } ); if (!trade.ok) { if (trade.reason === 'too_far') return true; + delivery.retryAt = Date.now() + 10000; BotPartyChat.announce(session, { priority: 'informational', key: `resource-delivery-failed:${bot.fetchId()}:${delivery.purchasedAt}`, templates: [`I brought ${delivery.itemName}, but could not open trade (${trade.reason}).`] }); - session.pendingResourceDelivery = undefined; WorkflowTelemetry.recordSupply(delivery.workflowId, 'trade', { botId: bot.fetchId(), playerId: delivery.playerId, amount: delivery.amount - }, 'failed', trade.reason || 'trade_open_failed'); - return false; + }, 'failed', trade.reason || 'trade_open_failed', { terminal: false }); + return true; } + delivery.tradeId = trade.trade?.id || trade.id || null; + delivery.retryAt = undefined; BotPartyChat.announce(session, { priority: 'informational', key: `resource-delivery:${bot.fetchId()}:${delivery.purchasedAt}`, templates: [`I brought ${delivery.amount} ${delivery.itemName}. Please confirm the trade.`] }); - session.pendingResourceDelivery = undefined; WorkflowTelemetry.recordSupply(delivery.workflowId, 'trade', { botId: bot.fetchId(), playerId: delivery.playerId, amount: delivery.amount, objectId: delivery.objectId - }, 'completed', 'native_trade_open'); + }, 'pending', 'native_trade_open'); recordRoleDecision(session, bot, 'deliver_resources', 'native_trade_open', { targetId: delivery.playerId }); return true; } diff --git a/src/GameServer/Bot/AI/States/ShoppingState.js b/src/GameServer/Bot/AI/States/ShoppingState.js index e27d1062..c6080757 100644 --- a/src/GameServer/Bot/AI/States/ShoppingState.js +++ b/src/GameServer/Bot/AI/States/ShoppingState.js @@ -177,7 +177,7 @@ module.exports = { playerId: companionErrand.playerId, itemSelfId: companionErrand.itemId, amount: companionErrand.amount - }, 'failed', error?.message || 'purchase_failed'); + }, 'failed', error?.message || 'purchase_failed', { terminal: false }); } this.scheduleResourceReturn(session, bot, BotAI, { deliveryReady }); return; @@ -358,6 +358,11 @@ module.exports = { setTimeout(() => { const resume = session.resumeAfterShopping; const workflowId = session.companionShopping?.workflowId || session.pendingResourceDelivery?.workflowId || resume?.workflowId || options.workflowId; + const wasSupplyErrand = session.supplyErrandPhase === 'cold' || session.supplyErrandPhase === 'shopping'; + if (wasSupplyErrand) { + session.supplyErrandPhase = 'returning'; + BotAI.stop?.(session); + } const leaderSession = resume?.followPlayerSession; session.plan = session.partyCompanion === true && leaderSession?.actor?.fetchIsOnline?.() ? 'following' @@ -367,6 +372,14 @@ module.exports = { session.companionShopping = undefined; session.resumeAfterShopping = undefined; session.preShopLocation = undefined; + if (session.coldLifeState) { + session.coldLifeState = { ...session.coldLifeState, activity: session.plan }; + } + const restoreHot = () => { + session.supplyErrandPhase = undefined; + BotTownTravel.revealSupplyErrand(session, bot); + if (!session.aiActive) BotAI.init?.(session); + }; if (session.plan === 'following') { const leader = leaderSession.actor; const destination = { @@ -378,26 +391,32 @@ module.exports = { // is away. Reappear in a valid companion slot instead of // making the player watch a long return route. bot.setLocXYZ?.(destination); - BotTownTravel.revealSupplyErrand(session, bot); - BotAI.say(session, options.deliveryReady === true - ? 'I am back with the new supplies. I will open trade when the party is safe.' - : 'I am back, but I could not complete that purchase.'); - WorkflowTelemetry.recordSupply(workflowId, 'return', { - botId: bot.fetchId(), - playerId: leaderSession?.actor?.fetchId?.() || null, - deliveryReady: options.deliveryReady === true - }, options.deliveryReady === true ? 'completed' : 'failed', options.deliveryReady === true ? 'returned_to_leader' : 'purchase_failed'); + const PopulationService = invoke('GameServer/Bot/Population/PopulationService'); + Promise.resolve().then(() => PopulationService.markHot(session, 'supply_errand_return')).catch(() => null).then(() => { + restoreHot(); + BotAI.say(session, options.deliveryReady === true + ? 'I am back with the new supplies. I will open trade when the party is safe.' + : 'I am back, but I could not complete that purchase.'); + WorkflowTelemetry.recordSupply(workflowId, 'return', { + botId: bot.fetchId(), + playerId: leaderSession?.actor?.fetchId?.() || null, + deliveryReady: options.deliveryReady === true + }, options.deliveryReady === true ? 'completed' : 'failed', options.deliveryReady === true ? 'returned_to_leader' : 'purchase_failed', { terminal: options.deliveryReady !== true }); + }); } else { session.pendingResourceDelivery = undefined; // The leader may have disconnected during the errand. Reveal // through the same packet path even when there is no return // target; otherwise every nearby client keeps a ghost bot. - BotTownTravel.revealSupplyErrand(session, bot); - WorkflowTelemetry.recordSupply(workflowId, 'return', { - botId: bot.fetchId(), - deliveryReady: false, - leaderOnline: false - }, 'failed', 'leader_offline'); + const PopulationService = invoke('GameServer/Bot/Population/PopulationService'); + Promise.resolve().then(() => PopulationService.markHot(session, 'supply_errand_leader_offline')).catch(() => null).then(() => { + restoreHot(); + WorkflowTelemetry.recordSupply(workflowId, 'return', { + botId: bot.fetchId(), + deliveryReady: false, + leaderOnline: false + }, 'failed', 'leader_offline', { terminal: true }); + }); } }, 1000); } diff --git a/src/GameServer/Bot/BotAI.js b/src/GameServer/Bot/BotAI.js index 072e7498..cf91c7f5 100644 --- a/src/GameServer/Bot/BotAI.js +++ b/src/GameServer/Bot/BotAI.js @@ -332,6 +332,11 @@ const BotAI = { const bot = session.actor; if (!bot) return; + // Supply errands are parked as a cold workflow while away from the + // leader. No autonomous state, ambient event, or LLM pass may run + // until the destination callback resumes the shopping phase. + if (session.supplyErrandPhase === 'cold' || session.supplyErrandPhase === 'returning') return; + PopulationService.recordHotTick(session); const botDead = bot.isDead(); if (botDead) { diff --git a/src/GameServer/Bot/BotTradeService.js b/src/GameServer/Bot/BotTradeService.js index f3c74bd3..7b262b9c 100644 --- a/src/GameServer/Bot/BotTradeService.js +++ b/src/GameServer/Bot/BotTradeService.js @@ -2,6 +2,7 @@ const DataCache = invoke('GameServer/DataCache'); const Database = invoke('Database'); const ServerResponse = invoke('GameServer/Network/Response'); const SpeckMath = invoke('GameServer/SpeckMath'); +const WorkflowTelemetry = invoke('GameServer/Bot/AI/BotWorkflowTelemetry'); const TRADE_RANGE = 1500; const TRADE_TTL_MS = 2 * 60 * 1000; @@ -33,6 +34,16 @@ function actorName(session) { return session?.actor?.fetchName?.() || session?.accountId || 'unknown'; } +function recordSupplyTrade(trade, outcome, reason = null, terminal = false, payload = {}) { + if (!trade?.workflowId) return; + WorkflowTelemetry.recordSupply(trade.workflowId, 'trade', { + botId: trade.botSession?.actor?.fetchId?.() || null, + playerId: trade.playerSession?.actor?.fetchId?.() || null, + tradeId: trade.id, + ...payload + }, outcome, reason, { terminal }); +} + function actorDistance(a, b) { return new SpeckMath.Point3D(a.fetchLocX(), a.fetchLocY(), a.fetchLocZ()) .distance(new SpeckMath.Point3D(b.fetchLocX(), b.fetchLocY(), b.fetchLocZ())); @@ -110,6 +121,11 @@ function cancelTrade(trade, reason = 'cancelled', notify = true) { if (!trade || ['cancelled', 'committed'].includes(trade.state)) return false; trade.state = 'cancelled'; trade.cancelReason = reason; + if (trade.botSession?.pendingResourceDelivery?.tradeId === trade.id) { + trade.botSession.pendingResourceDelivery.tradeId = undefined; + trade.botSession.pendingResourceDelivery.retryAt = now() + 10000; + } + recordSupplyTrade(trade, 'cancelled', reason, true); if (trade.negotiationId) { try { invoke('GameServer/Bot/Economy/BotNegotiationService').cancelForTrade(trade, reason); } catch (_) { /* optional negotiation module */ } } @@ -151,7 +167,7 @@ function attachTrade(trade) { trade.botSession.activeTrade = trade; } -function createTrade(playerSession, botSession, direction) { +function createTrade(playerSession, botSession, direction, metadata = {}) { return { id: `bot-trade-${++tradeSequence}`, direction, @@ -163,7 +179,9 @@ function createTrade(playerSession, botSession, direction) { botConfirmed: false, state: 'open', createdAt: now(), - expiresAt: now() + TRADE_TTL_MS + expiresAt: now() + TRADE_TTL_MS, + workflowId: metadata.workflowId || null, + supplyDelivery: metadata.supplyDelivery === true }; } @@ -186,7 +204,7 @@ function startPlayerTrade(playerSession, targetSession) { return { ok: true, trade }; } -function openBotTrade(botSession, playerSession, negotiation = null) { +function openBotTrade(botSession, playerSession, negotiation = null, metadata = {}) { const reason = canStart(playerSession, botSession, { allowMerchant: !!negotiation }); if (reason) return { ok: false, reason }; if (!negotiation && (botSession.partyCompanion !== true || botSession.followPlayerSession !== playerSession)) { @@ -195,13 +213,11 @@ function openBotTrade(botSession, playerSession, negotiation = null) { if (negotiation && (negotiation.botSession !== botSession || negotiation.playerSession !== playerSession || negotiation.state !== 'accepted')) { return { ok: false, reason: 'negotiation_not_ready' }; } - if (negotiation && (activeTradeFor(playerSession) || activeTradeFor(botSession))) { + if (activeTradeFor(playerSession) || activeTradeFor(botSession)) { return { ok: false, reason: 'trade_active' }; } - cancel(playerSession, 'replaced', false); - cancel(botSession, 'replaced', false); - const trade = createTrade(playerSession, botSession, 'bot_outbound'); + const trade = createTrade(playerSession, botSession, 'bot_outbound', metadata); if (negotiation) { trade.negotiationId = negotiation.id; trade.expectedNegotiatedItem = { objectId: negotiation.itemObjectId, count: negotiation.quantity }; @@ -229,8 +245,8 @@ function startBotTrade(botSession, playerSession) { return openBotTrade(botSession, playerSession); } -function startBotTradeWithOffer(botSession, playerSession, objectId, amount) { - const opened = openBotTrade(botSession, playerSession); +function startBotTradeWithOffer(botSession, playerSession, objectId, amount, metadata = {}) { + const opened = openBotTrade(botSession, playerSession, null, metadata); if (!opened.ok) return opened; const offered = offerBotItem(botSession, objectId, amount); if (!offered.ok) { @@ -289,10 +305,12 @@ function offerBotItem(botSession, objectId, amount) { return { ok: false, reason: 'insufficient_item' }; } - const ledger = botGiftLedger(botSession); const delta = Math.max(0, nextCount - (current?.count || 0)); - if (ledger.units + delta > MAX_BOT_GIFT_UNITS) return { ok: false, reason: 'gift_budget_exceeded' }; - ledger.units += delta; + if (!trade.supplyDelivery) { + const ledger = botGiftLedger(botSession); + if (ledger.units + delta > MAX_BOT_GIFT_UNITS) return { ok: false, reason: 'gift_budget_exceeded' }; + ledger.units += delta; + } const line = lineFor(item, nextCount); trade.botItems.set(canonicalObjectId, line); reservations.set(canonicalObjectId, { tradeId: trade.id, count: nextCount }); @@ -393,20 +411,32 @@ async function commit(playerSession) { const trade = activeTradeFor(playerSession); if (!trade || trade.playerSession !== playerSession) return { ok: false, reason: 'no_active_trade' }; - if (trade.playerItems.size === 0 && trade.botItems.size === 0) return { ok: false, reason: 'empty_or_invalid_trade' }; + if (trade.playerItems.size === 0 && trade.botItems.size === 0) { + const result = { ok: false, reason: 'empty_or_invalid_trade' }; + recordSupplyTrade(trade, 'failed', result.reason, false); + return result; + } if (trade.negotiationId) { const validation = invoke('GameServer/Bot/Economy/BotNegotiationService').validateTrade(trade); - if (!validation.ok) return validation; + if (!validation.ok) { + recordSupplyTrade(trade, 'failed', validation.reason, false); + return validation; + } } const entries = transferEntries(trade); for (const entry of entries) { const validation = validateLine(trade, entry.fromSession, entry.line, { botSide: entry.fromSession === trade.botSession }); - if (!validation.ok) return validation; + if (!validation.ok) { + recordSupplyTrade(trade, 'failed', validation.reason, false); + return validation; + } } if (!incomingSlots(trade.playerSession, [...trade.botItems.values()]) || !incomingSlots(trade.botSession, [...trade.playerItems.values()])) { - return { ok: false, reason: 'inventory_capacity' }; + const result = { ok: false, reason: 'inventory_capacity' }; + recordSupplyTrade(trade, 'failed', result.reason, false); + return result; } const databaseTransfers = entries.map((entry) => ({ @@ -425,7 +455,9 @@ async function commit(playerSession) { try { moved = await Database.transferInventoryBetweenCharacters(databaseTransfers); } catch (error) { - return { ok: false, reason: 'database_failed', error }; + const result = { ok: false, reason: 'database_failed', error }; + recordSupplyTrade(trade, 'failed', result.reason, false); + return result; } applyLocalTransfers(moved, entries); @@ -441,6 +473,12 @@ async function commit(playerSession) { partnerSession: trade.botSession, moved: publicMoved(entries) }; + recordSupplyTrade(trade, 'completed', 'native_trade_commit', true, { + moved: result.moved + }); + if (trade.botSession.pendingResourceDelivery?.tradeId === trade.id) { + trade.botSession.pendingResourceDelivery = undefined; + } if (trade.negotiationId) { try { invoke('GameServer/Bot/Economy/BotNegotiationService').completeTrade(trade); } catch (_) { /* optional negotiation module */ } } diff --git a/src/GameServer/Bot/Population/BackgroundResolver.js b/src/GameServer/Bot/Population/BackgroundResolver.js index 88f8f7e9..0b712079 100644 --- a/src/GameServer/Bot/Population/BackgroundResolver.js +++ b/src/GameServer/Bot/Population/BackgroundResolver.js @@ -166,6 +166,7 @@ function resolveTravel(state, timestamp = Date.now()) { function staleShopping(state) { return state?.activity === 'shopping' && !state.stats?.marketReturn + && !state.stats?.supplyErrand && state.currentRegion !== 'Giran'; } @@ -455,6 +456,37 @@ const BackgroundResolver = { if (travelResult) return travelResult; } + if (state.stats?.supplyErrand) { + const expiresAt = Number(state.stats.supplyErrand.expiresAt || 0); + if (expiresAt > 0 && timestamp >= expiresAt) { + return { + patch: { + activity: 'hunting', + stats: { + ...(state.stats || {}), + supplyErrand: null, + lastReason: 'supply_errand_expired' + } + }, + events: [{ + type: 'supply_errand_expired', + summary: `${state.name || 'Bot'} abandoned an expired companion supply errand and resumed hunting`, + weight: 2 + }], + materialize: { exp: 0, sp: 0, adena: 0, items: [] }, + nextResolveAt: timestamp + 30000, + debug: { activity: 'supply_errand_expired' } + }; + } + return { + patch: { activity: 'shopping', stats: { ...(state.stats || {}) } }, + events: [], + materialize: { exp: 0, sp: 0, adena: 0, items: [] }, + nextResolveAt: timestamp + 30000, + debug: { activity: 'supply_errand' } + }; + } + if (staleShopping(state) && spot) { return { patch: { diff --git a/src/GameServer/Bot/Population/BotLifeState.js b/src/GameServer/Bot/Population/BotLifeState.js index 6754f4d9..7a0678d3 100644 --- a/src/GameServer/Bot/Population/BotLifeState.js +++ b/src/GameServer/Bot/Population/BotLifeState.js @@ -253,6 +253,16 @@ function recordFromSession(session, phase, reason = '') { // expire, rather than retaining a stale buffed total indefinitely. coldCombat: ColdCombatProfile.capture(actor, timestamp), leaderId: session.followPlayerSession?.actor?.fetchId ? Number(session.followPlayerSession.actor.fetchId()) : null, + supplyErrand: session.companionShopping?.kind === 'player_resource_purchase' + ? { + workflowId: session.companionShopping.workflowId || null, + itemSelfId: Number(session.companionShopping.itemId || 0) || null, + amount: Number(session.companionShopping.amount || 0) || null, + phase: session.supplyErrandPhase || 'cold', + startedAt: Number(session.companionShopping.startedAt || 0) || null, + expiresAt: Number(session.companionShopping.expiresAt || 0) || null + } + : null, newbieAnchor: !!session.newbieAnchor, lastReason: reason }; diff --git a/src/GameServer/Bot/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js index ac9dba62..28b793e0 100644 --- a/src/GameServer/Bot/Population/PopulationService.js +++ b/src/GameServer/Bot/Population/PopulationService.js @@ -762,6 +762,7 @@ const PopulationService = { // as a solo hot bot and silently dissolve the group. const available = states.filter((state) => ( !['pk_hunting', 'traveling'].includes(state.activity) && + !state.stats?.supplyErrand && !state.party?.partyId )); const merchants = available.filter((state) => state.activity === 'merchant' && state.stats?.marketStore); diff --git a/src/GameServer/Bot/TradeService.js b/src/GameServer/Bot/TradeService.js index 8eeacd4e..e55fd455 100644 --- a/src/GameServer/Bot/TradeService.js +++ b/src/GameServer/Bot/TradeService.js @@ -1,6 +1,7 @@ const DataCache = invoke('GameServer/DataCache'); const Database = invoke('Database'); const BotEconomyPricing = invoke('GameServer/Bot/Economy/BotEconomyPricing'); +const storePurchaseQueues = new WeakMap(); function itemTemplate(selfId) { return DataCache.items.find((ob) => ob.selfId === selfId); @@ -188,29 +189,76 @@ function previewSaleToStore(actor, store) { return { totalAdena, itemCount, lines }; } -async function buyFromStore(actor, store, selfId, qty) { +async function buyFromStore(actor, store, selfId, qty, options = {}) { if (!store || store.storeType !== 1) { throw new Error("This store is not selling items."); } - const storeItem = store.items.find((item) => item.selfId === selfId); - if (!storeItem) { - throw new Error("Item is not available."); - } + if (!storePurchaseQueues.has(store)) storePurchaseQueues.set(store, new Map()); + const queues = storePurchaseQueues.get(store); + const queueKey = String(Number(selfId)); + const previous = queues.get(queueKey) || Promise.resolve(); + let release; + const current = new Promise((resolve) => { release = resolve; }); + const queued = previous.then(() => current); + queues.set(queueKey, queued); + await previous; + + try { + const storeItem = store.items.find((item) => Number(item.selfId) === Number(selfId)); + if (!storeItem) { + throw new Error("Item is not available."); + } + if (options.expectedUnitPrice !== undefined && Number(storeItem.price) !== Number(options.expectedUnitPrice)) { + throw new Error("Store price changed."); + } - const buyQty = Math.min(qty, storeItem.count); - if (buyQty <= 0) { - throw new Error("Item is out of stock."); - } + const buyQty = Math.min(Number(qty), Number(storeItem.count)); + if (buyQty <= 0) { + throw new Error("Item is out of stock."); + } - const totalCost = storeItem.price * buyQty; - await deductAdena(actor, totalCost); - await giveItem(actor, selfId, buyQty); + const totalCost = Number(storeItem.price) * buyQty; + const originalCount = Number(storeItem.count); + const originalIndex = store.items.indexOf(storeItem); + // Reserve the finite lot synchronously, before any database await. A + // second buyer therefore observes the reduced count even if this + // purchase is still waiting on SQLite/network I/O. + storeItem.count = originalCount - buyQty; + if (storeItem.count <= 0) store.items = store.items.filter((item) => item !== storeItem); + + let adenaDeducted = false; + try { + await deductAdena(actor, totalCost); + adenaDeducted = true; + await giveItem(actor, selfId, buyQty); + } catch (error) { + // Restore the reserved lot before releasing the queue. This keeps + // a failed purchase retryable and prevents a DB error from + // silently destroying finite stock. + storeItem.count = originalCount; + if (!store.items.includes(storeItem)) { + store.items.splice(Math.max(0, Math.min(originalIndex, store.items.length)), 0, storeItem); + } - storeItem.count -= buyQty; - store.items = store.items.filter((item) => item.count > 0); + if (adenaDeducted) { + try { + await giveAdena(actor, totalCost); + } catch (rollbackError) { + if (error && typeof error === 'object') { + error.rollbackError = rollbackError; + } + } + } + throw error; + } - return { qty: buyQty, totalAdena: totalCost, name: itemName(selfId) }; + return { qty: buyQty, totalAdena: totalCost, name: itemName(selfId) }; + } finally { + release(); + if (queues.get(queueKey) === queued) queues.delete(queueKey); + if (queues.size === 0) storePurchaseQueues.delete(store); + } } async function sellToStore(actor, store, selfId, qty) { diff --git a/tests/test_llm_supply_errand.js b/tests/test_llm_supply_errand.js index 3be9662b..9bc5ee4d 100644 --- a/tests/test_llm_supply_errand.js +++ b/tests/test_llm_supply_errand.js @@ -14,6 +14,8 @@ const BotTradeService = invoke('GameServer/Bot/BotTradeService'); const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); const BotPartyChat = invoke('GameServer/Bot/AI/BotPartyChat'); const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); +const LifeState = invoke('GameServer/Bot/Population/BotLifeState'); +const BotAI = invoke('GameServer/Bot/BotAI'); function item(id, selfId, amount, name = 'Soulshot: D-grade') { let count = amount; @@ -35,7 +37,11 @@ async function main() { scheduleReturn: ShoppingState.scheduleResourceReturn, trade: BotTradeService.startBotTradeWithOffer, inferRole: BotRoles.inferRole, - announce: BotPartyChat.announce + announce: BotPartyChat.announce, + markCold: LifeState.markCold, + botAiStop: BotAI.stop, + botAiInit: BotAI.init, + botAiWakeup: BotAI.wakeup }; const adena = item(10, 57, 100000, 'Adena'); const shots = item(11, 1463, 991); @@ -78,16 +84,42 @@ async function main() { sourceType: 'npc', sourceId: 7004, sourceName: 'D-grade grocer', town: 'Talking Island', selfId: 1463, price: 17, itemName: 'Soulshot: D-grade', available: true }); + let arrivalCallback = null; + let travelMode = 'walk'; BotTownTravel.request = (_session, _bot, _ai, _reason, options) => { assert.strictEqual(options.allowCompanion, true); assert.strictEqual(options.forceScrollOfEscape, true); - return 'walk'; + arrivalCallback = options.onArrival; + return travelMode; }; const requested = BotSupplyErrand.request(session, player, 1463, 200); assert.strictEqual(requested.ok, true); assert.strictEqual(requested.amount, 200); assert.strictEqual(session.companionShopping.amount, 200); + // A real companion supply escape is parked as a cold workflow: the + // AI loop is stopped, then resumed only by the destination callback. + session.companionShopping = undefined; + session.pendingResourceDelivery = undefined; + travelMode = 'escape'; + let stopped = 0; + let initialized = 0; + let woken = 0; + BotAI.stop = () => { stopped += 1; }; + BotAI.init = () => { initialized += 1; }; + BotAI.wakeup = () => { woken += 1; }; + LifeState.markCold = async () => ({ phase: 'cold', activity: 'shopping' }); + const parked = BotSupplyErrand.request(session, player, 1463, 1); + assert.strictEqual(parked.ok, true); + await Promise.resolve(); + assert.strictEqual(session.supplyErrandPhase, 'cold'); + assert.strictEqual(stopped, 1); + arrivalCallback(); + assert.strictEqual(session.supplyErrandPhase, 'shopping'); + assert.strictEqual(initialized, 1); + assert.strictEqual(woken, 1); + session.companionShopping.amount = 200; + TradeService.buyFromStore = async (_actor, store, selfId, amount) => { assert.strictEqual(store.items[0].selfId, 1463); assert.strictEqual(selfId, 1463); @@ -103,16 +135,17 @@ async function main() { let offered = null; BotTradeService.startBotTradeWithOffer = (_session, _player, objectId, amount) => { offered = { objectId, amount }; - return { ok: true, line: { objectId, count: amount } }; + return { ok: true, trade: { id: 'supply-trade-test' }, line: { objectId, count: amount } }; }; BotRoles.inferRole = () => 'dps'; BotPartyChat.announce = () => true; assert.strictEqual(FollowingState.deliverPurchasedResources(session, bot, player), true); assert.deepStrictEqual(offered, { objectId: 11, amount: 200 }); - assert.strictEqual(session.pendingResourceDelivery, undefined); + assert.strictEqual(session.pendingResourceDelivery.tradeId, 'supply-trade-test'); // Selecting the bot is normal before a native trade request. A - // selected target is not combat and must not keep delivery pending. + // selected target is not combat; delivery stays pending until the + // native trade is actually committed with the player. session.pendingResourceDelivery = { playerSession: player, playerId: 100, @@ -124,7 +157,7 @@ async function main() { offered = null; assert.strictEqual(FollowingState.deliverPurchasedResources(session, bot, player), true); assert.deepStrictEqual(offered, { objectId: 11, amount: 1 }); - assert.strictEqual(session.pendingResourceDelivery, undefined); + assert.strictEqual(session.pendingResourceDelivery.tradeId, 'supply-trade-test'); // An errand request must not cancel an active fight just because the // player asked at the wrong moment. @@ -158,6 +191,10 @@ async function main() { BotTradeService.startBotTradeWithOffer = originals.trade; BotRoles.inferRole = originals.inferRole; BotPartyChat.announce = originals.announce; + LifeState.markCold = originals.markCold; + BotAI.stop = originals.botAiStop; + BotAI.init = originals.botAiInit; + BotAI.wakeup = originals.botAiWakeup; DataCache.items = originalItems; } console.log('LLM supply errand checks passed'); diff --git a/tests/test_supply_trade_lifecycle.js b/tests/test_supply_trade_lifecycle.js new file mode 100644 index 00000000..84fa1b68 --- /dev/null +++ b/tests/test_supply_trade_lifecycle.js @@ -0,0 +1,138 @@ +const assert = require('assert'); +require('../src/Global'); + +const Database = invoke('Database'); +const BotTradeService = invoke('GameServer/Bot/BotTradeService'); +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); +const Item = invoke('GameServer/Item/Item'); + +function item(id, selfId, amount, name) { + return new Item(id, { + selfId, + name, + kind: 'Other.Material', + amount, + stackable: true, + equipped: false, + slot: 0 + }); +} + +function bag(items) { + return { + items, + fetchItems() { return this.items; }, + fetchItemRaw(id) { return this.items.find((entry) => Number(entry.fetchId()) === Number(id)); }, + fetchItemFromSelfId(selfId) { return this.items.find((entry) => Number(entry.fetchSelfId()) === Number(selfId)); }, + insertItem(id, selfId, data) { this.items.push(item(id, selfId, data.amount, data.name)); } + }; +} + +function actor(id, name, backpack) { + return { + fetchId: () => id, + fetchName: () => name, + fetchLocX: () => 0, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchIsOnline: () => true, + isDead: () => false, + backpack + }; +} + +async function main() { + const originalTransfer = Database.transferInventoryBetweenCharacters; + const originalStartObservation = LangfuseTracing.startObservation; + const observations = []; + const botItem = item(501, 1864, 4, 'Varnish'); + const player = { + accountId: 'player_supply_lifecycle', + dataSendToMe() {}, + actor: actor(510, 'SupplyLeader', bag([])) + }; + const bot = { + accountId: 'bot_supply_lifecycle', + partyCompanion: true, + followPlayerSession: player, + actor: actor(511, 'SupplyBot', bag([botItem])) + }; + try { + LangfuseTracing.startObservation = (name, input, metadata) => { + const entry = { name, input, metadata, ended: [] }; + observations.push(entry); + return { + end(value) { entry.ended.push(value); }, + child(childName, childInput, childMetadata) { + const child = { name: childName, input: childInput, metadata: childMetadata, ended: [] }; + observations.push(child); + return { end(value) { child.ended.push(value); } }; + } + }; + }; + bot.botTradeGiftLedger = { startedAt: Date.now(), units: 5000 }; + bot.pendingResourceDelivery = { + workflowId: 'supply-workflow-commit', + playerSession: player, + playerId: 510, + objectId: 501, + amount: 2, + itemName: 'Varnish' + }; + + const opened = BotTradeService.startBotTradeWithOffer(bot, player, 501, 2, { + workflowId: 'supply-workflow-commit', + supplyDelivery: true + }); + assert.strictEqual(opened.ok, true, 'supply delivery must not be blocked by the generic gift budget'); + bot.pendingResourceDelivery.tradeId = opened.trade.id; + assert.strictEqual(bot.botTradeGiftLedger.units, 5000, 'supply delivery must not consume generic gift budget'); + + Database.transferInventoryBetweenCharacters = async (entries) => entries.map((entry) => ({ + ...entry, + targetItemId: 601, + remaining: entry.fromCharacterId === 511 ? 2 : 0 + })); + const committed = await BotTradeService.commit(player); + assert.strictEqual(committed.ok, true); + assert.strictEqual(bot.pendingResourceDelivery, undefined, 'commit must complete the pending supply delivery'); + assert(observations.some((entry) => entry.name === 'bot.workflow.supply.trade' && entry.metadata.outcome === 'completed'), 'commit must emit a completed trade phase'); + + const existing = BotTradeService.startBotTrade(bot, player); + assert.strictEqual(existing.ok, true); + const blockedByExisting = BotTradeService.startBotTradeWithOffer(bot, player, 501, 1, { + workflowId: 'supply-workflow-blocked', + supplyDelivery: true + }); + assert.deepStrictEqual(blockedByExisting, { ok: false, reason: 'trade_active' }, 'supply delivery must not replace an unrelated active trade'); + assert.strictEqual(bot.activeTrade, existing.trade, 'the unrelated trade must remain active'); + BotTradeService.cancel(bot, 'test_cleanup', false); + + bot.pendingResourceDelivery = { + workflowId: 'supply-workflow-cancel', + playerSession: player, + playerId: 510, + objectId: 501, + amount: 1, + itemName: 'Varnish' + }; + const reopened = BotTradeService.startBotTradeWithOffer(bot, player, 501, 1, { + workflowId: 'supply-workflow-cancel', + supplyDelivery: true + }); + assert.strictEqual(reopened.ok, true); + bot.pendingResourceDelivery.tradeId = reopened.trade.id; + BotTradeService.cancel(bot, 'player_cancel', false); + assert.strictEqual(bot.pendingResourceDelivery.tradeId, undefined, 'cancel must release the delivery trade marker for retry'); + assert(observations.some((entry) => entry.name === 'bot.workflow.supply.trade' && entry.metadata.outcome === 'cancelled'), 'cancel must emit a terminal trade phase'); + console.log('Supply trade lifecycle checks passed'); + } finally { + Database.transferInventoryBetweenCharacters = originalTransfer; + LangfuseTracing.startObservation = originalStartObservation; + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_trade_store_atomicity.js b/tests/test_trade_store_atomicity.js new file mode 100644 index 00000000..67a3031d --- /dev/null +++ b/tests/test_trade_store_atomicity.js @@ -0,0 +1,104 @@ +const assert = require('assert'); +require('../src/Global'); + +const DataCache = invoke('GameServer/DataCache'); +const Database = invoke('Database'); +const TradeService = invoke('GameServer/Bot/TradeService'); +const Item = invoke('GameServer/Item/Item'); + +function item(id, selfId, amount, name) { + return new Item(id, { + selfId, + name, + kind: selfId === 57 ? 'Other.Currency' : 'Other.Material', + amount, + stackable: true, + equipped: false, + slot: 0 + }); +} + +function bag(items) { + return { + items, + fetchItems() { return this.items; }, + fetchItemFromSelfId(selfId) { return this.items.find((entry) => Number(entry.fetchSelfId()) === Number(selfId)); }, + stackableExists(selfId) { + const found = this.fetchItemFromSelfId(selfId); + return found ? Promise.resolve(found) : Promise.reject(new Error('missing_stack')); + }, + updateAmount(id, amount) { + const found = this.items.find((entry) => Number(entry.fetchId()) === Number(id)); + if (found) found.setAmount(amount); + }, + insertItem(id, selfId, data) { + this.items.push(item(id, selfId, data.amount, data.name || 'Varnish')); + } + }; +} + +function actor(id, adena = 100) { + return { + fetchId: () => id, + backpack: bag([item(id * 10, 57, adena, 'Adena')]) + }; +} + +async function main() { + const originalItems = DataCache.items; + const originals = { + updateItemAmount: Database.updateItemAmount, + deleteItem: Database.deleteItem, + setItem: Database.setItem + }; + let nextObjectId = 9000; + try { + DataCache.items = [{ selfId: 1864, template: { name: 'Varnish' }, etc: { stackable: true } }]; + Database.updateItemAmount = async () => {}; + Database.deleteItem = async () => {}; + Database.setItem = async () => ({ insertId: ++nextObjectId }); + + const store = { storeType: 1, items: [{ selfId: 1864, price: 10, count: 5 }] }; + const first = actor(1); + const second = actor(2); + const results = await Promise.allSettled([ + TradeService.buyFromStore(first, store, 1864, 5), + TradeService.buyFromStore(second, store, 1864, 5) + ]); + assert.strictEqual(results.filter((result) => result.status === 'fulfilled').length, 1, 'finite stock must allow only one full concurrent purchase'); + assert.strictEqual(results.filter((result) => result.status === 'rejected')[0].reason.message, 'Item is not available.', 'the second buyer must see the exhausted lot'); + assert.strictEqual(store.items.length, 0, 'the finite lot must be fully consumed exactly once'); + const boughtUnits = [first, second] + .map((buyer) => buyer.backpack.fetchItemFromSelfId(1864)?.fetchAmount() || 0) + .reduce((sum, amount) => sum + amount, 0); + assert.strictEqual(boughtUnits, 5, 'concurrent buyers must never receive more units than stock'); + + const rollbackStore = { storeType: 1, items: [{ selfId: 1864, price: 10, count: 2 }] }; + const rollbackBuyer = actor(3); + Database.setItem = async () => { throw new Error('forced item write failure'); }; + await assert.rejects( + TradeService.buyFromStore(rollbackBuyer, rollbackStore, 1864, 1), + /forced item write failure/ + ); + assert.strictEqual(rollbackStore.items[0].count, 2, 'failed item write must restore reserved stock'); + assert.strictEqual(rollbackBuyer.backpack.fetchItemFromSelfId(57).fetchAmount(), 100, 'failed item write must restore deducted Adena'); + + const repricedStore = { storeType: 1, items: [{ selfId: 1864, price: 11, count: 2 }] }; + await assert.rejects( + TradeService.buyFromStore(actor(4), repricedStore, 1864, 1, { expectedUnitPrice: 10 }), + /Store price changed/ + ); + assert.strictEqual(repricedStore.items[0].count, 2, 'a repriced lot must remain untouched'); + console.log('Trade store atomicity checks passed'); + } finally { + DataCache.items = originalItems; + Database.updateItemAmount = originals.updateItemAmount; + Database.deleteItem = originals.deleteItem; + Database.setItem = originals.setItem; + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From 74082a9de7373f5751461fe0a921d03702aa48d7 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:07:55 -0400 Subject: [PATCH 57/59] Implement LLM merchant price negotiation --- config/default.ini | 1 - scripts/run-tests.js | 1 + src/GameServer/Actor/Generics/Select.js | 8 +- src/GameServer/Bot/AI/BotAgentTools.js | 61 +++- src/GameServer/Bot/AI/BotBrain.js | 92 +++++- src/GameServer/Bot/AI/BotBrainContext.js | 21 ++ src/GameServer/Bot/AI/BotContextAssembler.js | 33 +- src/GameServer/Bot/AI/BotToolRegistry.js | 51 ++- src/GameServer/Bot/BotManager.js | 1 + .../Bot/Economy/BotMerchantStoreService.js | 203 ++++++++++++ .../Bot/Economy/BotNegotiationService.js | 212 +++++++++++-- .../Bot/Population/HotActivation.js | 1 + src/GameServer/Bot/TradeService.js | 8 + src/GameServer/Network/Request/Purchase.js | 12 +- tests/test_ai_config_surface.js | 2 + tests/test_bot_context_assembler.js | 30 ++ tests/test_bot_merchant_store_negotiation.js | 298 ++++++++++++++++++ tests/test_bot_negotiation_database.js | 3 +- tests/test_bot_negotiation_flow.js | 3 +- tests/test_bot_negotiation_policy.js | 3 +- tests/test_hot_bot_conversation_flow.js | 3 +- tests/test_llm_negotiation_tools.js | 3 +- 22 files changed, 960 insertions(+), 90 deletions(-) create mode 100644 src/GameServer/Bot/Economy/BotMerchantStoreService.js create mode 100644 tests/test_bot_merchant_store_negotiation.js diff --git a/config/default.ini b/config/default.ini index ccfee0a3..1c71c804 100644 --- a/config/default.ini +++ b/config/default.ini @@ -32,7 +32,6 @@ partyRouterModel = openai/gpt-5.6-luna temperature = 0.35 reasoningEffort = low maxConcurrentRequests = 32 -negotiationEnabled = false debug = false [Langfuse] diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 44051253..96bc66a3 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -42,6 +42,7 @@ const tests = [ 'tests/test_llm_equipment_tools.js', 'tests/test_llm_trade_tools.js', 'tests/test_llm_negotiation_tools.js', + 'tests/test_bot_merchant_store_negotiation.js', 'tests/test_bot_negotiation_policy.js', 'tests/test_bot_negotiation_flow.js', 'tests/test_bot_negotiation_database.js', diff --git a/src/GameServer/Actor/Generics/Select.js b/src/GameServer/Actor/Generics/Select.js index c519a4dc..36e73687 100644 --- a/src/GameServer/Actor/Generics/Select.js +++ b/src/GameServer/Actor/Generics/Select.js @@ -63,7 +63,13 @@ function openMerchantTradeWindow(session, merchant) { return; } - session.activeMerchantTrade = { merchant, store }; + store.revision = Math.max(1, Number(store.revision || 1)); + session.activeMerchantTrade = { + merchant, + store, + revision: store.revision, + prices: Object.fromEntries(store.items.map((line) => [Number(line.selfId), Number(line.price)])) + }; session.viewedPrivateStoreSeller = merchant; if (store.storeType === 1) { diff --git a/src/GameServer/Bot/AI/BotAgentTools.js b/src/GameServer/Bot/AI/BotAgentTools.js index 3167a2b7..0f69ec0b 100644 --- a/src/GameServer/Bot/AI/BotAgentTools.js +++ b/src/GameServer/Bot/AI/BotAgentTools.js @@ -742,7 +742,8 @@ function quoteItem(context) { context.session, negotiationPlayer(context), negotiationItemId(context.decision), - context.decision.negotiationAmount || context.decision.tradeAmount || context.decision.amount + context.decision.negotiationAmount || context.decision.tradeAmount || context.decision.amount, + negotiationPrice(context.decision) ); if (!result.ok) return { applied: false, reason: result.reason, negotiation: result.negotiation }; return { applied: true, reason: 'price_quoted', negotiation: result.negotiation }; @@ -763,9 +764,23 @@ function acceptPrice(context) { const totalPrice = Object.prototype.hasOwnProperty.call(decision, 'negotiationPrice') || Object.prototype.hasOwnProperty.call(decision, 'price') ? negotiationPrice(decision) : null; - const result = BotNegotiationService.acceptPrice(context.session, negotiationPlayer(context), totalPrice); - if (!result.ok) return { applied: false, reason: result.reason, negotiation: result.negotiation }; - return { applied: true, reason: 'price_accepted', negotiation: result.negotiation }; + const result = BotNegotiationService.acceptPrice( + context.session, + negotiationPlayer(context), + totalPrice, + negotiationItemId(decision), + decision.negotiationAmount || decision.tradeAmount || decision.amount + ); + const format = (resolved) => { + if (!resolved.ok) return { applied: false, reason: resolved.reason, negotiation: resolved.negotiation }; + return { + applied: true, + reason: resolved.reason || 'price_accepted', + negotiation: resolved.negotiation, + store: resolved.store || null + }; + }; + return result && typeof result.then === 'function' ? result.then(format) : format(result); } function declinePrice(context) { @@ -825,9 +840,16 @@ function isAuthorizedNegotiationParticipant(context) { } function economyActionAvailable(session, name) { - if (!BotNegotiationService.enabled()) return false; - if (name === 'quote_item') return !session.activeNegotiation; - return !!session.activeNegotiation; + const active = !!BotNegotiationService.activeSummary(session); + if (session.plan === 'merchant') { + if (!BotNegotiationService.canNegotiateStore(session)) return false; + if (name === 'open_negotiated_trade') return false; + if (name === 'quote_item') return !active; + if (name === 'accept_price') return true; + return active; + } + if (name === 'quote_item') return !active; + return active; } function registerTools() { @@ -862,11 +884,11 @@ function registerTools() { offer_resources: 'Reserve and display safe bot inventory resources in the open native trade window.', update_trade_offer: 'Change one reserved bot trade line after revalidating inventory truth.', cancel_trade: 'Cancel the open native trade and release every reservation.', - quote_item: 'Quote one safe bot inventory item using server-owned market and persona bounds.', + quote_item: 'Quote or answer an offer for one exact listed merchant item using server-owned price bounds.', counter_offer: 'Set a bounded counter price within the active negotiation range.', - accept_price: 'Accept the current server-bounded price before opening native trade.', + accept_price: 'Accept a server-bounded price. A merchant republishes the agreed quantity in its public store; a companion keeps the accepted native-trade price.', decline_price: 'Decline the active negotiation and release its stock reservation.', - open_negotiated_trade: 'Open native trade only after the bounded price has been accepted.' + open_negotiated_trade: 'For companions only, open native trade after the bounded price has been accepted.' }; const controlActions = new Set([ @@ -961,8 +983,11 @@ function execute(session, decision, visiblePlayers, requestContext = null) { requestContext, expectedWorldRevision: requestContext?.preparedWorldRevision || requestContext?.worldRevision }); - const { idempotent, ...publicOutcome } = outcome; - return { ...publicOutcome, applied: outcome.applied, reason: outcome.reason }; + const format = (resolved) => { + const { idempotent, ...publicOutcome } = resolved; + return { ...publicOutcome, applied: resolved.applied, reason: resolved.reason }; + }; + return outcome && typeof outcome.then === 'function' ? outcome.then(format) : format(outcome); } function remember(session, decision, result, model) { @@ -1049,8 +1074,10 @@ function rejectionReply(result = {}) { case 'trade_line_limit': return 'The trade already has the maximum number of item lines.'; case 'no_active_trade': return 'There is no open trade to change.'; case 'database_failed': return 'The trade was not committed because persistence failed.'; - case 'negotiation_disabled': return 'Price negotiation is not available right now.'; case 'bot_not_trading': return 'I am not offering a negotiated market trade in this state.'; + case 'merchant_store_unavailable': return 'My public store is not available to reprice right now.'; + case 'item_not_listed': return 'That item is not listed in my public store.'; + case 'insufficient_listed_stock': return 'I do not have that much listed in my public store.'; case 'item_not_negotiable': case 'stock_reserved': case 'insufficient_stock': return 'I cannot safely quote that stock item.'; @@ -1065,7 +1092,13 @@ function rejectionReply(result = {}) { case 'trade_active': return 'I will finish the current native trade before opening another one.'; case 'negotiated_item_mismatch': case 'negotiated_price_mismatch': return 'The native trade does not match our accepted price.'; - case 'stock_changed': return 'That stock changed before we could finish the agreement.'; + case 'stock_changed': + case 'listed_stock_changed': + case 'store_changed': + case 'store_repricing': return 'My public listing changed before we could finish the agreement.'; + case 'store_busy': return 'Someone is buying from my store right now. Ask me again in a moment.'; + case 'store_persist_failed': return 'I could not safely reopen my public store at that price.'; + case 'merchant_uses_store': return 'I sell this through my public store, not a private trade window.'; case 'insufficient_funds': return 'You need enough Adena for the agreed price while keeping a safe reserve.'; default: return 'I cannot do that safely right now.'; } diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 99690156..2453c8c2 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -114,7 +114,46 @@ function candidateSpots(status) { .slice(0, 6); } -function schema(allowedActions = BotAgentTools.ACTIONS) { +function merchantSchema(allowedActions) { + return { + type: 'object', + properties: { + action: { type: 'string', enum: allowedActions }, + reply: { + type: 'string', + description: 'Short in-character English merchant reply. State an exact total or unit price when discussing price.' + }, + negotiationItemId: { + type: 'number', + minimum: 0, + description: 'Exact selfId from bot.market.lines.' + }, + negotiationAmount: { + type: 'number', + minimum: 1, + maximum: 100, + description: 'Exact quantity requested by the player, bounded by the listed count.' + }, + negotiationPrice: { + type: 'number', + minimum: 1, + maximum: 100000000000, + description: 'Total Adena price for the whole negotiated quantity, never a unit price.' + }, + reason: { type: 'string', description: 'Short private reason for telemetry.' }, + confidence: { type: 'number', minimum: 0, maximum: 1 }, + worldRevision: { + type: 'string', + description: 'Echo toolContext.worldRevision for a mutating action.' + } + }, + required: ['action', 'reply', 'reason', 'confidence'], + additionalProperties: false + }; +} + +function schema(allowedActions = BotAgentTools.ACTIONS, session = null) { + if (session?.plan === 'merchant') return merchantSchema(allowedActions); return { type: 'object', properties: { @@ -229,7 +268,7 @@ function schema(allowedActions = BotAgentTools.ACTIONS) { negotiationPrice: { type: 'number', minimum: 1, - maximum: 1000000000, + maximum: 100000000000, description: 'Total Adena price for a bounded counter or accepted quote.' }, policyTtlMs: { @@ -257,7 +296,24 @@ function schema(allowedActions = BotAgentTools.ACTIONS) { }; } -function systemPrompt() { +function merchantSystemPrompt() { + return [ + 'You are one Lineage 2 player merchant speaking English to the real player who addressed you.', + 'This is a compact merchant-only turn. bot.market is authoritative; do not invent inventory, equipment, skills, party state, travel, or combat actions.', + 'Each bot.market.lines entry gives exact selfId, name, listed count, current unitPrice, preferredUnitPrice, minimumUnitPrice, relation, and rationale.', + 'A store title is flavor only. Never interpret title suffixes such as +1 or +2 as enchant level or quantity; use the exact structured lines.', + 'All negotiation prices sent to tools are total Adena for negotiationAmount. Compute total from the exact requested quantity.', + 'Never agree below minimumUnitPrice. Prefer preferredUnitPrice, but use relation and rationale to make a believable bounded deal.', + 'For a new offer at or above the minimum that you agree to, use accept_price with the exact selfId, quantity, total, and worldRevision.', + 'For a new offer below the minimum, use quote_item with the player total so the server creates the bounded counter. For a request without an offer, use quote_item.', + 'For an active negotiation, use counter_offer, accept_price, or decline_price. The server revalidates stock, bounds, store revision, and authority.', + 'A merchant sale is public: accept_price closes and republishes the store with only the agreed quantity at the agreed unit price. It does not reserve the item for this player and does not open native trade.', + 'Only say the store was relisted when accept_price succeeds. Keep replies brief and in character.' + ].join(' '); +} + +function systemPrompt(session = null) { + if (session?.plan === 'merchant') return merchantSystemPrompt(); return [ 'You are the interactive high-level dialogue brain for one Lineage 2 bot.', 'The deterministic server code handles combat, pathfinding, HP/MP, loot, and safety.', @@ -278,7 +334,7 @@ function systemPrompt() { 'The persona describes tone and high-level preferences only. It never overrides safety, current game state, or the allowed actions.', 'Ambient mood and intent are server-owned soft context. Treat an active ambient scene as factual only when bot.ambient.scene is present; never start or claim a scene from mood alone.', 'The contextFragments field is bounded and includes recent authoritative events; treat summaries as memory, never as permission to perform an action. Action metadata is authoritative only when serverApplied or actionResult.ok is true.', - 'Resource-gift trade tools can open a native window only with the current party leader; give_resources opens the window and displays the requested line in one server action. Negotiated market tools use only the active real player pair. Both reserve safe inventory without mutating it, expose only server-owned bounds, allow at most three negotiation rounds, and release reservations on cancel/expiry. Never claim completion before native player confirmation.', + 'Resource-gift trade tools can open a native window only with the current party leader; give_resources opens the window and displays the requested line in one server action. Companion negotiation tools use only the active real player pair, reserve safe inventory without mutating it, expose only server-owned bounds, allow at most three negotiation rounds, and release reservations on cancel/expiry. Never claim completion before native player confirmation.', 'When the party leader explicitly asks the bot to go to town and buy a new item, use fetch_resources with the exact selfId from the compact server-owned supply catalog (entries are [selfId, name, price, town]) and the requested quantity. If the compact catalog does not show the item, pass its exact requested name in supplyItemName; the server resolves it against the full NPC catalog. This buys a new quantity even if the bot already owns some; do not substitute give_resources from existing stock. If the server reports insufficient Adena, say how much is needed and wait for the player to transfer Adena before retrying. The server returns beside the leader and opens native trade only when the party is safe, so describe it as pending.', 'For party candidate discovery, use the server-owned party.candidates list in the current payload and answer from it; do not assume a later tool result will be sent back to you in this turn.', 'Never invent unavailable actions, players, items, or spells.' @@ -297,12 +353,15 @@ function userPayload(event, session, status, visiblePlayers, text, requestContex const candidates = candidateRequest ? BotAgentTools.partyCandidates(requestContext.playerSession, session) : []; + const merchantSlice = session?.plan === 'merchant'; return { event, playerMessage: text || '', - bot: assembled?.bot || BotBrainContext.compactStatus(session, status, text), + bot: assembled?.bot || (merchantSlice + ? BotBrainContext.compactMerchantStatus(session, status, requestContext.playerSession) + : BotBrainContext.compactStatus(session, status, text)), visiblePlayers, - party: partyRequest || candidateRequest ? { + party: !merchantSlice && (partyRequest || candidateRequest) ? { intent: candidateRequest ? 'candidate_discovery' : 'membership', availability: availability ? { available: availability.available === true, @@ -312,13 +371,18 @@ function userPayload(event, session, status, visiblePlayers, text, requestContex } : null, candidates } : null, - candidateSpots: candidateSpots(status), + candidateSpots: merchantSlice ? [] : candidateSpots(status), allowedActions: BotAgentTools.availableActions(session), tools: BotAgentTools.toolDescriptions(session), toolContext: { worldRevision: preparedWorldRevision }, - constraints: { + constraints: merchantSlice ? { + keepReplyShort: true, + englishOnly: true, + publicStoreSale: true, + noInventoryReservation: true + } : { keepReplyShort: true, splitLongRepliesIntoChatLines: true, avoidSpam: true, @@ -338,12 +402,12 @@ function estimatePromptTokens(payload) { function estimateRequestPromptTokens(payload, session) { return estimatePromptTokens({ messages: [ - { role: 'system', content: systemPrompt() }, + { role: 'system', content: systemPrompt(session) }, { role: 'user', content: JSON.stringify(payload) } ], responseSchema: { name: 'bot_brain_decision', - schema: schema(BotAgentTools.availableActions(session)) + schema: schema(BotAgentTools.availableActions(session), session) }, repairSchema: true }); @@ -367,12 +431,12 @@ async function requestDecision(payload, cfg, session, requestContext, visiblePla playerId, turnId: requestContext?.conversationTurn?.turnId || requestContext?.requestId || null, messages: [ - { role: 'system', content: systemPrompt() }, + { role: 'system', content: systemPrompt(session) }, { role: 'user', content: JSON.stringify(payload) } ], responseSchema: { name: 'bot_brain_decision', - schema: schema(BotAgentTools.availableActions(session)) + schema: schema(BotAgentTools.availableActions(session), session) }, repairSchema: true }); @@ -569,8 +633,8 @@ function queueConversationWrite(session, work, metadata = {}) { return next; } -function applyDecision(session, decision, visiblePlayers, requestContext) { - let result = BotAgentTools.execute(session, decision, visiblePlayers, requestContext); +async function applyDecision(session, decision, visiblePlayers, requestContext) { + let result = await BotAgentTools.execute(session, decision, visiblePlayers, requestContext); BotAgentTools.remember(session, decision, result, config().model); const playerSession = requestContext?.playerSession; let playerVisibleReply = null; diff --git a/src/GameServer/Bot/AI/BotBrainContext.js b/src/GameServer/Bot/AI/BotBrainContext.js index 37e493de..f4e36535 100644 --- a/src/GameServer/Bot/AI/BotBrainContext.js +++ b/src/GameServer/Bot/AI/BotBrainContext.js @@ -3,6 +3,7 @@ const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); const ShotStock = invoke('GameServer/Inventory/ShotStock'); const BotSkillCapabilities = invoke('GameServer/Bot/AI/BotSkillCapabilities'); const MarketOpportunity = invoke('GameServer/Bot/Economy/MarketOpportunity'); +const BotNegotiationService = invoke('GameServer/Bot/Economy/BotNegotiationService'); const SLOT_NAMES = { 1: 'right_ear', @@ -304,7 +305,27 @@ function compactStatus(session, status, text = '', options = {}) { }; } +function compactMerchantStatus(session, status, playerSession = null) { + if (!status || !status.available) return status; + const market = BotNegotiationService.storeContext(session, playerSession); + if (market) delete market.title; + return { + name: status.name, + level: status.level, + classId: status.classId, + mode: status.mode, + intent: status.intent, + role: status.role, + nearby: status.nearby || null, + blockers: status.blockers || null, + market, + persona: status.persona || null, + social: status.social || null + }; +} + module.exports = { compactStatus, + compactMerchantStatus, textRequestsInventory }; diff --git a/src/GameServer/Bot/AI/BotContextAssembler.js b/src/GameServer/Bot/AI/BotContextAssembler.js index afc2731f..839a5b11 100644 --- a/src/GameServer/Bot/AI/BotContextAssembler.js +++ b/src/GameServer/Bot/AI/BotContextAssembler.js @@ -84,19 +84,32 @@ async function assemble(input = {}) { const hardMaxTokens = Math.max(budget, Number(input.hardMaxTokens || HARD_MAX_TOKENS)); const conversation = requestContext.conversation || null; const recentTurns = conversation?.recentTurns || []; + const merchantSlice = session?.plan === 'merchant'; const itemFollowup = /^(?:is (?:it|that)|are (?:they|those)|what about (?:it|that|them)|and (?:it|that|them)|which one)\b/i.test(String(text || '').trim()) && recentTurns.slice(-4).some((turn) => BotBrainContext.textRequestsInventory(turn?.text)); - const itemIntent = BotBrainContext.textRequestsInventory(text) || itemFollowup; - const skillIntent = textWants(text, /\b(skill|skills|heal|buff|haste|shield|might|wind walk|windwalk|spoil|sweep)\b|скилл|хил|баф|хаст|щит|майт|винд|спойл|свип/); + const itemIntent = merchantSlice || BotBrainContext.textRequestsInventory(text) || itemFollowup; + const skillIntent = !merchantSlice && textWants(text, /\b(skill|skills|heal|buff|haste|shield|might|wind walk|windwalk|spoil|sweep)\b|скилл|хил|баф|хаст|щит|майт|винд|спойл|свип/); let bot; try { - bot = BotBrainContext.compactStatus(session, status, text, { - includeInventory: itemIntent, - includeSkills: skillIntent, - includeEquipment: itemIntent || skillIntent - }); + bot = merchantSlice + ? BotBrainContext.compactMerchantStatus(session, status, requestContext.playerSession) + : BotBrainContext.compactStatus(session, status, text, { + includeInventory: itemIntent, + includeSkills: skillIntent, + includeEquipment: itemIntent || skillIntent + }); } catch (_) { - bot = status || { available: false }; + bot = merchantSlice && status?.available + ? { + available: true, + name: status.name || session?.actor?.fetchName?.() || 'merchant', + level: status.level || null, + mode: status.mode || 'merchant', + market: null, + persona: status.persona || null, + social: status.social || null + } + : status || { available: false }; } let journal = []; @@ -106,6 +119,9 @@ async function assemble(input = {}) { botId: session.actor.fetchId(), limit: input.journalLimit || 10 }); + if (merchantSlice) { + journal = journal.filter((event) => /(?:merchant|market|negotiation|trade|store)/i.test(String(event.eventType || ''))); + } } const fragments = [ @@ -176,6 +192,7 @@ async function assemble(input = {}) { itemIntent, itemFollowup, skillIntent, + contextSlice: merchantSlice ? 'merchant' : 'general', journalCount: journal.length, estimatedTokens: Math.min(hardMaxTokens, serializedCost) } diff --git a/src/GameServer/Bot/AI/BotToolRegistry.js b/src/GameServer/Bot/AI/BotToolRegistry.js index 532d618a..25dc9a12 100644 --- a/src/GameServer/Bot/AI/BotToolRegistry.js +++ b/src/GameServer/Bot/AI/BotToolRegistry.js @@ -41,6 +41,10 @@ function worldRevision(session) { const leader = session.partyCompanion === true ? session.followPlayerSession : null; const partySettings = leader?.partyCompanionSettings || {}; const overlay = HotBotPolicyOverlay.get(session); + const privateStore = actor.fetchPrivateStore?.(); + const storeLines = Array.isArray(privateStore?.items) + ? privateStore.items.map((line) => `${Number(line.selfId)}.${Number(line.count)}.${Number(line.price)}`).join(',') + : ''; return [ actorId(session), text(session.plan, 32), @@ -61,7 +65,10 @@ function worldRevision(session) { String(session.activeNegotiation?.id || ''), String(session.activeNegotiation?.state || ''), Number(session.activeNegotiation?.round || 0), - Number(session.activeNegotiation?.currentUnitPrice || 0) + Number(session.activeNegotiation?.currentUnitPrice || 0), + Number(privateStore?.revision || 0), + Number(privateStore?.repricing === true ? 1 : 0), + storeLines ].join(':'); } @@ -172,7 +179,7 @@ function execute(context = {}) { const currentRevision = worldRevision(session); const expectedRevision = context.expectedWorldRevision || decision.worldRevision || null; const currentTurn = turnId(context); - const mutationKey = currentTurn && action ? `${currentTurn}:${action}` : null; + const mutationKey = currentTurn && action ? `${currentTurn}:${playerId(context) || 'none'}:${action}` : null; const mutationStore = session && (session.botToolExecutions ||= new Map()); audit({ ...context, decision: { ...decision, action } }, 'requested', 'requested', { @@ -186,6 +193,14 @@ function execute(context = {}) { audit({ ...context, decision: { ...decision, action } }, 'rejected', rejected.reason); return rejected; } + if (mutationStore && mutationKey && mutationStore.has(mutationKey)) { + const previous = mutationStore.get(mutationKey); + const replay = (resolved) => { + audit({ ...context, decision: { ...decision, action } }, auditOutcome(resolved), 'idempotent_replay'); + return { ...resolved, idempotent: true }; + }; + return previous && typeof previous.then === 'function' ? previous.then(replay) : replay(previous); + } if (isPkLocked(session, action)) { const rejected = result(false, 'pk_hunting_autonomous'); audit({ ...context, decision: { ...decision, action } }, 'rejected', rejected.reason); @@ -211,11 +226,6 @@ function execute(context = {}) { return rejected; } - if (mutationStore && mutationKey && mutationStore.has(mutationKey)) { - const previous = mutationStore.get(mutationKey); - audit({ ...context, decision: { ...decision, action } }, auditOutcome(previous), 'idempotent_replay'); - return { ...previous, idempotent: true }; - } if (mutationStore && currentTurn && definition.mutating) { const priorMutation = [...mutationStore.entries()] .find(([key]) => key.startsWith(`${currentTurn}:`)); @@ -240,20 +250,31 @@ function execute(context = {}) { } } + const finalize = (outcome) => { + const normalized = result(outcome?.applied, outcome?.reason, outcome); + if (mutationStore && mutationKey) mutationStore.set(mutationKey, normalized); + audit({ ...context, decision: { ...decision, action } }, auditOutcome(normalized), normalized.reason, { + idempotent: false, + currentRevision: worldRevision(session), + workflowId: normalized.workflowId || normalized.workflow?.id || null + }); + return normalized; + }; + let outcome; try { outcome = definition.execute(context); } catch (error) { outcome = result(false, `tool_error:${text(error.message, 120)}`); } - const normalized = result(outcome?.applied, outcome?.reason, outcome); - if (mutationStore && mutationKey) mutationStore.set(mutationKey, normalized); - audit({ ...context, decision: { ...decision, action } }, auditOutcome(normalized), normalized.reason, { - idempotent: false, - currentRevision: worldRevision(session), - workflowId: normalized.workflowId || normalized.workflow?.id || null - }); - return normalized; + if (outcome && typeof outcome.then === 'function') { + const pending = outcome + .then(finalize) + .catch((error) => finalize(result(false, `tool_error:${text(error.message, 120)}`))); + if (mutationStore && mutationKey) mutationStore.set(mutationKey, pending); + return pending; + } + return finalize(outcome); } module.exports = { diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js index 11d4e448..45a53adf 100644 --- a/src/GameServer/Bot/BotManager.js +++ b/src/GameServer/Bot/BotManager.js @@ -636,6 +636,7 @@ const BotManager = { session.actor.setPrivateStore({ storeType: privateStore.storeType, + revision: Math.max(1, Number(privateStore.revision || 1)), title: privateStore.title, town: privateStore.town, items: storeItems diff --git a/src/GameServer/Bot/Economy/BotMerchantStoreService.js b/src/GameServer/Bot/Economy/BotMerchantStoreService.js new file mode 100644 index 00000000..cfcd632d --- /dev/null +++ b/src/GameServer/Bot/Economy/BotMerchantStoreService.js @@ -0,0 +1,203 @@ +const World = invoke('GameServer/World/World'); +const ServerResponse = invoke('GameServer/Network/Response'); +const LifeState = invoke('GameServer/Bot/Population/BotLifeState'); +const DataCache = invoke('GameServer/DataCache'); +const { marketStoreTitle } = invoke('GameServer/Bot/Economy/MarketStoreTitle'); + +function itemName(selfId) { + return (DataCache.items || []).find((entry) => Number(entry.selfId) === Number(selfId))?.template?.name + || `Item ${selfId}`; +} + +function storeFor(session) { + const store = session?.actor?.fetchPrivateStore?.(); + return session?.plan === 'merchant' && store?.storeType === 1 && Array.isArray(store.items) + ? store + : null; +} + +function revision(store) { + return Math.max(1, Math.floor(Number(store?.revision) || 1)); +} + +function lineFor(store, identifier) { + const id = Number(identifier); + if (!store || !Number.isInteger(id) || id <= 0) return null; + return store.items.find((line) => Number(line.selfId) === id || Number(line.objectId) === id) || null; +} + +function compactLine(line) { + if (!line) return null; + return { + selfId: Number(line.selfId), + name: line.name || itemName(line.selfId), + count: Math.max(0, Number(line.count || 0)), + unitPrice: Math.max(1, Number(line.price || 0)) + }; +} + +function invalidateCustomerWindows(merchantActor) { + let invalidated = 0; + (World.user?.sessions || []).forEach((viewer) => { + if (viewer?.activeMerchantTrade?.merchant !== merchantActor) return; + viewer.activeMerchantTrade = null; + viewer.viewedPrivateStoreSeller = null; + viewer.dataSendToMe?.(ServerResponse.actionFailed()); + invalidated += 1; + }); + return invalidated; +} + +function applyClosed(actor) { + actor.setPrivateStoreType(0); + actor.state?.setSeated?.(false); +} + +function notifyClosed(session, actor) { + session.dataSendToOthers?.(ServerResponse.sitAndStand(actor), actor); + session.dataSendToOthers?.(ServerResponse.charInfo(actor), actor); +} + +function applyOpened(actor, store) { + actor.setPrivateStore(store); + actor.setPrivateStoreType(1); + actor.state?.setSeated?.(true); +} + +function notifyOpened(session, actor, store) { + session.dataSendToOthers?.(ServerResponse.sitAndStand(actor), actor); + session.dataSendToOthers?.(ServerResponse.charInfo(actor), actor); + session.dataSendToOthers?.(ServerResponse.privateStoreMsg(actor, store.title), actor); +} + +function safelyNotify(label, work) { + try { + work(); + return null; + } catch (error) { + const message = error?.message || String(error); + utils.infoWarn('BotMerchant', '%s broadcast failed: %s', label, message); + return message; + } +} + +function persistedState(session, store) { + const current = session.coldMarketState; + const marketStore = current?.stats?.marketStore; + if (!current || !marketStore) return null; + return { + ...current, + stats: { + ...(current.stats || {}), + marketStore: { + ...marketStore, + title: store.title, + revision: store.revision, + items: store.items.map((line) => ({ + selfId: Number(line.selfId), + name: line.name || itemName(line.selfId), + count: Number(line.count), + price: Number(line.price), + rank: line.rank || 'none' + })) + }, + lastNegotiatedStoreUpdate: { + revision: store.revision, + at: Date.now() + } + } + }; +} + +function restore(session, actor, store) { + store.repricing = false; + applyOpened(actor, store); + safelyNotify('restore', () => notifyOpened(session, actor, store)); +} + +async function republish(session, agreement) { + const actor = session?.actor; + const current = storeFor(session); + if (!actor || !current) return { ok: false, reason: 'merchant_store_unavailable' }; + if (current.repricing === true) return { ok: false, reason: 'store_repricing' }; + const persistedStoreId = String(session.coldMarketState?.stats?.marketStore?.id || ''); + if (!persistedStoreId || String(agreement.storeId || '') !== persistedStoreId) { + return { ok: false, reason: 'store_changed' }; + } + if (agreement.storeRevision && revision(current) !== Number(agreement.storeRevision)) { + return { ok: false, reason: 'store_changed' }; + } + + const line = lineFor(current, agreement.itemSelfId); + const quantity = Math.floor(Number(agreement.quantity)); + const unitPrice = Math.floor(Number(agreement.unitPrice)); + if (!line || quantity < 1 || quantity > Number(line.count || 0)) { + return { ok: false, reason: 'listed_stock_changed' }; + } + if (!Number.isSafeInteger(unitPrice) || unitPrice < 1) { + return { ok: false, reason: 'invalid_store_price' }; + } + + const inventoryItem = actor.backpack?.fetchItemFromSelfId?.(line.selfId); + if (!inventoryItem || inventoryItem.fetchEquipped?.() || Number(inventoryItem.fetchAmount?.() || 0) < quantity) { + return { ok: false, reason: 'listed_stock_changed' }; + } + + const previous = current; + previous.repricing = true; + if (Number(previous.activePurchases || 0) > 0) { + previous.repricing = false; + return { ok: false, reason: 'store_busy' }; + } + const nextItems = previous.items.map((entry) => ( + entry === line + ? { ...entry, name: entry.name || itemName(entry.selfId), count: quantity, price: unitPrice } + : { ...entry, name: entry.name || itemName(entry.selfId) } + )); + const nextStore = { + ...previous, + repricing: false, + activePurchases: 0, + revision: revision(previous) + 1, + items: nextItems, + title: marketStoreTitle(nextItems) + }; + + const invalidatedWindows = invalidateCustomerWindows(actor); + applyClosed(actor); + const closeBroadcastWarning = safelyNotify('close', () => notifyClosed(session, actor)); + + let saved; + try { + const nextState = persistedState(session, nextStore); + if (!nextState) throw new Error('market_state_missing'); + saved = await LifeState.upsertState(nextState, 'merchant_negotiated_reprice'); + if (!saved) throw new Error('state_save_failed'); + } catch (error) { + restore(session, actor, previous); + return { ok: false, reason: 'store_persist_failed', error: error.message || String(error) }; + } + + session.coldMarketState = saved; + applyOpened(actor, nextStore); + const openBroadcastWarning = safelyNotify('open', () => notifyOpened(session, actor, nextStore)); + return { + ok: true, + reason: 'store_reopened', + store: { + revision: nextStore.revision, + title: nextStore.title, + item: compactLine(lineFor(nextStore, agreement.itemSelfId)) + }, + invalidatedWindows, + broadcastWarning: openBroadcastWarning || closeBroadcastWarning + }; +} + +module.exports = { + compactLine, + lineFor, + republish, + revision, + storeFor +}; diff --git a/src/GameServer/Bot/Economy/BotNegotiationService.js b/src/GameServer/Bot/Economy/BotNegotiationService.js index 0097daf9..c7a73369 100644 --- a/src/GameServer/Bot/Economy/BotNegotiationService.js +++ b/src/GameServer/Bot/Economy/BotNegotiationService.js @@ -5,11 +5,12 @@ const BotEconomyPricing = invoke('GameServer/Bot/Economy/BotEconomyPricing'); const BotPersona = invoke('GameServer/Bot/AI/BotPersona'); const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); const BotToolAudit = invoke('GameServer/Bot/AI/BotToolAudit'); +const BotMerchantStoreService = invoke('GameServer/Bot/Economy/BotMerchantStoreService'); const NEGOTIATION_TTL_MS = 90 * 1000; const MAX_ROUNDS = 3; const MAX_QUANTITY = 100; -const MAX_UNIT_PRICE = 10_000_000; +const MAX_UNIT_PRICE = 1_000_000_000; const NEGOTIABLE_BOT_PLANS = new Set(['merchant', 'following']); const negotiations = new Map(); @@ -17,10 +18,6 @@ let sequence = 0; function now() { return Date.now(); } -function enabled() { - return options.default.OpenRouter?.negotiationEnabled === true; -} - function actorId(session) { return Number(session?.actor?.fetchId?.() || 0); } function actorName(session) { return session?.actor?.fetchName?.() || session?.accountId || 'unknown'; } @@ -78,12 +75,45 @@ function personaFor(bot) { }; } -function pricePolicy(bot, player, item, quantity) { +function pricePolicy(bot, player, item, quantity, listing = null) { const template = templateFor(item.fetchSelfId()); const basePrice = Math.max(1, Number(template?.template?.price || item.fetchPrice?.() || item.model?.price || 1)); - const referenceUnitPrice = BotEconomyPricing.scalePrice(basePrice, 1); + const listingUnitPrice = Math.max(0, Number(listing?.price || 0)); + const referenceUnitPrice = listingUnitPrice || BotEconomyPricing.scalePrice(basePrice, 1); const persona = personaFor(bot); const relation = relationshipFor(player, bot); + if (listingUnitPrice) { + const driveFloorModifier = persona.primaryDrive === 'wealth' + ? 0.08 + : persona.primaryDrive === 'social' ? -0.03 : 0.02; + const minimumFactor = Math.max(0.65, Math.min(0.98, + 0.72 + persona.traits.caution * 0.12 + driveFloorModifier + relation.modifier + )); + const desiredFactor = Math.max(0.90, Math.min(1, + 1 + relation.modifier + (persona.primaryDrive === 'social' ? -0.02 : 0) + )); + const minimumUnitPrice = Math.max(1, Math.floor(referenceUnitPrice * minimumFactor)); + const desiredUnitPrice = Math.max(minimumUnitPrice, Math.round(referenceUnitPrice * desiredFactor)); + const rationale = relation.name === 'trusted' + ? 'I can move meaningfully below my listed price for someone I trust.' + : relation.name === 'friendly' + ? 'I can make a modest discount from my listed price.' + : persona.primaryDrive === 'wealth' + ? 'I need to protect most of my listed value.' + : 'I can negotiate within a bounded discount from my current listing.'; + return { + baseUnitPrice: basePrice, + listingUnitPrice, + referenceUnitPrice, + desiredUnitPrice, + minimumUnitPrice, + maximumUnitPrice: MAX_UNIT_PRICE, + quantity, + relation: relation.name, + rationale, + policyVersion: 2 + }; + } const driveModifier = persona.primaryDrive === 'wealth' ? 0.06 : persona.primaryDrive === 'social' ? -0.04 : 0.02; @@ -126,6 +156,9 @@ function summary(negotiation) { itemSelfId: negotiation.itemSelfId, itemName: negotiation.itemName, quantity: negotiation.quantity, + storeId: negotiation.storeId || null, + storeRevision: negotiation.storeRevision || null, + listingUnitPrice: negotiation.listingUnitPrice || null, referenceUnitPrice: negotiation.referenceUnitPrice, desiredUnitPrice: negotiation.desiredUnitPrice, minimumUnitPrice: negotiation.minimumUnitPrice, @@ -146,7 +179,7 @@ function summary(negotiation) { function persist(negotiation) { if (!Database.isReady?.() || !actorId(negotiation.botSession)) return Promise.resolve(false); - return Database.execute([ + const write = () => Database.execute([ `INSERT INTO bot_negotiations ( id, playerId, botId, itemObjectId, itemSelfId, amount, referenceUnitPrice, desiredUnitPrice, minimumUnitPrice, maximumUnitPrice, @@ -180,9 +213,18 @@ function persist(negotiation) { negotiation.expiresAt, now(), negotiation.reason || '', - JSON.stringify({ relation: negotiation.relation, policyVersion: negotiation.policyVersion }).slice(0, 1200) + JSON.stringify({ + relation: negotiation.relation, + policyVersion: negotiation.policyVersion, + storeId: negotiation.storeId || null, + storeRevision: negotiation.storeRevision || null, + listingUnitPrice: negotiation.listingUnitPrice || null + }).slice(0, 1200) ] ], 'bot-negotiation:upsert').then(() => true).catch(() => false); + const pending = Promise.resolve(negotiation.persistPromise).catch(() => false).then(write); + negotiation.persistPromise = pending; + return pending; } function audit(negotiation, outcome, reason, meta = {}) { @@ -235,7 +277,14 @@ function setTerminal(negotiation, state, reason) { function stockValid(negotiation) { const item = resolveInventoryItem(negotiation.botSession?.actor?.backpack, negotiation.itemObjectId); - return safeItem(item) && Number(item.fetchSelfId()) === Number(negotiation.itemSelfId) && Number(item.fetchAmount()) >= negotiation.quantity; + if (!safeItem(item) || Number(item.fetchSelfId()) !== Number(negotiation.itemSelfId) || Number(item.fetchAmount()) < negotiation.quantity) { + return false; + } + if (!negotiation.storeRevision) return true; + const store = BotMerchantStoreService.storeFor(negotiation.botSession); + const line = BotMerchantStoreService.lineFor(store, negotiation.itemSelfId); + return !!line && Number(line.count || 0) >= negotiation.quantity && + BotMerchantStoreService.revision(store) === Number(negotiation.storeRevision); } function resolveInventoryItem(backpack, identifier) { @@ -248,6 +297,14 @@ function resolveInventoryItem(backpack, identifier) { return candidates.length === 1 ? candidates[0] : null; } +function resolveListedInventoryItem(backpack, selfId) { + const id = Number(selfId); + if (!backpack || !Number.isInteger(id) || id <= 0) return null; + const direct = backpack.fetchItemFromSelfId?.(id); + if (direct && Number(direct.fetchSelfId?.()) === id) return direct; + return (backpack.fetchItems?.() || []).find((item) => Number(item.fetchSelfId?.()) === id) || null; +} + function activeFor(session) { const negotiation = session?.activeNegotiation; if (!negotiation) return null; @@ -272,44 +329,73 @@ function canAccess(bot, player) { return null; } -function quoteItem(bot, player, itemObjectId, amount = 1) { - if (!enabled()) return { ok: false, reason: 'negotiation_disabled' }; +function canNegotiateStore(bot) { + return !!(bot?.coldMarketState?.stats?.marketStore && BotMerchantStoreService.storeFor(bot)); +} + +function quoteItem(bot, player, itemObjectId, amount = 1, offeredTotalPrice = null) { const access = canAccess(bot, player); if (access) return { ok: false, reason: access }; if (activeFor(bot)) return { ok: false, reason: 'negotiation_active' }; - const item = resolveInventoryItem(bot.actor.backpack, itemObjectId); + const merchantStore = bot.plan === 'merchant' ? BotMerchantStoreService.storeFor(bot) : null; + if (bot.plan === 'merchant' && !canNegotiateStore(bot)) return { ok: false, reason: 'merchant_store_unavailable' }; + const listedLine = merchantStore ? BotMerchantStoreService.lineFor(merchantStore, itemObjectId) : null; + if (merchantStore && !listedLine) return { ok: false, reason: 'item_not_listed' }; + const item = listedLine + ? resolveListedInventoryItem(bot.actor.backpack, listedLine.selfId) + : resolveInventoryItem(bot.actor.backpack, itemObjectId); if (!safeItem(item)) return { ok: false, reason: 'item_not_negotiable' }; const canonicalObjectId = Number(item.fetchId()); const quantity = Math.max(1, Math.min(MAX_QUANTITY, Math.floor(Number(amount) || 1))); + if (listedLine && Number(listedLine.count || 0) < quantity) return { ok: false, reason: 'insufficient_listed_stock' }; const reservations = bot.botNegotiationReservations || (bot.botNegotiationReservations = new Map()); - const reservation = reservations.get(canonicalObjectId); + const reservation = merchantStore ? null : reservations.get(canonicalObjectId); if (reservation) return { ok: false, reason: 'stock_reserved' }; if (Number(item.fetchAmount()) < quantity) return { ok: false, reason: 'insufficient_stock' }; - const policy = pricePolicy(bot, player, item, quantity); + const policy = pricePolicy(bot, player, item, quantity, listedLine); + let currentUnitPrice = policy.desiredUnitPrice; + let state = 'open'; + let reason = 'quoted'; + if (offeredTotalPrice !== null && offeredTotalPrice !== undefined) { + const offeredTotal = Math.floor(Number(offeredTotalPrice)); + if (!Number.isSafeInteger(offeredTotal) || offeredTotal < 1 || offeredTotal % quantity !== 0) { + return { ok: false, reason: 'price_must_be_whole_unit' }; + } + const offeredUnitPrice = offeredTotal / quantity; + if (offeredUnitPrice >= policy.minimumUnitPrice) { + currentUnitPrice = Math.min(MAX_UNIT_PRICE, offeredUnitPrice); + reason = 'player_offer_in_range'; + } else { + state = 'countered'; + reason = 'player_offer_too_low'; + } + } const negotiation = { id: negotiationId(bot, player), botSession: bot, playerSession: player, itemObjectId: canonicalObjectId, itemSelfId: Number(item.fetchSelfId()), - itemName: item.fetchName(), + itemName: listedLine?.name || item.fetchName(), quantity, ...policy, - currentUnitPrice: policy.desiredUnitPrice, + storeId: merchantStore ? String(bot.coldMarketState.stats.marketStore.id || '') : null, + storeRevision: merchantStore ? BotMerchantStoreService.revision(merchantStore) : null, + currentUnitPrice, agreedTotalPrice: null, round: 0, - state: 'open', - reason: 'quoted', + state, + reason, createdAt: now(), expiresAt: now() + NEGOTIATION_TTL_MS }; - reservations.set(negotiation.itemObjectId, { negotiationId: negotiation.id, count: quantity }); + if (!merchantStore) reservations.set(negotiation.itemObjectId, { negotiationId: negotiation.id, count: quantity }); negotiations.set(negotiation.id, negotiation); bot.activeNegotiation = negotiation; player.activeNegotiation = negotiation; persist(negotiation); - audit(negotiation, 'proposed', 'quote_created', { currentTotalPrice: negotiation.currentUnitPrice * quantity }); + audit(negotiation, state === 'countered' ? 'countered' : 'proposed', reason, { currentTotalPrice: negotiation.currentUnitPrice * quantity }); journal(negotiation, 'proposed', `${actorName(bot)} quoted ${negotiation.itemName} x${quantity}.`); return { ok: true, negotiation: summary(negotiation) }; } @@ -336,11 +422,51 @@ function counterOffer(bot, player, totalPrice) { return { ok: true, negotiation: summary(negotiation) }; } -function acceptPrice(bot, player, totalPrice = null) { - const negotiation = activeFor(bot); +async function republishAcceptedStore(bot, negotiation) { + const reopened = await BotMerchantStoreService.republish(bot, { + storeId: negotiation.storeId, + storeRevision: negotiation.storeRevision, + itemSelfId: negotiation.itemSelfId, + quantity: negotiation.quantity, + unitPrice: negotiation.currentUnitPrice + }); + if (!reopened.ok) { + negotiation.state = 'countered'; + negotiation.reason = reopened.reason; + negotiation.agreedTotalPrice = null; + persist(negotiation); + audit(negotiation, 'rejected', reopened.reason); + return { ok: false, reason: reopened.reason, negotiation: summary(negotiation) }; + } + negotiation.state = 'completed'; + negotiation.reason = 'store_reopened'; + const result = summary(negotiation); + persist(negotiation); + audit(negotiation, 'completed', 'store_reopened', { storeRevision: reopened.store?.revision }); + journal(negotiation, 'completed', `${actorName(bot)} reopened the store with ${negotiation.itemName} x${negotiation.quantity} at ${negotiation.currentUnitPrice} Adena each.`); + clear(negotiation); + return { ok: true, reason: 'store_reopened', negotiation: result, store: reopened.store }; +} + +function acceptPrice(bot, player, totalPrice = null, itemIdentifier = null, amount = 1) { + let negotiation = activeFor(bot); + if (!negotiation && itemIdentifier && totalPrice !== null) { + const quoted = quoteItem(bot, player, itemIdentifier, amount, totalPrice); + if (!quoted.ok) return quoted; + negotiation = activeFor(bot); + } if (!negotiation || negotiation.playerSession !== player) return { ok: false, reason: 'no_active_negotiation' }; - if (totalPrice !== null && Math.floor(Number(totalPrice)) !== negotiation.currentUnitPrice * negotiation.quantity) { - return { ok: false, reason: 'price_mismatch', negotiation: summary(negotiation) }; + if (totalPrice !== null) { + const total = Math.floor(Number(totalPrice)); + const minTotal = negotiation.minimumUnitPrice * negotiation.quantity; + const maxTotal = negotiation.maximumUnitPrice * negotiation.quantity; + if (!Number.isSafeInteger(total) || total < minTotal || total > maxTotal) { + return { ok: false, reason: 'price_out_of_bounds', negotiation: summary(negotiation) }; + } + if (total % negotiation.quantity !== 0) { + return { ok: false, reason: 'price_must_be_whole_unit', negotiation: summary(negotiation) }; + } + negotiation.currentUnitPrice = total / negotiation.quantity; } negotiation.agreedTotalPrice = negotiation.currentUnitPrice * negotiation.quantity; negotiation.state = 'accepted'; @@ -348,6 +474,9 @@ function acceptPrice(bot, player, totalPrice = null) { persist(negotiation); audit(negotiation, 'accepted', 'price_accepted', { agreedTotalPrice: negotiation.agreedTotalPrice }); journal(negotiation, 'accepted', `${actorName(bot)} and ${actorName(player)} accepted ${negotiation.agreedTotalPrice} Adena.`); + if (negotiation.storeRevision) { + return republishAcceptedStore(bot, negotiation); + } return { ok: true, negotiation: summary(negotiation) }; } @@ -362,6 +491,7 @@ function declinePrice(bot, player, reason = 'declined') { function openNegotiatedTrade(bot, player) { const negotiation = activeFor(bot); if (!negotiation || negotiation.playerSession !== player) return { ok: false, reason: 'no_active_negotiation' }; + if (negotiation.storeRevision || bot.plan === 'merchant') return { ok: false, reason: 'merchant_uses_store' }; if (negotiation.state !== 'accepted' || !negotiation.agreedTotalPrice) return { ok: false, reason: 'price_not_accepted' }; const adena = player.actor.backpack.fetchItemFromSelfId?.(57); if (!adena || Number(adena.fetchAmount?.() || 0) < negotiation.agreedTotalPrice + 1000) { @@ -428,6 +558,35 @@ function activeSummary(session) { return summary(activeFor(session)); } +function storeContext(bot, player) { + const store = BotMerchantStoreService.storeFor(bot); + if (!store || !canNegotiateStore(bot)) return null; + const lines = store.items.flatMap((line) => { + const item = resolveListedInventoryItem(bot.actor?.backpack, line.selfId); + if (!safeItem(item) || Number(item.fetchAmount()) < 1 || Number(line.count || 0) < 1) return []; + const policy = pricePolicy(bot, player, item, 1, line); + return [{ + selfId: Number(line.selfId), + name: line.name || item.fetchName(), + count: Math.min(Number(line.count), Number(item.fetchAmount())), + unitPrice: Number(line.price), + preferredUnitPrice: policy.desiredUnitPrice, + minimumUnitPrice: policy.minimumUnitPrice, + relation: policy.relation, + rationale: policy.rationale + }]; + }); + return { + id: String(bot.coldMarketState.stats.marketStore.id || ''), + revision: BotMerchantStoreService.revision(store), + type: 'sell', + title: store.title || '', + town: store.town || bot.coldMarketState.stats.marketStore.town || null, + lines, + activeNegotiation: activeSummary(bot) + }; +} + module.exports = { MAX_QUANTITY, MAX_ROUNDS, @@ -439,10 +598,11 @@ module.exports = { cleanup, counterOffer, declinePrice, - enabled, completeTrade, + canNegotiateStore, openNegotiatedTrade, quoteItem, + storeContext, reset() { negotiations.clear(); sequence = 0; diff --git a/src/GameServer/Bot/Population/HotActivation.js b/src/GameServer/Bot/Population/HotActivation.js index 37dca519..6c933b4d 100644 --- a/src/GameServer/Bot/Population/HotActivation.js +++ b/src/GameServer/Bot/Population/HotActivation.js @@ -196,6 +196,7 @@ const HotActivation = { coldCraftState: craftShop ? state : null, privateStore: marketStore ? { storeType: Number(marketStore.storeType || 1), + revision: Math.max(1, Number(marketStore.revision || 1)), title: marketStore.autoTitle === false ? marketStore.title : marketStoreTitle(marketStore.items), diff --git a/src/GameServer/Bot/TradeService.js b/src/GameServer/Bot/TradeService.js index e55fd455..aee29e9f 100644 --- a/src/GameServer/Bot/TradeService.js +++ b/src/GameServer/Bot/TradeService.js @@ -203,8 +203,15 @@ async function buyFromStore(actor, store, selfId, qty, options = {}) { const queued = previous.then(() => current); queues.set(queueKey, queued); await previous; + store.activePurchases = Math.max(0, Number(store.activePurchases || 0)) + 1; try { + if (store.repricing === true) { + throw new Error("Store listing changed."); + } + if (options.expectedRevision !== undefined && Number(store.revision || 1) !== Number(options.expectedRevision)) { + throw new Error("Store listing changed."); + } const storeItem = store.items.find((item) => Number(item.selfId) === Number(selfId)); if (!storeItem) { throw new Error("Item is not available."); @@ -255,6 +262,7 @@ async function buyFromStore(actor, store, selfId, qty, options = {}) { return { qty: buyQty, totalAdena: totalCost, name: itemName(selfId) }; } finally { + store.activePurchases = Math.max(0, Number(store.activePurchases || 0) - 1); release(); if (queues.get(queueKey) === queued) queues.delete(queueKey); if (queues.size === 0) storePurchaseQueues.delete(store); diff --git a/src/GameServer/Network/Request/Purchase.js b/src/GameServer/Network/Request/Purchase.js index 47f58f0d..40c586c9 100644 --- a/src/GameServer/Network/Request/Purchase.js +++ b/src/GameServer/Network/Request/Purchase.js @@ -56,10 +56,16 @@ async function consume(session, data) { if (store && store.storeType === 1) { try { + if (store.repricing === true || Number(trade.revision || 1) !== Number(store.revision || 1)) { + throw new Error("Store listing changed."); + } const bought = []; let sellerSession = null; for (const item of data.list) { - const result = await TradeService.buyFromStore(session.actor, store, item.selfId, item.amount); + const result = await TradeService.buyFromStore(session.actor, store, item.selfId, item.amount, { + expectedRevision: trade.revision, + expectedUnitPrice: trade.prices?.[Number(item.selfId)] + }); bought.push(result); sellerSession = BotManager.sessions.find((candidate) => candidate.actor === trade.merchant); if (sellerSession?.coldMarketState) { @@ -102,6 +108,10 @@ async function consume(session, data) { )); } catch (err) { utils.infoWarn('Purchase', 'merchant purchase error: %s', err.message || err); + if (store.repricing === true || Number(trade.revision || 1) !== Number(store.revision || 1) || /changed/i.test(String(err.message || err))) { + session.activeMerchantTrade = null; + session.viewedPrivateStoreSeller = null; + } session.dataSendToMe(ServerResponse.actionFailed()); } return; diff --git a/tests/test_ai_config_surface.js b/tests/test_ai_config_surface.js index 4e5d8916..5d1b303f 100644 --- a/tests/test_ai_config_surface.js +++ b/tests/test_ai_config_surface.js @@ -27,6 +27,7 @@ try { maxTokens: 1, timeoutMs: 1, backgroundInferenceEnabled: true, + negotiationEnabled: true, hotBotGlobalMaxInFlight: 1 }; const openRouter = OpenRouterGateway.config(); @@ -39,6 +40,7 @@ try { assert.strictEqual(openRouter.maxTokens, 320, 'completion safety belongs to internal policy'); assert.strictEqual(openRouter.timeoutMs, 3500, 'provider timeout belongs to internal policy'); assert.strictEqual(openRouter.backgroundInferenceEnabled, undefined); + assert.strictEqual(openRouter.negotiationEnabled, undefined); assert.strictEqual(openRouter.hotBotGlobalMaxInFlight, undefined); options.default.Langfuse = { diff --git a/tests/test_bot_context_assembler.js b/tests/test_bot_context_assembler.js index d747bf03..d1a855e4 100644 --- a/tests/test_bot_context_assembler.js +++ b/tests/test_bot_context_assembler.js @@ -7,7 +7,9 @@ const BotEventJournal = invoke('GameServer/Bot/AI/BotEventJournal'); async function main() { const originalCompact = BotBrainContext.compactStatus; + const originalMerchantCompact = BotBrainContext.compactMerchantStatus; let compactOptions = null; + let merchantCompactCalls = 0; BotEventJournal.resetMemory(); BotBrainContext.compactStatus = (_session, _status, _text, options) => { compactOptions = options; @@ -21,6 +23,18 @@ async function main() { skills: null }; }; + BotBrainContext.compactMerchantStatus = () => { + merchantCompactCalls += 1; + return { + available: true, + name: 'BoundedMerchant', + market: { + id: 'store-20', + revision: 1, + lines: [{ selfId: 625, name: 'Bone Shield', count: 3, unitPrice: 522450, minimumUnitPrice: 450000 }] + } + }; + }; try { await BotEventJournal.record({ botId: 20, playerId: 10, eventType: 'level_up', summary: 'Reached level 20.', weight: 4 }); await BotEventJournal.record({ botId: 20, playerId: 10, eventType: 'trade_completed', summary: 'Received healing potions from the player.', weight: 4 }); @@ -69,9 +83,25 @@ async function main() { assert.strictEqual(followup.telemetry.itemFollowup, true, 'pronoun follow-up should inherit recent equipment context'); assert.strictEqual(compactOptions.includeEquipment, true); assert.strictEqual(compactOptions.includeInventory, true); + + compactOptions = null; + const merchant = await BotContextAssembler.assemble({ + session: { plan: 'merchant', actor: { fetchId: () => 20 } }, + status: { available: true }, + text: 'How much for the Bone Shield?', + requestContext: { playerId: 10, playerSession: { actor: { fetchId: () => 10 } } } + }); + assert.strictEqual(merchantCompactCalls, 1); + assert.strictEqual(compactOptions, null, 'merchant context must not invoke the general inventory/skill snapshot'); + assert.strictEqual(merchant.telemetry.contextSlice, 'merchant'); + assert.strictEqual(merchant.telemetry.skillIntent, false, 'an item named Shield must not pull the skill slice'); + assert.strictEqual(merchant.bot.market.lines[0].unitPrice, 522450); + assert.strictEqual(merchant.bot.inventory, undefined); + assert.strictEqual(merchant.bot.skills, undefined); console.log('Bot context assembler checks passed'); } finally { BotBrainContext.compactStatus = originalCompact; + BotBrainContext.compactMerchantStatus = originalMerchantCompact; } } diff --git a/tests/test_bot_merchant_store_negotiation.js b/tests/test_bot_merchant_store_negotiation.js new file mode 100644 index 00000000..6ba8e7f3 --- /dev/null +++ b/tests/test_bot_merchant_store_negotiation.js @@ -0,0 +1,298 @@ +const assert = require('assert'); +require('../src/Global'); + +const BotAgentTools = invoke('GameServer/Bot/AI/BotAgentTools'); +const BotNegotiationService = invoke('GameServer/Bot/Economy/BotNegotiationService'); +const BotMerchantStoreService = invoke('GameServer/Bot/Economy/BotMerchantStoreService'); +const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); +const LifeState = invoke('GameServer/Bot/Population/BotLifeState'); +const ServerResponse = invoke('GameServer/Network/Response'); +const World = invoke('GameServer/World/World'); +const Item = invoke('GameServer/Item/Item'); + +function backpack(items) { + return { + items, + fetchItems() { return this.items; }, + fetchItemRaw(id) { return this.items.find((item) => Number(item.fetchId()) === Number(id)); }, + fetchItemFromSelfId(id) { return this.items.find((item) => Number(item.fetchSelfId()) === Number(id)); } + }; +} + +function playerActor(id, name) { + return { + fetchId: () => id, + fetchName: () => name, + fetchLocX: () => 0, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchIsOnline: () => true + }; +} + +function merchantActor(id, bag, initialStore) { + let store = initialStore; + let storeType = 1; + let seated = true; + return { + backpack: bag, + fetchId: () => id, + fetchName: () => 'StorekeeperTest', + fetchLocX: () => 0, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchDestId: () => 0, + isDead: () => false, + fetchPrivateStore: () => store, + setPrivateStore: (next) => { store = next; }, + fetchPrivateStoreType: () => storeType, + setPrivateStoreType: (next) => { storeType = next; }, + state: { + fetchSeated: () => seated, + setSeated: (next) => { seated = !!next; } + } + }; +} + +function decision(action, turnId, extra = {}) { + return { action, confidence: 0.99, reason: 'merchant negotiation test', turnId, ...extra }; +} + +async function main() { + const shieldSelfId = 500001; + const shield = new Item(71001, { + selfId: shieldSelfId, + name: 'Test Bone Shield', + kind: 'Armor.Shield', + price: 100000, + amount: 3, + stackable: false, + equipped: false, + slot: 8 + }); + const objectIdCollision = new Item(shieldSelfId, { + selfId: 500099, + name: 'Unrelated Collision Item', + kind: 'Other.Material', + price: 10, + amount: 1, + stackable: true, + equipped: false, + slot: 0 + }); + const initialStore = { + storeType: 1, + revision: 7, + title: 'Test Bone Shield x3 +2', + town: 'Giran', + items: [ + { objectId: 81001, selfId: shieldSelfId, name: 'Test Bone Shield', count: 3, price: 522450 }, + { objectId: 81002, selfId: 500002, name: 'Test Trident Edge', count: 16, price: 367350 } + ] + }; + const actor = merchantActor(72001, backpack([objectIdCollision, shield]), initialStore); + const bot = { + accountId: 'bot_storekeeper_test', + plan: 'merchant', + actor, + persona: { + primaryDrive: 'wealth', + traits: { caution: 0.8, ambition: 0.6, assertiveness: 0.7 } + }, + coldMarketState: { + characterId: 72001, + name: 'StorekeeperTest', + phase: 'hot', + activity: 'merchant', + stats: { + marketStore: { + id: 'store-72001', + storeType: 1, + revision: 7, + title: initialStore.title, + town: 'Giran', + items: initialStore.items.map((line) => ({ ...line })) + } + } + }, + dataSendToOthers() {} + }; + const player = { accountId: 'merchant_test_player', actor: playerActor(73001, 'BuyerTest') }; + const viewerPackets = []; + const viewer = { + accountId: 'viewer_test_player', + actor: playerActor(73002, 'ViewerTest'), + activeMerchantTrade: { merchant: actor, store: initialStore, revision: 7 }, + viewedPrivateStoreSeller: actor, + dataSendToMe(packet) { viewerPackets.push(packet); } + }; + + const originalWorldUser = World.user; + const originalUpsert = LifeState.upsertState; + const originalSnapshot = BotSocialMemory.getSnapshot; + const responseNames = ['actionFailed', 'sitAndStand', 'charInfo', 'privateStoreMsg']; + const originalResponses = Object.fromEntries(responseNames.map((name) => [name, ServerResponse[name]])); + const savedStates = []; + let failSave = false; + let holdSave = false; + let releaseSave = null; + let failOpenBroadcast = false; + try { + World.user = { sessions: [player, viewer, bot] }; + LifeState.upsertState = async (state, reason) => { + savedStates.push({ state, reason }); + if (holdSave) await new Promise((resolve) => { releaseSave = resolve; }); + return failSave ? null : state; + }; + BotSocialMemory.getSnapshot = () => ({ trust: 0, familiarity: 0 }); + responseNames.forEach((name) => { + ServerResponse[name] = () => { + if (name === 'privateStoreMsg' && failOpenBroadcast) throw new Error('synthetic store broadcast failure'); + return [name]; + }; + }); + + const context = BotNegotiationService.storeContext(bot, player); + assert.strictEqual(context.id, 'store-72001'); + assert.strictEqual(context.revision, 7); + assert.deepStrictEqual(context.lines.map((line) => ({ + selfId: line.selfId, + count: line.count, + unitPrice: line.unitPrice + })), [{ selfId: shieldSelfId, count: 3, unitPrice: 522450 }]); + assert(context.lines[0].minimumUnitPrice < context.lines[0].unitPrice); + assert(context.lines[0].preferredUnitPrice >= context.lines[0].minimumUnitPrice); + + const actions = BotAgentTools.availableActions(bot); + assert(actions.includes('quote_item')); + assert(actions.includes('accept_price')); + assert(!actions.includes('open_negotiated_trade'), 'merchant sales must not use native trade'); + + const quoted = BotAgentTools.execute( + bot, + decision('quote_item', 'merchant-quote', { + negotiationItemId: shieldSelfId, + negotiationAmount: 1, + negotiationPrice: 400000 + }), + [], + { playerSession: player, conversationTurn: { turnId: 'merchant-quote' } } + ); + assert.strictEqual(quoted.applied, true); + assert.strictEqual(quoted.negotiation.state, 'countered'); + assert.strictEqual(quoted.negotiation.itemObjectId, shield.fetchId(), 'listed selfId must not collide with another item objectId'); + assert.strictEqual(quoted.negotiation.itemSelfId, shieldSelfId); + assert(quoted.negotiation.currentUnitPrice > 400000, 'server must counter an offer below its floor'); + assert.strictEqual(bot.botNegotiationReservations.size, 0, 'public merchant stock is never reserved for one buyer'); + + const acceptedTotal = quoted.negotiation.minimumUnitPrice; + assert.notStrictEqual(acceptedTotal, quoted.negotiation.currentTotalPrice, 'test must exercise accepting a new player offer'); + holdSave = true; + failOpenBroadcast = true; + const preparedWorldRevision = BotAgentTools.worldRevision(bot); + const acceptContext = { + playerSession: player, + conversationTurn: { turnId: 'merchant-accept' }, + preparedWorldRevision + }; + const acceptedPromise = BotAgentTools.execute( + bot, + decision('accept_price', 'merchant-accept', { + negotiationPrice: acceptedTotal + }), + [], + acceptContext + ); + const replayPromise = BotAgentTools.execute( + bot, + decision('accept_price', 'merchant-accept', { + negotiationPrice: acceptedTotal + }), + [], + acceptContext + ); + assert.strictEqual(typeof releaseSave, 'function', 'first async mutation must reach persistence before replay'); + holdSave = false; + releaseSave(); + const [accepted, replayed] = await Promise.all([acceptedPromise, replayPromise]); + assert.strictEqual(accepted.applied, true); + assert.strictEqual(replayed.applied, true, 'same pending turn must replay instead of failing freshness'); + assert.strictEqual(replayed.reason, 'store_reopened'); + assert.strictEqual(accepted.reason, 'store_reopened'); + assert.strictEqual(accepted.negotiation.state, 'completed'); + assert.strictEqual(accepted.store.revision, 8); + assert.deepStrictEqual(accepted.store.item, { + selfId: shieldSelfId, + name: 'Test Bone Shield', + count: 1, + unitPrice: acceptedTotal + }); + assert.strictEqual(actor.fetchPrivateStoreType(), 1); + assert.strictEqual(actor.state.fetchSeated(), true); + assert.strictEqual(actor.fetchPrivateStore().revision, 8, 'broadcast failure must not roll back a committed store'); + assert.strictEqual(bot.coldMarketState.stats.marketStore.revision, 8); + assert.strictEqual(actor.fetchPrivateStore().items.length, 2, 'unrelated store lines remain published'); + assert.strictEqual(actor.fetchPrivateStore().items[1].count, 16); + assert.strictEqual(savedStates.length, 1); + assert.strictEqual(savedStates[0].reason, 'merchant_negotiated_reprice'); + assert.strictEqual(savedStates[0].state.stats.marketStore.revision, 8); + assert.strictEqual(savedStates[0].state.stats.marketStore.items[0].count, 1); + assert.strictEqual(savedStates[0].state.stats.marketStore.items[0].price, acceptedTotal); + assert.strictEqual(viewer.activeMerchantTrade, null, 'old client purchase windows are invalidated'); + assert.strictEqual(viewer.viewedPrivateStoreSeller, null); + assert.strictEqual(viewerPackets.length, 1); + assert.strictEqual(BotNegotiationService.activeSummary(bot), null); + failOpenBroadcast = false; + + const stale = await BotMerchantStoreService.republish(bot, { + storeId: 'store-72001', + storeRevision: 7, + itemSelfId: shieldSelfId, + quantity: 1, + unitPrice: 450000 + }); + assert.strictEqual(stale.ok, false); + assert.strictEqual(stale.reason, 'store_changed'); + assert.strictEqual(actor.fetchPrivateStore().revision, 8); + + actor.fetchPrivateStore().activePurchases = 1; + const busy = await BotMerchantStoreService.republish(bot, { + storeId: 'store-72001', + storeRevision: 8, + itemSelfId: shieldSelfId, + quantity: 1, + unitPrice: 450000 + }); + assert.strictEqual(busy.ok, false); + assert.strictEqual(busy.reason, 'store_busy'); + assert.strictEqual(actor.fetchPrivateStore().repricing, false); + actor.fetchPrivateStore().activePurchases = 0; + + failSave = true; + const failedSave = await BotMerchantStoreService.republish(bot, { + storeId: 'store-72001', + storeRevision: 8, + itemSelfId: shieldSelfId, + quantity: 1, + unitPrice: 450000 + }); + assert.strictEqual(failedSave.ok, false); + assert.strictEqual(failedSave.reason, 'store_persist_failed'); + assert.strictEqual(actor.fetchPrivateStore().revision, 8, 'failed persistence restores the published listing'); + assert.strictEqual(actor.fetchPrivateStoreType(), 1); + assert.strictEqual(actor.state.fetchSeated(), true); + } finally { + World.user = originalWorldUser; + LifeState.upsertState = originalUpsert; + BotSocialMemory.getSnapshot = originalSnapshot; + responseNames.forEach((name) => { ServerResponse[name] = originalResponses[name]; }); + BotNegotiationService.reset(); + } + + console.log('Bot merchant store negotiation checks passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_bot_negotiation_database.js b/tests/test_bot_negotiation_database.js index fa6f0e9a..e238289b 100644 --- a/tests/test_bot_negotiation_database.js +++ b/tests/test_bot_negotiation_database.js @@ -9,7 +9,6 @@ const BotNegotiationService = invoke('GameServer/Bot/Economy/BotNegotiationServi const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); const Item = invoke('GameServer/Item/Item'); -options.default.OpenRouter.negotiationEnabled = true; function actor(id, name, backpack) { return { fetchId: () => id, fetchName: () => name, fetchLocX: () => 0, fetchLocY: () => 0, fetchLocZ: () => 0, fetchIsOnline: () => true, isDead: () => false, backpack }; @@ -30,7 +29,7 @@ async function main() { const playerId = Number((await Database.fetchCharacterName('NegDbPlayer'))[0].id); const botId = Number((await Database.fetchCharacterName('NegDbBot'))[0].id); const player = { accountId: 'neg_db_player', actor: actor(playerId, 'NegDbPlayer', backpack([new Item(960, { selfId: 57, name: 'Adena', kind: 'Other.Currency', amount: 5000, stackable: true, equipped: false, slot: 0 })])) }; - const bot = { accountId: 'bot_neg_db', plan: 'merchant', actor: actor(botId, 'NegDbBot', backpack([new Item(961, { selfId: 9061, name: 'Database Cloth', kind: 'Other.Material', price: 1000, amount: 2, stackable: true, equipped: false, slot: 0 })])) }; + const bot = { accountId: 'bot_neg_db', plan: 'following', actor: actor(botId, 'NegDbBot', backpack([new Item(961, { selfId: 9061, name: 'Database Cloth', kind: 'Other.Material', price: 1000, amount: 2, stackable: true, equipped: false, slot: 0 })])) }; const originalSnapshot = BotSocialMemory.getSnapshot; BotSocialMemory.getSnapshot = () => ({ trust: 0, familiarity: 0 }); try { diff --git a/tests/test_bot_negotiation_flow.js b/tests/test_bot_negotiation_flow.js index 105cfa26..aff70483 100644 --- a/tests/test_bot_negotiation_flow.js +++ b/tests/test_bot_negotiation_flow.js @@ -7,7 +7,6 @@ const BotTradeService = invoke('GameServer/Bot/BotTradeService'); const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); const Item = invoke('GameServer/Item/Item'); -options.default.OpenRouter.negotiationEnabled = true; function actor(id, name, backpack) { return { @@ -40,7 +39,7 @@ const playerBackpack = backpack([playerItem]); const botBackpack = backpack([botItem]); const packets = []; const player = { accountId: 'player_neg_flow', dataSendToMe: (packet) => packets.push(packet), actor: actor(920, 'FlowPlayer', playerBackpack) }; -const bot = { accountId: 'bot_neg_flow', plan: 'merchant', actor: actor(921, 'FlowMerchant', botBackpack) }; +const bot = { accountId: 'bot_neg_flow', plan: 'following', actor: actor(921, 'FlowMerchant', botBackpack) }; const originalSnapshot = BotSocialMemory.getSnapshot; const originalTransfer = Database.transferInventoryBetweenCharacters; diff --git a/tests/test_bot_negotiation_policy.js b/tests/test_bot_negotiation_policy.js index 7b0fa011..f53a06a3 100644 --- a/tests/test_bot_negotiation_policy.js +++ b/tests/test_bot_negotiation_policy.js @@ -5,7 +5,6 @@ const BotNegotiationService = invoke('GameServer/Bot/Economy/BotNegotiationServi const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory'); const Item = invoke('GameServer/Item/Item'); -options.default.OpenRouter.negotiationEnabled = true; function actor(id, name, backpack) { return { @@ -41,7 +40,7 @@ const item = new Item(911, { }); const bot = { accountId: 'bot_neg_policy', - plan: 'merchant', + plan: 'following', persona: { primaryDrive: 'wealth', traits: { caution: 0.8, ambition: 0.7, assertiveness: 0.7 } diff --git a/tests/test_hot_bot_conversation_flow.js b/tests/test_hot_bot_conversation_flow.js index c5dedc3b..a28a17b2 100644 --- a/tests/test_hot_bot_conversation_flow.js +++ b/tests/test_hot_bot_conversation_flow.js @@ -45,14 +45,13 @@ async function main() { enabled: true, apiKey: 'conversation-test-key', model: 'test/conversation', - negotiationEnabled: true, maxConcurrentRequests: 32 }; const playerSession = { accountId: 'player_flow', actor: actor(301, 'FlowPlayer') }; const botSession = { accountId: 'bot_flow', actor: actor(302, 'FlowBot', 100), - plan: 'merchant' + plan: 'hunting' }; World.user = { sessions: [playerSession, botSession] }; BotBrainContext.compactStatus = (_session, status) => status; diff --git a/tests/test_llm_negotiation_tools.js b/tests/test_llm_negotiation_tools.js index 8435e66f..53ad23fd 100644 --- a/tests/test_llm_negotiation_tools.js +++ b/tests/test_llm_negotiation_tools.js @@ -6,7 +6,6 @@ const BotNegotiationService = invoke('GameServer/Bot/Economy/BotNegotiationServi const BotTradeService = invoke('GameServer/Bot/BotTradeService'); const Item = invoke('GameServer/Item/Item'); -options.default.OpenRouter.negotiationEnabled = true; function backpack(items) { return { @@ -39,7 +38,7 @@ const player = { ])) }; const botItem = new Item(941, { selfId: 9041, name: 'Quoted Cloth', kind: 'Other.Material', price: 1000, amount: 2, stackable: true, equipped: false, slot: 0 }); -const bot = { accountId: 'bot_llm_neg', plan: 'merchant', actor: actor(942, 'NegotiationMerchant', backpack([botItem])) }; +const bot = { accountId: 'bot_llm_neg', plan: 'following', actor: actor(942, 'NegotiationMerchant', backpack([botItem])) }; function decision(action, turnId, extra = {}) { return { action, confidence: 0.99, reason: 'negotiation tool test', turnId, ...extra }; From d34cc82397f2e51d7099f1d57e103a7ea42aeb55 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:48:04 -0400 Subject: [PATCH 58/59] Harden AI admission and trade concurrency --- src/GameServer/Bot/AI/BotInferenceBudget.js | 92 +++++++++++++++++-- .../Bot/AI/States/FollowingState.js | 8 +- src/GameServer/Bot/TradeService.js | 34 +++++-- tests/test_bot_inference_interactive_queue.js | 81 ++++++++++++++++ tests/test_llm_supply_errand.js | 39 ++++++++ tests/test_trade_store_atomicity.js | 32 ++++++- 6 files changed, 262 insertions(+), 24 deletions(-) diff --git a/src/GameServer/Bot/AI/BotInferenceBudget.js b/src/GameServer/Bot/AI/BotInferenceBudget.js index 46b7d422..5a73254e 100644 --- a/src/GameServer/Bot/AI/BotInferenceBudget.js +++ b/src/GameServer/Bot/AI/BotInferenceBudget.js @@ -9,6 +9,9 @@ const LIMITS = Object.freeze({ globalPromptTokens: 300000, globalCompletionTokens: 64000 }); +const RESERVATION_TTL_MS = WINDOW_MS; +const GLOBAL_WAITER_TTL_MS = 5 * 60 * 1000; +const MAX_GLOBAL_WAITERS = 256; const buckets = new Map(); const globalBucket = { entries: [], inFlight: 0, lastDeniedAt: 0, lastDeniedReason: null }; const globalWaiters = []; @@ -47,8 +50,52 @@ function globalConfig() { }; } +function completeWaiter(waiter, result) { + if (!waiter || waiter.completed) return false; + waiter.completed = true; + if (waiter.expiryTimer) clearTimeout(waiter.expiryTimer); + waiter.resolve(result); + return true; +} + +function removeWaiter(waiter, result) { + const index = globalWaiters.indexOf(waiter); + if (index >= 0) globalWaiters.splice(index, 1); + return completeWaiter(waiter, result); +} + +function waiterTimeout(waiter) { + return removeWaiter(waiter, { + ok: false, + reason: 'inference_budget_queue_timeout', + retryAfterMs: 0, + status: null + }); +} + +function releaseGlobalSlot(reservation, options = {}) { + if (!reservation || reservation.globalSlotReleased) return false; + reservation.globalSlotReleased = true; + if (reservation.expiryTimer) clearTimeout(reservation.expiryTimer); + reservation.expiryTimer = null; + if (options.expired === true) { + reservation.expired = true; + reservation.settled = true; + } + globalBucket.inFlight = Math.max(0, globalBucket.inFlight - 1); + if (options.pump !== false) pumpGlobalWaiters(); + return true; +} + function prune(bucket, now) { - bucket.entries = bucket.entries.filter((entry) => now - entry.startedAt < WINDOW_MS); + const expired = []; + bucket.entries = bucket.entries.filter((entry) => { + const keep = now - entry.startedAt < WINDOW_MS; + if (!keep && bucket === globalBucket && !entry.settled) expired.push(entry); + return keep; + }); + expired.forEach((entry) => releaseGlobalSlot(entry, { expired: true, pump: false })); + if (expired.length > 0) pumpGlobalWaiters(); } function usageValue(usage, key) { @@ -104,11 +151,24 @@ function globalRejection(reason, now, retryAfterMs = 0) { } function queueInteractiveReservation(session, input, now) { + if (globalWaiters.length >= MAX_GLOBAL_WAITERS) { + return globalRejection('inference_budget_queue_full', now, 1000); + } let resolveReady; const ready = new Promise((resolve) => { resolveReady = resolve; }); - globalWaiters.push({ session, input: { ...input }, now, resolve: resolveReady }); + const waiter = { + session, + input: { ...input }, + now, + resolve: resolveReady, + completed: false, + expiryTimer: null + }; + waiter.expiryTimer = setTimeout(() => waiterTimeout(waiter), GLOBAL_WAITER_TTL_MS); + waiter.expiryTimer.unref?.(); + globalWaiters.push(waiter); return { ok: true, bypassed: true, @@ -123,12 +183,13 @@ function pumpGlobalWaiters() { const global = globalConfig(); while (globalWaiters.length > 0 && globalBucket.inFlight < global.maxInFlight) { const waiter = globalWaiters.shift(); + if (waiter.completed) continue; const result = reserve(waiter.session, { ...waiter.input, now: Date.now(), _grantingQueued: true }); - waiter.resolve(result); + completeWaiter(waiter, result); } } @@ -205,12 +266,18 @@ function reserve(session, input = {}) { bypass: input.bypass === true, event: String(input.event || 'hot_decision').slice(0, 48), priority: String(input.priority || 'normal').slice(0, 24), - globalEntry: null + globalEntry: null, + globalSlotReleased: false, + expiryTimer: null }; bucket.entries.push(reservation); reservation.globalEntry = reservation; globalBucket.entries.push(reservation); globalBucket.inFlight += 1; + reservation.expiryTimer = setTimeout(() => { + releaseGlobalSlot(reservation, { expired: true }); + }, RESERVATION_TTL_MS); + reservation.expiryTimer.unref?.(); return { ok: true, bypassed: input.bypass === true, reservation, status: status(session, now) }; } @@ -227,8 +294,7 @@ function settle(reservation, usage = null) { reservation.totalTokens = reservation.promptTokens + reservation.completionTokens; reservation.cost = Number.isFinite(Number(usage?.cost)) ? Number(usage.cost) : null; reservation.settled = true; - if (reservation.globalEntry) globalBucket.inFlight = Math.max(0, globalBucket.inFlight - 1); - pumpGlobalWaiters(); + if (reservation.globalEntry) releaseGlobalSlot(reservation); return true; } @@ -319,6 +385,9 @@ function status(session, now = Date.now()) { const BotInferenceBudget = { WINDOW_MS, + RESERVATION_TTL_MS, + GLOBAL_WAITER_TTL_MS, + MAX_GLOBAL_WAITERS, reserve, reserveForBotId, settle, @@ -333,7 +402,7 @@ const BotInferenceBudget = { if (bucket) { bucket.entries.forEach((entry) => { if (entry.globalEntry && !entry.settled) { - globalBucket.inFlight = Math.max(0, globalBucket.inFlight - 1); + releaseGlobalSlot(entry, { expired: true, pump: false }); } entry.globalEntry = null; }); @@ -343,7 +412,7 @@ const BotInferenceBudget = { } for (let index = globalWaiters.length - 1; index >= 0; index -= 1) { if (actorId(globalWaiters[index].session) === id) { - globalWaiters[index].resolve({ ok: false, reason: 'inference_budget_reset' }); + completeWaiter(globalWaiters[index], { ok: false, reason: 'inference_budget_reset' }); globalWaiters.splice(index, 1); } } @@ -351,12 +420,17 @@ const BotInferenceBudget = { return; } buckets.clear(); + globalBucket.entries.forEach((entry) => { + if (entry.expiryTimer) clearTimeout(entry.expiryTimer); + entry.expiryTimer = null; + entry.globalSlotReleased = true; + }); globalBucket.entries = []; globalBucket.inFlight = 0; globalBucket.lastDeniedAt = 0; globalBucket.lastDeniedReason = null; while (globalWaiters.length > 0) { - globalWaiters.shift().resolve({ ok: false, reason: 'inference_budget_reset' }); + completeWaiter(globalWaiters.shift(), { ok: false, reason: 'inference_budget_reset' }); } reservationSequence = 0; }, diff --git a/src/GameServer/Bot/AI/States/FollowingState.js b/src/GameServer/Bot/AI/States/FollowingState.js index a69f0e8e..150cf891 100644 --- a/src/GameServer/Bot/AI/States/FollowingState.js +++ b/src/GameServer/Bot/AI/States/FollowingState.js @@ -709,7 +709,7 @@ function deliverPurchasedResources(session, bot, playerSession) { recordRoleDecision(session, bot, 'deliver_resources', 'trade_pending_confirmation', { targetId: delivery.playerId }); return true; } - if (Number(delivery.retryAt || 0) > Date.now()) return true; + if (Number(delivery.retryAt || 0) > Date.now()) return false; if (point(bot).distance(point(playerSession.actor)) > 350) { if (!bot.state?.fetchTowards?.()) { bot.moveTo({ from: loc(bot), to: loc(playerSession.actor) }); @@ -735,7 +735,7 @@ function deliverPurchasedResources(session, bot, playerSession) { }); } recordRoleDecision(session, bot, 'deliver_resources', 'wait_for_safe_trade', { targetId: delivery.playerId }); - return true; + return false; } const trade = invoke('GameServer/Bot/BotTradeService').startBotTradeWithOffer( session, @@ -745,7 +745,7 @@ function deliverPurchasedResources(session, bot, playerSession) { { workflowId: delivery.workflowId, supplyDelivery: true } ); if (!trade.ok) { - if (trade.reason === 'too_far') return true; + if (trade.reason === 'too_far') return false; delivery.retryAt = Date.now() + 10000; BotPartyChat.announce(session, { priority: 'informational', @@ -757,7 +757,7 @@ function deliverPurchasedResources(session, bot, playerSession) { playerId: delivery.playerId, amount: delivery.amount }, 'failed', trade.reason || 'trade_open_failed', { terminal: false }); - return true; + return false; } delivery.tradeId = trade.trade?.id || trade.id || null; delivery.retryAt = undefined; diff --git a/src/GameServer/Bot/TradeService.js b/src/GameServer/Bot/TradeService.js index aee29e9f..d5de5d05 100644 --- a/src/GameServer/Bot/TradeService.js +++ b/src/GameServer/Bot/TradeService.js @@ -2,6 +2,7 @@ const DataCache = invoke('GameServer/DataCache'); const Database = invoke('Database'); const BotEconomyPricing = invoke('GameServer/Bot/Economy/BotEconomyPricing'); const storePurchaseQueues = new WeakMap(); +const actorPurchaseQueues = new WeakMap(); function itemTemplate(selfId) { return DataCache.items.find((ob) => ob.selfId === selfId); @@ -197,12 +198,19 @@ async function buyFromStore(actor, store, selfId, qty, options = {}) { if (!storePurchaseQueues.has(store)) storePurchaseQueues.set(store, new Map()); const queues = storePurchaseQueues.get(store); const queueKey = String(Number(selfId)); - const previous = queues.get(queueKey) || Promise.resolve(); - let release; - const current = new Promise((resolve) => { release = resolve; }); - const queued = previous.then(() => current); - queues.set(queueKey, queued); - await previous; + const previousStorePurchase = queues.get(queueKey) || Promise.resolve(); + let releaseStorePurchase; + const currentStorePurchase = new Promise((resolve) => { releaseStorePurchase = resolve; }); + const queuedStorePurchase = previousStorePurchase.then(() => currentStorePurchase); + queues.set(queueKey, queuedStorePurchase); + await previousStorePurchase; + + const previousActorPurchase = actorPurchaseQueues.get(actor) || Promise.resolve(); + let releaseActorPurchase; + const currentActorPurchase = new Promise((resolve) => { releaseActorPurchase = resolve; }); + const queuedActorPurchase = previousActorPurchase.then(() => currentActorPurchase); + actorPurchaseQueues.set(actor, queuedActorPurchase); + await previousActorPurchase; store.activePurchases = Math.max(0, Number(store.activePurchases || 0)) + 1; try { @@ -220,8 +228,12 @@ async function buyFromStore(actor, store, selfId, qty, options = {}) { throw new Error("Store price changed."); } - const buyQty = Math.min(Number(qty), Number(storeItem.count)); - if (buyQty <= 0) { + const requestedQty = Number(qty); + if (!Number.isSafeInteger(requestedQty) || requestedQty <= 0) { + throw new Error("Invalid quantity."); + } + const buyQty = Math.min(requestedQty, Number(storeItem.count)); + if (!Number.isSafeInteger(buyQty) || buyQty <= 0) { throw new Error("Item is out of stock."); } @@ -263,8 +275,10 @@ async function buyFromStore(actor, store, selfId, qty, options = {}) { return { qty: buyQty, totalAdena: totalCost, name: itemName(selfId) }; } finally { store.activePurchases = Math.max(0, Number(store.activePurchases || 0) - 1); - release(); - if (queues.get(queueKey) === queued) queues.delete(queueKey); + releaseActorPurchase(); + if (actorPurchaseQueues.get(actor) === queuedActorPurchase) actorPurchaseQueues.delete(actor); + releaseStorePurchase(); + if (queues.get(queueKey) === queuedStorePurchase) queues.delete(queueKey); if (queues.size === 0) storePurchaseQueues.delete(store); } } diff --git a/tests/test_bot_inference_interactive_queue.js b/tests/test_bot_inference_interactive_queue.js index cf5c94b2..a323695e 100644 --- a/tests/test_bot_inference_interactive_queue.js +++ b/tests/test_bot_inference_interactive_queue.js @@ -52,6 +52,87 @@ async function main() { assert.strictEqual(BotInferenceBudget.globalStatus(1001).queuedRequests, 0); BotInferenceBudget.settle(granted.reservation, { promptTokens: 9000, completionTokens: 9000 }); assert.strictEqual(BotInferenceBudget.globalStatus(1001).inFlight, 0); + + BotInferenceBudget.reset(); + const abandoned = BotInferenceBudget.reserve(session(9403), { + event: 'state_change', + estimatedPromptTokens: 20, + maxCompletionTokens: 20, + now: 2000 + }); + const afterAbandoned = BotInferenceBudget.reserve(session(9404), { + event: 'player_chat', + bypass: true, + estimatedPromptTokens: 20, + maxCompletionTokens: 0, + now: 2001 + }); + assert.strictEqual(afterAbandoned.queued, true); + BotInferenceBudget.globalStatus(2000 + BotInferenceBudget.RESERVATION_TTL_MS + 1); + const afterExpiry = await afterAbandoned.ready; + assert.strictEqual(afterExpiry.ok, true, 'an expired reservation must release the next interactive waiter'); + assert.strictEqual(abandoned.reservation.expired, true); + assert.strictEqual(BotInferenceBudget.settle(abandoned.reservation), false, 'late settlement must not release the slot twice'); + assert.strictEqual(BotInferenceBudget.globalStatus().inFlight, 1); + BotInferenceBudget.settle(afterExpiry.reservation, { promptTokens: 20, completionTokens: 20 }); + + BotInferenceBudget.reset(); + const blocker = BotInferenceBudget.reserve(session(9500), { + event: 'state_change', + estimatedPromptTokens: 20, + maxCompletionTokens: 20 + }); + const waiters = []; + for (let index = 0; index < BotInferenceBudget.MAX_GLOBAL_WAITERS; index += 1) { + const waiting = BotInferenceBudget.reserve(session(9600 + index), { + event: 'player_chat', + bypass: true, + estimatedPromptTokens: 20, + maxCompletionTokens: 0 + }); + assert.strictEqual(waiting.queued, true); + waiters.push(waiting); + } + const overflow = BotInferenceBudget.reserve(session(9999), { + event: 'player_chat', + bypass: true, + estimatedPromptTokens: 20, + maxCompletionTokens: 0 + }); + assert.strictEqual(overflow.ok, false, 'the global interactive queue must have a hard bound'); + assert.strictEqual(overflow.reason, 'inference_budget_queue_full'); + BotInferenceBudget.settle(blocker.reservation, { promptTokens: 20, completionTokens: 20 }); + const firstWaiting = await waiters[0].ready; + assert.strictEqual(firstWaiting.ok, true); + BotInferenceBudget.settle(firstWaiting.reservation, { promptTokens: 20, completionTokens: 20 }); + BotInferenceBudget.reset(); + + const timeoutBlocker = BotInferenceBudget.reserve(session(9700), { + event: 'state_change', + estimatedPromptTokens: 20, + maxCompletionTokens: 20 + }); + const originalSetTimeout = global.setTimeout; + global.setTimeout = (callback) => { + const timer = originalSetTimeout(callback, 0); + timer.unref = () => timer; + return timer; + }; + let timedOut; + try { + timedOut = BotInferenceBudget.reserve(session(9701), { + event: 'player_chat', + bypass: true, + estimatedPromptTokens: 20, + maxCompletionTokens: 0 + }); + } finally { + global.setTimeout = originalSetTimeout; + } + const timeoutResult = await timedOut.ready; + assert.strictEqual(timeoutResult.ok, false, 'a queued waiter must not remain pending forever'); + assert.strictEqual(timeoutResult.reason, 'inference_budget_queue_timeout'); + BotInferenceBudget.settle(timeoutBlocker.reservation, { promptTokens: 20, completionTokens: 20 }); console.log('Interactive inference queue checks passed'); } finally { options.default.OpenRouter = originalConfig; diff --git a/tests/test_llm_supply_errand.js b/tests/test_llm_supply_errand.js index 9bc5ee4d..cc74843c 100644 --- a/tests/test_llm_supply_errand.js +++ b/tests/test_llm_supply_errand.js @@ -159,6 +159,45 @@ async function main() { assert.deepStrictEqual(offered, { objectId: 11, amount: 1 }); assert.strictEqual(session.pendingResourceDelivery.tradeId, 'supply-trade-test'); + session.pendingResourceDelivery = { + playerSession: player, + playerId: 100, + objectId: 11, + itemName: 'Soulshot: D-grade', + amount: 1, + purchasedAt: Date.now(), + retryAt: Date.now() + 10000 + }; + offered = null; + assert.strictEqual( + FollowingState.deliverPurchasedResources(session, bot, player), + false, + 'a delivery retry delay must leave the normal companion tick available' + ); + assert.strictEqual(offered, null); + + session.pendingResourceDelivery.retryAt = undefined; + player.actor.state.fetchCombats = () => true; + assert.strictEqual( + FollowingState.deliverPurchasedResources(session, bot, player), + false, + 'waiting for a safe trade must not suppress combat or support behavior' + ); + player.actor.state.fetchCombats = () => false; + + BotTradeService.startBotTradeWithOffer = () => ({ ok: false, reason: 'too_far' }); + assert.strictEqual( + FollowingState.deliverPurchasedResources(session, bot, player), + false, + 'a transient trade distance failure must leave the normal companion tick available' + ); + BotTradeService.startBotTradeWithOffer = () => ({ ok: false, reason: 'trade_busy' }); + assert.strictEqual( + FollowingState.deliverPurchasedResources(session, bot, player), + false, + 'a failed trade open must leave the normal companion tick available' + ); + // An errand request must not cancel an active fight just because the // player asked at the wrong moment. session.companionShopping = undefined; diff --git a/tests/test_trade_store_atomicity.js b/tests/test_trade_store_atomicity.js index 67a3031d..c79de4ee 100644 --- a/tests/test_trade_store_atomicity.js +++ b/tests/test_trade_store_atomicity.js @@ -53,7 +53,10 @@ async function main() { }; let nextObjectId = 9000; try { - DataCache.items = [{ selfId: 1864, template: { name: 'Varnish' }, etc: { stackable: true } }]; + DataCache.items = [ + { selfId: 1864, template: { name: 'Varnish' }, etc: { stackable: true } }, + { selfId: 1865, template: { name: 'Suede' }, etc: { stackable: true } } + ]; Database.updateItemAmount = async () => {}; Database.deleteItem = async () => {}; Database.setItem = async () => ({ insertId: ++nextObjectId }); @@ -82,6 +85,7 @@ async function main() { ); assert.strictEqual(rollbackStore.items[0].count, 2, 'failed item write must restore reserved stock'); assert.strictEqual(rollbackBuyer.backpack.fetchItemFromSelfId(57).fetchAmount(), 100, 'failed item write must restore deducted Adena'); + Database.setItem = async () => ({ insertId: ++nextObjectId }); const repricedStore = { storeType: 1, items: [{ selfId: 1864, price: 11, count: 2 }] }; await assert.rejects( @@ -89,6 +93,32 @@ async function main() { /Store price changed/ ); assert.strictEqual(repricedStore.items[0].count, 2, 'a repriced lot must remain untouched'); + + const invalidStore = { storeType: 1, items: [{ selfId: 1864, price: 10, count: 2 }] }; + const invalidBuyer = actor(5); + await assert.rejects( + TradeService.buyFromStore(invalidBuyer, invalidStore, 1864, 'not-a-number'), + /Invalid quantity/ + ); + await assert.rejects( + TradeService.buyFromStore(invalidBuyer, invalidStore, 1864, 1.5), + /Invalid quantity/ + ); + assert.strictEqual(invalidBuyer.backpack.fetchItemFromSelfId(57).fetchAmount(), 100, 'invalid quantities must not deduct Adena'); + assert.strictEqual(invalidStore.items[0].count, 2, 'invalid quantities must not reserve stock'); + + const sharedBuyer = actor(6); + const firstActorStore = { storeType: 1, items: [{ selfId: 1864, price: 10, count: 6 }] }; + const secondActorStore = { storeType: 1, items: [{ selfId: 1865, price: 10, count: 3 }] }; + await Promise.all([ + TradeService.buyFromStore(sharedBuyer, firstActorStore, 1864, 6), + TradeService.buyFromStore(sharedBuyer, secondActorStore, 1865, 3) + ]); + assert.strictEqual( + sharedBuyer.backpack.fetchItemFromSelfId(57).fetchAmount(), + 10, + 'purchases of different items and stores must serialize Adena deductions per actor' + ); console.log('Trade store atomicity checks passed'); } finally { DataCache.items = originalItems; From af082e3013b412b665268d23a8a112faf8061340 Mon Sep 17 00:00:00 2001 From: Slava Trofimov <26082149+pmbstyle@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:52:15 -0400 Subject: [PATCH 59/59] Strengthen stale-price purchase regression --- tests/test_trade_store_atomicity.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_trade_store_atomicity.js b/tests/test_trade_store_atomicity.js index c79de4ee..3b7dff4b 100644 --- a/tests/test_trade_store_atomicity.js +++ b/tests/test_trade_store_atomicity.js @@ -88,11 +88,13 @@ async function main() { Database.setItem = async () => ({ insertId: ++nextObjectId }); const repricedStore = { storeType: 1, items: [{ selfId: 1864, price: 11, count: 2 }] }; + const repricedBuyer = actor(4); await assert.rejects( - TradeService.buyFromStore(actor(4), repricedStore, 1864, 1, { expectedUnitPrice: 10 }), + TradeService.buyFromStore(repricedBuyer, repricedStore, 1864, 1, { expectedUnitPrice: 10 }), /Store price changed/ ); assert.strictEqual(repricedStore.items[0].count, 2, 'a repriced lot must remain untouched'); + assert.strictEqual(repricedBuyer.backpack.fetchItemFromSelfId(57).fetchAmount(), 100, 'a repriced lot must not deduct Adena'); const invalidStore = { storeType: 1, items: [{ selfId: 1864, price: 10, count: 2 }] }; const invalidBuyer = actor(5);