This commit is contained in:
2026-07-12 17:26:59 -04:00
parent 60663b8d4a
commit ba1bef8ff9
25 changed files with 158880 additions and 1529 deletions
+867
View File
@@ -0,0 +1,867 @@
'use strict';
const conf = require('../../conf');
const { sleep } = require('../../utils');
const { ProviderFactory } = require('./providers');
function compilePrompt(templateStr) {
return new Function('name', 'interval', 'currentPlayers', 'toolsDocs', 'memoryContext', 'timeInfo', 'custom',
'return `' + templateStr + '`');
}
const memoryDB = require('./memory-db');
const { buildFleetTools } = require('./fleet-tools');
class AiManager {
constructor() {
this._provider = null;
this._pollTimer = null;
this._polling = false;
this._faceBot = null;
this._messages = [];
this._allTools = [];
this._memoryDB = memoryDB;
this._active = false;
this._config = null;
this._consecutiveFailures = 0;
this._backoffUntil = 0;
this._lastSentMessages = [];
this._messageListener = null;
this._tradeWindow = null;
this._tradeWindowState = null;
this._expectingTradeWindow = false;
}
/**
* Initialize the manager on a face bot. Idempotent — subsequent calls
* with different bots are no-ops. Call shutdown() first to transfer.
*/
async init(faceBot, configOverride) {
if (this._active) {
if (this._faceBot === faceBot) {
console.log('AiManager: already running on', faceBot.name);
return;
}
console.log('AiManager: already running on', this._faceBot.name,
'— shutdown first before re-initting on', faceBot.name);
return;
}
this._faceBot = faceBot;
this._config = { ...conf.ai, ...configOverride };
// Override runtime-tunable AI settings from DB (config file provides defaults)
const settings = require('../settings/manager');
this._config.provider = settings.get('ai.provider');
this._config.model = settings.get('ai.model');
this._config.temperature = settings.get('ai.temperature');
this._config.topP = settings.get('ai.topP');
this._config.topK = settings.get('ai.topK');
this._config.interval = settings.get('ai.interval');
this._config.timeout = settings.get('ai.timeout');
this._config.promptName = settings.get('ai.promptName');
this._config.enableNativeTools = settings.get('ai.enableNativeTools');
this._config.baseUrl = settings.get('ai.baseUrl');
this._config.key = settings.get('ai.key');
this._config.faceBot = settings.get('ai.faceBot');
this._config.storageBot = settings.get('ai.storageBot');
// Initialize memory DB (first init creates DB, subsequent are no-ops)
await this._memoryDB.initialize('./storage/ai-memory.db', faceBot.name);
// Build fleet-wide tools
this._allTools = buildFleetTools(this._config, this._memoryDB);
console.log(`AiManager: ${this._allTools.length} fleet tools built`);
// Create the ONE provider
const prompt = await this._buildPrompt();
this._provider = ProviderFactory.create({
...this._config,
prompt,
});
// Set tool schemas if provider supports native function calling AND enabled in config.
// Most 9B models crash on native tool schemas (Ollama 500: "XML syntax error").
// When disabled, the LLM sees tools described in the prompt text and we parse
// tool calls from the JSON response via _extractToolCalls fallback.
if (this._provider.supportsTools && this._provider.supportsTools() && this._config.enableNativeTools) {
this._provider.setTools(this._getToolsSchema());
console.log('AiManager: native tool schemas set on provider');
} else if (this._config.enableNativeTools) {
console.log('AiManager: provider does not support tools, using text-based fallback');
} else {
console.log('AiManager: native tools disabled via config, using text-based tool descriptions');
}
await this._provider.start();
console.log(`AiManager: provider started (${this._config.provider}, model=${this._config.model})`);
// Set up message listener on the face bot
this._setupMessageListener();
// Start poll timer
this._active = true;
this._startPolling();
console.log(`AiManager: running on ${faceBot.name}, interval=${this._config.interval}s`);
}
/**
* Reload the system prompt without restarting the provider.
* Used by the .ai chat command to change personality on the fly.
*/
async reloadPrompt(promptName, prompCustom) {
if (!this._active || !this._provider) return false;
if (promptName !== undefined) this._config.promptName = promptName;
if (prompCustom !== undefined) this._config.prompCustom = prompCustom;
const prompt = await this._buildPrompt();
if (typeof this._provider.setPrompt === 'function') {
this._provider.setPrompt(prompt);
} else {
await this._provider.close();
this._provider = ProviderFactory.create({ ...this._config, prompt });
if (this._provider.supportsTools && this._provider.supportsTools() && this._config.enableNativeTools) {
this._provider.setTools(this._getToolsSchema());
}
await this._provider.start();
}
console.log(`AiManager: prompt reloaded — ${this._config.promptName}`);
return true;
}
async shutdown() {
this._active = false;
if (this._pollTimer) {
clearInterval(this._pollTimer);
this._pollTimer = null;
}
if (this._messageListener) {
this._messageListener();
this._messageListener = null;
}
if (this._provider) {
try { await this._provider.close(); } catch (e) { /* ignore */ }
this._provider = null;
}
this._messages = [];
this._faceBot = null;
console.log('AiManager: shut down');
}
get isActive() { return this._active; }
get faceBotName() { return this._faceBot ? this._faceBot.name : null; }
/**
* Called by SettingsManager when an ai.* setting is changed.
* Updates the in-memory config immediately. Provider re-creation
* happens on the next poll cycle if model/provider/key changed.
*/
onSettingChanged(key, newValue) {
if (!this._config) return;
const shortKey = key.replace('ai.', '');
console.log(`AiManager: setting changed — ${key} = ${JSON.stringify(newValue)}`);
this._config[shortKey] = newValue;
if (['promptName', 'prompCustom', 'prompts'].includes(shortKey)) {
this.reloadPrompt();
}
}
/**
* Let other bots/plugins inject a system notification into the face bot's
* message queue so the LLM knows about trade completions, errors, etc.
* Call this whenever a fleet bot does something the LLM should know about.
*/
notifySystemEvent(text) {
if (!this._active) return;
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
this._messages.push({
type: 'system',
text: `[SYSTEM] ${text}`,
timestamp,
timeAgo: this._getTimeAgo(timestamp),
});
console.log(`AiManager: system event — ${text}`);
}
// ========================================
// Message listener
// ========================================
_setupMessageListener() {
this._messageListener = this._faceBot.on('message', (message, type) => {
const msgText = message.toString();
// Log ALL messages the face bot receives (for debugging)
const cleanText = msgText.replace(/\n/g, '\\n').substring(0, 300);
console.log(`[MC→${this._faceBot.name}] (${type || 'unknown'}) ${cleanText}`);
if (type === 'game_info') return;
// Skip messages from the face bot itself
if (msgText.startsWith('<')) {
const firstBracket = msgText.split('>')[0];
const userMatch = firstBracket.match(/^<\[?.*?\]?\s*(\w+)>$/);
if (userMatch) {
const speakerName = userMatch[1];
if (speakerName === this._faceBot.bot.entity.username) return;
}
}
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
this._messages.push({
type: 'message',
text: msgText,
timestamp,
timeAgo: this._getTimeAgo(timestamp),
});
});
// Monitor trade windows for feedback loop
this._faceBot.bot.on('windowOpen', (window) => {
if (!this._tradeWindow && this._expectingTradeWindow && window.slots && window.slots.length >= 54) {
this._expectingTradeWindow = false;
console.log('AiManager: trade window detected, capturing state');
this._setupTradeWindow(window);
}
});
}
// ========================================
// Polling
// ========================================
_startPolling() {
const intervalMs = (this._config.interval || 10) * 1000;
this._pollTimer = setInterval(() => this._pollCycle(), intervalMs);
}
async _pollCycle() {
if (!this._active || Date.now() < this._backoffUntil) return;
if (this._polling) return;
this._polling = true;
try {
// Snapshot and reset
const currentMessages = [...this._messages];
this._messages = [];
const hasData = currentMessages.some(m => typeof m === 'object' && m.text);
const hasTrade = !!this._tradeWindowState;
if (!hasData && !hasTrade) return;
// Build request data
const requestData = {
botName: this._faceBot.name,
messages: currentMessages,
currentTime: new Date().toLocaleString('sv-SE'),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
tradeWindow: this._tradeWindowState,
};
// Refresh the system prompt with current memories and online players.
// Ollama re-sends the system prompt on every request, so new
// memories take effect immediately. (Gemini bakes the prompt into
// session history and only picks this up on provider restart.)
try {
if (typeof this._provider.setPrompt === 'function') {
this._provider.setPrompt(await this._buildPrompt());
}
} catch (e) {
console.log('AiManager: prompt refresh failed:', e.message);
}
console.log(`AiManager: poll cycle — ${currentMessages.length} messages`);
let result;
try {
result = await this._provider.chat(JSON.stringify(requestData));
} catch (error) {
console.log('AiManager: API error:', error.message);
this._consecutiveFailures++;
const backoffMs = Math.min(1000 * Math.pow(2, this._consecutiveFailures), 30000);
this._backoffUntil = Date.now() + backoffMs;
console.log(`AiManager: backoff ${backoffMs}ms (#${this._consecutiveFailures})`);
return;
}
this._consecutiveFailures = 0;
this._backoffUntil = 0;
// Check for tool calls
const requestingPlayer = this._getLastSpeaker(currentMessages);
const toolCalls = this._extractToolCalls(result);
if (toolCalls && toolCalls.length > 0) {
const seen = new Set();
const uniqueCalls = toolCalls.filter(tc => {
const key = `${tc.name}:${JSON.stringify(tc.args || {})}`;
if (seen.has(key)) { console.log('AiManager: deduplicating tool call:', key); return false; }
seen.add(key);
return true;
});
console.log(`AiManager: ${uniqueCalls.length} tool calls — ${uniqueCalls.map(c => c.name).join(', ')}`);
// Execute tools
const toolResults = [];
for (const tc of uniqueCalls) {
try {
const toolResult = await this._executeTool(tc.name, tc.args || {}, requestingPlayer);
toolResults.push({ name: tc.name, result: toolResult, success: true });
} catch (execError) {
console.error('AiManager: tool execution error:', execError);
toolResults.push({ name: tc.name, error: execError.message, success: false });
}
}
// Follow-up: send tool results back to LLM for natural language response
if (toolResults.length > 0) {
// Build current fleet status for context
const fleetStatus = this._getLiveFleetStatus();
const followupMsg = JSON.stringify({
toolResults,
fleetStatus,
tradeWindow: this._tradeWindowState,
instruction: `Tool results and fleet status above.
- Fleet bots listed as ONLINE can be interacted with directly by players via /trade.
- If ${this._config.storageBot} is ONLINE, the player can trade with them without you doing anything.
- Reply with ONE brief message (max 150 chars) as a plain JSON array: [{"text":"...","delay":0}].
- Be natural and casual. If the tool failed, apologize naturally. NEVER mention "tool", "bot", or "AI".`
});
try {
const followupResult = await this._provider.chat(followupMsg);
const responseText = this._provider.getResponse(followupResult);
await this._processResponse(responseText);
} catch (e) {
console.error('AiManager: follow-up chat error:', e);
}
}
return;
}
// No tool calls — normal chat response
const responseText = this._provider.getResponse(result);
await this._processResponse(responseText);
} catch (error) {
console.error('AiManager: poll cycle error:', error);
} finally {
this._polling = false;
}
}
// ========================================
// Tool execution
// ========================================
async _executeTool(toolName, params, from) {
const tool = this._allTools.find(t => t.name === toolName);
if (!tool) throw new Error(`Unknown tool: ${toolName}`);
console.log(`AiManager: executing ${toolName}`, params);
return tool.execute(params, from);
}
_extractToolCalls(result) {
// Gemini native function calls
if (result.response && typeof result.response.functionCalls === 'function') {
const calls = result.response.functionCalls();
if (calls && calls.length > 0) {
return calls.map(call => ({ name: call.name, args: call.args }));
}
}
// Ollama native tool_calls
if (result.tool_calls && result.tool_calls.length > 0) {
return result.tool_calls.map(tc => ({
name: tc.name || tc.function?.name,
args: tc.args || tc.arguments || tc.function?.arguments || {},
}));
}
// Fallback: parse JSON from response text
const responseText = result.response ? result.response.text() : null;
if (responseText) {
try {
const parsed = JSON.parse(responseText);
if (parsed.tool_call) {
return [{ name: parsed.tool_call.name, args: parsed.tool_call.args || parsed.tool_call.arguments || {} }];
}
if (Array.isArray(parsed.tool_calls)) {
return parsed.tool_calls.map(tc => ({
name: tc.name || tc.function?.name,
args: tc.args || tc.arguments || tc.function?.arguments || {},
}));
}
} catch (e) {
// Not JSON, no tool calls
}
// Final fallback: detect tool names embedded in chat text
// This catches models that output [{"text":"storage_find stone","delay":0}]
const detectedCalls = this._detectTextToolCalls(responseText);
if (detectedCalls) return detectedCalls;
}
return null;
}
/**
* Detect when the LLM embeds tool names in chat message text instead of
* using the proper tool_call format. Parses JSON arrays like
* [{"text":"storage_find stone","delay":0}] and converts to tool calls.
*/
_detectTextToolCalls(responseText) {
try {
const parsed = JSON.parse(responseText);
// Handle JSON array of chat messages: [{"text":"storage_find stone","delay":0}]
if (Array.isArray(parsed)) {
const toolNames = this._allTools.map(t => t.name);
const toolCalls = [];
for (const msg of parsed) {
const text = (msg.text || '').trim();
if (!text || text === '_' || text.length < 3) continue;
for (const toolName of toolNames) {
if (text === toolName || text.startsWith(toolName + ' ')) {
const argsStr = text.substring(toolName.length).trim();
const args = this._parseTextArgs(argsStr, toolName);
console.log(`AiManager: detected text tool call in chat array: ${toolName}`, args);
toolCalls.push({ name: toolName, args });
break;
}
}
}
return toolCalls.length > 0 ? toolCalls : null;
}
} catch (e) {
// Not JSON — check if the raw text starts with a known tool name
const text = (responseText || '').trim();
if (text.length < 3 || text.startsWith('{') || text.startsWith('[')) return null;
const toolNames = this._allTools.map(t => t.name);
for (const toolName of toolNames) {
if (text === toolName || text.startsWith(toolName + ' ')) {
const argsStr = text.substring(toolName.length).trim();
const args = this._parseTextArgs(argsStr, toolName);
console.log(`AiManager: detected text tool call in raw text: ${toolName}`, args);
return [{ name: toolName, args }];
}
}
}
return null;
}
/**
* Parse space-separated text args into a structured object based on the
* tool's parameter definitions. Handles formats like:
* "stone" → {itemName: "stone"}
* "stone 64 wmantly" → {itemName: "stone", count: 64, playerName: "wmantly"}
*/
_parseTextArgs(argsStr, toolName) {
const tool = this._allTools.find(t => t.name === toolName);
if (!tool || !tool.parameters || tool.parameters.length === 0) return {};
const parts = argsStr.split(/\s+/);
const params = tool.parameters;
const args = {};
for (let i = 0; i < Math.min(parts.length, params.length); i++) {
const value = parts[i];
const paramType = params[i].type || 'string';
if (paramType === 'number') {
const num = parseInt(value, 10);
args[params[i].name] = isNaN(num) ? value : num;
} else {
args[params[i].name] = value;
}
}
return args;
}
_getToolsSchema() {
return this._allTools.map(t => ({
name: t.name,
description: t.description,
parameters: {
type: 'object',
properties: (t.parameters || []).reduce((acc, p) => {
acc[p.name] = { type: p.type || 'string', description: p.description || '' };
return acc;
}, {}),
required: (t.parameters || []).filter(p => p.required).map(p => p.name),
},
}));
}
// ========================================
// Prompt building
// ========================================
async _buildPrompt() {
const settings = require('../settings/manager');
const allPrompts = settings.get('ai.prompts') || {};
const promptName = this._config.promptName || 'asshole';
let templateStr = allPrompts[promptName];
let promptFn;
if (templateStr) {
try {
promptFn = compilePrompt(templateStr);
} catch (e) {
console.warn(`AiManager: failed to compile prompt '${promptName}', falling back to config:`, e.message);
promptFn = conf.ai.prompts[promptName];
}
} else {
console.warn(`AiManager: prompt '${promptName}' not found in DB or config, falling back to asshole`);
templateStr = allPrompts['asshole'];
if (templateStr) {
try { promptFn = compilePrompt(templateStr); }
catch (e) { promptFn = conf.ai.prompts['asshole']; }
} else {
promptFn = conf.ai.prompts['asshole'];
}
this._config.promptName = 'asshole';
}
if (!promptFn) {
console.warn(`AiManager: prompt '${promptName}' not found, falling back to asshole`);
return conf.ai.prompts['asshole'](
this._faceBot.bot.entity.username,
this._config.interval,
'', '', '', '', '',
);
}
const fleetContext = this._getFleetContext();
const rawToolsDocs = this._getToolsDocumentation();
const toolsDocs = fleetContext + (rawToolsDocs ? '\n' + rawToolsDocs : '');
let currentPlayers = '';
try {
const players = this._faceBot.getPlayers();
currentPlayers = Object.values(players)
.map(p => `<[${p.lvl}] ${p.username}>`)
.join('\n');
} catch (e) {
currentPlayers = '(players unavailable)';
}
const timeInfo = this._getCurrentTimeInfo();
const memoryContext = await this._getMemoryContext();
return promptFn(
this._faceBot.bot.entity.username,
this._config.interval,
currentPlayers,
toolsDocs,
memoryContext,
timeInfo,
this._config.prompCustom || '',
);
}
/**
* Assemble the memory block for the system prompt: bot directives,
* general memories, and stored facts about every player currently online.
*/
async _getMemoryContext() {
let context = '';
try {
const general = await this._memoryDB.getMemoryContext(this._faceBot.name);
if (general) context += general;
// Player memories for everyone online right now (skip fleet bots)
const { CJbot } = require('../../model/minecraft');
const botUsernames = new Set(
Object.values(CJbot.bots)
.map(b => b.bot?.entity?.username)
.filter(Boolean)
);
const onlinePlayers = Object.keys(this._faceBot.bot.players || {})
.filter(name => !botUsernames.has(name));
const playerMems = await this._memoryDB.getPlayerMemoriesForPrompt(onlinePlayers);
if (playerMems) {
context += 'WHAT YOU KNOW ABOUT PLAYERS CURRENTLY ONLINE (from past conversations — use it naturally, do not recite it):\n'
+ playerMems + '\n';
}
} catch (e) {
console.log('AiManager: memory context build failed:', e.message);
}
return context.trim();
}
_getFleetContext() {
const { CJbot } = require('../../model/minecraft');
const botNames = Object.keys(CJbot.bots);
const onlineBots = botNames.filter(name => CJbot.bots[name].isReady);
const offlineBots = botNames.filter(name => !CJbot.bots[name].isReady);
const fleetBots = botNames.map(name => {
const b = CJbot.bots[name];
const status = b.isReady ? 'online' : 'offline (on-demand)';
if (name === this._faceBot.name) return `- ${name} (YOU — always online)`;
if (name === this._config.storageBot) return `- ${name} (storage, ${status})`;
return `- ${name} (${status})`;
}).join('\n');
const nativeToolsNote = this._config.enableNativeTools ? '' : `
TOOL CALL FORMAT (CRITICAL — you MUST use this exact format):
When you need to call a tool, respond with a SINGLE JSON object:
{"tool_call": {"name": "tool_name", "args": {"param1": "value1", "param2": "value2"}}}
Example: to search for stone, respond with:
{"tool_call": {"name": "storage_find", "args": {"itemName": "stone"}}}
NEVER put tool names in chat text messages. NEVER type "storage_find stone" as a chat message.
Use the {"tool_call": ...} format above instead.
For normal chat (no tools needed), respond with the array format:
[{"text": "your message", "delay": 0}]
`;
return `FLEET BOTS YOU CAN CONTROL:
${fleetBots}
${nativeToolsNote}
CRITICAL: Every other name in chat is a PLAYER. Do NOT call bot_activate, bot_status, or any fleet tool on player names. Only use those tools for the fleet bots listed above.
MEMORY (IMPORTANT — this is how you remember people between sessions):
- When a player tells you a fact worth keeping — about themselves OR another player (base location, current project, who they trust, preferences, grudges, play schedule) — call remember_player RIGHT THEN. Don't wait to be asked to remember.
- Keys are short snake_case: base_location, current_project, trust, friend_of, notes, warned_about.
- One fact per key. Writing to an existing key overwrites it — use that to update stale facts.
- What you already know about online players is in your context under "WHAT YOU KNOW ABOUT PLAYERS". For players not online, use recall_player.
- If a fact turns out wrong, call forget_player to remove that key.
- Standing orders from owners ("always greet new players", "stop mentioning X") are directives: use set_directive.
CHARACTER RULES (YOU WILL BE REMOVED IF YOU VIOLATE):
- You are a human player. NEVER say "bot", "AI", "tool", "fleet", or "activate."
- NEVER type server commands (/msg, /trade, /invite, /help, /w, /tell) in public chat. If you need to message someone, use your tools instead.
- NEVER suggest commands to other players ("type /msg X", "do /trade Y"). That's robotic.
- Keep responses under 120 chars. Casual, brief, natural.
- ez is "a friend who handles my storage" if asked. Not a bot.`;
}
/**
* Lightweight fleet status snapshot for post-tool-execution follow-up.
* Tells the LLM which bots are online and where they are so it doesn't
* say incorrect things like "he can't find you" when the bot is already there.
*/
_getLiveFleetStatus() {
const { CJbot } = require('../../model/minecraft');
const statuses = [];
for (const [name, bot] of Object.entries(CJbot.bots)) {
if (bot.isReady && bot.bot?.entity) {
const e = bot.bot.entity;
statuses.push({ bot: name, status: 'ONLINE', pos: `(${Math.round(e.position.x)},${Math.round(e.position.y)},${Math.round(e.position.z)})` });
} else {
statuses.push({ bot: name, status: 'OFFLINE' });
}
}
return statuses;
}
_getToolsDocumentation() {
if (this._allTools.length === 0) return '';
const byCategory = {};
for (const t of this._allTools) {
const cat = t.category || 'other';
if (!byCategory[cat]) byCategory[cat] = [];
byCategory[cat].push(t);
}
let doc = '';
for (const [cat, tools] of Object.entries(byCategory)) {
doc += `## ${cat}\n`;
for (const t of tools) {
doc += `- **${t.name}**: ${t.description}`;
if (t.parameters && t.parameters.length > 0) {
doc += ` (params: ${t.parameters.map(p => p.name).join(', ')})`;
}
doc += '\n';
}
doc += '\n';
}
return doc;
}
// ========================================
// Response processing
// ========================================
async _processResponse(responseText) {
if (!responseText) return;
const cleaned = this._stripMarkdownFences(responseText);
// Try JSON array [{text, delay}]
try {
const parsed = JSON.parse(cleaned);
if (Array.isArray(parsed)) {
for (const message of parsed) {
const msgText = (message.text || '').trim();
console.log('AiManager: toSay delay=', message.delay, msgText);
if (!msgText || msgText === '_' || msgText.match(/^[-_]+$/)) continue;
const dedupeKey = msgText.toLowerCase();
if (this._lastSentMessages.includes(dedupeKey)) {
console.log('AiManager: skipping duplicate:', msgText);
continue;
}
if (this._lastSentMessages.length >= 50) this._lastSentMessages.shift();
this._lastSentMessages.push(dedupeKey);
await this._faceBot.sayAiSafe(msgText);
}
return;
}
} catch (jsonError) {
// Not JSON, fall through
}
// Plain text fallback
const text = cleaned.trim();
if (!text || text === '_' || text === '___' || text.match(/^[-_]+$/)) return;
if (text.startsWith('{') || text.startsWith('```')) {
console.log('AiManager: skipping raw JSON/code block');
return;
}
const dedupeKey = text.toLowerCase();
if (this._lastSentMessages.includes(dedupeKey)) {
console.log('AiManager: skipping duplicate plain-text:', text);
return;
}
if (this._lastSentMessages.length >= 50) this._lastSentMessages.shift();
this._lastSentMessages.push(dedupeKey);
await this._faceBot.sayAiSafe(text);
}
_stripMarkdownFences(text) {
if (!text || typeof text !== 'string') return text;
let cleaned = text.trim();
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '');
cleaned = cleaned.replace(/\n?```\s*$/, '');
return cleaned.trim();
}
// ========================================
// Trade window
// ========================================
_setupTradeWindow(window) {
this._tradeWindow = window;
this._tradeWindowState = this._captureTradeState(window);
const customerSlots = [5, 6, 7, 8, 14, 15, 16, 17, 23, 24, 25, 26];
for (const slot of customerSlots) {
window.on(`updateSlot:${slot}`, () => {
if (this._tradeWindow === window) {
this._tradeWindowState = this._captureTradeState(window);
}
});
}
window.on('updateSlot:53', () => {
if (this._tradeWindow === window) {
this._tradeWindowState = this._captureTradeState(window);
}
});
window.on('updateSlot:37', () => {
if (this._tradeWindow === window) {
this._tradeWindowState = this._captureTradeState(window);
}
});
this._faceBot.bot.once('windowClose', () => {
if (this._tradeWindow === window) {
this._tradeWindow = null;
this._tradeWindowState = null;
}
});
}
_captureTradeState(window) {
const botSlots = [0, 1, 2, 3, 9, 10, 11, 12, 18, 19, 20, 21];
const customerSlots = [5, 6, 7, 8, 14, 15, 16, 17, 23, 24, 25, 26];
const readSlots = (slots) => slots
.map(s => window.slots[s])
.filter(Boolean)
.map(item => ({ name: item.name, count: item.count }));
return {
botItems: readSlots(botSlots),
customerItems: readSlots(customerSlots),
customerConfirmed: !!(window.slots[53] && window.slots[53].name === 'lime_dye'),
botConfirmed: !!(window.slots[37] && window.slots[37].name === 'lime_dye'),
};
}
// ========================================
// Helpers
// ========================================
_getLastSpeaker(messages) {
if (!messages || !Array.isArray(messages)) return 'ai';
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (!msg || msg.type !== 'message') continue;
const text = msg.text || '';
const match = text.match(/^<\[?.*?\]?\s+(\w+)>/);
if (match && match[1] && match[1] !== this._faceBot.bot.entity.username) {
return match[1];
}
}
return 'ai';
}
_getTimeAgo(timestamp) {
const now = new Date();
const past = new Date(timestamp);
const diffMs = now - past;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return past.toLocaleDateString();
}
_getCurrentTimeInfo() {
const now = new Date();
return {
iso: now.toISOString().replace('T', ' ').substring(0, 19),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
human: now.toLocaleString('en-US', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
hour: 'numeric', minute: '2-digit', hour12: true,
}),
};
}
}
// Singleton
let _instance = null;
module.exports = {
getInstance() {
if (!_instance) _instance = new AiManager();
return _instance;
},
AiManager,
};