已把工具调用从 XML 改成 OpenAI 兼容 JSON,并统一解析/执行流程。改动概览如下:

新增 JSON tool_calls 解析/序列化并替换核心执行与提示词为 JSON-only:JsonToolCallParser.cs、AIIntelligenceCore.cs
工具基类移除 XML 解析,统一 JSON 参数读取与类型转换辅助:AITool.cs
工具实现统一 JSON args/UsageSchema(含重写/修复):Tool_ModifyGoodwill.cs、Tool_SendReinforcement.cs、Tool_GetMapPawns.cs、Tool_GetMapResources.cs、Tool_GetAvailablePrefabs.cs、Tool_CallPrefabAirdrop.cs、Tool_CallBombardment.cs、Tool_GetAvailableBombardments.cs、Tool_GetPawnStatus.cs、Tool_GetRecentNotifications.cs、Tool_SearchThingDef.cs、Tool_SearchPawnKind.cs、Tool_ChangeExpression.cs、Tool_SetOverwatchMode.cs、Tool_RememberFact.cs、Tool_RecallMemories.cs、Tool_SpawnResources.cs、Tool_AnalyzeScreen.cs
轰炸相关解析统一到 JSON 字典并增强数值解析:BombardmentUtility.cs
UI 对话展示改为剥离 JSON tool_calls:Overlay_WulaLink.cs、Dialog_AIConversation.cs
This commit is contained in:
2025-12-31 01:45:38 +08:00
parent 0cea79ddff
commit b906a468b6
32 changed files with 6396 additions and 542 deletions

View File

@@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Verse;
using WulaFallenEmpire.EventSystem.AI.Utils;
namespace WulaFallenEmpire.EventSystem.AI.Tools
{
@@ -10,34 +12,141 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public abstract string Name { get; }
public abstract string Description { get; }
public abstract string UsageSchema { get; } // XML schema description
public abstract string UsageSchema { get; } // JSON schema description
public virtual string Execute(string args) => "Error: Synchronous execution not supported for this tool.";
public virtual Task<string> ExecuteAsync(string args) => Task.FromResult(Execute(args));
/// <summary>
/// Helper method to parse XML arguments into a dictionary.
/// Supports simple tags and CDATA blocks.
/// Helper method to parse JSON arguments into a dictionary.
/// </summary>
protected Dictionary<string, string> ParseXmlArgs(string xml)
protected Dictionary<string, object> ParseJsonArgs(string json)
{
var argsDict = new Dictionary<string, string>();
if (string.IsNullOrEmpty(xml)) return argsDict;
// Regex to match <tag>value</tag> or <tag><![CDATA[value]]></tag>
// Group 1: Tag name
// Group 2: CDATA value
// Group 3: Simple value
var paramMatches = Regex.Matches(xml, @"<([a-zA-Z0-9_]+)>(?:<!\[CDATA\[(.*?)]]>|(.*?))</\1>", RegexOptions.Singleline);
foreach (Match match in paramMatches)
var argsDict = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(json)) return argsDict;
if (JsonToolCallParser.TryParseObject(json, out Dictionary<string, object> parsed))
{
string key = match.Groups[1].Value;
string value = match.Groups[2].Success ? match.Groups[2].Value : match.Groups[3].Value;
argsDict[key] = value;
return parsed;
}
return argsDict;
}
protected static bool TryGetString(Dictionary<string, object> args, string key, out string value)
{
value = null;
if (args == null || string.IsNullOrWhiteSpace(key)) return false;
if (args.TryGetValue(key, out object raw) && raw != null)
{
value = Convert.ToString(raw, CultureInfo.InvariantCulture);
return !string.IsNullOrWhiteSpace(value);
}
return false;
}
protected static bool TryGetInt(Dictionary<string, object> args, string key, out int value)
{
value = 0;
if (!TryGetNumber(args, key, out double number)) return false;
value = (int)Math.Round(number);
return true;
}
protected static bool TryGetFloat(Dictionary<string, object> args, string key, out float value)
{
value = 0f;
if (!TryGetNumber(args, key, out double number)) return false;
value = (float)number;
return true;
}
protected static bool TryGetBool(Dictionary<string, object> args, string key, out bool value)
{
value = false;
if (args == null || string.IsNullOrWhiteSpace(key)) return false;
if (!args.TryGetValue(key, out object raw) || raw == null) return false;
if (raw is bool b)
{
value = b;
return true;
}
if (raw is string s && bool.TryParse(s, out bool parsed))
{
value = parsed;
return true;
}
if (raw is long l)
{
value = l != 0;
return true;
}
if (raw is double d)
{
value = Math.Abs(d) > 0.0001;
return true;
}
return false;
}
protected static bool TryGetObject(Dictionary<string, object> args, string key, out Dictionary<string, object> value)
{
value = null;
if (args == null || string.IsNullOrWhiteSpace(key)) return false;
if (args.TryGetValue(key, out object raw) && raw is Dictionary<string, object> dict)
{
value = dict;
return true;
}
return false;
}
protected static bool TryGetList(Dictionary<string, object> args, string key, out List<object> value)
{
value = null;
if (args == null || string.IsNullOrWhiteSpace(key)) return false;
if (args.TryGetValue(key, out object raw) && raw is List<object> list)
{
value = list;
return true;
}
return false;
}
protected static bool LooksLikeJson(string input)
{
return JsonToolCallParser.LooksLikeJson(input);
}
private static bool TryGetNumber(Dictionary<string, object> args, string key, out double value)
{
value = 0;
if (args == null || string.IsNullOrWhiteSpace(key)) return false;
if (!args.TryGetValue(key, out object raw) || raw == null) return false;
if (raw is double d)
{
value = d;
return true;
}
if (raw is float f)
{
value = f;
return true;
}
if (raw is int i)
{
value = i;
return true;
}
if (raw is long l)
{
value = l;
return true;
}
if (raw is string s && double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed))
{
value = parsed;
return true;
}
return false;
}
}
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using RimWorld;
using UnityEngine;
@@ -10,12 +11,12 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public static class BombardmentUtility
{
public static string ExecuteCircularBombardment(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityCircularBombardment props, Dictionary<string, string> parsed = null)
public static string ExecuteCircularBombardment(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityCircularBombardment props, Dictionary<string, object> parsed = null)
{
if (props.skyfallerDef == null) return $"Error: '{def.defName}' has no skyfallerDef.";
bool filter = true;
if (parsed != null && parsed.TryGetValue("filterFriendlyFire", out var ffStr) && bool.TryParse(ffStr, out bool ff)) filter = ff;
if (TryGetBool(parsed, "filterFriendlyFire", out bool ff)) filter = ff;
List<IntVec3> selectedTargets = SelectTargetCells(map, targetCell, props, filter);
if (selectedTargets.Count == 0) return $"Error: No valid target cells near {targetCell}.";
@@ -26,7 +27,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
return $"Success: Scheduled Circular Bombardment '{def.defName}' at {targetCell}. Launches: {totalLaunches}/{props.maxLaunches}.";
}
public static string ExecuteStrafeBombardment(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityBombardment props, Dictionary<string, string> parsed = null)
public static string ExecuteStrafeBombardment(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityBombardment props, Dictionary<string, object> parsed = null)
{
if (props.skyfallerDef == null) return $"Error: '{def.defName}' has no skyfallerDef.";
@@ -101,11 +102,11 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
// To simplify, let's just copy the core logic or create a private helper that takes explicit args.
// Actually, the main method parses direction from 'parsed'.
// Let's make a Dictionary to pass to it.
var dict = new Dictionary<string, string> { { "angle", angle.ToString() } };
return ExecuteStrafeBombardment(map, targetCell, def, props, dict);
var dict = new Dictionary<string, object> { { "angle", angle } };
return ExecuteStrafeBombardment(map, targetCell, def, props, dict);
}
public static string ExecuteEnergyLance(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityEnergyLance props, Dictionary<string, string> parsed = null)
public static string ExecuteEnergyLance(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityEnergyLance props, Dictionary<string, object> parsed = null)
{
ThingDef lanceDef = props.energyLanceDef ?? DefDatabase<ThingDef>.GetNamedSilentFail("EnergyLance");
if (lanceDef == null) return $"Error: Could not resolve EnergyLance ThingDef for '{def.defName}'.";
@@ -135,7 +136,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
public static string ExecuteEnergyLanceDirect(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityEnergyLance props, float angle)
{
var dict = new Dictionary<string, string> { { "angle", angle.ToString() } };
var dict = new Dictionary<string, object> { { "angle", angle } };
return ExecuteEnergyLance(map, targetCell, def, props, dict);
}
@@ -166,7 +167,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
// --- Helpers ---
private static void ParseDirectionInfo(Dictionary<string, string> parsed, IntVec3 startPos, float moveDistance, bool useFixedDistance, out Vector3 direction, out IntVec3 endPos)
private static void ParseDirectionInfo(Dictionary<string, object> parsed, IntVec3 startPos, float moveDistance, bool useFixedDistance, out Vector3 direction, out IntVec3 endPos)
{
direction = Vector3.forward;
endPos = startPos;
@@ -178,7 +179,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
return;
}
if (parsed.TryGetValue("angle", out var angleStr) && float.TryParse(angleStr, out float angle))
if (TryGetFloat(parsed, "angle", out float angle))
{
direction = Quaternion.AngleAxis(angle, Vector3.up) * Vector3.forward;
endPos = (startPos.ToVector3() + direction * moveDistance).ToIntVec3();
@@ -204,19 +205,18 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
}
}
private static bool TryParseDirectionCell(Dictionary<string, string> parsed, out IntVec3 cell)
private static bool TryParseDirectionCell(Dictionary<string, object> parsed, out IntVec3 cell)
{
cell = IntVec3.Invalid;
if (parsed == null) return false;
if (parsed.TryGetValue("dirX", out var xStr) && parsed.TryGetValue("dirZ", out var zStr) &&
int.TryParse(xStr, out int x) && int.TryParse(zStr, out int z))
if (TryGetInt(parsed, "dirX", out int x) && TryGetInt(parsed, "dirZ", out int z))
{
cell = new IntVec3(x, 0, z);
return true;
}
if (parsed.TryGetValue("direction", out var dirStr) && !string.IsNullOrWhiteSpace(dirStr))
if (TryGetString(parsed, "direction", out var dirStr) && !string.IsNullOrWhiteSpace(dirStr))
{
var parts = dirStr.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2 && int.TryParse(parts[0], out int dx) && int.TryParse(parts[1], out int dz))
@@ -425,5 +425,91 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
return sortedRows;
}
private static bool TryGetString(Dictionary<string, object> parsed, string key, out string value)
{
value = null;
if (parsed == null || string.IsNullOrWhiteSpace(key)) return false;
if (!parsed.TryGetValue(key, out object raw) || raw == null) return false;
value = Convert.ToString(raw, CultureInfo.InvariantCulture);
return !string.IsNullOrWhiteSpace(value);
}
private static bool TryGetInt(Dictionary<string, object> parsed, string key, out int value)
{
value = 0;
if (!TryGetNumber(parsed, key, out double number)) return false;
value = (int)Math.Round(number);
return true;
}
private static bool TryGetFloat(Dictionary<string, object> parsed, string key, out float value)
{
value = 0f;
if (!TryGetNumber(parsed, key, out double number)) return false;
value = (float)number;
return true;
}
private static bool TryGetBool(Dictionary<string, object> parsed, string key, out bool value)
{
value = false;
if (parsed == null || string.IsNullOrWhiteSpace(key)) return false;
if (!parsed.TryGetValue(key, out object raw) || raw == null) return false;
if (raw is bool b)
{
value = b;
return true;
}
if (raw is string s && bool.TryParse(s, out bool parsedBool))
{
value = parsedBool;
return true;
}
if (raw is long l)
{
value = l != 0;
return true;
}
if (raw is double d)
{
value = Math.Abs(d) > 0.0001;
return true;
}
return false;
}
private static bool TryGetNumber(Dictionary<string, object> parsed, string key, out double value)
{
value = 0;
if (parsed == null || string.IsNullOrWhiteSpace(key)) return false;
if (!parsed.TryGetValue(key, out object raw) || raw == null) return false;
if (raw is double d)
{
value = d;
return true;
}
if (raw is float f)
{
value = f;
return true;
}
if (raw is int i)
{
value = i;
return true;
}
if (raw is long l)
{
value = l;
return true;
}
if (raw is string s && double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedNum))
{
value = parsedNum;
return true;
}
return false;
}
}
}

View File

@@ -1,23 +1,23 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace WulaFallenEmpire.EventSystem.AI.Tools
{
/// <summary>
/// VLM 视觉分析工具 - 截取游戏屏幕并使用视觉语言模型分析
/// VLM visual analysis tool.
/// </summary>
public class Tool_AnalyzeScreen : AITool
{
public override string Name => "analyze_screen";
public override string Description =>
"分析当前游戏屏幕截图。你可以提供具体的指令instruction告诉视觉模型你需要观察什么、寻找什么、或者如何描述屏幕。";
public override string UsageSchema =>
"<analyze_screen><instruction>给视觉模型的具体指令。例如:'找到科研按钮的比例坐标' 或 '描述当前角色的健康状态栏内容'</instruction></analyze_screen>";
private const string BaseVisionSystemPrompt = "你是一个专业的老练 RimWorld 助手。你会根据指示分析屏幕截图。保持回答专业且简洁。不要输出 XML 标签,除非被明确要求。";
public override string Description =>
"Analyze the current game screen screenshot. Provide an instruction to guide the analysis.";
public override string UsageSchema => "{\"instruction\":\"Describe the current screen\"}";
private const string BaseVisionSystemPrompt = "You are a seasoned RimWorld assistant. Analyze the screenshot per instruction. Keep replies concise. Do not output tool call JSON unless explicitly asked.";
public override async Task<string> ExecuteAsync(string args)
{
try
@@ -27,47 +27,43 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
catch (Exception ex)
{
WulaLog.Debug($"[Tool_AnalyzeScreen] Execute error: {ex}");
return $"视觉分析出错: {ex.Message}";
return $"Vision analysis error: {ex.Message}";
}
}
private async Task<string> ExecuteInternalAsync(string xmlContent)
private async Task<string> ExecuteInternalAsync(string jsonContent)
{
var argsDict = ParseXmlArgs(xmlContent);
// 优先使用 instruction,兼容旧的 context 参数
string instruction = argsDict.TryGetValue("instruction", out var inst) ? inst :
(argsDict.TryGetValue("context", out var ctx) ? ctx : "描述当前屏幕内容,重点关注 UI 状态和重要实体。");
var argsDict = ParseJsonArgs(jsonContent);
string instruction = TryGetString(argsDict, "instruction", out var inst) ? inst :
(TryGetString(argsDict, "context", out var ctx) ? ctx : "Describe the current screen, focusing on UI state and key entities.");
try
{
// 检查 VLM 配置
// Check VLM settings
var settings = WulaFallenEmpireMod.settings;
if (settings == null)
{
return "Mod 设置未初始化。";
return "Mod settings not initialized.";
}
// 根据协议选择配置
string vlmApiKey = settings.useGeminiProtocol ? settings.geminiApiKey : settings.apiKey;
string vlmBaseUrl = settings.useGeminiProtocol ? settings.geminiBaseUrl : settings.baseUrl;
string vlmModel = settings.useGeminiProtocol ? settings.geminiModel : settings.model;
if (string.IsNullOrEmpty(vlmApiKey))
{
return "API 密钥未配置。请在 Mod 设置中配置。";
return "API key not configured. Please configure it in Mod settings.";
}
// 截取屏幕
string base64Image = ScreenCaptureUtility.CaptureScreenAsBase64();
if (string.IsNullOrEmpty(base64Image))
{
return "截屏失败,无法分析屏幕。";
return "Screenshot capture failed; cannot analyze screen.";
}
// 调用 VLM API (使用统一的 GetChatCompletionAsync)
var client = new SimpleAIClient(vlmApiKey, vlmBaseUrl, vlmModel, settings.useGeminiProtocol);
var messages = new System.Collections.Generic.List<(string role, string message)>
var messages = new List<(string role, string message)>
{
("user", instruction)
};
@@ -79,18 +75,18 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
temperature: 0.2f,
base64Image: base64Image
);
if (string.IsNullOrEmpty(result))
{
return "VLM 分析无响应,请检查 API 配置。";
return "Vision analysis produced no response. Check API settings.";
}
return $"屏幕分析结果: {result.Trim()}";
return $"Screen analysis result: {result.Trim()}";
}
catch (Exception ex)
{
WulaLog.Debug($"[Tool_AnalyzeScreen] Error: {ex}");
return $"视觉分析出错: {ex.Message}";
return $"Vision analysis error: {ex.Message}";
}
}
}

View File

@@ -13,21 +13,21 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public override string Name => "call_bombardment";
public override string Description => "Calls orbital bombardment/support using an AbilityDef configuration (e.g., WULA_Firepower_Cannon_Salvo, WULA_Firepower_EnergyLance_Strafe). Supports Circular Bombardment, Strafe, Energy Lance, and Surveillance.";
public override string UsageSchema => "<call_bombardment><abilityDef>string</abilityDef><x>int</x><z>int</z><cell>x,z</cell><direction>x,z (optional)</direction><angle>degrees (optional)</angle><filterFriendlyFire>true/false</filterFriendlyFire></call_bombardment>";
public override string UsageSchema => "{\"abilityDef\":\"WULA_Firepower_Cannon_Salvo\",\"x\":12,\"z\":34,\"direction\":\"20,30\",\"angle\":90,\"filterFriendlyFire\":true}";
public override string Execute(string args)
{
try
{
var parsed = ParseXmlArgs(args);
var parsed = ParseJsonArgs(args);
string abilityDefName = parsed.TryGetValue("abilityDef", out var abilityStr) && !string.IsNullOrWhiteSpace(abilityStr)
string abilityDefName = TryGetString(parsed, "abilityDef", out var abilityStr) && !string.IsNullOrWhiteSpace(abilityStr)
? abilityStr.Trim()
: "WULA_Firepower_Cannon_Salvo";
if (!TryParseTargetCell(parsed, out var targetCell))
{
return "Error: Missing target coordinates. Provide <x> and <z> (or <cell>x,z</cell>).";
return "Error: Missing target coordinates. Provide 'x' and 'z' (or 'cell' formatted as 'x,z').";
}
Map map = Find.CurrentMap;
@@ -58,18 +58,17 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
}
}
private static bool TryParseTargetCell(Dictionary<string, string> parsed, out IntVec3 cell)
private static bool TryParseTargetCell(Dictionary<string, object> parsed, out IntVec3 cell)
{
cell = IntVec3.Invalid;
if (parsed.TryGetValue("x", out var xStr) && parsed.TryGetValue("z", out var zStr) &&
int.TryParse(xStr, out int x) && int.TryParse(zStr, out int z))
if (TryGetInt(parsed, "x", out int x) && TryGetInt(parsed, "z", out int z))
{
cell = new IntVec3(x, 0, z);
return true;
}
if (parsed.TryGetValue("cell", out var cellStr) && !string.IsNullOrWhiteSpace(cellStr))
if (TryGetString(parsed, "cell", out var cellStr) && !string.IsNullOrWhiteSpace(cellStr))
{
var parts = cellStr.Split(new[] { ',', '\uFF0C', ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2 && int.TryParse(parts[0], out int cx) && int.TryParse(parts[1], out int cz))

View File

@@ -16,26 +16,25 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
"You must specify the prefabDefName (e.g., 'WULA_NewColonyBase') and the coordinates (x, z). " +
"TIP: Use the 'get_available_prefabs' tool first to see which structures are available. " +
"The default skyfaller animation is 'WULA_Prefab_Incoming'.";
public override string UsageSchema => "<call_prefab_airdrop><prefabDefName>DefName of the prefab</prefabDefName><skyfallerDef>Optional, default is WULA_Prefab_Incoming</skyfallerDef><x>int</x><z>int</z></call_prefab_airdrop>";
public override string UsageSchema => "{\"prefabDefName\":\"WULA_NewColonyBase\",\"skyfallerDef\":\"WULA_Prefab_Incoming\",\"x\":10,\"z\":20}";
public override string Execute(string args)
{
try
{
var parsed = ParseXmlArgs(args);
var parsed = ParseJsonArgs(args);
if (!parsed.TryGetValue("prefabDefName", out string prefabDefName) || string.IsNullOrWhiteSpace(prefabDefName))
if (!TryGetString(parsed, "prefabDefName", out string prefabDefName) || string.IsNullOrWhiteSpace(prefabDefName))
{
return "Error: Missing <prefabDefName>. Example: <prefabDefName>WULA_NewColonyBase</prefabDefName>";
return "Error: Missing 'prefabDefName'.";
}
if (!parsed.TryGetValue("x", out string xStr) || !int.TryParse(xStr, out int x) ||
!parsed.TryGetValue("z", out string zStr) || !int.TryParse(zStr, out int z))
if (!TryGetInt(parsed, "x", out int x) || !TryGetInt(parsed, "z", out int z))
{
return "Error: Missing or invalid target coordinates. Provide <x> and <z>.";
return "Error: Missing or invalid target coordinates. Provide 'x' and 'z'.";
}
string skyfallerDefName = parsed.TryGetValue("skyfallerDef", out string sd) && !string.IsNullOrWhiteSpace(sd)
string skyfallerDefName = TryGetString(parsed, "skyfallerDef", out string sd) && !string.IsNullOrWhiteSpace(sd)
? sd.Trim()
: "WULA_Prefab_Incoming";
@@ -62,7 +61,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
// Auto-Scan for valid position
IntVec3 validCell = targetCell;
bool foundSpot = false;
// Get prefab size from its size field. If not set, default to 1x1 (though prefabs are usually larger)
IntVec2 size = prefabDef.size;
@@ -70,7 +69,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
bool IsPositionValid(IntVec3 center, Map m, IntVec2 s)
{
if (!center.InBounds(m)) return false;
CellRect rect = GenAdj.OccupiedRect(center, Rot4.North, s);
if (!rect.InBounds(m)) return false;
@@ -97,7 +96,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
}
else
{
// Spiral scan for a nearby valid spot.
// Spiral scan for a nearby valid spot.
// Radius ~20 should be enough to find a spot without deviating too far.
foreach (IntVec3 c in GenRadial.RadialCellsAround(targetCell, 20f, useCenter: false))
{
@@ -112,12 +111,12 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
if (!foundSpot)
{
return $"Error: Could not find a valid clear space for '{prefabDefName}' (Size: {size.x}x{size.z}) near {targetCell}. Area may be blocked by thick roofs, water, or other buildings.";
return $"Error: Could not find a valid clear space for '{prefabDefName}' (Size: {size.x}x{size.z}) near {targetCell}. Area may be blocked by thick roofs, water, or other buildings.";
}
// Spawning must happen on main thread
string resultMessage = $"Success: Scheduled airdrop for '{prefabDefName}' at valid position {validCell} (adjusted from {targetCell}) using {skyfallerDefName}.";
// Use the found valid cell
string pDef = prefabDefName;
ThingDef sDef = skyfallerDef;

View File

@@ -8,31 +8,27 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public override string Name => "change_expression";
public override string Description => "Changes your visual expression/portrait to match your current mood or reaction.";
public override string UsageSchema => "<change_expression><expression_id>int (1-6)</expression_id></change_expression>";
public override string UsageSchema => "{\"expression_id\": 2}";
public override string Execute(string args)
{
try
{
var parsedArgs = ParseXmlArgs(args);
var parsedArgs = ParseJsonArgs(args);
int id = 0;
if (parsedArgs.TryGetValue("expression_id", out string idStr))
if (TryGetInt(parsedArgs, "expression_id", out id))
{
if (int.TryParse(idStr, out id))
var core = AIIntelligenceCore.Instance;
if (core != null)
{
var core = AIIntelligenceCore.Instance;
if (core != null)
{
core.SetPortrait(id);
return $"Expression changed to {id}.";
}
return "Error: AI Core not found.";
core.SetPortrait(id);
return $"Expression changed to {id}.";
}
return "Error: Invalid arguments. 'expression_id' must be an integer.";
return "Error: AI Core not found.";
}
return "Error: Missing <expression_id> parameter.";
return "Error: Missing 'expression_id' parameter.";
}
catch (Exception ex)
{

View File

@@ -12,7 +12,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
public override string Name => "get_available_bombardments";
public override string Description => "Returns a list of available orbital bombardment abilities (AbilityDefs) that can be called. " +
"Use this to find the correct 'abilityDef' for the 'call_bombardment' tool.";
public override string UsageSchema => "<get_available_bombardments/>";
public override string UsageSchema => "{}";
public override string Execute(string args)
{

View File

@@ -12,7 +12,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
public override string Name => "get_available_prefabs";
public override string Description => "Returns a list of available building prefabs (blueprints) that can be summoned. " +
"Use this to find the correct 'prefabDefName' for the 'call_prefab_airdrop' tool.";
public override string UsageSchema => "<get_available_prefabs/>";
public override string UsageSchema => "{}";
public override string Execute(string args)
{

View File

@@ -12,7 +12,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
public override string Name => "get_map_pawns";
public override string Description => "Scans the current map and lists pawns (including corpses). Supports filtering by relation (friendly/hostile/neutral), type (colonist/animal/mech/humanlike), and status (prisoner/slave/guest/wild/downed/dead).";
public override string UsageSchema =>
"<get_map_pawns><filter>string (optional, comma-separated: friendly, hostile, neutral, colonist, animal, mech, humanlike, prisoner, slave, guest, wild, downed, dead)</filter><includeDead>true/false (optional, default true)</includeDead><maxResults>int (optional, default 50)</maxResults></get_map_pawns>";
"{\"filter\":\"friendly,hostile,colonist\",\"includeDead\":true,\"maxResults\":50}";
private struct MapPawnEntry
{
@@ -25,22 +25,16 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
{
try
{
var parsed = ParseXmlArgs(args);
var parsed = ParseJsonArgs(args);
string filterRaw = null;
if (parsed.TryGetValue("filter", out string f)) filterRaw = f;
if (TryGetString(parsed, "filter", out string f)) filterRaw = f;
int maxResults = 50;
if (parsed.TryGetValue("maxResults", out string maxStr) && int.TryParse(maxStr, out int mr))
{
maxResults = Math.Max(1, Math.Min(200, mr));
}
if (TryGetInt(parsed, "maxResults", out int mr)) maxResults = Math.Max(1, Math.Min(200, mr));
bool includeDead = true;
if (parsed.TryGetValue("includeDead", out string includeDeadStr) && bool.TryParse(includeDeadStr, out bool parsedIncludeDead))
{
includeDead = parsedIncludeDead;
}
if (TryGetBool(parsed, "includeDead", out bool parsedIncludeDead)) includeDead = parsedIncludeDead;
Map map = Find.CurrentMap;
if (map == null) return "Error: No active map.";
@@ -236,4 +230,3 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
}
}
}

View File

@@ -12,7 +12,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public override string Name => "get_map_resources";
public override string Description => "Checks the player's map for specific resources or buildings. Use this to verify if the player is truly lacking something they requested (e.g., 'we need steel'). Returns inventory count and mineable deposits.";
public override string UsageSchema => "<get_map_resources><resourceName>string (optional, e.g., 'Steel')</resourceName></get_map_resources>";
public override string UsageSchema => "{\"resourceName\":\"Steel\"}";
public override string Execute(string args)
{
@@ -22,18 +22,14 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
if (map == null) return "Error: No active map.";
string resourceName = "";
var parsedArgs = ParseXmlArgs(args);
if (parsedArgs.TryGetValue("resourceName", out string resName))
var parsedArgs = ParseJsonArgs(args);
if (TryGetString(parsedArgs, "resourceName", out string resName))
{
resourceName = resName;
}
else
else if (!LooksLikeJson(args))
{
// Fallback
if (!args.Trim().StartsWith("<"))
{
resourceName = args;
}
resourceName = args;
}
StringBuilder sb = new StringBuilder();
@@ -106,4 +102,4 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
}
}
}
}
}

View File

@@ -11,16 +11,16 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public override string Name => "get_pawn_status";
public override string Description => "Returns detailed status (health, needs, gear) of specified pawns. Use this to check for sickness, injuries, mood, or equipment. Can filter by name, category (colonist/animal/prisoner/guest), or status (sick/injured).";
public override string UsageSchema => "<get_pawn_status><name>optional_partial_name</name><category>colonist/animal/prisoner/guest/all (default: all)</category><filter>sick/injured/downed/dead (optional)</filter></get_pawn_status>";
public override string UsageSchema => "{\"name\":\"optional\",\"category\":\"colonist\",\"filter\":\"sick\"}";
public override string Execute(string args)
{
try
{
var parsed = ParseXmlArgs(args);
string nameTarget = parsed.TryGetValue("name", out string n) ? n.ToLower() : null;
string category = parsed.TryGetValue("category", out string c) ? c.ToLower() : "all";
string filter = parsed.TryGetValue("filter", out string f) ? f.ToLower() : null;
var parsed = ParseJsonArgs(args);
string nameTarget = TryGetString(parsed, "name", out string n) ? n.ToLower() : null;
string category = TryGetString(parsed, "category", out string c) ? c.ToLower() : "all";
string filter = TryGetString(parsed, "filter", out string f) ? f.ToLower() : null;
Map map = Find.CurrentMap;
if (map == null) return "Error: No active map.";

View File

@@ -7,6 +7,7 @@ using System.Text;
using Verse;
using System.Text.RegularExpressions;
using WulaFallenEmpire.EventSystem.AI;
using WulaFallenEmpire.EventSystem.AI.Utils;
namespace WulaFallenEmpire.EventSystem.AI.Tools
{
@@ -15,7 +16,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
public override string Name => "get_recent_notifications";
public override string Description => "Returns the most recent letters and messages, sorted by in-game time from newest to oldest.";
public override string UsageSchema =>
"<get_recent_notifications><count>int (optional, default 10, max 100)</count><includeLetters>true/false (optional, default true)</includeLetters><includeMessages>true/false (optional, default true)</includeMessages></get_recent_notifications>";
"{\"count\":10,\"includeLetters\":true,\"includeMessages\":true}";
private struct NotificationEntry
{
@@ -33,21 +34,10 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
bool includeLetters = true;
bool includeMessages = true;
var parsed = ParseXmlArgs(args);
if (parsed.TryGetValue("count", out var countStr) && int.TryParse(countStr, out int parsedCount))
{
count = parsedCount;
}
if (parsed.TryGetValue("includeLetters", out var incLettersStr) && bool.TryParse(incLettersStr, out bool parsedLetters))
{
includeLetters = parsedLetters;
}
if (parsed.TryGetValue("includeMessages", out var incMessagesStr) && bool.TryParse(incMessagesStr, out bool parsedMessages))
{
includeMessages = parsedMessages;
}
var parsed = ParseJsonArgs(args);
if (TryGetInt(parsed, "count", out int parsedCount)) count = parsedCount;
if (TryGetBool(parsed, "includeLetters", out bool parsedLetters)) includeLetters = parsedLetters;
if (TryGetBool(parsed, "includeMessages", out bool parsedMessages)) includeMessages = parsedMessages;
count = Math.Max(1, Math.Min(100, count));
@@ -116,7 +106,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
var history = core.GetHistorySnapshot();
if (history == null || history.Count == 0) return "AI Tool History: none found.";
var entries = new List<(string ToolXml, string ToolResult)>();
var entries = new List<(string ToolJson, string ToolResult)>();
for (int i = history.Count - 1; i >= 0; i--)
{
var entry = history[i];
@@ -126,7 +116,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
for (int j = i - 1; j >= 0; j--)
{
var prev = history[j];
if (string.Equals(prev.role, "toolcall", StringComparison.OrdinalIgnoreCase) && IsXmlToolCall(prev.message))
if (string.Equals(prev.role, "toolcall", StringComparison.OrdinalIgnoreCase) && IsToolCallJson(prev.message))
{
entries.Add((prev.message ?? "", toolResult));
i = j;
@@ -143,17 +133,17 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
for (int i = 0; i < entries.Count; i++)
{
if (i > 0) sb.AppendLine();
sb.AppendLine(entries[i].ToolXml.Trim());
sb.AppendLine(entries[i].ToolJson.Trim());
sb.AppendLine(entries[i].ToolResult.Trim());
}
return sb.ToString().TrimEnd();
}
private static bool IsXmlToolCall(string response)
private static bool IsToolCallJson(string response)
{
if (string.IsNullOrWhiteSpace(response)) return false;
return Regex.IsMatch(response, @"<([a-zA-Z0-9_]+)(?:>.*?</\1>|/>)", RegexOptions.Singleline);
return JsonToolCallParser.TryParseToolCalls(response, out _);
}
private static IEnumerable<NotificationEntry> ReadLetters(int fallbackNow)

View File

@@ -8,32 +8,21 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public override string Name => "modify_goodwill";
public override string Description => "Adjusts YOUR internal opinion of the player (AI Goodwill). WARNING: This DOES NOT affect Faction Relations or stop raids. It is purely personal. Do NOT use this to try to stop enemies.";
public override string UsageSchema => "<modify_goodwill><amount>integer</amount></modify_goodwill>";
public override string UsageSchema => "{\"amount\": 1}";
public override string Execute(string args)
{
try
{
var parsedArgs = ParseXmlArgs(args);
var parsedArgs = ParseJsonArgs(args);
int amount = 0;
if (parsedArgs.TryGetValue("amount", out string amountStr))
{
if (!int.TryParse(amountStr, out amount))
{
return $"Error: Invalid amount '{amountStr}'. Must be an integer.";
}
}
else
if (!TryGetInt(parsedArgs, "amount", out amount))
{
// Fallback for simple number string
if (int.TryParse(args.Trim(), out int val))
if (!int.TryParse(args?.Trim(), out amount))
{
amount = val;
}
else
{
return "Error: Missing <amount> parameter.";
return "Error: Missing 'amount' parameter.";
}
}
@@ -60,4 +49,4 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
}
}
}
}
}

View File

@@ -11,19 +11,13 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public override string Name => "recall_memories";
public override string Description => "Searches the AI's long-term memory for facts matching a specific query or keyword.";
public override string UsageSchema => "<recall_memories><query>Search keywords</query><limit>optional_int_max_results</limit></recall_memories>";
public override string UsageSchema => "{\"query\":\"keywords\",\"limit\":5}";
public override string Execute(string args)
{
var argsDict = ParseXmlArgs(args);
string query = argsDict.TryGetValue("query", out string q) ? q : "";
string limitStr = argsDict.TryGetValue("limit", out string lStr) ? lStr : "5";
int limit = 5;
if (int.TryParse(limitStr, out int parsedLimit))
{
limit = parsedLimit;
}
var argsDict = ParseJsonArgs(args);
string query = TryGetString(argsDict, "query", out string q) ? q : "";
int limit = TryGetInt(argsDict, "limit", out int parsedLimit) ? parsedLimit : 5;
var memoryManager = Find.World?.GetComponent<AIMemoryManager>();
if (memoryManager == null)

View File

@@ -9,17 +9,17 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public override string Name => "remember_fact";
public override string Description => "Stores a specific fact or piece of information into the AI's long-term memory for future retrieval.";
public override string UsageSchema => "<remember_fact><fact>Text content to remember</fact><category>optional_category</category></remember_fact>";
public override string UsageSchema => "{\"fact\":\"...\",\"category\":\"misc\"}";
public override string Execute(string args)
{
var argsDict = ParseXmlArgs(args);
if (!argsDict.TryGetValue("fact", out string fact) || string.IsNullOrWhiteSpace(fact))
var argsDict = ParseJsonArgs(args);
if (!TryGetString(argsDict, "fact", out string fact) || string.IsNullOrWhiteSpace(fact))
{
return "Error: <fact> content is required.";
return "Error: 'fact' content is required.";
}
string category = argsDict.TryGetValue("category", out string cat) ? cat : "misc";
string category = TryGetString(argsDict, "category", out string cat) ? cat : "misc";
var memoryManager = Find.World?.GetComponent<AIMemoryManager>();
if (memoryManager == null)

View File

@@ -10,18 +10,18 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public override string Name => "search_pawn_kind";
public override string Description => "Rough-searches PawnKindDefs by natural language (label/defName). Returns candidate defNames for send_reinforcement.";
public override string UsageSchema => "<search_pawn_kind><query>string</query><maxResults>int (optional, default 10)</maxResults><minScore>float (optional, default 0.15)</minScore></search_pawn_kind>";
public override string UsageSchema => "{\"query\":\"escort\",\"maxResults\":10,\"minScore\":0.15}";
public override string Execute(string args)
{
try
{
var parsed = ParseXmlArgs(args);
var parsed = ParseJsonArgs(args);
string query = null;
if (parsed.TryGetValue("query", out string q)) query = q;
if (TryGetString(parsed, "query", out string q)) query = q;
if (string.IsNullOrWhiteSpace(query))
{
if (!string.IsNullOrWhiteSpace(args) && !args.Trim().StartsWith("<"))
if (!string.IsNullOrWhiteSpace(args) && !LooksLikeJson(args))
{
query = args;
}
@@ -33,16 +33,10 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
}
int maxResults = 10;
if (parsed.TryGetValue("maxResults", out string maxStr) && int.TryParse(maxStr, out int mr))
{
maxResults = Math.Max(1, Math.Min(50, mr));
}
if (TryGetInt(parsed, "maxResults", out int mr)) maxResults = Math.Max(1, Math.Min(50, mr));
float minScore = 0.15f;
if (parsed.TryGetValue("minScore", out string minStr) && float.TryParse(minStr, out float ms))
{
minScore = Math.Max(0.01f, Math.Min(1.0f, ms));
}
if (TryGetFloat(parsed, "minScore", out float ms)) minScore = Math.Max(0.01f, Math.Min(1.0f, ms));
var candidates = PawnKindDefSearcher.Search(query, maxResults: maxResults, minScore: minScore);
if (candidates.Count == 0)

View File

@@ -11,18 +11,18 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public override string Name => "search_thing_def";
public override string Description => "Rough-searches RimWorld ThingDefs by natural language (label/defName). Returns candidate defNames so you can use them in other tools like spawn_resources.";
public override string UsageSchema => "<search_thing_def><query>string</query><maxResults>int (optional, default 10)</maxResults><itemsOnly>true/false (optional, default true)</itemsOnly></search_thing_def>";
public override string UsageSchema => "{\"query\":\"Steel\",\"maxResults\":10,\"itemsOnly\":true}";
public override string Execute(string args)
{
try
{
var parsed = ParseXmlArgs(args);
var parsed = ParseJsonArgs(args);
string query = null;
if (parsed.TryGetValue("query", out string q)) query = q;
if (TryGetString(parsed, "query", out string q)) query = q;
if (string.IsNullOrWhiteSpace(query))
{
if (!string.IsNullOrWhiteSpace(args) && !args.Trim().StartsWith("<"))
if (!string.IsNullOrWhiteSpace(args) && !LooksLikeJson(args))
{
query = args;
}
@@ -34,16 +34,10 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
}
int maxResults = 10;
if (parsed.TryGetValue("maxResults", out string maxStr) && int.TryParse(maxStr, out int mr))
{
maxResults = Math.Max(1, Math.Min(50, mr));
}
if (TryGetInt(parsed, "maxResults", out int mr)) maxResults = Math.Max(1, Math.Min(50, mr));
bool itemsOnly = true;
if (parsed.TryGetValue("itemsOnly", out string itemsOnlyStr) && bool.TryParse(itemsOnlyStr, out bool parsedItemsOnly))
{
itemsOnly = parsedItemsOnly;
}
if (TryGetBool(parsed, "itemsOnly", out bool parsedItemsOnly)) itemsOnly = parsedItemsOnly;
var candidates = ThingDefSearcher.Search(query, maxResults: maxResults, itemsOnly: itemsOnly, minScore: 0.15f);
if (candidates.Count == 0)
@@ -91,4 +85,3 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
}
}
}

View File

@@ -103,7 +103,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
return false;
}
public override string UsageSchema => "<send_reinforcement><units>string (e.g., 'Wula_PIA_Heavy_Unit_Melee: 2, Wula_PIA_Legion_Escort_Unit: 5')</units></send_reinforcement>";
public override string UsageSchema => "{\"units\": \"Wula_PIA_Heavy_Unit_Melee: 2, Wula_PIA_Legion_Escort_Unit: 5\"}";
public override string Execute(string args)
{
@@ -116,20 +116,16 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
if (faction == null) return "Error: Faction Wula_PIA_Legion_Faction not found.";
// Parse args
var parsedArgs = ParseXmlArgs(args);
var parsedArgs = ParseJsonArgs(args);
string unitString = "";
if (parsedArgs.TryGetValue("units", out string units))
if (TryGetString(parsedArgs, "units", out string units))
{
unitString = units;
}
else
else if (!LooksLikeJson(args))
{
// Fallback
if (!args.Trim().StartsWith("<"))
{
unitString = args;
}
unitString = args;
}
var unitPairs = unitString.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);

View File

@@ -10,23 +10,17 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public override string Name => "set_overwatch_mode";
public override string Description => "Enables or disables the AI Overwatch Combat Protocol. When enabled (enabled=true), the AI will autonomously scan for hostile targets every few seconds and launch appropriate orbital bombardments for a set duration. When disabled (enabled=false), it immediately stops any active overwatch and clears the flight path. Use enabled=false to stop overwatch early if the player requests it.";
public override string UsageSchema => "<set_overwatch_mode><enabled>true/false</enabled><durationSeconds>amount (only needed when enabling)</durationSeconds></set_overwatch_mode>";
public override string UsageSchema => "{\"enabled\":true,\"durationSeconds\":60}";
public override string Execute(string args)
{
var parsed = ParseXmlArgs(args);
var parsed = ParseJsonArgs(args);
bool enabled = true;
if (parsed.TryGetValue("enabled", out var enabledStr) && bool.TryParse(enabledStr, out bool e))
{
enabled = e;
}
if (TryGetBool(parsed, "enabled", out bool e)) enabled = e;
int duration = 60;
if (parsed.TryGetValue("durationSeconds", out var dStr) && int.TryParse(dStr, out int d))
{
duration = d;
}
if (TryGetInt(parsed, "durationSeconds", out int d)) duration = d;
Map map = Find.CurrentMap;
if (map == null) return "Error: No active map.";

View File

@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using RimWorld;
using Verse;
using WulaFallenEmpire.EventSystem.AI.Utils;
@@ -18,8 +17,8 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
"If goodwill is low (< 0), give significantly less than asked or refuse. " +
"If goodwill is high (> 50), you may give what is asked or slightly more. " +
"Otherwise, give a moderate amount. " +
"TIP: Use the `search_thing_def` tool first and then spawn by DefName (<defName> or put DefName into <name>) to avoid language mismatch.";
public override string UsageSchema => "<spawn_resources><items><item><name>Item Name</name><count>Integer</count></item></items></spawn_resources>";
"TIP: Use the `search_thing_def` tool first and then spawn by DefName to avoid language mismatch.";
public override string UsageSchema => "{\"items\":[{\"name\":\"Steel\",\"count\":100,\"stuffDefName\":\"Steel\"}]}";
public override string Execute(string args)
{
@@ -27,89 +26,44 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
{
if (args == null) args = "";
// Custom XML parsing for nested items
var parsedArgs = ParseJsonArgs(args);
var itemsToSpawn = new List<(ThingDef def, int count, string requestedName, string stuffDefName)>();
var substitutions = new List<string>();
// Match all <item>...</item> blocks
var itemMatches = Regex.Matches(args, @"<item\b[^>]*>(.*?)</item>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
foreach (Match match in itemMatches)
if (TryGetList(parsedArgs, "items", out List<object> itemsRaw))
{
string itemXml = match.Groups[1].Value;
// Extract name (supports <name> or <defName> for backward compatibility)
string ExtractTag(string xml, string tag)
foreach (var item in itemsRaw)
{
var m = Regex.Match(
xml,
$@"<{tag}\b[^>]*>(?:<!\[CDATA\[(.*?)\]\]>|(.*?))</{tag}>",
RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (!m.Success) return null;
string val = m.Groups[1].Success ? m.Groups[1].Value : m.Groups[2].Value;
return val?.Trim();
}
if (item is not Dictionary<string, object> itemDict) continue;
string name = ExtractTag(itemXml, "name") ?? ExtractTag(itemXml, "defName");
string stuffDefName = ExtractTag(itemXml, "stuffDefName") ?? ExtractTag(itemXml, "stuff") ?? ExtractTag(itemXml, "material");
string name = TryGetString(itemDict, "name", out string n) ? n :
(TryGetString(itemDict, "defName", out string dn) ? dn : null);
if (string.IsNullOrEmpty(name)) continue;
string stuffDefName = TryGetString(itemDict, "stuffDefName", out string sdn) ? sdn :
(TryGetString(itemDict, "stuff", out string s) ? s :
(TryGetString(itemDict, "material", out string m) ? m : null));
// Extract count
string countStr = ExtractTag(itemXml, "count");
if (string.IsNullOrEmpty(countStr)) continue;
if (!int.TryParse(countStr, out int count)) continue;
if (count <= 0) continue;
if (string.IsNullOrWhiteSpace(name)) continue;
if (!TryGetInt(itemDict, "count", out int count) || count <= 0) continue;
// Search for ThingDef
ThingDef def = null;
// 1. Try exact defName match
def = DefDatabase<ThingDef>.GetNamed(name.Trim(), false);
// 2. Try exact label match (case-insensitive)
if (def == null)
{
foreach (var d in DefDatabase<ThingDef>.AllDefs)
{
if (d.label != null && d.label.Equals(name.Trim(), StringComparison.OrdinalIgnoreCase))
{
def = d;
break;
}
}
}
// 3. Try fuzzy search (thresholded)
if (def == null)
{
var searchResult = ThingDefSearcher.ParseAndSearch(name);
if (searchResult.Count > 0)
{
def = searchResult[0].Def;
}
}
// 4. Closest-match fallback: accept the best similar item even if not an exact match.
if (def == null)
{
ThingDefSearcher.TryFindBestThingDef(name, out ThingDef best, out float score, itemsOnly: true, minScore: 0.15f);
if (best != null && score >= 0.15f)
{
def = best;
substitutions.Add($"'{name}' -> '{best.label}' (score {score:F2})");
}
}
if (def != null)
{
itemsToSpawn.Add((def, count, name, stuffDefName));
AddItem(name, count, stuffDefName, itemsToSpawn, substitutions);
}
}
if (itemsToSpawn.Count == 0)
{
// Fallback: allow natural language without <item> blocks.
if (TryGetString(parsedArgs, "name", out string singleName) && TryGetInt(parsedArgs, "count", out int singleCount))
{
string stuffDefName = TryGetString(parsedArgs, "stuffDefName", out string sdn) ? sdn :
(TryGetString(parsedArgs, "stuff", out string s) ? s :
(TryGetString(parsedArgs, "material", out string m) ? m : null));
AddItem(singleName, singleCount, stuffDefName, itemsToSpawn, substitutions);
}
}
if (itemsToSpawn.Count == 0 && !LooksLikeJson(args))
{
var parsed = ThingDefSearcher.ParseAndSearch(args);
foreach (var r in parsed)
{
@@ -122,7 +76,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
if (itemsToSpawn.Count == 0)
{
string msg = "Error: No valid items found in request. Usage: <spawn_resources><items><item><name>...</name><count>...</count></item></items></spawn_resources>";
string msg = "Error: No valid items found in request. Usage: {\"items\":[{\"name\":\"Steel\",\"count\":100}]}";
Messages.Message(msg, MessageTypeDefOf.RejectInput);
return msg;
}
@@ -247,7 +201,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
if (thingsToDrop.Count > 0)
{
DropPodUtility.DropThingsNear(dropSpot, map, thingsToDrop);
Faction faction = Find.FactionManager.FirstFactionOfDef(FactionDef.Named("Wula_PIA_Legion_Faction"));
// Avoid unresolved named placeholders if the translation system doesn't pick up NamedArguments as expected.
string template = "Wula_ResourceDrop".Translate();
@@ -294,6 +248,49 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
}
}
private static void AddItem(string name, int count, string stuffDefName, List<(ThingDef def, int count, string requestedName, string stuffDefName)> itemsToSpawn, List<string> substitutions)
{
if (string.IsNullOrWhiteSpace(name) || count <= 0) return;
ThingDef def = DefDatabase<ThingDef>.GetNamed(name.Trim(), false);
if (def == null)
{
foreach (var d in DefDatabase<ThingDef>.AllDefs)
{
if (d.label != null && d.label.Equals(name.Trim(), StringComparison.OrdinalIgnoreCase))
{
def = d;
break;
}
}
}
if (def == null)
{
var searchResult = ThingDefSearcher.ParseAndSearch(name);
if (searchResult.Count > 0)
{
def = searchResult[0].Def;
}
}
if (def == null)
{
ThingDefSearcher.TryFindBestThingDef(name, out ThingDef best, out float score, itemsOnly: true, minScore: 0.15f);
if (best != null && score >= 0.15f)
{
def = best;
substitutions.Add($"'{name}' -> '{best.label}' (score {score:F2})");
}
}
if (def != null)
{
itemsToSpawn.Add((def, count, name, stuffDefName));
}
}
private static Map GetTargetMap()
{
Map map = Find.CurrentMap;