diff --git a/config/default.ini b/config/default.ini index 6bc6fac4..1c71c804 100644 --- a/config/default.ini +++ b/config/default.ini @@ -27,19 +27,26 @@ showMonsterLevel = true [OpenRouter] enabled = false apiKey = -model = google/gemini-2.5-flash-lite +model = openai/gpt-5.6-luna +partyRouterModel = openai/gpt-5.6-luna temperature = 0.35 -maxTokens = 160 -timeoutMs = 3500 -cooldownMs = 45000 -chatCooldownMs = 12000 -visibilityRadius = 6000 -maxPromptPrice = 0.15 -maxCompletionPrice = 0.60 +reasoningEffort = low +maxConcurrentRequests = 32 +debug = false + +[Langfuse] +enabled = false +envFile = +baseUrl = http://localhost:3000 +capturePayloads = true debug = false [BotPopulation] enabled = true +ambientScenesEnabled = true +ambientSceneCooldownMs = 180000 +ambientPairCooldownMs = 90000 +ambientSceneTtlMs = 8000 backgroundResolverEnabled = true backgroundPartyEnabled = true phasePolicyEnabled = true diff --git a/config/local.example.ini b/config/local.example.ini index 715b20e3..551ef94d 100644 --- a/config/local.example.ini +++ b/config/local.example.ini @@ -1,5 +1,14 @@ [OpenRouter] enabled = true apiKey = sk-or-v1-your-key-here -model = google/gemini-2.5-flash-lite +model = openai/gpt-5.6-luna +# Optional cheap model for ambiguous party-chat routing; empty keeps deterministic routing only. +partyRouterModel = +debug = true + +[Langfuse] +enabled = false +envFile = +baseUrl = http://localhost:3000 +capturePayloads = true debug = true diff --git a/database/sql/sqlite.sql b/database/sql/sqlite.sql index 12d0faa1..9c480f88 100644 --- a/database/sql/sqlite.sql +++ b/database/sql/sqlite.sql @@ -239,6 +239,121 @@ 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, + 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, + 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, + 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 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_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_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, + 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/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/scripts/run-tests.js b/scripts/run-tests.js index 878af3c9..96bc66a3 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -6,12 +6,59 @@ 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_ai_config_surface.js', + '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_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', + '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', + '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_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', + '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', + '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', + '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', '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', @@ -30,6 +77,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', @@ -81,6 +129,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/src/Database.js b/src/Database.js index 7a3fb4f0..89ddb059 100644 --- a/src/Database.js +++ b/src/Database.js @@ -199,7 +199,139 @@ 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); + `)], + [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); + `)], + [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); + `)], + [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); + `)], + [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]) => { @@ -354,6 +486,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/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/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 5b1ad817..0f69ec0b 100644 --- a/src/GameServer/Bot/AI/BotAgentTools.js +++ b/src/GameServer/Bot/AI/BotAgentTools.js @@ -3,21 +3,63 @@ 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 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 BotTradeService = invoke('GameServer/Bot/BotTradeService'); +const BotNegotiationService = invoke('GameServer/Bot/Economy/BotNegotiationService'); +const BotAvailability = invoke('GameServer/Bot/AI/BotAvailability'); const ACTIONS = [ 'none', 'say', 'follow_player', + 'regroup_party', + 'stay_party', 'stay_here', 'hunt', 'rest', 'shop', + 'fetch_resources', 'move_to_spot', 'buff_target', - 'heal_target' + 'set_buff_policy', + 'heal_target', + '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', + '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' ]; -const PK_LOCKED_ACTIONS = new Set(['follow_player', 'stay_here', 'hunt', 'rest', 'shop', 'move_to_spot']); +const PK_LOCKED_ACTIONS = new Set([ + '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', + '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' +]); function clean(text) { const BotChatText = invoke('GameServer/Bot/AI/BotChatText'); @@ -64,6 +106,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); @@ -142,16 +193,30 @@ 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' }; + 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 clearChatArrival(session, reason) { + try { invoke('GameServer/Bot/AI/ChatArrivalState').clear(session, reason); } catch (_) { /* optional movement overlay */ } } function applyHealTarget(session, bot, decision, targetSession) { @@ -163,13 +228,19 @@ 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) { +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' }; @@ -188,22 +259,32 @@ function execute(session, decision, visiblePlayers) { return { applied: true, reason: 'none' }; } if (action === 'say') { - return { applied: say(session, decision.reply, targetSession), reason: 'say' }; + 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' }; 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); + 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, { + 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); + const reply = replyOutcome(session, decision.reply || `Coming closer. Invite me if you want party follow.`, targetSession); + return { applied: true, reason: 'follow_player', ...reply }; } - return { applied: true, reason: 'follow_player' }; } if (action === 'stay_here') { + clearChatArrival(session, 'stay_here'); session.botStay = true; session.stayLocation = { locX: bot.fetchLocX(), @@ -213,26 +294,28 @@ function execute(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 reply = replyOutcome(session, decision.reply || 'Holding this position.', targetSession); + return { applied: true, reason: 'stay_here', ...reply }; } if (action === 'hunt') { + clearChatArrival(session, 'hunt'); stand(session, bot); 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 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; - say(session, decision.reply, targetSession); - return { applied: true, reason: 'hunt' }; + const reply = replyOutcome(session, decision.reply, targetSession); + return { applied: true, reason: 'hunt', ...reply }; } 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) { @@ -243,34 +326,34 @@ function execute(session, decision, visiblePlayers) { } else { session.plan = 'hunting'; } - say(session, decision.reply || "I'm already recovered.", targetSession); - return { applied: true, reason: 'already_recovered' }; + 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); - say(session, decision.reply, targetSession); - return { applied: true, reason: 'rest' }; + const reply = replyOutcome(session, decision.reply, targetSession); + return { applied: true, reason: 'rest', ...reply }; } if (action === 'shop') { - if (startShopping(session, bot)) { - say(session, decision.reply, targetSession); - } else { - say(session, decision.reply || 'I will stay with the party and sell later.', targetSession); + if (!startShopping(session, bot)) { + return { applied: false, reason: 'party_companion_cannot_shop_now' }; } - return { applied: true, reason: 'shop' }; + clearChatArrival(session, 'shop'); + const reply = replyOutcome(session, decision.reply, targetSession); + return { applied: true, reason: 'shop', ...reply }; } if (action === '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' }; + return { applied: false, reason: 'party_companion_stays_with_party' }; } + clearChatArrival(session, 'move_to_spot'); const applied = applyMoveToSpot(session, bot, decision.spotId); - if (applied) say(session, decision.reply, targetSession); - return { applied, reason: applied ? 'move_to_spot' : 'invalid_spot' }; + 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); @@ -282,40 +365,763 @@ function execute(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, 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, + ...policyContext + }; +} + +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) + ? 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}`, { + pullRestore: inheritedRestore || { + pullMode: current.pullMode || 'auto', + pullerId: current.pullerId + } + }); +} + +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' }; + + const current = PartyCompanionService.getSettings(leader); + const inheritedRestore = inheritedPullRestore(session, current); + 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', { + pullRestore: inheritedRestore || { + pullMode: current.pullMode || 'auto', + pullerId: current.pullerId + } + }); +} + +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. + 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', { + pullRestore: inheritedRestore || { + pullMode: settings.pullMode || 'auto', + pullerId: settings.pullerId + } + }); +} + +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 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 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)) || + (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 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 || []); + excluded.delete(capability.type); + if (mode === 'deny') excluded.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: [] } + }, context, `buff_policy:${mode}:${capability.type}`); +} + +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 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 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 }; + 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, + playerVisibleReply: `I put ${line.count} ${line.name} in the trade window. Please confirm the trade when you are ready.` + }; +} + +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 || resolved?.selfId, + 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, + itemName: result.itemName, + 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; + 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 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, + negotiationPrice(context.decision) + ); + 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, + 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) { + 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 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) { + 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, + playerVisibleReply: reply + }; +} + +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) { + 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() { + 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: '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 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.', + 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.', + 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.', + 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 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: 'For companions only, open native trade after the bounded price has been accepted.' + }; + + 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', + '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, + stay_party: stayParty, + set_pull_policy: applyPullPolicy, + stop_pulling_and_return: stopPullingAndReturn, + assign_puller: assignPuller, + unassign_puller: unassignPuller, + 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, + list_party_candidates: listPartyCandidates, + propose_trade: proposeTrade, + give_resources: giveResources, + fetch_resources: fetchResources, + offer_resources: offerResources, + update_trade_offer: updateTradeOffer, + cancel_trade: cancelBotTrade, + quote_item: quoteItem, + counter_offer: counterOffer, + accept_price: acceptPrice, + decline_price: declinePrice, + open_negotiated_trade: openNegotiatedTrade + }; + + ACTIONS.forEach((name) => { + BotToolRegistry.register({ + name, + description: descriptions[name], + 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', 'list_party_candidates'].includes(name), + available(session) { + if (!session) return true; + 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 (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; + 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; } + } + if (name === 'heal_target') { + try { if (!BotRoles.isHealer(session.actor)) return false; } catch (_) { return true; } + } + return true; + }, + 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 || []); + } + }); + }); +} + +registerTools(); + +function execute(session, decision, visiblePlayers, requestContext = null) { + const outcome = BotToolRegistry.execute({ + session, + decision, + visiblePlayers, + requestContext, + expectedWorldRevision: requestContext?.preparedWorldRevision || requestContext?.worldRevision + }); + 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) { if (!result?.applied) return; session.lastBrainDecision = { 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 ? { - 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 }; } -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 '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 '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 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_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); + 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 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.'; + 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.'; + 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.'; + 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': + 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.'; + } +} + +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, - toolDescriptions + pendingReply, + rejectionReply, + toolDescriptions, + worldRevision, + partyCandidates }; diff --git a/src/GameServer/Bot/AI/BotAmbientDirector.js b/src/GameServer/Bot/AI/BotAmbientDirector.js new file mode 100644 index 00000000..ee3b08cf --- /dev/null +++ b/src/GameServer/Bot/AI/BotAmbientDirector.js @@ -0,0 +1,330 @@ +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 DEFAULT_STATE_TTL_MS = 5 * 1000; +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; + 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 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', + 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 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(':'); +} + +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; + 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' }; + prunePairHistory(now); + 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 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.ambientLastSceneAt && now - session.ambientLastSceneAt < 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; + pairLastSceneAt.set(pairKey(initiator, responder), 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, + DEFAULT_STATE_TTL_MS, + enabled, + deriveMood, + refresh, + snapshot, + eligible, + start, + finish, + cleanup, + sceneFor, + activeScenes() { return [...activeScenes.values()].map((scene) => ({ ...scene, conversation: undefined })); }, + reset() { + activeScenes.clear(); + pairLastSceneAt.clear(); + } +}; + +module.exports = BotAmbientDirector; diff --git a/src/GameServer/Bot/AI/BotBrain.js b/src/GameServer/Bot/AI/BotBrain.js index 8ba6393f..2453c8c2 100644 --- a/src/GameServer/Bot/AI/BotBrain.js +++ b/src/GameServer/Bot/AI/BotBrain.js @@ -1,37 +1,19 @@ 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'); +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_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; -} - +const ALLOWED_EVENTS = new Set(['player_chat']); 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(); } function debugSkip(session, cfg, reason) { @@ -81,7 +63,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'); @@ -92,13 +74,29 @@ 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) { - if (!status || !status.available || status.mode !== 'hunting') return []; + if (!status || !status.available) return []; + + 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 SpotService.ensureIndexed() + return indexed .map((spot) => ({ id: spot.id, name: spot.name, @@ -116,13 +114,52 @@ function candidateSpots(status) { .slice(0, 6); } -function schema() { +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: { action: { type: 'string', - enum: BotAgentTools.ACTIONS + enum: allowedActions }, reply: { type: 'string', @@ -138,8 +175,107 @@ function schema() { }, 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', + 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'], + 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.' + }, + supplyItemId: { + type: 'number', + minimum: 0, + 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', + minimum: 1, + maximum: 5000, + description: 'Exact new quantity to buy and deliver for fetch_resources.' + }, + 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.' + }, + 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: 100000000000, + description: 'Total Adena price for a bounded counter or accepted quote.' + }, + policyTtlMs: { + type: 'number', + minimum: 5000, + maximum: 1800000, + description: 'Optional hot policy lifetime, clamped by the server.' }, reason: { type: 'string', @@ -149,126 +285,540 @@ 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'], + required: ['action', 'reply', 'reason', 'confidence'], additionalProperties: false }; } -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 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.', - 'React only when a real visible player writes to this bot or nearby bots.', - 'For player_chat, react only if the message is addressed to this bot, nearby bots, or clearly asks for help.', + '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 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.', + '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.', - 'Do not offer trading, selling, price negotiation, or private stores; those tools are intentionally unavailable for now.', + '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. 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.' ].join(' '); } -function userPayload(event, session, status, visiblePlayers, text) { +function userPayload(event, session, status, visiblePlayers, text, requestContext = null) { + 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 = candidateRequest + ? BotAgentTools.partyCandidates(requestContext.playerSession, session) + : []; + const merchantSlice = session?.plan === 'merchant'; return { event, playerMessage: text || '', - bot: BotBrainContext.compactStatus(session, status, text), + bot: assembled?.bot || (merchantSlice + ? BotBrainContext.compactMerchantStatus(session, status, requestContext.playerSession) + : BotBrainContext.compactStatus(session, status, text)), visiblePlayers, - candidateSpots: candidateSpots(status), - allowedActions: BotAgentTools.ACTIONS, - tools: BotAgentTools.toolDescriptions(), - constraints: { + party: !merchantSlice && (partyRequest || candidateRequest) ? { + intent: candidateRequest ? 'candidate_discovery' : 'membership', + 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: merchantSlice ? [] : candidateSpots(status), + allowedActions: BotAgentTools.availableActions(session), + tools: BotAgentTools.toolDescriptions(session), + toolContext: { + worldRevision: preparedWorldRevision + }, + constraints: merchantSlice ? { + keepReplyShort: true, + englishOnly: true, + publicStoreSale: true, + noInventoryReservation: true + } : { keepReplyShort: true, splitLongRepliesIntoChatLines: true, avoidSpam: true, noCombatMicromanagement: true }, + conversation: requestContext?.conversation || null, + contextFragments: assembled?.fragments || null, + contextTelemetry: assembled?.telemetry || null, lastDecision: session.lastBrainDecision || null }; } -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); +function estimatePromptTokens(payload) { + try { return Math.max(1, Math.ceil(JSON.stringify(payload).length / 4)); } catch (_) { return 1; } +} - 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; - } +function estimateRequestPromptTokens(payload, session) { + return estimatePromptTokens({ + messages: [ + { role: 'system', content: systemPrompt(session) }, + { role: 'user', content: JSON.stringify(payload) } + ], + responseSchema: { + name: 'bot_brain_decision', + schema: schema(BotAgentTools.availableActions(session), session) + }, + repairSchema: true + }); +} - 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 || + null; + const result = await OpenRouterGateway.request({ + config: cfg, + 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', + botId, + playerId, + turnId: requestContext?.conversationTurn?.turnId || requestContext?.requestId || null, messages: [ - { role: 'system', content: systemPrompt() }, + { role: 'system', content: systemPrompt(session) }, { 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(BotAgentTools.availableActions(session), session) + }, + repairSchema: true + }); + + if (!result.ok) return result; + return { + ...result.data, + usage: result.usage, + llmTelemetry: result.telemetry }; +} + +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}`; +} - if (Object.keys(provider).length > 0) { - body.provider = provider; +function compactActionResult(result) { + if (!result) return null; + const confirmed = result.applied === true && result.outcome !== 'pending'; + const compact = { + ok: confirmed, + 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) { + 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 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; +} - 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 - }); +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) { + const value = String(text || ''); + if (isPartyCandidateRequest(value)) return false; + // 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 (!requestContext?.playerSession) return decision; + const value = String(text || '').toLowerCase(); + const group = /\b(?:everyone|everybody|all|guys|bots|companions|party|team)\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', + 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, + 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 + ? 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, + 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; + 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(visibleReply) + .slice(0, BotChatText.DEFAULT_LINE_LIMIT * BotChatText.DEFAULT_MAX_LINES); + if (!reply) return; + + queueConversationWrite(session, () => BotConversationService.recordBotReply({ + playerSession: requestContext.playerSession, + botSession: session, + turnId: turn.turnId, + channel: turn.channel, + text: reply, + requestId: requestContext.requestId, + meta: { + action, + reason: result.reason || null, + serverApplied: result.applied === true && result.outcome !== 'pending', + actionResult: compactActionResult(result) + } + })); +} + +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( + 'bot.conversation.persist', + { botId: session?.actor?.fetchId?.() || session?.accountId || null }, + { + botId: session?.actor?.fetchId?.() || session?.accountId || null, + source: 'hot_dialogue', + ...metadata + }, + work, + 'chain' + ); + const next = previous.catch(() => {}).then(persist).catch(() => false); + session.lastConversationWrite = next; + return next; +} - 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; +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; + 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 = 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 }; + } + if (result.replyDelivered === true) recordDialogueDelivery(session, playerVisibleReply, requestContext); + recordConversationReply(session, decision, result, requestContext); + } + return { ...result, playerVisibleReply }; + } + 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; + recordDialogueDelivery(session, reply, requestContext); + if (requestContext.conversationTurn) { + queueConversationWrite(session, () => BotConversationService.recordFallback({ + playerSession: requestContext.playerSession, + botSession: session, + turnId: requestContext.conversationTurn.turnId, + channel: requestContext.conversationTurn.channel, + text: reply, + reason: `tool_rejected:${result.reason}` + })); } + } + return { ...result, replyDelivered: !!playerVisibleReply, playerVisibleReply }; +} + +function rememberTelemetry(session, result) { + const telemetry = result?.llmTelemetry || result?.telemetry; + if (!telemetry) return; + + session.lastBrainTelemetry = { + ...telemetry, + usage: result.usage || telemetry.usage || null + }; +} - const json = await response.json(); - const content = json.choices?.[0]?.message?.content; - if (!content) return null; +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'; - 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); + 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 } - return null; - } finally { - clearTimeout(timeout); + }).catch(() => {}); +} + +function fallbackReply(session, requestContext, outcome, persistMetadata = {}) { + 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.' + : 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); + recordDialogueDelivery(session, reply, requestContext); + if (requestContext?.conversationTurn) { + queueConversationWrite( + session, + () => BotConversationService.recordFallback({ + playerSession, + botSession: session, + turnId: requestContext.conversationTurn.turnId, + channel: requestContext.conversationTurn.channel, + text: reply, + reason: outcome || 'fallback' + }), + persistMetadata + ); } + return reply; } -function applyDecision(session, decision, visiblePlayers) { - const result = BotAgentTools.execute(session, decision, visiblePlayers); - BotAgentTools.remember(session, decision, result, config().model); - return result.applied; +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 = { @@ -277,13 +827,17 @@ const BotBrain = { return cfg.enabled && !!cfg.apiKey; }, + applyPartyPolicy, + isPartyRequest, + isPartyCandidateRequest, + visibleRealPlayers, - maybeThink(session, event, status, text = '') { + maybeThink(session, event, status, text = '', requestContext = null) { 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; } @@ -296,60 +850,356 @@ const BotBrain = { return false; } if (session.brainInFlight) { - debugSkip(session, cfg, 'request_in_flight'); - return false; - } - if (bot.isDead && bot.isDead()) { - debugSkip(session, cfg, 'dead'); - return false; - } - if (session.plan === 'merchant') { - debugSkip(session, cfg, 'merchant_plan'); - return false; - } - if (session.plan === 'getting_buffed') { - debugSkip(session, cfg, 'refreshing_buffs'); - return false; - } - if (!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); + 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 && Date.now() - lastAt < cooldown) { - debugSkip(session, cfg, `cooldown:${event}`); - return false; - } + if (requestContext && !requestContext.enqueuedAt) requestContext.enqueuedAt = Date.now(); - if (event !== 'player_chat' && Math.random() > 0.12) { - debugSkip(session, cfg, 'ambient_sample_skip'); - return false; + if (requestContext) { + requestContext.preparedWorldRevision = BotAgentTools.worldRevision(session); + requestContext.worldRevision = requestContext.preparedWorldRevision; } - - if (event === 'player_chat') { - session.lastBrainChatAt = Date.now(); - } else { - session.lastBrainThinkAt = Date.now(); + const payload = userPayload(event, session, status, visiblePlayers, text, requestContext); + const estimatedPromptTokens = estimateRequestPromptTokens(payload, session); + const admission = BotInferenceBudget.reserve(session, { + event, + bypass: true, + priority: 'interactive', + estimatedPromptTokens, + maxCompletionTokens: 0 + }); + 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, + 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; - const payload = userPayload(event, session, status, visiblePlayers, text); + 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({ + 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, + 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); - }).finally(() => { + let providerResult = null; + 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 && providerResult?.toolApplied !== false, + outcome: turnOutcome, + 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, + toolOutcome: actionResult?.outcome || null, + toolReason: actionResult?.reason || 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 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 (nextPending.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, + session + ); + const previousCount = requestContext.conversation?.recentTurns?.length || 0; + if ((fresh?.recentTurns?.length || 0) >= previousCount) { + requestContext.conversation = fresh; + } + } catch (_) { + // Keep the ingress snapshot if persistence is + // temporarily unavailable. + } + requestContext.conversation = orderedConversation(requestContext.conversation); + requestContext.assembledContext = await BotContextAssembler.assemble({ + session, + status: pendingStatus, + text: nextPending.text, + requestContext + }); + } + const started = BotBrain.maybeThink( + session, + nextPending.event, + pendingStatus, + nextPending.text, + requestContext + ); + if (!started) { + await tracePreProviderFallback(session, requestContext, 'queued_not_started', nextPending.text); + } + }); + const continuePending = (nextPending) => Promise.resolve(session.lastConversationWrite) + .catch(() => {}) + .then(() => startPending(nextPending)) + .catch(async (error) => { + utils.infoWarn('BotBrain', 'queued dialogue failed for %s: %s', bot.fetchName(), error.message); + await tracePreProviderFallback( + session, + nextPending.requestContext, + 'queued_context_error', + nextPending.text, + error + ); + + 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 () => { + 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, + channel: requestContext?.channel || null, + botId: bot.fetchId?.(), + playerId: requestContext?.playerSession?.actor?.fetchId?.() || requestContext?.playerId || null, + turnId, + requestId: requestContext?.requestId || turnId, + sessionId: conversationSessionId(session, requestContext) + }; + await LangfuseTracing.withObservation( + 'bot.context.assemble', + { + event, + playerMessage: text || '', + fragments: requestContext?.assembledContext?.telemetry?.included || [], + estimatedTokens: requestContext?.assembledContext?.estimatedTokens || null + }, + stageMetadata, + async () => requestContext?.assembledContext || null, + 'chain' + ); + const providerDecision = await requestDecision(payload, cfg, session, requestContext, visiblePlayers); + providerResult = await LangfuseTracing.withObservation( + 'bot.schema.validate', + { event, providerOutcome: providerDecision?.telemetry?.outcome || providerDecision?.llmTelemetry?.outcome || null }, + stageMetadata, + async () => validateDecisionResult(providerDecision, session), + 'chain' + ); + rememberTelemetry(session, providerResult); + if (providerResult?.ok === false) { + 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; + } + providerResult = applyPartyPolicy(session, providerResult, requestContext, text); + recordInferenceEvent(session, event, providerResult, requestContext); + const actionResult = await LangfuseTracing.withObservation( + 'bot.tool.execute', + { + action: providerResult.action || null, + confidence: providerResult.confidence || null, + worldRevision: requestContext?.preparedWorldRevision || null + }, + stageMetadata, + async () => applyDecision(session, providerResult, visiblePlayers, requestContext), + 'tool' + ); + 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, + delivered: actionResult.replyDelivered === true + }, + stageMetadata, + async () => playerVisibleReply, + 'chain' + ); + providerResult = { + ...providerResult, + 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 && actionResult.outcome !== 'pending', + toolApplied: actionResult.applied === true, + playerVisibleReply, + replyDelivered: actionResult.replyDelivered === true + } + }; + return providerResult; + } 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); + 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(); + } + }; + LangfuseTracing.withRootObservation( + '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, + sessionId: conversationSessionId(session, requestContext), + queueWaitMs: requestContext?.enqueuedAt + ? Math.max(0, Date.now() - requestContext.enqueuedAt) + : 0 + }, + runTurn, + 'agent' + ).catch((error) => { + utils.infoWarn('Langfuse', 'hot-bot observation failed: %s', error.message); }); return true; diff --git a/src/GameServer/Bot/AI/BotBrainContext.js b/src/GameServer/Bot/AI/BotBrainContext.js index 2ed5f6f4..f4e36535 100644 --- a/src/GameServer/Bot/AI/BotBrainContext.js +++ b/src/GameServer/Bot/AI/BotBrainContext.js @@ -1,6 +1,9 @@ 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 BotNegotiationService = invoke('GameServer/Bot/Economy/BotNegotiationService'); const SLOT_NAMES = { 1: 'right_ear', @@ -32,6 +35,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|equip|equipped|upgrade)\b/.test(lower) || + /(инвент|вещ|шмот|оруж|брон|аден|сос|шоты|шот|трейд|лут|дай|принес|принести|запас|нужн|есть|прод|экип|надет|улучш)/.test(lower); +} + function compactTarget(target) { if (!target) return null; return { @@ -177,8 +186,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); @@ -204,6 +212,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 }; @@ -241,16 +256,25 @@ 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 }; } -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,15 +292,40 @@ 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, + trade: status.trade || null, + ambient: status.ambient || null, + inference: status.inference || null, + policy: status.policy || null, + persona: status.persona || null, + social: status.social || null + }; +} + +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 + compactStatus, + compactMerchantStatus, + textRequestsInventory }; 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/BotContextAssembler.js b/src/GameServer/Bot/AI/BotContextAssembler.js new file mode 100644 index 00000000..839a5b11 --- /dev/null +++ b/src/GameServer/Bot/AI/BotContextAssembler.js @@ -0,0 +1,207 @@ +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) => { + 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, + outcome: meta.actionResult.outcome || null, + effect: meta.actionResult.effect || null + }; + } + return Object.keys(result).length ? result : null; +} + +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 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 = 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 = merchantSlice + ? BotBrainContext.compactMerchantStatus(session, status, requestContext.playerSession) + : BotBrainContext.compactStatus(session, status, text, { + includeInventory: itemIntent, + includeSkills: skillIntent, + includeEquipment: itemIntent || skillIntent + }); + } catch (_) { + 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 = []; + if (session?.actor?.fetchId) { + journal = await BotEventJournal.recent({ + playerId: requestContext.playerSession?.actor?.fetchId?.() || requestContext.playerId, + 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 = [ + { + 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 = []; + // `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) { + 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({ 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({ bot, fragments: 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, + itemFollowup, + skillIntent, + contextSlice: merchantSlice ? 'merchant' : 'general', + 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 new file mode 100644 index 00000000..f6b6d6b9 --- /dev/null +++ b/src/GameServer/Bot/AI/BotConversationService.js @@ -0,0 +1,184 @@ +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; + +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'; +} + +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 && + actorId(playerSession) > 0 && + actorId(botSession) > 0 && + !String(playerSession.accountId || '').toLowerCase().startsWith('bot_') && + isBotIdentity(botSession) + ); +} + +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) => { + 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) + }; +} + +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: actorName(input.botSession) + } + }).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(() => { + BotConversationSummarizer.summarize({ + playerId: actorId(input.playerSession), + botId: actorId(input.botSession), + requestId: input.requestId + }).catch(() => {}); + return 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, + 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; + } +}; + +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..c6c433d5 --- /dev/null +++ b/src/GameServer/Bot/AI/BotConversationStore.js @@ -0,0 +1,461 @@ +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), + 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) + }; +} + +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), + 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 modelVisibleTurn(turn) { + return turn?.role !== 'bot' || (turn?.delivered !== false && turn?.meta?.fallback !== true); +} + +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, + summaryThroughOrdinal: 0, + nextTurnOrdinal: 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, summaryThroughOrdinal, nextTurnOrdinal, 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, summaryThroughOrdinal, nextTurnOrdinal, version, createdAt, updatedAt) + VALUES (?, ?, '', 0, 0, 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; + const count = Math.max(1, Number(limit) || DEFAULT_RECENT_TURNS); + 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); + } + + try { + const rows = await Database.execute([ + `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, count * 2] + : [conversationId, summaryThroughOrdinal, count * 2] + ], 'bot-conversation:recent'); + 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); + } +} + +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; + 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 turnOrdinal = Number(input.turnOrdinal || 0) || await turnOrdinalFor(entry, turnId); + 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, + turnOrdinal, + messageOrder: Number(input.messageOrder ?? roleOrder(role)), + compacted: false, + 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, turnOrdinal, messageOrder, compacted) + 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, + 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, + turnOrdinal, messageOrder, compacted + 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, + 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'); + 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), + summaryThroughOrdinal: Number(entry.conversation.summaryThroughOrdinal || 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)); + 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 = ?, summaryThroughOrdinal = ?, version = ?, updatedAt = ? + WHERE id = ? AND version = ?`, + [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) }; + } + } + + conversation.summary = summary; + conversation.summaryThroughId = throughId; + conversation.summaryThroughOrdinal = throughOrdinal; + 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/BotConversationSummarizer.js b/src/GameServer/Bot/AI/BotConversationSummarizer.js new file mode 100644 index 00000000..51e8771d --- /dev/null +++ b/src/GameServer/Bot/AI/BotConversationSummarizer.js @@ -0,0 +1,253 @@ +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; +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 = {}; + ['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, + outcome: meta.actionResult.outcome || null, + effect: meta.actionResult.effect || null + }; + } + return Object.keys(result).length ? result : null; +} + +function compactTurns(turns) { + return (turns || []).map((turn) => { + const compact = { + id: turn.id, + turnId: turn.turnId, + role: turn.role, + channel: turn.channel, + text: turn.text + }; + const meta = compactMeta(turn.meta); + if (meta) compact.meta = meta; + return compact; + }); +} + +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', + schema: { + type: 'object', + properties: { + summary: { type: 'string' }, + openTopics: { 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 + } + }; +} + +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 = []) { + 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 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.']; + 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); + const blocked = backoffState(key); + if (blocked) return blocked; + 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 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 }; + } + + 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); + 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 }; + } + + const cfg = OpenRouterGateway.config({ maxTokens: SUMMARY_MAX_TOKENS, temperature: 0.1 }); + 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. 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) }) } + ]; + 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: `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(), + repairSchema: true + }); + } catch (_) { + recordFailure(key, 'summary_provider_error'); + return { ok: false, reason: 'summary_provider_error' }; + } finally { + BotInferenceBudget.settle(admission.reservation, result?.usage); + } + if (!result.ok) { + const reason = result.reason || 'summary_provider_error'; + recordFailure(key, reason); + return { ok: false, reason }; + } + + const summary = normalizeSummary(result.data, compacted); + if (!summary) { + recordFailure(key, 'empty_summary'); + return { ok: false, reason: 'empty_summary' }; + } + const saved = await BotConversationStore.setSummary({ + playerId, + botId, + summary, + summaryThroughId: throughId, + summaryThroughOrdinal: throughOrdinal, + expectedVersion: Number(current.version || 0) + }); + if (!saved.ok) { + recordFailure(key, saved.reason || 'summary_store_error'); + return saved; + } + failures.delete(key); + return { + ok: true, + summary, + summaryThroughId: throughId, + summaryThroughOrdinal: throughOrdinal, + 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(); failures.clear(); } +}; + +module.exports = BotConversationSummarizer; diff --git a/src/GameServer/Bot/AI/BotDialogueArbiter.js b/src/GameServer/Bot/AI/BotDialogueArbiter.js new file mode 100644 index 00000000..aec0c81d --- /dev/null +++ b/src/GameServer/Bot/AI/BotDialogueArbiter.js @@ -0,0 +1,204 @@ +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'; + 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); + 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, + 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; + recordDeliveredReply(input, turn, reply); + 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; + recordDeliveredReply(input, turn, reply); + } 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 = {}) { + if (!BotConversationService.validPair(input.playerSession, input.botSession)) { + 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, + 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, + worldRevision: BotAgentTools.worldRevision(input.botSession), + allowFallback: input.allowFallback !== false, + queued: input.queued === true + }; + + return BotContextAssembler.assemble({ + session: input.botSession, + status, + 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, assembledContext }; + }); + }).catch((error) => { + if (input.allowFallback === false || !input.playerSession?.actor || !input.botSession?.actor) { + return { ok: false, reason: 'conversation_error' }; + } + if (turnForFallback) { + return deliverFallback(input, turnForFallback, 'conversation_error').then((result) => ({ + ...result, + error: error.message + })); + } + 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 + })); + }); +} + +module.exports = { + route, + fallbackText +}; 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/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/BotInferenceBudget.js b/src/GameServer/Bot/AI/BotInferenceBudget.js new file mode 100644 index 00000000..5a73254e --- /dev/null +++ b/src/GameServer/Bot/AI/BotInferenceBudget.js @@ -0,0 +1,440 @@ +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 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 = []; +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 globalConfig() { + const cfg = config(); + return { + maxInFlight: Math.max(1, Math.floor(number(cfg.maxConcurrentRequests, 32))), + maxRequests: LIMITS.globalMaxRequests, + promptBudget: LIMITS.globalPromptTokens, + completionBudget: LIMITS.globalCompletionTokens + }; +} + +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) { + 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) { + 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 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); +} + +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; + return { + ok: false, + reason, + retryAfterMs: Math.max(0, Math.ceil(retryAfterMs)), + status: null + }; +} + +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 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; + }); + 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, + queued: true, + reservation: null, + ready, + status: status(session, now) + }; +} + +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 + }); + completeWaiter(waiter, result); + } +} + +function reserve(session, input = {}) { + const id = actorId(session); + if (!id) return { ok: false, reason: 'missing_bot' }; + + const cfg = config(); + + const now = Number(input.now || Date.now()); + const bucket = bucketFor(id); + prune(bucket, now); + 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); + + 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 && 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 && 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); + } + + const global = globalConfig(); + 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 = { + id: `inference-${id}-${++reservationSequence}`, + botId: id, + startedAt: now, + promptTokens, + completionTokens, + 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), + 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) }; +} + +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'); + 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; + if (reservation.globalEntry) releaseGlobalSlot(reservation); + return true; +} + +function globalStatus(now = Date.now()) { + const cfg = globalConfig(); + 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: true, + 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 - 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, + queuedRequests: globalWaiters.length + }; +} + +function status(session, now = Date.now()) { + const id = actorId(session); + if (!id) { + return { + enabled: true, + windowMs: WINDOW_MS, + requests: 0, + bypassedRequests: 0, + maxRequests: LIMITS.perBotMaxRequests, + promptTokens: 0, + promptBudget: LIMITS.perBotPromptTokens, + completionTokens: 0, + completionBudget: LIMITS.perBotCompletionTokens, + cost: 0, + remainingRequests: 0, + remainingPromptTokens: 0, + remainingCompletionTokens: 0, + nextResetAt: null, + lastDeniedReason: null, + global: globalStatus(now) + }; + } + + const bucket = bucketFor(id); + prune(bucket, now); + 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'); + const quotaCompletionTokens = budgetSum(bucket, 'completionTokens'); + const quotaRequests = budgetCount(bucket); + const oldest = bucket.entries[0]; + return { + enabled: true, + windowMs: WINDOW_MS, + requests: bucket.entries.length, + bypassedRequests: bypassedCount(bucket), + maxRequests, + promptTokens, + promptBudget, + completionTokens, + completionBudget, + cost: knownCost(bucket), + 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, + global: globalStatus(now) + }; +} + +const BotInferenceBudget = { + WINDOW_MS, + RESERVATION_TTL_MS, + GLOBAL_WAITER_TTL_MS, + MAX_GLOBAL_WAITERS, + reserve, + reserveForBotId, + settle, + status, + snapshot: status, + globalStatus, + reset(session = null) { + if (session) { + const id = actorId(session); + if (id) { + const bucket = buckets.get(id); + if (bucket) { + bucket.entries.forEach((entry) => { + if (entry.globalEntry && !entry.settled) { + releaseGlobalSlot(entry, { expired: true, pump: false }); + } + entry.globalEntry = null; + }); + } + 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) { + completeWaiter(globalWaiters[index], { ok: false, reason: 'inference_budget_reset' }); + globalWaiters.splice(index, 1); + } + } + pumpGlobalWaiters(); + 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) { + completeWaiter(globalWaiters.shift(), { ok: false, reason: 'inference_budget_reset' }); + } + reservationSequence = 0; + }, + bucketCount() { return buckets.size; } +}; + +module.exports = BotInferenceBudget; 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/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/BotRemoteChat.js b/src/GameServer/Bot/AI/BotRemoteChat.js index 53d42675..5cfa8eef 100644 --- a/src/GameServer/Bot/AI/BotRemoteChat.js +++ b/src/GameServer/Bot/AI/BotRemoteChat.js @@ -2,33 +2,32 @@ 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 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'); -const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; -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 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 config() { + return OpenRouterGateway.config({ timeoutMs: 0 }); } -function num(value, fallback) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : fallback; +function estimatePromptTokens(payload) { + try { return Math.max(1, Math.ceil(JSON.stringify(payload).length / 4)); } catch (_) { return 1; } } -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) - }; +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) { @@ -52,11 +51,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, @@ -80,7 +81,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 @@ -131,13 +132,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', @@ -149,151 +150,410 @@ 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) { - if (typeof fetch !== 'function') return null; +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-chat:${botId}:${playerId}`, + circuitBreaker: false, + interactive: true, + 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) } + ], + responseSchema: { + name: 'bot_remote_chat', + schema: schema() + }, + repairSchema: true + }); + if (!result.ok) { + return { + providerFailure: true, + providerOutcome: result.reason, + usage: result.usage, + llmTelemetry: result.telemetry + }; + } + 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 controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), cfg.timeoutMs); + 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, + action: parsed.action || 'say', + reason: parsed.reason || 'llm', + confidence: Number(parsed.confidence || 0), + llm: true, + usage: result.usage, + llmTelemetry: result.llmTelemetry + }; +} +function playerLocation(playerSession) { + const actor = playerSession?.actor; + if (!actor) return null; + return { + locX: actor.fetchLocX(), + locY: actor.fetchLocY(), + locZ: actor.fetchLocZ() + }; +} + +function activateNearPlayer(playerSession, state) { + 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' }; + + 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 = {}) { + 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 }, + { + 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: 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 { - 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' + 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); + const fallback = { + ok: true, + reply: fallbackReply(state, availability, text), + action: 'say', + reason: 'fallback', + isFallback: true + }; + const begin = BotConversationService.beginTurn({ + playerSession, + botSession: state, + text, + channel, + source: 'cold_chat' + }); + + 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 }, - 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 + 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; + 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: 0 + }) + : { ok: false, reason: 'disabled', reservation: null }; + let reservation = admission.reservation; - 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; - } + const rootPromise = LangfuseTracing.withRootObservation( + 'cold-bot.dialogue', + payload, + { + 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()}` + }, + async () => { + 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()}` + }; + 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 json = await response.json(); - const content = json.choices?.[0]?.message?.content; - if (!content) return null; + const deliver = (reply, extra = {}) => LangfuseTracing.withObservation( + 'bot.reply.deliver', + { + action: reply?.action || 'say', + reply: reply?.reply || null, + providerOutcome: reply?.providerOutcome || null + }, + stageMetadata, + () => { + 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' + ); - 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; + 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: grantedAdmission.reason, retryAfterMs: grantedAdmission.retryAfterMs || 0 }, + async () => ({ ok: false, reason: grantedAdmission.reason, retryAfterMs: grantedAdmission.retryAfterMs || 0 }), + 'chain' + ); + } + const failed = !llmReady + ? fallback + : { + ...fallback, + providerOutcome: grantedAdmission.reason, + reason: grantedAdmission.reason, + isFallback: true + }; + return deliver(failed); + } - return { - reply, - intent: parsed.intent || 'none', - reason: parsed.reason || 'llm', - llm: true - }; - } catch (err) { - if (err.name !== 'AbortError') { - utils.infoWarn('BotRemoteChat', 'OpenRouter error: %s', err.message); - } - return null; - } finally { - clearTimeout(timeout); - } + const providerResult = await requestLlmReply(payload, cfg, turn, state, playerSession); + const result = await LangfuseTracing.withObservation( + 'bot.schema.validate', + { + event: 'cold_chat', + providerOutcome: providerResult?.llmTelemetry?.outcome || providerResult?.providerOutcome || null + }, + stageMetadata, + async () => validateLlmReply(providerResult), + 'chain' + ); + if (result?.providerFailure) { + state.lastRemoteChatTelemetry = result.llmTelemetry || null; + const failed = { + ...fallback, + providerOutcome: result.providerOutcome, + usage: result.usage || null, + llmTelemetry: result.llmTelemetry || null, + isFallback: true + }; + 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, + reason: `come_to_player:${actionResult.reason || 'rejected'}`, + providerOutcome: 'action_rejected', + isFallback: true + }; + 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' + ); + return rootPromise.then((result) => { + BotInferenceBudget.settle(reservation, result?.usage || result?.llmTelemetry?.usage); + return result; + }, (error) => { + BotInferenceBudget.settle(reservation); + throw error; + }); + }); } const BotRemoteChat = { - replyForState(playerSession, state, text) { + replyForState(playerSession, state, text, channel = 'client_tell') { if (!playerSession?.actor || !state) { return Promise.resolve({ ok: false, reason: 'missing_context' }); } - 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' + return enqueue(key, () => replyForStateNow(playerSession, state, text, channel)) + .then((result) => { + BotSocialMemory.recordEvent(playerSession, state, 'chat', result.reason || 'remote_chat'); + return result; }); - } - 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 - } - }; - - const fallback = { - ok: true, - reply: fallbackReply(state, availability, text), - intent: 'none', - reason: 'fallback' - }; - - const llmReady = cfg.enabled && !!cfg.apiKey; - if (!llmReady) return fallback; - - return requestLlmReply(payload, cfg).then((result) => result || fallback); - }).then((result) => { - BotSocialMemory.recordEvent(playerSession, state, 'chat', result.reason || 'remote_chat'); - return result; - }); } }; 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/BotStatus.js b/src/GameServer/Bot/AI/BotStatus.js index 55449e60..477564f0 100644 --- a/src/GameServer/Bot/AI/BotStatus.js +++ b/src/GameServer/Bot/AI/BotStatus.js @@ -7,6 +7,10 @@ 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'); +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; @@ -186,8 +190,14 @@ 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 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 || '', @@ -240,6 +250,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; @@ -337,7 +348,15 @@ const BotStatus = { }, nearby: nearbySnapshot(bot), 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, lastSocialEvent: session.lastSocialEvent || null, blockers: [] @@ -357,6 +376,8 @@ 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 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 @@ -370,7 +391,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}${inference}${social}${roleDecision}${targetDecision}${combatDecision}${pvpDecision}${build}${path}${buffs}${blockers}`; } }; diff --git a/src/GameServer/Bot/AI/BotSupplyErrand.js b/src/GameServer/Bot/AI/BotSupplyErrand.js new file mode 100644 index 00000000..ff23870c --- /dev/null +++ b/src/GameServer/Bot/AI/BotSupplyErrand.js @@ -0,0 +1,276 @@ +const DataCache = invoke('GameServer/DataCache'); +const MarketOpportunity = invoke('GameServer/Bot/Economy/MarketOpportunity'); +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 LifeState = invoke('GameServer/Bot/Population/BotLifeState'); + +const MAX_REQUEST_AMOUNT = 5000; + +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) { + 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 }; + } + 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; + 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 (!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 Market = MarketOpportunity.bestSupplyOffer(selfId, { amount }); + 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(Market.price) * amount; + const adena = actorAdena(bot); + 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', + followPlayerSession: playerSession, + partyCompanion: true, + botStay: false, + stayLocation: null + }; + const workflowStartedAt = Date.now(); + session.companionShopping = { + kind: 'player_resource_purchase', + playerSession, + playerId: player.fetchId(), + itemId: selfId, + itemName: Market.itemName, + amount, + unitPrice: Number(Market.price), + totalCost: cost, + sourceType: Market.sourceType, + sourceId: Market.sourceId, + sourceName: Market.sourceName, + 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`, + locX: Number(Market.locX ?? town.x), + locY: Number(Market.locY ?? town.y), + locZ: Number(Market.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); + + // 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, + bot, + BotAI, + null, + { + allowCompanion: true, + preserveShoppingTarget: true, + destinationTown: town, + forceScrollOfEscape: true, + announce: false, + onArrival: () => { + session.supplyErrandPhase = 'shopping'; + BotAI.init?.(session); + BotAI.wakeup?.(session, { urgent: true }); + } + } + ); + if (travel === 'deferred') { + 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' }; + } + 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(), + itemSelfId: selfId, + amount, + cost, + sourceType: Market.sourceType, + sourceId: Market.sourceId, + town: town.name, + travel + }, 'pending', 'supply_errand_started'); + return { + ok: true, + outcome: 'pending', + reason: 'supply_errand_started', + itemSelfId: selfId, + itemName: Market.itemName, + amount, + cost, + town: town.name, + sourceType: Market.sourceType, + workflowId: session.companionShopping.workflowId, + travel + }; +} + +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' }; + } + + 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), { + expectedUnitPrice: Number(errand.unitPrice) + }); + 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) { + 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), + cost: Number(bought.totalAdena), + item + }; + } 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 }; + } +} + +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..2d559d91 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,8 @@ 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 || []); return skills .filter((skill) => skill && !skill.fetchPassive?.()) .filter((skill) => { @@ -51,9 +54,14 @@ 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) && + // 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/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..25dc9a12 --- /dev/null +++ b/src/GameServer/Bot/AI/BotToolRegistry.js @@ -0,0 +1,287 @@ +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(); +const SOFT_FRESHNESS_ACTIONS = new Set([ + '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) { + 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?.() || []; + 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), + 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, + Number(overlay?.updatedAt || 0), + String(partySettings.pullMode || ''), + Number(partySettings.pullerId || 0), + String(session.activeTrade?.id || ''), + Number(session.activeTrade?.botItems?.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), + Number(privateStore?.revision || 0), + Number(privateStore?.repricing === true ? 1 : 0), + storeLines + ].join(':'); +} + +function isPkLocked(session, action) { + return session?.plan === 'pk_hunting' && new Set([ + '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', + '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); +} + +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; +} + +function register(definition) { + if (!definition?.name) throw new Error('tool name is required'); + definitions.set(String(definition.name), { + mutating: true, + description: '', + kind: definition.mutating === false ? 'read' : 'mutation', + risk: 'low', + parameters: null, + ...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, + kind: definition.kind, + risk: definition.risk, + parameters: definition.parameters || null + })); +} + +function availableNames(session = null) { + return descriptors(session).map((definition) => definition.action); +} + +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: argumentsForTrace, + expectedWorldRevision: context.expectedWorldRevision || null + }, + { + botId: actorId(context.session), + playerId: playerId(context), + turnId: turnId(context), + workflowId: meta.workflowId || null, + outcome, + reason, + phase: outcome === 'requested' ? 'request' : 'result' + }, + 'tool' + ); + const status = outcome === 'rejected' + ? LangfuseTracing.observationStatus({ applied: false, reason }) + : {}; + observation?.end({ outcome, reason, phase: outcome === 'requested' ? 'request' : 'result', ...meta }, status); + 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 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 || {}; + 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}:${playerId(context) || 'none'}:${action}` : null; + const mutationStore = session && (session.botToolExecutions ||= new Map()); + + audit({ ...context, decision: { ...decision, action } }, 'requested', 'requested', { + expectedRevision, + currentRevision, + freshness: requiresFreshWorld(action) ? 'strict' : 'soft' + }); + + if (!definition) { + const rejected = result(false, 'unknown_tool'); + 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); + 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 && requiresFreshWorld(action)) { + const rejected = result(false, 'stale_world_state'); + audit({ ...context, decision: { ...decision, action } }, 'rejected', rejected.reason, { + expectedRevision, + currentRevision, + freshness: 'strict' + }); + 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 && 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; + } + } + + 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)}`); + } + 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 = { + register, + execute, + descriptors, + availableNames, + worldRevision, + reset() { definitions.clear(); } +}; diff --git a/src/GameServer/Bot/AI/BotTownTravel.js b/src/GameServer/Bot/AI/BotTownTravel.js index b5d7d5cb..1c4ea943 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; @@ -25,16 +26,58 @@ function clearCombatTrip(session) { session.townEscape = undefined; } +function revealSupplyErrand(session, bot, options = {}) { + 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 (options.clearErrand === true && session.companionShopping?.kind === 'player_resource_purchase') { + session.companionShopping = undefined; + session.shoppingTarget = undefined; + session.resumeAfterShopping = undefined; + session.preShopLocation = undefined; + } +} + +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); + 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, @@ -44,7 +87,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; @@ -52,34 +97,61 @@ function beginEscape(session, bot, town) { bot.state.setCasts(false); session.townEscape = undefined; session.plan = 'hunting'; + restoreSupplyHot(session, bot, 'supply_errand_interrupted'); 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 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', + 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); } -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() }; 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(); - 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) { - BotAI.say(session, `${town.name} is far away. Using a Scroll of Escape.`); - beginEscape(session, bot, town); + 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, options); return 'escape'; } @@ -90,4 +162,14 @@ function request(session, bot, BotAI, reason) { 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, + restoreSupplyHot +}; diff --git a/src/GameServer/Bot/AI/BotWorkflowTelemetry.js b/src/GameServer/Bot/AI/BotWorkflowTelemetry.js new file mode 100644 index 00000000..ea63c69f --- /dev/null +++ b/src/GameServer/Bot/AI/BotWorkflowTelemetry.js @@ -0,0 +1,67 @@ +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, options = {}) { + if (!workflowId) return null; + const safePayload = { + workflowId: text(workflowId, 128), + phase: text(phase, 64), + ...payload + }; + 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; +} + +module.exports = { recordSupply }; diff --git a/src/GameServer/Bot/AI/ChatArrivalState.js b/src/GameServer/Bot/AI/ChatArrivalState.js new file mode 100644 index 00000000..403b55db --- /dev/null +++ b/src/GameServer/Bot/AI/ChatArrivalState.js @@ -0,0 +1,95 @@ +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; + session.chatArrivalPersistent = false; + session.chatArrivalStopOnArrival = false; + return true; +} + +function start(session, targetSession, options = {}) { + if (!session?.actor || !online(targetSession)) return false; + session.chatArrivalActive = true; + session.chatArrivalTargetSession = targetSession; + 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; + 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) || (!session.chatArrivalPersistent && 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?.(); + if (session.chatArrivalStopOnArrival) { + clear(session, 'arrived'); + return false; + } + return true; +} + +module.exports = { + HOLD_DISTANCE, + DEFAULT_HOLD_MS, + start, + tick, + clear +}; diff --git a/src/GameServer/Bot/AI/HotBotPolicyOverlay.js b/src/GameServer/Bot/AI/HotBotPolicyOverlay.js new file mode 100644 index 00000000..04c781f5 --- /dev/null +++ b/src/GameServer/Bot/AI/HotBotPolicyOverlay.js @@ -0,0 +1,225 @@ +// 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 MAX_BUFF_POLICIES = 24; +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 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) + .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 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; + 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; + restorePullPolicy(session, overlay); + delete session.hotPolicyOverlay; + session.lastHotPolicyReset = { reason: 'expired', at: now() }; + 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, + 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(), + 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) + }; + + 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; + if (patch.buffPolicy === null) updated.buffPolicy = { excluded: [], allowed: [] }; + 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; + restorePullPolicy(session, session.hotPolicyOverlay); + 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, + buffPolicy: { + excluded: [...(overlay.buffPolicy?.excluded || [])], + allowed: [...(overlay.buffPolicy?.allowed || [])] + }, + skillPriorities: { ...(overlay.skillPriorities || {}) } + }; +} + +module.exports = { + DEFAULT_TTL_MS, + MAX_SKILL_WEIGHT, + MAX_BUFF_POLICIES, + STANCES: [...STANCES], + clear, + clearForCold, + clearForDeath, + clearForPartyDetach, + combatPolicy, + get, + normalizePriorities, + normalizeStance, + normalizeBuffPolicy, + set, + status +}; diff --git a/src/GameServer/Bot/AI/LangfuseTracing.js b/src/GameServer/Bot/AI/LangfuseTracing.js new file mode 100644 index 00000000..c27d744e --- /dev/null +++ b/src/GameServer/Bot/AI/LangfuseTracing.js @@ -0,0 +1,312 @@ +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: {} }; +let otelContext = null; +let rootContext = 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 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] || fallback; + const source = { + enabled: bool(process.env.LANGFUSE_ENABLED || option.enabled, false), + 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(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 { + ...source, + ...overrides, + enabled: bool(overrides.enabled, source.enabled), + baseUrl: overrides.baseUrl || source.baseUrl, + publicKey: overrides.publicKey || source.publicKey, + secretKey: overrides.secretKey || source.secretKey, + capturePayloads: bool(overrides.capturePayloads, source.capturePayloads) + }; +} + +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.capturePayloads) return serializable(value); + return { captured: false, type: typeof value }; +} + +function observationOutput(value, cfg) { + if (cfg.capturePayloads) return serializable(value); + 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 (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', 'output_truncated', '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 || value?.actionResult?.ok === false) { + 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); +} + +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); + 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'); + const api = require('@opentelemetry/api'); + otelContext = api.context; + rootContext = api.ROOT_CONTEXT; + 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?.sessionId || metadata?.turnId, 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(traceOutput(result), cfg), + ...observationStatus(result) + }); + 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 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(); + try { + const observation = tracing.startObservation(String(name), { + input: observationInput(input, cfg), + metadata: serializable(metadata || {}) + }, { asType }); + return wrapObservation(observation, cfg); + } 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, + withRootObservation, + startObservation, + observationStatus, + activeTraceId, + shutdown +}; diff --git a/src/GameServer/Bot/AI/OpenRouterGateway.js b/src/GameServer/Bot/AI/OpenRouterGateway.js new file mode 100644 index 00000000..9ef08dea --- /dev/null +++ b/src/GameServer/Bot/AI/OpenRouterGateway.js @@ -0,0 +1,716 @@ +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, + completionLimitParam: 'max_tokens', + openAiStrictSchema: true, + provider: Object.freeze({ + order: Object.freeze(['OpenAI']), + sort: 'price', + allow_fallbacks: false + }) + }), + [GPT_OSS_120B_MODEL]: Object.freeze({ + supportsTemperature: true, + completionLimitParam: 'max_tokens' + }) +}); + +const DEFAULTS = Object.freeze({ + enabled: false, + apiKey: '', + model: LUNA_MODEL, + partyRouterModel: 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, + timeoutMs: 3500, + visibilityRadius: 6000, + circuitBreakerFailureThreshold: 3, + circuitBreakerOpenMs: 30000 +}); + +let transport = null; +let requestSequence = 0; +const circuits = new Map(); + +const metrics = { + total: 0, + success: 0, + fallback: 0, + timeout: 0, + providerError: 0, + schemaError: 0, + outputTruncated: 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 reasoningEffort(value, fallback = DEFAULTS.reasoningEffort) { + const normalized = String(value || fallback).trim().toLowerCase(); + return REASONING_EFFORTS.has(normalized) ? normalized : 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, + 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( + optn.maxConcurrentRequests, + DEFAULTS.maxConcurrentRequests + ))), + 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, + 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( + overrides.maxConcurrentRequests, + source.maxConcurrentRequests + ))), + maxTokens: num(overrides.maxTokens, source.maxTokens), + timeoutMs: num(overrides.timeoutMs, source.timeoutMs), + 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)), + 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 modelProfile(model) { + return MODEL_PROFILES[String(model || '').trim()] || null; +} + +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; +} + +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') { + 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 === '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; + 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, + finishReason: extra.finishReason || null, + providerRequestId: extra.providerRequestId || null, + rawContent: extra.rawContent || null, + 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, + initialFinishReason: extra.initialFinishReason || null + }; +} + +function shouldRepairSchema(spec, result) { + return spec.repairSchema === true && + spec.responseSchema?.schema && + result?.reason === 'schema_error' && + 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 + : request.interactive === true + ? null + : cfg.maxTokens; + const value = Number(configured); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : null; +} + +function repairConfig(spec) { + const source = config(spec.config || {}); + const current = completionLimit(source, spec); + if (current === null) { + return { ...(spec.config || {}) }; + } + const rescue = Math.max(2048, current * 2); + return { + ...(spec.config || {}), + maxTokens: rescue + }; +} + +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'), + reasoningTokens: sum('reasoningTokens'), + visibleCompletionTokens: sum('visibleCompletionTokens'), + totalTokens: sum('totalTokens'), + cachedPromptTokens: sum('cachedPromptTokens'), + cacheWriteTokens: sum('cacheWriteTokens'), + cost + }; +} + +function repairedResult(initial, repaired, repairType = 'schema') { + const telemetry = repaired?.telemetry || {}; + const usage = combinedUsage(initial?.usage, repaired?.usage); + return { + ...repaired, + usage, + telemetry: { + ...telemetry, + usage, + attempts: 2, + repairTriggered: true, + repairType, + initialOutcome: initial?.reason || null, + initialRawContent: initial?.telemetry?.rawContent || null, + initialFinishReason: initial?.telemetry?.finishReason || null, + initialUsage: initial?.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; + 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, + 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), + 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); +} + +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), + 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 (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 = cfg.timeoutMs > 0 + ? setTimeout(() => controller.abort(), Math.max(1, cfg.timeoutMs)) + : null; + const body = { + model: cfg.model, + messages: Array.isArray(requestData.messages) ? requestData.messages : [] + }; + if (supportsTemperature(cfg.model)) body.temperature = cfg.temperature; + + const maxCompletionTokens = completionLimit(cfg, requestData); + if (maxCompletionTokens !== null) body[completionLimitParam(cfg.model)] = maxCompletionTokens; + + if (cfg.reasoningEffort !== 'off') { + body.reasoning = { + effort: cfg.reasoningEffort, + exclude: true + }; + } + + const effectiveResponseSchema = responseSchemaForModel(requestData.responseSchema, cfg.model); + if (effectiveResponseSchema) { + body.response_format = { + type: 'json_schema', + json_schema: { + name: effectiveResponseSchema.name, + strict: true, + schema: effectiveResponseSchema.schema + } + }; + } + + if (requestData.sessionId) body.session_id = requestData.sessionId; + body.usage = { include: true }; + + const provider = providerOptions(cfg.model, 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, + responsePreview: String(response?.statusText || '').slice(0, 240) + }); + } + + const json = await response.json(); + 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; + 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 (_) { + markFailure(cfg, requestData.circuitKey); + return complete(requestData, cfg, 'schema_error', startedAt, { + 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), + finishReason, + providerRequestId: json.id || null, + rawContent + }); + } + + const validationError = validateContent(data, effectiveResponseSchema); + 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), + 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 { + if (timeout) clearTimeout(timeout); + } +} + +async function request(spec = {}) { + const input = { + messages: spec.messages || [], + responseSchema: spec.responseSchema?.name || null, + model: spec.config?.model || config().model, + interactive: spec.interactive === true + }; + 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) => { + let result = await requestUntraced(spec); + 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 + }); + result = repairedResult(result, repaired); + } + if (observation && result?.telemetry) { + const usage = result.usage || result.telemetry.usage || {}; + const effectiveConfig = config(result.telemetry.repairTriggered + ? repairConfig(spec) + : (spec.config || {})); + const effectiveMaxTokens = completionLimit(effectiveConfig, { + ...spec, + interactive: spec.interactive === true + }); + const modelParameters = { + reasoning: { + enabled: effectiveConfig.reasoningEffort !== 'off', + effort: effectiveConfig.reasoningEffort, + exclude: true + } + }; + 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), + // 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), + 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, + 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 + } + }); + result.telemetry.traceId = observation.traceId || LangfuseTracing.activeTraceId(); + result.telemetry.observationId = observation.id || null; + } + return result; + }, + 'generation' + ); +} + +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/src/GameServer/Bot/AI/PartyAddressResolver.js b/src/GameServer/Bot/AI/PartyAddressResolver.js new file mode 100644 index 00000000..80e4c2b2 --- /dev/null +++ b/src/GameServer/Bot/AI/PartyAddressResolver.js @@ -0,0 +1,195 @@ +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 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); + 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' + }; + } + + // 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 769c3e4b..56017448 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 = { @@ -22,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 }, @@ -105,7 +110,21 @@ function updateSettings(leaderSession, patch = {}) { settings[key] = patch[key]; } }); - return getSettings(leaderSession); + 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?.(), + 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 +138,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; } @@ -461,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. @@ -477,6 +523,113 @@ 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 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); @@ -575,6 +728,8 @@ 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; companionSession.followPlayerSession = null; @@ -614,6 +769,11 @@ const PartyCompanionService = { formationTargetFor, + beginRegroup, + holdParty, + + regroupActive, + sendPartyPositions, updatePosition(session, actor) { @@ -732,6 +892,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 +924,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/PartyDialogueRouter.js b/src/GameServer/Bot/AI/PartyDialogueRouter.js new file mode 100644 index 00000000..b608e1be --- /dev/null +++ b/src/GameServer/Bot/AI/PartyDialogueRouter.js @@ -0,0 +1,295 @@ +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 routeMetrics = { + messages: 0, + deterministicRoutes: 0, + routerInvocations: 0, + routerBotRoutes: 0, + clarified: 0, + unresolved: 0, + dispatches: 0, + multiDispatchViolations: 0 +}; + +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; +} + +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|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)|(?:can|could|would) you\b|but\b|and\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 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 requestedRoles = Object.entries(ROLE_ALIASES) + .filter(([, aliases]) => aliases.some((alias) => roleAliasIsAddressed(text, alias))) + .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 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); + 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, + id: actorId(actor), + name: actorName(actor), + companion, + selected: typeof player?.fetchDestId === 'function' && player.fetchDestId() === actorId(actor), + distance, + role, + puller: isPuller(session, role) || Number(actorId(actor)) === configuredPullerId, + pendingInteraction: !!( + session.activeTrade?.playerSession === playerSession || + session.activeNegotiation?.playerSession === playerSession + ), + 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(), + dialogueState = null, + activeResponderId, + activeResponderAt, + allowSpokespersonFallback = true, + 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') { + 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, + status: 'ambiguous', + reason: 'explicit_ambiguous', + matchType: explicit.matchType, + matches: explicit.matches + }; + } + + 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); + if (pending) { + return { candidate: pending, candidates, status: 'matched', reason: 'pending_interaction', matchType: null }; + } + + 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 = 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 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); + 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 }; + } + + return { candidate: null, candidates, status: 'none', reason: 'unresolved', matchType: null }; +} + +module.exports = { + ACTIVE_RESPONDER_TTL_MS, + HEARING_RADIUS, + PARTY_CHANNEL_KIND, + buildCandidates, + isGroupAddress, + isContinuationMessage, + metrics, + recordMetric, + resetMetrics, + roleAddressMatches, + select +}; diff --git a/src/GameServer/Bot/AI/PartyDialogueState.js b/src/GameServer/Bot/AI/PartyDialogueState.js new file mode 100644 index 00000000..9eb1fb9e --- /dev/null +++ b/src/GameServer/Bot/AI/PartyDialogueState.js @@ -0,0 +1,167 @@ +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, + routerInFlightAt: 0, + 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 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; + 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, + routerInFlightAt: state.routerInFlightAt, + recentTurns: [...(state.recentTurns || [])] + }; +} + +function reset(playerSession) { + if (!playerSession) return; + delete playerSession.partyDialogueState; + delete playerSession.botDialogueResponderId; + delete playerSession.botDialogueResponderAt; +} + +module.exports = { + MAX_RECENT_TURNS, + beginRequest, + beginRouter, + clearInFlight, + clearRouter, + ensure, + recordDeliveredReply, + reset, + snapshot +}; diff --git a/src/GameServer/Bot/AI/PartyLLMRouter.js b/src/GameServer/Bot/AI/PartyLLMRouter.js new file mode 100644 index 00000000..a93bc4b9 --- /dev/null +++ b/src/GameServer/Bot/AI/PartyLLMRouter.js @@ -0,0 +1,211 @@ +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 = 512; + +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 partyTurns(state) { + return (state?.recentTurns || []).filter((turn) => turn.channel === 'party_chat'); +} + +function candidateCard(candidate, state) { + const id = candidate?.id ?? null; + const lastTurn = [...partyTurns(state)] + .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) { + 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) { + const invalid = { + ok: false, + route: 'clarify', + candidate: null, + intent: 'unknown', + confidence: 0, + reason: 'invalid_bot_id', + data + }; + invalid.traceOutput = { ...invalid, candidate: null }; + return invalid; + } + 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, + 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) { + 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: partyTurns(input.dialogueState).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, + sessionId: `party-chat:${botId || 'unknown'}` + }; + + return LangfuseTracing.withObservation( + 'party.router.generation', + userPayload, + metadata, + async () => { + const result = await OpenRouterGateway.request({ + config: { + ...cfg, + model: cfg.model, + reasoningEffort: 'low', + 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.', + '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, + repairSchema: true + }); + + 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/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..150cf891 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'); @@ -697,7 +698,87 @@ 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 (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 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; + } + let partyThreat = null; + try { partyThreat = PartyAwareness.findThreatTargetingParty(playerSession); } catch (_) { /* lightweight test/session */ } + const leaderBusy = !!( + playerSession.actor.state?.fetchHits?.() || + playerSession.actor.state?.fetchCasts?.() || + playerSession.actor.state?.fetchCombats?.() + ); + 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 false; + } + const trade = invoke('GameServer/Bot/BotTradeService').startBotTradeWithOffer( + session, + playerSession, + delivery.objectId, + delivery.amount, + { workflowId: delivery.workflowId, supplyDelivery: true } + ); + if (!trade.ok) { + if (trade.reason === 'too_far') return false; + 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}).`] + }); + WorkflowTelemetry.recordSupply(delivery.workflowId, 'trade', { + botId: bot.fetchId(), + playerId: delivery.playerId, + amount: delivery.amount + }, 'failed', trade.reason || 'trade_open_failed', { terminal: false }); + return false; + } + 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.`] + }); + WorkflowTelemetry.recordSupply(delivery.workflowId, 'trade', { + botId: bot.fetchId(), + playerId: delivery.playerId, + amount: delivery.amount, + objectId: delivery.objectId + }, 'pending', 'native_trade_open'); + 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 +833,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 +1505,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 140a1e44..c6080757 100644 --- a/src/GameServer/Bot/AI/States/ShoppingState.js +++ b/src/GameServer/Bot/AI/States/ShoppingState.js @@ -8,6 +8,8 @@ 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'); +const WorkflowTelemetry = invoke('GameServer/Bot/AI/BotWorkflowTelemetry'); function findStoreSession(actorId) { const BotManager = invoke('GameServer/Bot/BotManager'); @@ -103,6 +105,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); } }, @@ -111,6 +122,67 @@ module.exports = { const NpcTalkResponse = invoke(path.world + 'NpcTalkResponse'); const companionErrand = session.companionShopping; + if (companionErrand?.kind === 'player_resource_purchase') { + let deliveryReady = false; + try { + 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 = purchased.item || bot.backpack.fetchItemFromSelfId(companionErrand.itemId); + 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.`); + 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.pendingResourceDelivery = undefined; + session.lastTradeSummary = `could not buy ${companionErrand.amount}x ${companionErrand.itemName}`; + 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); + WorkflowTelemetry.recordSupply(companionErrand.workflowId, 'return', { + botId: bot.fetchId(), + playerId: companionErrand.playerId, + itemSelfId: companionErrand.itemId, + amount: companionErrand.amount + }, 'failed', error?.message || 'purchase_failed', { terminal: false }); + } + this.scheduleResourceReturn(session, bot, BotAI, { deliveryReady }); + return; + } + if (companionErrand?.kind === 'market_purchase') { const sellerSession = findStoreSession(companionErrand.target.actorId); const seller = sellerSession?.actor; @@ -241,6 +313,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; @@ -271,5 +352,72 @@ module.exports = { }); } }, 9000); + }, + + scheduleResourceReturn(session, bot, BotAI, options = {}) { + 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' + : 'hunting'; + session.shoppingDoneAnnounced = false; + session.shoppingTarget = undefined; + 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 = { + 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); + 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. + 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 92ddffa6..cf91c7f5 100644 --- a/src/GameServer/Bot/BotAI.js +++ b/src/GameServer/Bot/BotAI.js @@ -8,6 +8,9 @@ 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 BotTradeService = invoke('GameServer/Bot/BotTradeService'); +const ChatArrivalState = invoke('GameServer/Bot/AI/ChatArrivalState'); const CHAT_PHRASES = { foundTarget: [ @@ -135,6 +138,12 @@ const BotAI = { stop(session) { session.aiActive = false; + 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) { clearTimeout(session.aiTimeout); session.aiTimeout = null; @@ -323,10 +332,34 @@ 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) clearTacticalState(session); + if (botDead) { + 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. + HotBotPolicyOverlay.get(session); + } session.botStatus = BotStatus.getStatus(session); + // 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'); @@ -523,7 +556,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/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js index bcb840f1..45a53adf 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,9 @@ 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 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'); @@ -634,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 @@ -727,6 +730,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; @@ -734,14 +770,282 @@ 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 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|everyone|everybody|companions|all)\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; + 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, + playerSession, + sessions: this.sessions, + kind: data.kind, + dialogueState, + activeResponderId: playerSession.botDialogueResponderId, + activeResponderAt: playerSession.botDialogueResponderAt, + allowSpokespersonFallback: !cheapRouterAvailable + }); + llmResponder = deterministicRoute.candidate; + if (llmResponder) { + PartyDialogueState.beginRequest(playerSession, llmResponder.session, { + reason: deterministicRoute.reason, + text: rawText, + channel: partyChannel ? 'party_chat' : 'local_chat', + 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') => { + 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 + }); + } + 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, + text: rawText, + channel: partyChannel ? 'party_chat' : 'local_chat', + 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 }; + }); + }; + 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' + )) { + 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; + 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)); + 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' }, { + source: 'llm_router', + dispatchCount: fallbackRoute.candidate ? 1 : 0 + }); + return fallbackRoute.candidate + ? dispatchLLM(fallbackRoute.candidate.session, '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 + }); + } + } + + let brainGroupResponderPicked = false; this.sessions.forEach((session) => { const bot = session.actor; 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(); @@ -750,7 +1054,18 @@ 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 llmAddressed = llmEnabled && llmResponder?.session === session; + if (llmEnabled) { + if (!llmAddressed) return; + dispatchLLM(session, deterministicRoute?.reason); + return; + } if (directCommandTarget && INSULT_PATTERN.test(rawText)) { handledByRule = true; @@ -850,7 +1165,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); + }); } } }); @@ -1053,16 +1377,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); @@ -1071,7 +1403,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/BotTradeService.js b/src/GameServer/Bot/BotTradeService.js index efa1317b..7b262b9c 100644 --- a/src/GameServer/Bot/BotTradeService.js +++ b/src/GameServer/Bot/BotTradeService.js @@ -1,19 +1,47 @@ 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; +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 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) { @@ -21,157 +49,495 @@ 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 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 giveItem(actor, sourceItem, amount) { - const selfId = sourceItem.fetchSelfId(); +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; +} - 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 minimumRetain(item) { + return Number(item.fetchSelfId?.() || 0) === 57 ? MIN_ADENA_RETAIN : 0; +} - 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 botReservations(session) { + if (!session.botTradeReservations) session.botTradeReservations = new Map(); + return session.botTradeReservations; } -function takeItem(actor, item, amount) { - return new Promise((resolve, reject) => { - if (!item || item.fetchAmount() < amount) { - reject(new Error('Not enough items.')); - return; - } +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; +} - const total = item.fetchAmount() - amount; - if (total > 0) { - Database.updateItemAmount(actor.fetchId(), item.fetchId(), total).then(() => { - item.setAmount(total); - resolve(); - }).catch(reject); - 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); + } +} - Database.deleteItem(actor.fetchId(), item.fetchId()).then(() => { - actor.backpack.items = actor.backpack.items.filter((ob) => ob.fetchId() !== item.fetchId()); - resolve(); - }).catch(reject); - }); +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; } -const BotTradeService = { - startPlayerTrade(playerSession, targetSession) { - if (!playerSession?.actor || !targetSession?.actor || !isBotSession(targetSession)) { - return { ok: false, reason: 'invalid_target' }; - } +function sendToPlayer(trade, packet) { + if (trade?.playerSession?.dataSendToMe) trade.playerSession.dataSendToMe(packet); +} - if (targetSession.plan === 'merchant') { - return { ok: false, reason: 'merchant_store' }; - } +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 */ } + } + releaseReservations(trade); + if (notify) sendToPlayer(trade, ServerResponse.tradeDone(false)); + clearAttachedTrade(trade); + return true; +} - if (actorDistance(playerSession.actor, targetSession.actor) > TRADE_RANGE) { - return { ok: false, reason: 'too_far' }; - } +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; +} - 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 activeTradeFor(session) { + const trade = session?.activeTrade; + if (!tradeIsOpen(trade)) return null; + return 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 attachTrade(trade) { + trade.playerSession.activeTrade = trade; + trade.botSession.activeTrade = trade; +} + +function createTrade(playerSession, botSession, direction, metadata = {}) { + 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, + workflowId: metadata.workflowId || null, + supplyDelivery: metadata.supplyDelivery === true + }; +} + +function canStart(playerSession, botSession, { allowMerchant = false } = {}) { + if (!isRealPlayerSession(playerSession) || !isBotSession(botSession) || !botSession.actor) return 'invalid_target'; + if (botSession.plan === 'merchant' && !allowMerchant) return 'merchant_store'; + if (actorDistance(playerSession.actor, botSession.actor) > TRADE_RANGE) return 'too_far'; + return null; +} - 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 startPlayerTrade(playerSession, targetSession) { + const reason = canStart(playerSession, targetSession); + if (reason) return { ok: false, reason }; + cancel(playerSession, 'replaced', false); + cancel(targetSession, 'replaced', false); + + 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 }; +} + +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)) { + 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 (activeTradeFor(playerSession) || activeTradeFor(botSession)) { + return { ok: false, reason: 'trade_active' }; + } + + const trade = createTrade(playerSession, botSession, 'bot_outbound', metadata); + 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( + botSession.actor, + playerSession.actor.backpack.fetchItems() + )); + } + 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 }; +} - 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 startBotTrade(botSession, playerSession) { + return openBotTrade(botSession, playerSession); +} - cancel(playerSession) { - if (playerSession) { - playerSession.activeTrade = null; +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) { + 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); +} + +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 = resolveInventoryItem(botSession.actor.backpack, objectId); + if (!isSafeOfferItem(item)) return { ok: false, reason: 'item_not_tradable' }; + 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(canonicalObjectId); + 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 delta = Math.max(0, nextCount - (current?.count || 0)); + 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 }); + 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; +} + +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)); } - }, - async commit(playerSession) { - const trade = playerSession.activeTrade; - if (!trade || trade.confirmed) { - return { ok: false, reason: 'no_active_trade' }; + 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 + }); } + }); +} + +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 }; + } - const partnerSession = trade.partnerSession; - if (!playerSession.actor || !partnerSession?.actor || trade.items.size === 0) { - return { ok: false, reason: 'empty_or_invalid_trade' }; + 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) { + 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) { + recordSupplyTrade(trade, 'failed', validation.reason, false); + return validation; } + } - 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 entries = transferEntries(trade); + for (const entry of entries) { + const validation = validateLine(trade, entry.fromSession, entry.line, { botSide: entry.fromSession === trade.botSession }); + if (!validation.ok) { + recordSupplyTrade(trade, 'failed', validation.reason, false); + return validation; } + } + if (!incomingSlots(trade.playerSession, [...trade.botItems.values()]) || !incomingSlots(trade.botSession, [...trade.playerItems.values()])) { + const result = { ok: false, reason: 'inventory_capacity' }; + recordSupplyTrade(trade, 'failed', result.reason, false); + return result; + } + + 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 + })); - playerSession.activeTrade = null; - console.info("BotTrade :: %s completed trade with %s: %s", actorName(playerSession), actorName(partnerSession), summarizeItems(moved)); - return { ok: true, partnerSession, moved }; + let moved; + try { + moved = await Database.transferInventoryBetweenCharacters(databaseTransfers); + } catch (error) { + const result = { ok: false, reason: 'database_failed', error }; + recordSupplyTrade(trade, 'failed', result.reason, false); + return result; } -}; -module.exports = BotTradeService; + 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, + negotiationId: trade.negotiationId || null, + 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 */ } + } + 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') { + 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) { + 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, + negotiationId: trade.negotiationId || null, + expectedAdena: trade.expectedAdena || null, + 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, + resolveInventoryItem, + addItem: addPlayerItem, + cancel, + cleanup, + commit, + isTradableItem: isSafeOfferItem, + offerBotItem, + startNegotiatedTrade, + startBotTrade, + startBotTradeWithOffer, + startPlayerTrade, + updateOffer +}; 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 new file mode 100644 index 00000000..c7a73369 --- /dev/null +++ b/src/GameServer/Bot/Economy/BotNegotiationService.js @@ -0,0 +1,612 @@ +const crypto = require('crypto'); +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 BotMerchantStoreService = invoke('GameServer/Bot/Economy/BotMerchantStoreService'); + +const NEGOTIATION_TTL_MS = 90 * 1000; +const MAX_ROUNDS = 3; +const MAX_QUANTITY = 100; +const MAX_UNIT_PRICE = 1_000_000_000; +const NEGOTIABLE_BOT_PLANS = new Set(['merchant', 'following']); + +const negotiations = new Map(); +let sequence = 0; + +function now() { return Date.now(); } + +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; } + +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, listing = null) { + const template = templateFor(item.fetchSelfId()); + const basePrice = Math.max(1, Number(template?.template?.price || item.fetchPrice?.() || item.model?.price || 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; + 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, + storeId: negotiation.storeId || null, + storeRevision: negotiation.storeRevision || null, + listingUnitPrice: negotiation.listingUnitPrice || null, + 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); + const write = () => 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, + 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 = {}) { + 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 = resolveInventoryItem(negotiation.botSession?.actor?.backpack, negotiation.itemObjectId); + 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) { + 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 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; + 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 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 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 = 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, 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: listedLine?.name || item.fetchName(), + quantity, + ...policy, + storeId: merchantStore ? String(bot.coldMarketState.stats.marketStore.id || '') : null, + storeRevision: merchantStore ? BotMerchantStoreService.revision(merchantStore) : null, + currentUnitPrice, + agreedTotalPrice: null, + round: 0, + state, + reason, + createdAt: now(), + expiresAt: now() + NEGOTIATION_TTL_MS + }; + 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, 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) }; +} + +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) }; +} + +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) { + 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'; + 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.`); + if (negotiation.storeRevision) { + return republishAcceptedStore(bot, negotiation); + } + 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.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) { + 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)); +} + +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, + MAX_UNIT_PRICE, + NEGOTIATION_TTL_MS, + acceptPrice, + activeSummary, + cancelForTrade, + cleanup, + counterOffer, + declinePrice, + completeTrade, + canNegotiateStore, + openNegotiatedTrade, + quoteItem, + storeContext, + reset() { + negotiations.clear(); + sequence = 0; + }, + summary, + validateTrade +}; diff --git a/src/GameServer/Bot/Economy/MarketOpportunity.js b/src/GameServer/Bot/Economy/MarketOpportunity.js index b0b6275a..6cd8ee75 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,60 @@ function npcOffers(selfId, town) { return offers; } +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 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 = liveStore ? Number(line.price) : TradeService.ratedPrice(selfId, line.priceRate ?? 1); + if (price <= 0) return []; + const actor = liveSession?.actor; + return [{ + sourceType: 'configured_store', + 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, + 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 + }]; + }); +} + function privateOffers(selfId, town) { return (World.user?.sessions || []).flatMap((session) => { const actor = session?.actor; @@ -123,6 +220,67 @@ 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; + 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 && + (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) { + 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) => Number(a.price) - Number(b.price) || Number(a.sourceType !== 'npc') - Number(b.sourceType !== 'npc'))[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 +301,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/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/Cooldown.js b/src/GameServer/Bot/Population/Cooldown.js index a0f0ecee..d60c0d9d 100644 --- a/src/GameServer/Bot/Population/Cooldown.js +++ b/src/GameServer/Bot/Population/Cooldown.js @@ -33,6 +33,8 @@ 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 */ } + 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' }; @@ -58,6 +60,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/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/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js index 2967f1cd..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); @@ -805,6 +806,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/Bot/TradeService.js b/src/GameServer/Bot/TradeService.js index 8eeacd4e..d5de5d05 100644 --- a/src/GameServer/Bot/TradeService.js +++ b/src/GameServer/Bot/TradeService.js @@ -1,6 +1,8 @@ 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); @@ -188,29 +190,97 @@ 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 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 { + 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."); + } + 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 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."); + } - 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 { + store.activePurchases = Math.max(0, Number(store.activePurchases || 0) - 1); + 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); + } } async function sellToStore(actor, store, selfId, qty) { 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/src/GameServer/Network/Request/Speak.js b/src/GameServer/Network/Request/Speak.js index 335d57e9..ae7a9d93 100644 --- a/src/GameServer/Network/Request/Speak.js +++ b/src/GameServer/Network/Request/Speak.js @@ -57,19 +57,29 @@ 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) { + return World.messageBotByName(session, session.actor, target, text, 'client_tell'); + } + + 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/Network/Request/TradeDone.js b/src/GameServer/Network/Request/TradeDone.js index dea1b950..86e0b6fd 100644 --- a/src/GameServer/Network/Request/TradeDone.js +++ b/src/GameServer/Network/Request/TradeDone.js @@ -30,18 +30,43 @@ 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 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); BotSocialMemory.recordEvent( session, result.partnerSession, 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, - 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/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/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/src/GameServer/World/World.js b/src/GameServer/World/World.js index addacc90..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) => { @@ -53,6 +96,8 @@ function waitForBotSession(BotManager, name, attempts = 40) { } const World = { + waitForBotSession, + init() { this.user = { sessions : [], revision: 0 }; this.npc = { spawns : [], grid: {}, nextId: 1000000 }; @@ -273,36 +318,34 @@ 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) => { if (!state) { - session.dataSendToMe(ServerResponse.actionFailed()); + unknownBotReply(session, lookup, BotManager, LifeState); return false; } - return BotRemoteChat.replyForState(session, state, message).then((result) => { - if (!result?.ok || !result.reply) { + return BotRemoteChat.replyForState(session, state, message, source).then((result) => { + 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/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_ai_config_surface.js b/tests/test_ai_config_surface.js new file mode 100644 index 00000000..5d1b303f --- /dev/null +++ b/tests/test_ai_config_surface.js @@ -0,0 +1,68 @@ +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); + assert.strictEqual(OpenRouterGateway.DEFAULTS.partyRouterModel, 'openai/gpt-5.6-luna'); + + 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, + negotiationEnabled: 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.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); + assert.strictEqual(openRouter.negotiationEnabled, 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_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_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_ambient_director.js b/tests/test_bot_ambient_director.js new file mode 100644 index 00000000..00a52ae8 --- /dev/null +++ b/tests/test_bot_ambient_director.js @@ -0,0 +1,128 @@ +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' }); +const clara = bot(2000109, 'Clara', { role: 'dps' }); + +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' +); +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'); + +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 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); +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'); diff --git a/tests/test_bot_brain_state_change.js b/tests/test_bot_brain_state_change.js new file mode 100644 index 00000000..84c4948c --- /dev/null +++ b/tests/test_bot_brain_state_change.js @@ -0,0 +1,149 @@ +const assert = require('assert'); + +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'); + +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 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', + maxConcurrentRequests: 5 + }; + 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)); + return response({ + choices: [{ message: { content: JSON.stringify({ + action: 'none', + reply: '', + targetPlayerName: '', + spotId: '', + buffType: '', + reason: 'state_change_probe', + confidence: 0.9 + }) } }] + }); + }); + + 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, 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, 'player_chat', { available: true }, 'disabled probe', { playerSession }), + false, + 'disabled OpenRouter must keep chat routing inert' + ); + options.default.OpenRouter.enabled = true; + + // An explicit tell is still an interactive LLM turn while the hot bot + // is dead or in a transient deterministic plan. + 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' + ); + for (let attempt = 0; attempt < 50 && botSession.brainInFlight; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.strictEqual(requests.length, beforeChat + 1); + 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 { + 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; +}); 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_bot_context_assembler.js b/tests/test_bot_context_assembler.js new file mode 100644 index 00000000..d1a855e4 --- /dev/null +++ b/tests/test_bot_context_assembler.js @@ -0,0 +1,111 @@ +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; + const originalMerchantCompact = BotBrainContext.compactMerchantStatus; + let compactOptions = null; + let merchantCompactCalls = 0; + BotEventJournal.resetMemory(); + BotBrainContext.compactStatus = (_session, _status, _text, options) => { + compactOptions = options; + return { + available: true, + name: 'BoundedBot', + level: 20, + mode: 'hunting', + party: { members: [] }, + inventory: null, + 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 }); + 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.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'); + 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'); + 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); + + 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; + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); 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_conversation_summary.js b/tests/test_bot_conversation_summary.js new file mode 100644 index 00000000..75670953 --- /dev/null +++ b/tests/test_bot_conversation_summary.js @@ -0,0 +1,165 @@ +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'); +const BotInferenceBudget = invoke('GameServer/Bot/AI/BotInferenceBudget'); + +async function main() { + BotConversationStore.resetMemory(); + BotConversationSummarizer.reset(); + BotInferenceBudget.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: [ + { 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 } + }; + } + })); + + 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}.`, + 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); + + 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 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(); + 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}` + }); + } + 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); + console.log('Bot conversation summary checks passed'); + } finally { + OpenRouterGateway.resetTransport(); + OpenRouterGateway.resetCircuit(); + BotInferenceBudget.reset(); + options.default.OpenRouter = {}; + } +} + +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..49938f20 --- /dev/null +++ b/tests/test_bot_dialogue_arbiter.js @@ -0,0 +1,231 @@ +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 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'); +const PartyDialogueState = invoke('GameServer/Bot/AI/PartyDialogueState'); +const PartyLLMRouter = invoke('GameServer/Bot/AI/PartyLLMRouter'); + +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 originalEnabled = BotBrain.isEnabled; + const originalStatus = BotAI.getStatus; + 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 originalPartyRouterEnabled = PartyLLMRouter.enabled; + 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')); + const bot = session('bot_dialogue_arbiter', actor(201, 'Aria')); + 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 }); + 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, + 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), + '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, + 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'); + 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] }; + 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) => { + 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'); + + 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'); + + 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; + PartyDialogueState.reset(player); + routes.length = 0; + await 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; + BotContextAssembler.assemble = originalAssemble; + LangfuseTracing.withObservation = originalWithObservation; + LangfuseTracing.withRootObservation = originalWithRootObservation; + BotManager.sessions = originalSessions; + BotDialogueArbiter.route = originalRoute; + PartyLLMRouter.enabled = originalPartyRouterEnabled; + 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_bot_inference_budget.js b/tests/test_bot_inference_budget.js new file mode 100644 index 00000000..ac28e6bb --- /dev/null +++ b/tests/test_bot_inference_budget.js @@ -0,0 +1,157 @@ +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, + maxConcurrentRequests: 8 + }; + BotInferenceBudget.reset(); + + const first = BotInferenceBudget.reserve(bot, { + 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'); + BotInferenceBudget.settle(first.reservation, { promptTokens: 20, completionTokens: 10, cost: 0.001 }); + + const second = BotInferenceBudget.reserve(bot, { + 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'); + 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); + + 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, + maxRequests: 2, + promptBudget: 300, + completionBudget: 100, + 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 }); + 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'); + assert.strictEqual(promptDenied.reason, 'inference_budget_prompt_tokens'); + + 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.maxConcurrentRequests = 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); + + BotInferenceBudget.reset(); + options.default.OpenRouter.maxConcurrentRequests = 8; + 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, + 300000, + '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 }); + + BotInferenceBudget.reset(); + 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: 'conversation_summary', estimatedPromptTokens: 10, maxCompletionTokens: 10, now: 2241 + }); + assert.strictEqual(globalRequestDenied.ok, false); + assert.strictEqual(globalRequestDenied.reason, 'inference_budget_global_requests'); + + assert.strictEqual(BotInferenceBudget.reserve({ actor: null }).reason, 'missing_bot'); + console.log('Bot inference budget checks passed'); +} finally { + options.default.OpenRouter = originalConfig; + BotInferenceBudget.reset(); +} diff --git a/tests/test_bot_inference_interactive_queue.js b/tests/test_bot_inference_interactive_queue.js new file mode 100644 index 00000000..a323695e --- /dev/null +++ b/tests/test_bot_inference_interactive_queue.js @@ -0,0 +1,146 @@ +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, + maxConcurrentRequests: 1 + }; + 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); + + 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; + BotInferenceBudget.reset(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_bot_llm_party_policy.js b/tests/test_bot_llm_party_policy.js new file mode 100644 index 00000000..4a0e1982 --- /dev/null +++ b/tests/test_bot_llm_party_policy.js @@ -0,0 +1,93 @@ +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() { + 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', + 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.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'); + + 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'); +} + +try { + main(); +} catch (error) { + console.error(error); + process.exitCode = 1; +} 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_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_negotiation_database.js b/tests/test_bot_negotiation_database.js new file mode 100644 index 00000000..e238289b --- /dev/null +++ b/tests/test_bot_negotiation_database.js @@ -0,0 +1,57 @@ +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'); + + +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: '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 { + 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..aff70483 --- /dev/null +++ b/tests/test_bot_negotiation_flow.js @@ -0,0 +1,112 @@ +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'); + + +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: 'following', 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); + 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; + 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..f53a06a3 --- /dev/null +++ b/tests/test_bot_negotiation_policy.js @@ -0,0 +1,86 @@ +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'); + + +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: 'following', + 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_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_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_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_tool_authorization.js b/tests/test_bot_tool_authorization.js new file mode 100644 index 00000000..0847074c --- /dev/null +++ b/tests/test_bot_tool_authorization.js @@ -0,0 +1,68 @@ +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(), dataSendToOthers() {} }; + 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.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' }); + console.log('Bot tool authorization checks passed'); +} + +try { + 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_bot_tool_registry.js b/tests/test_bot_tool_registry.js new file mode 100644 index 00000000..a2fc2426 --- /dev/null +++ b/tests/test_bot_tool_registry.js @@ -0,0 +1,111 @@ +const assert = require('assert'); +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 { + 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() { + 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')); + 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')); + 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 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 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', replyDelivered: false, playerVisibleReply: null }, + '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 { + main(); +} catch (error) { + console.error(error); + process.exitCode = 1; +} 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_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_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_cold_bot_chat.js b/tests/test_cold_bot_chat.js new file mode 100644 index 00000000..067a123f --- /dev/null +++ b/tests/test_cold_bot_chat.js @@ -0,0 +1,186 @@ +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 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'); + +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 originalWithObservation = LangfuseTracing.withObservation; + const originalWithRootObservation = LangfuseTracing.withRootObservation; + const requests = []; + const observations = []; + try { + options.default.OpenRouter = { + ...originalConfig, + enabled: true, + apiKey: 'cold-chat-test-key', + model: 'test/cold-chat', + maxConcurrentRequests: 1 + }; + 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)); + return response({ + choices: [{ message: { content: JSON.stringify({ + action: 'say', + reply: `reply-${requests.length}`, + reason: 'test_reply', + confidence: 0.95 + }) } }] + }); + }); + + 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', + 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.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( + 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; + } + + BotInferenceBudget.reset(); + options.default.OpenRouter.maxConcurrentRequests = 1; + 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 secondAdmissionPromise = BotRemoteChat.replyForState( + playerSession, + { ...state, characterId: 9112, accountName: 'rejected_cold_chat', name: 'RejectedColdChat' }, + 'second admission' + ); + assert.strictEqual(requests.length, beforeAdmissionRequests + 1, 'global admission must queue the second cold request before OpenRouter'); + releaseAdmissionRequest(); + 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, 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'); + } finally { + options.default.OpenRouter = originalConfig; + LangfuseTracing.withObservation = originalWithObservation; + LangfuseTracing.withRootObservation = originalWithRootObservation; + OpenRouterGateway.resetCircuit(); + OpenRouterGateway.resetTransport(); + BotConversationStore.resetMemory(); + } +} + +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..a28a17b2 --- /dev/null +++ b/tests/test_hot_bot_conversation_flow.js @@ -0,0 +1,164 @@ +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 LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); +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; + const originalWithObservation = LangfuseTracing.withObservation; + const originalWithRootObservation = LangfuseTracing.withRootObservation; + let firstRequestRelease; + const requests = []; + const observations = []; + + try { + options.default.OpenRouter = { + ...originalConfig, + enabled: true, + apiKey: 'conversation-test-key', + model: 'test/conversation', + maxConcurrentRequests: 32 + }; + const playerSession = { accountId: 'player_flow', actor: actor(301, 'FlowPlayer') }; + const botSession = { + accountId: 'bot_flow', + actor: actor(302, 'FlowBot', 100), + plan: 'hunting' + }; + 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)); + 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 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', + { 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'); + 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'); + + 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'); + const secondPayload = JSON.parse(requests[1].messages[1].content); + assert.deepStrictEqual( + 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(); + } + + console.log('Hot bot conversation flow checks passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); 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_hot_bot_queue_failure.js b/tests/test_hot_bot_queue_failure.js new file mode 100644 index 00000000..2541c76d --- /dev/null +++ b/tests/test_hot_bot_queue_failure.js @@ -0,0 +1,172 @@ +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', + maxConcurrentRequests: 32 + }; + 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; +}); diff --git a/tests/test_hot_bot_schema_repair.js b/tests/test_hot_bot_schema_repair.js new file mode 100644 index 00000000..42bbedf2 --- /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', + maxConcurrentRequests: 32 + }; + + 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_completion_tokens, undefined); + assert.strictEqual(requests[1].max_completion_tokens, undefined); + 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 { + 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; +}); diff --git a/tests/test_hot_conversation_history_queue.js b/tests/test_hot_conversation_history_queue.js new file mode 100644 index 00000000..3a250bdc --- /dev/null +++ b/tests/test_hot_conversation_history_queue.js @@ -0,0 +1,121 @@ +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', + maxConcurrentRequests: 32 + }; + 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', '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?.(); + 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; +}); diff --git a/tests/test_langfuse_tracing.js b/tests/test_langfuse_tracing.js new file mode 100644 index 00000000..bbd9589b --- /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: false, 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; +} 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_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_negotiation_tools.js b/tests/test_llm_negotiation_tools.js new file mode 100644 index 00000000..53ad23fd --- /dev/null +++ b/tests/test_llm_negotiation_tools.js @@ -0,0 +1,76 @@ +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'); + + +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: 'following', 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(); +} 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_pull_policy_tools.js b/tests/test_llm_pull_policy_tools.js new file mode 100644 index 00000000..f8c5c4b9 --- /dev/null +++ b/tests/test_llm_pull_policy_tools.js @@ -0,0 +1,95 @@ +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'); +const HotBotPolicyOverlay = invoke('GameServer/Bot/AI/HotBotPolicyOverlay'); + +function actor(id, name) { + return { + fetchId: () => id, + fetchName: () => name, + fetchLocX: () => 0, + fetchLocY: () => 0, + fetchLocZ: () => 0, + fetchDestId: () => 0, + isDead: () => false, + state: { fetchSeated: () => false, setSeated() {} }, + unselect() {}, + moveTo() {} + }; +} + +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; +const originalNow = Date.now; +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'); + + 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'); + + 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; + 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'); +} diff --git a/tests/test_llm_supply_errand.js b/tests/test_llm_supply_errand.js new file mode 100644 index 00000000..cc74843c --- /dev/null +++ b/tests/test_llm_supply_errand.js @@ -0,0 +1,245 @@ +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 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'); +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; + return { + fetchId: () => id, + fetchSelfId: () => selfId, + fetchAmount: () => count, + setAmount: (value) => { count = value; }, + fetchName: () => name + }; +} + +async function main() { + const originals = { + bestSupplyOffer: MarketOpportunity.bestSupplyOffer, + request: BotTownTravel.request, + purchase: BotSupplyErrandModule.purchaseAtDestination, + tradePurchase: TradeService.buyFromStore, + scheduleReturn: ShoppingState.scheduleResourceReturn, + trade: BotTradeService.startBotTradeWithOffer, + inferRole: BotRoles.inferRole, + 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); + 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, + state: { fetchHits: () => false, fetchCasts: () => false, fetchCombats: () => false, fetchDestId: () => 1000 } + } + }; + const bot = { + fetchId: () => 1, + fetchName: () => 'Caelan', + fetchIsOnline: () => true, + fetchLocX: () => 0, + 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 { + 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 + }); + let arrivalCallback = null; + let travelMode = 'walk'; + BotTownTravel.request = (_session, _bot, _ai, _reason, options) => { + assert.strictEqual(options.allowCompanion, true); + assert.strictEqual(options.forceScrollOfEscape, true); + 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); + 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() {} }); + 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, 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.tradeId, 'supply-trade-test'); + + // Selecting the bot is normal before a native trade request. A + // selected target is not combat; delivery stays pending until the + // native trade is actually committed with the player. + 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.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; + 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.bestSupplyOffer = originals.bestSupplyOffer; + BotTownTravel.request = originals.request; + BotSupplyErrandModule.purchaseAtDestination = originals.purchase; + TradeService.buyFromStore = originals.tradePurchase; + ShoppingState.scheduleResourceReturn = originals.scheduleReturn; + 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'); +} + +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 new file mode 100644 index 00000000..a1898b9f --- /dev/null +++ b/tests/test_llm_trade_tools.js @@ -0,0 +1,102 @@ +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: 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([])) }; + 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); + + 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.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'); +} catch (error) { + console.error(error); + process.exitCode = 1; +} diff --git a/tests/test_openrouter_gateway.js b/tests/test_openrouter_gateway.js new file mode 100644 index 00000000..c56f3b43 --- /dev/null +++ b/tests/test_openrouter_gateway.js @@ -0,0 +1,381 @@ +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, + completion_tokens_details: { reasoning_tokens: 2 }, + 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, + reasoningTokens: 2, + visibleCompletionTokens: 1, + 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.max_completion_tokens, 320); + 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) => { + repairBodies.push(JSON.parse(init.body)); + if (repairBodies.length === 1) { + return response({ + choices: [{ message: { content: '{"reply":' } }], + usage: { prompt_tokens: 7, completion_tokens: 2, total_tokens: 9 } + }); + } + 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_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":'); + 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) { + 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(); + 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' } }] + })); + 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, undefined); + 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'); + }); + 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.outputTruncated >= 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); +}); diff --git a/tests/test_party_address_resolver.js b/tests/test_party_address_resolver.js new file mode 100644 index 00000000..02016ec8 --- /dev/null +++ b/tests/test_party_address_resolver.js @@ -0,0 +1,62 @@ +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'); + + 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'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_party_chat_routing_integration.js b/tests/test_party_chat_routing_integration.js new file mode 100644 index 00000000..9321d80d --- /dev/null +++ b/tests/test_party_chat_routing_integration.js @@ -0,0 +1,171 @@ +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 PartyDialogueRouter = invoke('GameServer/Bot/AI/PartyDialogueRouter'); +const LangfuseTracing = invoke('GameServer/Bot/AI/LangfuseTracing'); +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, + dataSendToMe() {} + }; +} + +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 originalStartObservation = LangfuseTracing.startObservation; + 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; + 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; + 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({ bot: input.botSession.actor.fetchName(), text: input.text }); + 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.map((entry) => entry.bot), ['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'] + ); + + 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); + BotManager.sessions = originalSessions; + World.user = originalWorldUser; + BotBrain.isEnabled = originalEnabled; + PartyLLMRouter.enabled = originalRouterEnabled; + PartyLLMRouter.route = originalRouterRoute; + BotDialogueArbiter.route = originalArbiterRoute; + LangfuseTracing.startObservation = originalStartObservation; + } + + 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 new file mode 100644 index 00000000..d5b431d0 --- /dev/null +++ b/tests/test_party_dialogue_router.js @@ -0,0 +1,170 @@ +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, role = null) { + const value = { + actor: actor(id, name, x), + partyCompanion: companion, + followPlayerSession: null, + role + }; + return value; +} + +async function main() { + const player = { actor: actor(100, 'Slava', 0) }; + const nice = session(1, 'NiceBot', 5000); + const healer = session(2, 'Mira', 100, true, 'healer'); + 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); + + 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, + 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, 'active_responder', 'a pronoun follow-up must beat a stale client selection'); + 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, + 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; +}); 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; +}); diff --git a/tests/test_party_llm_router.js b/tests/test_party_llm_router.js new file mode 100644 index 00000000..d90fee2d --- /dev/null +++ b/tests/test_party_llm_router.js @@ -0,0 +1,112 @@ +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: 'openai/gpt-oss-120b' + }; + 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: 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, '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.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({ + 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'); + + 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(); + OpenRouterGateway.setTransport = originalTransport; + LangfuseTracing.withObservation = originalWithObservation; + } + + console.log('Party LLM router checks passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); 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; +}); 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..3b7dff4b --- /dev/null +++ b/tests/test_trade_store_atomicity.js @@ -0,0 +1,136 @@ +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 } }, + { selfId: 1865, template: { name: 'Suede' }, 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'); + 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(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); + 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; + Database.updateItemAmount = originals.updateItemAmount; + Database.deleteItem = originals.deleteItem; + Database.setItem = originals.setItem; + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +});