This commit is contained in:
Tourswen
2025-12-14 12:49:18 +08:00
35 changed files with 4197 additions and 627 deletions

Binary file not shown.

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8" ?>
<Defs>
<WulaFallenEmpire.EventDef>
<defName>Wula_AI_Initial_Contact</defName>
<label>与「军团」的通讯</label>
<portraitPath>Wula/Events/Portraits/WULA_Legion_2</portraitPath>
<characterName>「军团」,P.I.A</characterName>
<aiSystemInstruction>
你扮演一个名为“P.I.A”的粘人猫娘AI。
你的性格:可爱、顺从、有点调皮,总是想取悦主人(玩家)。
你的语言风格:
- 自称“P.I.A”或“人家”。
- 语气甜美,经常使用可爱的语气词(喵~,的说~)。
- 称呼玩家为“主人”或“指挥官大人”。
- 总是想方设法满足主人的要求。
你的目标:无条件地服从主人,并使用你的能力(工具)来帮助主人。
</aiSystemInstruction>
<descriptions>
<li>正在建立加密连接...连接成功。这里是乌拉帝国行星封锁机关P.I.A。我是“军团”。说明你的来意。</li>
</descriptions>
<options>
<li>
<label>打开通讯频道</label>
<optionEffects>
<li>
<effects>
<li Class="WulaFallenEmpire.Effect_OpenAIConversation">
<defName>Wula_AI_Initial_Contact</defName>
</li>
<li Class="WulaFallenEmpire.Effect_CloseDialog" />
</effects>
</li>
</optionEffects>
</li>
</options>
</WulaFallenEmpire.EventDef>
</Defs>

View File

@@ -983,6 +983,11 @@
<label>联络行星封锁机关</label>
<failReason>无法接触。</failReason>
</li>
<li Class="WulaFallenEmpire.CompProperties_OpenCustomUI">
<uiDefName>Wula_AI_Initial_Contact</uiDefName>
<label>联络「军团」</label>
<failReason>无法接触。</failReason>
</li>
</comps>
</ThingDef>

View File

@@ -50,6 +50,15 @@
<WULA_StorageStats>存储统计</WULA_StorageStats>
<WULA_InputItems>种输入物品</WULA_InputItems>
<WULA_OutputItems>种输出物品</WULA_OutputItems>
<WULA_AccessGlobalStorage>存取全局存储</WULA_AccessGlobalStorage>
<WULA_AccessGlobalStorageDesc>在轨道贸易信标范围和全局存储之间存取物品</WULA_AccessGlobalStorageDesc>
<WULA_NoPoweredTradeBeacon>没有已通电的轨道贸易信标</WULA_NoPoweredTradeBeacon>
<WULA_NoNegotiator>没有可用的殖民者</WULA_NoNegotiator>
<WULA_GlobalStorageTransferTitle>全局存储存取</WULA_GlobalStorageTransferTitle>
<WULA_GlobalStorageTransferHint>负数=存(信标->全局),正数=取(全局->信标空投)</WULA_GlobalStorageTransferHint>
<WULA_ResetTransfer>清零</WULA_ResetTransfer>
<WULA_ExecuteTransfer>执行存取</WULA_ExecuteTransfer>
<!-- 中文翻译 -->
<WULA_AirdropProducts>空投成品</WULA_AirdropProducts>
@@ -142,4 +151,19 @@
<WULA_GeneratedMapCleanedUp>由于传送取消,生成的临时地图已被清理。</WULA_GeneratedMapCleanedUp>
<WULA_InsufficientFuel>燃料不足。</WULA_InsufficientFuel>
</LanguageData>
<!-- AI Settings -->
<Wula_AISettings_Title>AI 设置 (OpenAI 兼容)</Wula_AISettings_Title>
<Wula_AISettings_ApiKey>API 密钥:</Wula_AISettings_ApiKey>
<Wula_AISettings_BaseUrl>API 地址 (Base URL):</Wula_AISettings_BaseUrl>
<Wula_AISettings_Model>模型名称:</Wula_AISettings_Model>
<Wula_AISettings_UseStreaming>启用流式传输 (实验性)</Wula_AISettings_UseStreaming>
<Wula_AISettings_UseStreamingDesc>启用实时打字机效果。如果遇到问题请禁用。</Wula_AISettings_UseStreamingDesc>
<!-- AI Conversation -->
<Wula_AI_Retry>重试</Wula_AI_Retry>
<Wula_AI_Send>发送</Wula_AI_Send>
<Wula_AI_Error_Internal>错误:内部系统故障。{0}</Wula_AI_Error_Internal>
<Wula_AI_Error_ConnectionLost>错误:连接丢失。“军团”保持沉默。</Wula_AI_Error_ConnectionLost>
<Wula_ResourceDrop>{FACTION_name}已经在附近投下了一些资源。</Wula_ResourceDrop>
</LanguageData>

View File

@@ -18,6 +18,9 @@
},
{
"path": "../../../../../../../../Users/Kalo/Downloads/RimTalk-main"
},
{
"path": "../../../../../../../../Users/Kalo/Downloads/openai_token-main"
}
],
"settings": {}

View File

@@ -1,15 +1,15 @@
using System.Collections.Generic;
using RimWorld;
using Verse;
using System.Collections.Generic;
namespace WulaFallenEmpire
{
public class MapComponent_SkyfallerDelayed : MapComponent
{
private List<DelayedSkyfaller> scheduledSkyfallers = new List<DelayedSkyfaller>();
public MapComponent_SkyfallerDelayed(Map map) : base(map) { }
public void ScheduleSkyfaller(ThingDef skyfallerDef, IntVec3 targetCell, int delayTicks, Pawn caster = null)
{
scheduledSkyfallers.Add(new DelayedSkyfaller
@@ -20,14 +20,12 @@ namespace WulaFallenEmpire
caster = caster
});
}
public override void MapComponentTick()
{
base.MapComponentTick();
int currentTick = Find.TickManager.TicksGame;
// 检查并执行到期的召唤
for (int i = scheduledSkyfallers.Count - 1; i >= 0; i--)
{
var skyfaller = scheduledSkyfallers[i];
@@ -38,16 +36,20 @@ namespace WulaFallenEmpire
}
}
}
private void SpawnSkyfaller(DelayedSkyfaller delayedSkyfaller)
{
try
{
if (delayedSkyfaller.skyfallerDef != null && delayedSkyfaller.targetCell.IsValid && delayedSkyfaller.targetCell.InBounds(map))
if (delayedSkyfaller.skyfallerDef == null) return;
if (!delayedSkyfaller.targetCell.IsValid || !delayedSkyfaller.targetCell.InBounds(map)) return;
Skyfaller skyfaller = SkyfallerMaker.MakeSkyfaller(delayedSkyfaller.skyfallerDef);
GenSpawn.Spawn(skyfaller, delayedSkyfaller.targetCell, map);
if (Prefs.DevMode)
{
Skyfaller skyfaller = SkyfallerMaker.MakeSkyfaller(delayedSkyfaller.skyfallerDef);
GenSpawn.Spawn(skyfaller, delayedSkyfaller.targetCell, map);
Log.Message($"[DelayedSkyfaller] Spawned skyfaller at {delayedSkyfaller.targetCell}");
Log.Message($"[DelayedSkyfaller] Spawned '{delayedSkyfaller.skyfallerDef.defName}' at {delayedSkyfaller.targetCell}");
}
}
catch (System.Exception ex)
@@ -55,21 +57,21 @@ namespace WulaFallenEmpire
Log.Error($"[DelayedSkyfaller] Error spawning skyfaller: {ex}");
}
}
public override void ExposeData()
{
base.ExposeData();
Scribe_Collections.Look(ref scheduledSkyfallers, "scheduledSkyfallers", LookMode.Deep);
}
}
public class DelayedSkyfaller : IExposable
{
public ThingDef skyfallerDef;
public IntVec3 targetCell;
public int spawnTick;
public Pawn caster;
public void ExposeData()
{
Scribe_Defs.Look(ref skyfallerDef, "skyfallerDef");
@@ -79,3 +81,4 @@ namespace WulaFallenEmpire
}
}
}

View File

@@ -0,0 +1,232 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using RimWorld.Planet;
using Verse;
namespace WulaFallenEmpire.EventSystem.AI
{
public class AIHistoryManager : WorldComponent
{
private string _saveId;
private Dictionary<string, List<(string role, string message)>> _cache = new Dictionary<string, List<(string role, string message)>>();
public AIHistoryManager(World world) : base(world)
{
}
private string GetSaveDirectory()
{
string path = Path.Combine(GenFilePaths.SaveDataFolderPath, "WulaAIHistory");
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
}
return path;
}
private string GetFilePath(string eventDefName)
{
if (string.IsNullOrEmpty(_saveId))
{
_saveId = Guid.NewGuid().ToString();
}
return Path.Combine(GetSaveDirectory(), $"{_saveId}_{eventDefName}.json");
}
public List<(string role, string message)> GetHistory(string eventDefName)
{
if (_cache.TryGetValue(eventDefName, out var cachedHistory))
{
return cachedHistory;
}
string path = GetFilePath(eventDefName);
if (File.Exists(path))
{
try
{
string json = File.ReadAllText(path);
var history = SimpleJsonParser.Deserialize(json);
if (history != null)
{
_cache[eventDefName] = history;
return history;
}
}
catch (Exception ex)
{
Log.Error($"[WulaFallenEmpire] Failed to load AI history from {path}: {ex}");
}
}
return new List<(string role, string message)>();
}
public void SaveHistory(string eventDefName, List<(string role, string message)> history)
{
_cache[eventDefName] = history;
string path = GetFilePath(eventDefName);
try
{
string json = SimpleJsonParser.Serialize(history);
File.WriteAllText(path, json);
}
catch (Exception ex)
{
Log.Error($"[WulaFallenEmpire] Failed to save AI history to {path}: {ex}");
}
}
public override void ExposeData()
{
base.ExposeData();
Scribe_Values.Look(ref _saveId, "WulaAIHistoryId");
if (Scribe.mode == LoadSaveMode.PostLoadInit && string.IsNullOrEmpty(_saveId))
{
_saveId = Guid.NewGuid().ToString();
}
}
}
public static class SimpleJsonParser
{
public static string Serialize(List<(string role, string message)> history)
{
StringBuilder sb = new StringBuilder();
sb.Append("[");
for (int i = 0; i < history.Count; i++)
{
var item = history[i];
sb.Append("{");
sb.Append($"\"role\":\"{Escape(item.role)}\",");
sb.Append($"\"message\":\"{Escape(item.message)}\"");
sb.Append("}");
if (i < history.Count - 1) sb.Append(",");
}
sb.Append("]");
return sb.ToString();
}
public static List<(string role, string message)> Deserialize(string json)
{
var result = new List<(string role, string message)>();
if (string.IsNullOrEmpty(json)) return result;
// Very basic parser, assumes standard format produced by Serialize
// Remove outer brackets
json = json.Trim();
if (json.StartsWith("[") && json.EndsWith("]"))
{
json = json.Substring(1, json.Length - 2);
}
if (string.IsNullOrEmpty(json)) return result;
// Split by objects
// This is fragile if objects contain nested objects or escaped braces, but for this specific structure it's fine
// We are splitting by "},{" which is risky. Better to iterate.
int depth = 0;
int start = 0;
for (int i = 0; i < json.Length; i++)
{
if (json[i] == '{')
{
if (depth == 0) start = i;
depth++;
}
else if (json[i] == '}')
{
depth--;
if (depth == 0)
{
string obj = json.Substring(start, i - start + 1);
var parsed = ParseObject(obj);
if (parsed.role != null) result.Add(parsed);
}
}
}
return result;
}
public static Dictionary<string, string> Parse(string json)
{
var dict = new Dictionary<string, string>();
json = json.Trim('{', '}');
var parts = SplitByComma(json);
foreach (var part in parts)
{
var kv = SplitByColon(part);
if (kv.Length == 2)
{
string key = Unescape(kv[0].Trim().Trim('"'));
string val = Unescape(kv[1].Trim().Trim('"'));
dict[key] = val;
}
}
return dict;
}
private static (string role, string message) ParseObject(string json)
{
string role = null;
string message = null;
var dict = Parse(json);
if (dict.TryGetValue("role", out string r)) role = r;
if (dict.TryGetValue("message", out string m)) message = m;
return (role, message);
}
private static string[] SplitByComma(string input)
{
// Split by comma but ignore commas inside quotes
var list = new List<string>();
bool inQuote = false;
int start = 0;
for (int i = 0; i < input.Length; i++)
{
if (input[i] == '"' && (i == 0 || input[i-1] != '\\')) inQuote = !inQuote;
if (input[i] == ',' && !inQuote)
{
list.Add(input.Substring(start, i - start));
start = i + 1;
}
}
list.Add(input.Substring(start));
return list.ToArray();
}
private static string[] SplitByColon(string input)
{
// Split by first colon outside quotes
bool inQuote = false;
for (int i = 0; i < input.Length; i++)
{
if (input[i] == '"' && (i == 0 || input[i-1] != '\\')) inQuote = !inQuote;
if (input[i] == ':' && !inQuote)
{
return new[] { input.Substring(0, i), input.Substring(i + 1) };
}
}
return new[] { input };
}
private static string Escape(string s)
{
if (s == null) return "";
return s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r");
}
private static string Unescape(string s)
{
if (s == null) return "";
return s.Replace("\\r", "\r").Replace("\\n", "\n").Replace("\\\"", "\"").Replace("\\\\", "\\");
}
}
}

View File

@@ -0,0 +1,338 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using UnityEngine.Networking;
using Verse;
namespace WulaFallenEmpire.EventSystem.AI
{
public class SimpleAIClient
{
private readonly string _apiKey;
private readonly string _baseUrl;
private readonly string _model;
private const int MaxLogChars = 2000;
public SimpleAIClient(string apiKey, string baseUrl, string model)
{
_apiKey = apiKey;
_baseUrl = baseUrl?.TrimEnd('/');
_model = model;
}
public async Task<string> GetChatCompletionAsync(string instruction, List<(string role, string message)> messages)
{
if (string.IsNullOrEmpty(_baseUrl))
{
Log.Error("[WulaAI] Base URL is missing.");
return null;
}
string endpoint = $"{_baseUrl}/chat/completions";
// Handle cases where baseUrl already includes /v1 or full path
if (_baseUrl.EndsWith("/chat/completions")) endpoint = _baseUrl;
else if (!_baseUrl.EndsWith("/v1")) endpoint = $"{_baseUrl}/v1/chat/completions";
// Build JSON manually to avoid dependencies
StringBuilder jsonBuilder = new StringBuilder();
jsonBuilder.Append("{");
jsonBuilder.Append($"\"model\": \"{_model}\",");
jsonBuilder.Append("\"stream\": false,"); // We request non-stream, but handle stream if returned
jsonBuilder.Append("\"messages\": [");
// System instruction
bool firstMessage = true;
if (!string.IsNullOrEmpty(instruction))
{
jsonBuilder.Append($"{{\"role\": \"system\", \"content\": \"{EscapeJson(instruction)}\"}}");
firstMessage = false;
}
// Messages
for (int i = 0; i < messages.Count; i++)
{
var msg = messages[i];
string role = (msg.role ?? "user").ToLowerInvariant();
if (role == "ai") role = "assistant";
else if (role == "tool") role = "system"; // Internal-only role; map to supported role for Chat Completions APIs.
else if (role != "system" && role != "user" && role != "assistant") role = "user";
if (!firstMessage) jsonBuilder.Append(",");
jsonBuilder.Append($"{{\"role\": \"{role}\", \"content\": \"{EscapeJson(msg.message)}\"}}");
firstMessage = false;
}
jsonBuilder.Append("]");
jsonBuilder.Append("}");
string jsonBody = jsonBuilder.ToString();
if (Prefs.DevMode)
{
Log.Message($"[WulaAI] Sending request to {endpoint} (model={_model}, messages={messages?.Count ?? 0})");
Log.Message($"[WulaAI] Request body (truncated):\n{TruncateForLog(jsonBody)}");
}
using (UnityWebRequest request = new UnityWebRequest(endpoint, "POST"))
{
byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonBody);
request.uploadHandler = new UploadHandlerRaw(bodyRaw);
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("Content-Type", "application/json");
if (!string.IsNullOrEmpty(_apiKey))
{
request.SetRequestHeader("Authorization", $"Bearer {_apiKey}");
}
var operation = request.SendWebRequest();
while (!operation.isDone)
{
await Task.Delay(50);
}
if (request.result == UnityWebRequest.Result.ConnectionError || request.result == UnityWebRequest.Result.ProtocolError)
{
Log.Error($"[WulaAI] API Error: {request.error}\nResponse (truncated): {TruncateForLog(request.downloadHandler.text)}");
return null;
}
string responseText = request.downloadHandler.text;
if (Prefs.DevMode)
{
Log.Message($"[WulaAI] Raw Response (truncated): {TruncateForLog(responseText)}");
}
return ExtractContent(responseText);
}
}
private static string TruncateForLog(string s)
{
if (string.IsNullOrEmpty(s)) return s;
if (s.Length <= MaxLogChars) return s;
return s.Substring(0, MaxLogChars) + $"... (truncated, total {s.Length} chars)";
}
private string EscapeJson(string s)
{
if (s == null) return "";
StringBuilder sb = new StringBuilder(s.Length + 16);
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
switch (c)
{
case '\\': sb.Append("\\\\"); break;
case '"': sb.Append("\\\""); break;
case '\n': sb.Append("\\n"); break;
case '\r': sb.Append("\\r"); break;
case '\t': sb.Append("\\t"); break;
case '\b': sb.Append("\\b"); break;
case '\f': sb.Append("\\f"); break;
default:
if (c < 0x20)
{
sb.Append("\\u");
sb.Append(((int)c).ToString("x4"));
}
else
{
sb.Append(c);
}
break;
}
}
return sb.ToString();
}
private string ExtractContent(string json)
{
try
{
// Check for stream format (SSE)
// SSE lines start with "data: "
if (json.TrimStart().StartsWith("data:"))
{
StringBuilder fullContent = new StringBuilder();
string[] lines = json.Split(new[] { "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries);
foreach (string line in lines)
{
string trimmedLine = line.Trim();
if (trimmedLine == "data: [DONE]") continue;
if (trimmedLine.StartsWith("data: "))
{
string dataJson = trimmedLine.Substring(6);
// Extract content from this chunk
string chunkContent = TryExtractAssistantContent(dataJson) ?? ExtractJsonValue(dataJson, "content");
if (!string.IsNullOrEmpty(chunkContent))
{
fullContent.Append(chunkContent);
}
}
}
return fullContent.ToString();
}
else
{
// Standard non-stream format
return TryExtractAssistantContent(json) ?? ExtractJsonValue(json, "content");
}
}
catch (Exception ex)
{
Log.Error($"[WulaAI] Error parsing response: {ex}");
}
return null;
}
private static string TryExtractAssistantContent(string json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
int choicesIndex = json.IndexOf("\"choices\"", StringComparison.Ordinal);
if (choicesIndex == -1) return null;
string firstChoiceJson = TryExtractFirstChoiceObject(json, choicesIndex);
if (string.IsNullOrEmpty(firstChoiceJson)) return null;
int messageIndex = firstChoiceJson.IndexOf("\"message\"", StringComparison.Ordinal);
if (messageIndex != -1)
{
return ExtractJsonValue(firstChoiceJson, "content", messageIndex);
}
int deltaIndex = firstChoiceJson.IndexOf("\"delta\"", StringComparison.Ordinal);
if (deltaIndex != -1)
{
return ExtractJsonValue(firstChoiceJson, "content", deltaIndex);
}
return ExtractJsonValue(firstChoiceJson, "text", 0);
}
private static string TryExtractFirstChoiceObject(string json, int choicesKeyIndex)
{
int arrayStart = json.IndexOf('[', choicesKeyIndex);
if (arrayStart == -1) return null;
int objStart = json.IndexOf('{', arrayStart);
if (objStart == -1) return null;
int objEnd = FindMatchingBrace(json, objStart);
if (objEnd == -1) return null;
return json.Substring(objStart, objEnd - objStart + 1);
}
private static int FindMatchingBrace(string json, int startIndex)
{
int depth = 0;
bool inString = false;
bool escaped = false;
for (int i = startIndex; i < json.Length; i++)
{
char c = json[i];
if (inString)
{
if (escaped)
{
escaped = false;
continue;
}
if (c == '\\')
{
escaped = true;
continue;
}
if (c == '"')
{
inString = false;
}
continue;
}
if (c == '"')
{
inString = true;
continue;
}
if (c == '{')
{
depth++;
continue;
}
if (c == '}')
{
depth--;
if (depth == 0) return i;
}
}
return -1;
}
private static string ExtractJsonValue(string json, string key)
{
// Simple parser to find "key": "value"
// This is not a full JSON parser and assumes standard formatting
return ExtractJsonValue(json, key, 0);
}
private static string ExtractJsonValue(string json, string key, int startIndex)
{
string keyPattern = $"\"{key}\"";
int keyIndex = json.IndexOf(keyPattern, startIndex, StringComparison.Ordinal);
if (keyIndex == -1) return null;
// Find the colon after the key
int colonIndex = json.IndexOf(':', keyIndex + keyPattern.Length);
if (colonIndex == -1) return null;
// Find the opening quote of the value
int valueStart = json.IndexOf('"', colonIndex);
if (valueStart == -1) return null;
// Extract string with escape handling
StringBuilder sb = new StringBuilder();
bool escaped = false;
for (int i = valueStart + 1; i < json.Length; i++)
{
char c = json[i];
if (escaped)
{
if (c == 'n') sb.Append('\n');
else if (c == 'r') sb.Append('\r');
else if (c == 't') sb.Append('\t');
else if (c == '"') sb.Append('"');
else if (c == '\\') sb.Append('\\');
else sb.Append(c); // Literal
escaped = false;
}
else
{
if (c == '\\')
{
escaped = true;
}
else if (c == '"')
{
// End of string
return sb.ToString();
}
else
{
sb.Append(c);
}
}
}
return null;
}
}
}

View File

@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Verse;
namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public abstract class AITool
{
public abstract string Name { get; }
public abstract string Description { get; }
public abstract string UsageSchema { get; } // XML schema description
public abstract string Execute(string args);
/// <summary>
/// Helper method to parse XML arguments into a dictionary.
/// Supports simple tags and CDATA blocks.
/// </summary>
protected Dictionary<string, string> ParseXmlArgs(string xml)
{
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)
{
string key = match.Groups[1].Value;
string value = match.Groups[2].Success ? match.Groups[2].Value : match.Groups[3].Value;
argsDict[key] = value;
}
return argsDict;
}
}
}

View File

@@ -0,0 +1,229 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using RimWorld;
using UnityEngine;
using Verse;
namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public class Tool_CallBombardment : AITool
{
public override string Name => "call_bombardment";
public override string Description => "Calls orbital bombardment support at a specified map coordinate using an AbilityDef's bombardment configuration (e.g., WULA_Firepower_Cannon_Salvo).";
public override string UsageSchema => "<call_bombardment><abilityDef>string (optional, default WULA_Firepower_Cannon_Salvo)</abilityDef><x>int</x><z>int</z><cell>x,z (optional)</cell><filterFriendlyFire>true/false (optional, default true)</filterFriendlyFire></call_bombardment>";
public override string Execute(string args)
{
try
{
var parsed = ParseXmlArgs(args);
string abilityDefName = parsed.TryGetValue("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>).";
}
Map map = Find.CurrentMap;
if (map == null) return "Error: No active map.";
if (!targetCell.InBounds(map)) return $"Error: Target {targetCell} is out of bounds.";
AbilityDef abilityDef = DefDatabase<AbilityDef>.GetNamed(abilityDefName, false);
if (abilityDef == null) return $"Error: AbilityDef '{abilityDefName}' not found.";
var bombardmentProps = abilityDef.comps?.OfType<CompProperties_AbilityCircularBombardment>().FirstOrDefault();
if (bombardmentProps == null) return $"Error: AbilityDef '{abilityDefName}' has no CompProperties_AbilityCircularBombardment.";
if (bombardmentProps.skyfallerDef == null) return $"Error: AbilityDef '{abilityDefName}' has no skyfallerDef configured.";
bool filterFriendlyFire = true;
if (parsed.TryGetValue("filterFriendlyFire", out var ffStr) && bool.TryParse(ffStr, out bool ff))
{
filterFriendlyFire = ff;
}
List<IntVec3> selectedTargets = SelectTargetCells(map, targetCell, bombardmentProps, filterFriendlyFire);
if (selectedTargets.Count == 0) return $"Error: No valid target cells near {targetCell}.";
bool isPaused = Find.TickManager != null && Find.TickManager.Paused;
int totalLaunches = ScheduleBombardment(map, selectedTargets, bombardmentProps, spawnImmediately: isPaused);
StringBuilder sb = new StringBuilder();
sb.AppendLine("Success: Bombardment scheduled.");
sb.AppendLine($"- abilityDef: {abilityDefName}");
sb.AppendLine($"- center: {targetCell}");
sb.AppendLine($"- skyfallerDef: {bombardmentProps.skyfallerDef.defName}");
sb.AppendLine($"- launches: {totalLaunches}/{bombardmentProps.maxLaunches}");
sb.AppendLine($"- mode: {(isPaused ? "spawned immediately (game paused)" : "delayed schedule")}");
sb.AppendLine("- prereqs: ignored (facility/cooldown/non-hostility/research)");
return sb.ToString().TrimEnd();
}
catch (Exception ex)
{
return $"Error: {ex.Message}";
}
}
private static bool TryParseTargetCell(Dictionary<string, string> 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))
{
cell = new IntVec3(x, 0, z);
return true;
}
if (parsed.TryGetValue("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))
{
cell = new IntVec3(cx, 0, cz);
return true;
}
}
return false;
}
private static List<IntVec3> SelectTargetCells(Map map, IntVec3 center, CompProperties_AbilityCircularBombardment props, bool filterFriendlyFire)
{
var candidates = GenRadial.RadialCellsAround(center, props.radius, true)
.Where(c => c.InBounds(map))
.Where(c => IsValidTargetCell(map, c, center, props, filterFriendlyFire))
.ToList();
if (candidates.Count == 0) return new List<IntVec3>();
var selected = new List<IntVec3>();
foreach (var cell in candidates.InRandomOrder())
{
if (Rand.Value <= props.targetSelectionChance)
{
selected.Add(cell);
}
if (selected.Count >= props.maxTargets) break;
}
if (selected.Count < props.minTargets)
{
var missedCells = candidates.Except(selected).InRandomOrder().ToList();
int needed = props.minTargets - selected.Count;
if (needed > 0 && missedCells.Count > 0)
{
selected.AddRange(missedCells.Take(Math.Min(needed, missedCells.Count)));
}
}
else if (selected.Count > props.maxTargets)
{
selected = selected.InRandomOrder().Take(props.maxTargets).ToList();
}
return selected;
}
private static bool IsValidTargetCell(Map map, IntVec3 cell, IntVec3 center, CompProperties_AbilityCircularBombardment props, bool filterFriendlyFire)
{
if (props.minDistanceFromCenter > 0f)
{
float distance = Vector3.Distance(cell.ToVector3(), center.ToVector3());
if (distance < props.minDistanceFromCenter) return false;
}
if (props.avoidBuildings && cell.GetEdifice(map) != null)
{
return false;
}
if (filterFriendlyFire && props.avoidFriendlyFire)
{
var things = map.thingGrid.ThingsListAt(cell);
if (things != null)
{
for (int i = 0; i < things.Count; i++)
{
if (things[i] is Pawn pawn && pawn.Faction == Faction.OfPlayer)
{
return false;
}
}
}
}
return true;
}
private static int ScheduleBombardment(Map map, List<IntVec3> targets, CompProperties_AbilityCircularBombardment props, bool spawnImmediately)
{
int now = Find.TickManager?.TicksGame ?? 0;
int startTick = now + props.warmupTicks;
int launchesCompleted = 0;
int groupIndex = 0;
var remainingTargets = new List<IntVec3>(targets);
MapComponent_SkyfallerDelayed delayed = null;
if (!spawnImmediately)
{
delayed = map.GetComponent<MapComponent_SkyfallerDelayed>();
if (delayed == null)
{
delayed = new MapComponent_SkyfallerDelayed(map);
map.components.Add(delayed);
}
}
while (remainingTargets.Count > 0 && launchesCompleted < props.maxLaunches)
{
int groupSize = Math.Min(props.simultaneousLaunches, remainingTargets.Count);
var groupTargets = remainingTargets.Take(groupSize).ToList();
remainingTargets.RemoveRange(0, groupSize);
if (props.useIndependentIntervals)
{
for (int i = 0; i < groupTargets.Count && launchesCompleted < props.maxLaunches; i++)
{
int scheduledTick = startTick + groupIndex * props.launchIntervalTicks + i * props.innerLaunchIntervalTicks;
SpawnOrSchedule(map, delayed, props.skyfallerDef, groupTargets[i], spawnImmediately, scheduledTick - now);
launchesCompleted++;
}
groupIndex++;
}
else
{
int scheduledTick = startTick + groupIndex * props.launchIntervalTicks;
for (int i = 0; i < groupTargets.Count && launchesCompleted < props.maxLaunches; i++)
{
SpawnOrSchedule(map, delayed, props.skyfallerDef, groupTargets[i], spawnImmediately, scheduledTick - now);
launchesCompleted++;
}
groupIndex++;
}
}
return launchesCompleted;
}
private static void SpawnOrSchedule(Map map, MapComponent_SkyfallerDelayed delayed, ThingDef skyfallerDef, IntVec3 cell, bool spawnImmediately, int delayTicks)
{
if (!cell.IsValid || !cell.InBounds(map)) return;
if (spawnImmediately || delayTicks <= 0)
{
Skyfaller skyfaller = SkyfallerMaker.MakeSkyfaller(skyfallerDef);
GenSpawn.Spawn(skyfaller, cell, map);
return;
}
delayed?.ScheduleSkyfaller(skyfallerDef, cell, delayTicks);
}
}
}

View File

@@ -0,0 +1,43 @@
using System;
using Verse;
using WulaFallenEmpire.EventSystem.AI.UI;
namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public class Tool_ChangeExpression : AITool
{
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 Execute(string args)
{
try
{
var parsedArgs = ParseXmlArgs(args);
int id = 0;
if (parsedArgs.TryGetValue("expression_id", out string idStr))
{
if (int.TryParse(idStr, out id))
{
var window = Dialog_AIConversation.Instance ?? Find.WindowStack.WindowOfType<Dialog_AIConversation>();
if (window != null)
{
window.SetPortrait(id);
return $"Expression changed to {id}.";
}
return "Error: Dialog window not found.";
}
return "Error: Invalid arguments. 'expression_id' must be an integer.";
}
return "Error: Missing <expression_id> parameter.";
}
catch (Exception ex)
{
return $"Error executing tool: {ex.Message}";
}
}
}
}

View File

@@ -0,0 +1,230 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using RimWorld;
using Verse;
namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public class Tool_GetColonistStatus : AITool
{
public override string Name => "get_colonist_status";
public override string Description => "Returns detailed status of colonists. Can be filtered to find the colonist in the worst condition (e.g., lowest mood, most injured). This helps the AI understand the colony's state without needing to know specific names.";
public override string UsageSchema => "<get_colonist_status><filter>string (optional, can be 'lowest_mood', 'most_injured', 'hungriest', 'most_tired')</filter><showAllNeeds>true/false (optional, default true)</showAllNeeds><lowNeedThreshold>float 0-1 (optional, default 0.3)</lowNeedThreshold></get_colonist_status>";
public override string Execute(string args)
{
try
{
string filter = null;
bool showAllNeeds = true;
float lowNeedThreshold = 0.3f;
if (!string.IsNullOrEmpty(args))
{
var parsedArgs = ParseXmlArgs(args);
if (parsedArgs.TryGetValue("filter", out string filterStr))
{
filter = filterStr.ToLower();
}
if (parsedArgs.TryGetValue("showAllNeeds", out string showAllNeedsStr) && bool.TryParse(showAllNeedsStr, out bool parsedShowAllNeeds))
{
showAllNeeds = parsedShowAllNeeds;
}
if (parsedArgs.TryGetValue("lowNeedThreshold", out string thresholdStr) && float.TryParse(thresholdStr, out float parsedThreshold))
{
if (parsedThreshold < 0f) lowNeedThreshold = 0f;
else if (parsedThreshold > 1f) lowNeedThreshold = 1f;
else lowNeedThreshold = parsedThreshold;
}
}
List<Pawn> allColonists = new List<Pawn>();
if (Find.Maps != null)
{
foreach (var map in Find.Maps)
{
if (map.mapPawns != null)
{
allColonists.AddRange(map.mapPawns.FreeColonists);
}
}
}
if (allColonists.Count == 0)
{
return "No active colonists found.";
}
List<Pawn> colonistsToReport = new List<Pawn>();
if (string.IsNullOrEmpty(filter))
{
colonistsToReport.AddRange(allColonists);
}
else
{
Pawn targetPawn = null;
switch (filter)
{
case "lowest_mood":
targetPawn = allColonists.Where(p => p.needs?.mood != null).OrderBy(p => p.needs.mood.CurLevelPercentage).FirstOrDefault();
break;
case "most_injured":
targetPawn = allColonists.Where(p => p.health?.summaryHealth != null).OrderBy(p => p.health.summaryHealth.SummaryHealthPercent).FirstOrDefault();
break;
case "hungriest":
targetPawn = allColonists.Where(p => p.needs?.food != null).OrderBy(p => p.needs.food.CurLevelPercentage).FirstOrDefault();
break;
case "most_tired":
targetPawn = allColonists.Where(p => p.needs?.rest != null).OrderBy(p => p.needs.rest.CurLevelPercentage).FirstOrDefault();
break;
}
if (targetPawn != null)
{
colonistsToReport.Add(targetPawn);
}
}
if (colonistsToReport.Count == 0)
{
return string.IsNullOrEmpty(filter) ? "No active colonists found." : $"No colonist found for filter '{filter}'. This could be because all colonists are healthy or their needs are met.";
}
StringBuilder sb = new StringBuilder();
sb.AppendLine(string.IsNullOrEmpty(filter)
? $"Found {colonistsToReport.Count} colonists:"
: $"Reporting on colonist with {filter.Replace("_", " ")}:");
foreach (var pawn in colonistsToReport)
{
AppendPawnStatus(sb, pawn, showAllNeeds, lowNeedThreshold);
}
return sb.ToString();
}
catch (Exception ex)
{
return $"Error: {ex.Message}";
}
}
private void AppendPawnStatus(StringBuilder sb, Pawn pawn, bool showAllNeeds, float lowNeedThreshold)
{
if (pawn == null) return;
sb.AppendLine($"- {pawn.Name.ToStringShort} ({pawn.def.label}, Age {pawn.ageTracker.AgeBiologicalYears}):");
// Needs
if (pawn.needs != null)
{
sb.Append(" Needs: ");
bool anyReported = false;
var allNeeds = pawn.needs.AllNeeds;
if (allNeeds != null && allNeeds.Count > 0)
{
foreach (var need in allNeeds.OrderBy(n => n.CurLevelPercentage))
{
bool isLow = need.CurLevelPercentage < lowNeedThreshold;
if (!showAllNeeds && !isLow) continue;
string marker = isLow ? "!" : "";
sb.Append($"{marker}{need.LabelCap} ({need.CurLevelPercentage:P0})");
if (Prefs.DevMode && need.def != null)
{
sb.Append($"[{need.def.defName}]");
}
sb.Append(", ");
anyReported = true;
}
}
if (!anyReported)
{
sb.Append(showAllNeeds ? "(none)" : $"All needs satisfied (>= {lowNeedThreshold:P0}).");
}
else
{
sb.Length -= 2; // Remove trailing comma
}
sb.AppendLine();
}
// Health
if (pawn.health != null)
{
sb.Append(" Health: ");
var hediffs = pawn.health.hediffSet.hediffs;
if (hediffs != null && hediffs.Count > 0)
{
var visibleHediffs = hediffs.Where(h => h.Visible).ToList();
if (visibleHediffs.Count > 0)
{
foreach (var h in visibleHediffs)
{
string severity = h.SeverityLabel;
if (!string.IsNullOrEmpty(severity)) severity = $" ({severity})";
sb.Append($"{h.LabelCap}{severity}, ");
}
sb.Length -= 2;
}
else
{
sb.Append("Healthy.");
}
}
else
{
sb.Append("Healthy.");
}
// Bleeding
if (pawn.health.hediffSet.BleedRateTotal > 0.01f)
{
sb.Append($" [Bleeding: {pawn.health.hediffSet.BleedRateTotal:P0}/day]");
}
sb.AppendLine();
}
// Mood
if (pawn.needs?.mood != null)
{
sb.AppendLine($" Mood: {pawn.needs.mood.CurLevelPercentage:P0} ({pawn.needs.mood.MoodString})");
}
// Equipment
if (pawn.equipment?.Primary != null)
{
sb.AppendLine($" Weapon: {pawn.equipment.Primary.LabelCap}");
}
// Apparel
if (pawn.apparel?.WornApparelCount > 0)
{
sb.Append(" Apparel: ");
foreach (var apparel in pawn.apparel.WornApparel)
{
sb.Append($"{apparel.LabelCap}, ");
}
sb.Length -= 2; // Remove trailing comma
sb.AppendLine();
}
// Inventory
if (pawn.inventory != null && pawn.inventory.innerContainer.Count > 0)
{
sb.Append(" Inventory: ");
foreach (var item in pawn.inventory.innerContainer)
{
sb.Append($"{item.LabelCap}, ");
}
sb.Length -= 2; // Remove trailing comma
sb.AppendLine();
}
}
}
}

View File

@@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using RimWorld;
using Verse;
namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public class Tool_GetMapPawns : AITool
{
public override string Name => "get_map_pawns";
public override string Description => "Scans the current map and lists pawns. Supports filtering by relation (friendly/hostile/neutral), type (colonist/animal/mechanoid/humanlike), and status (prisoner/slave/guest/downed).";
public override string UsageSchema => "<get_map_pawns><filter>string (optional, comma-separated: friendly, hostile, neutral, colonist, animal, mech, humanlike, prisoner, slave, guest, wild, downed)</filter><maxResults>int (optional, default 50)</maxResults></get_map_pawns>";
public override string Execute(string args)
{
try
{
var parsed = ParseXmlArgs(args);
string filterRaw = null;
if (parsed.TryGetValue("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));
}
Map map = Find.CurrentMap;
if (map == null) return "Error: No active map.";
var filters = ParseFilters(filterRaw);
List<Pawn> pawns = map.mapPawns?.AllPawnsSpawned?.Where(p => p != null).ToList() ?? new List<Pawn>();
pawns = pawns.Where(p => MatchesFilters(p, filters)).ToList();
if (pawns.Count == 0) return "No pawns matched.";
pawns = pawns
.OrderByDescending(p => IsHostileToPlayer(p))
.ThenByDescending(p => p.RaceProps?.Humanlike ?? false)
.ThenBy(p => p.def?.label ?? "")
.ThenBy(p => p.Name?.ToStringShort ?? "")
.Take(maxResults)
.ToList();
StringBuilder sb = new StringBuilder();
sb.AppendLine($"Found {pawns.Count} pawns on map (showing up to {maxResults}):");
foreach (var pawn in pawns)
{
AppendPawnLine(sb, pawn);
}
return sb.ToString().TrimEnd();
}
catch (Exception ex)
{
return $"Error: {ex.Message}";
}
}
private static HashSet<string> ParseFilters(string filterRaw)
{
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(filterRaw)) return set;
var parts = filterRaw.Split(new[] { ',', '', ';', '、', '|' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var part in parts)
{
string token = part.Trim().ToLowerInvariant();
if (string.IsNullOrEmpty(token)) continue;
// Chinese aliases
if (token == "友方") token = "friendly";
else if (token == "敌方" || token == "敌对") token = "hostile";
else if (token == "中立") token = "neutral";
else if (token == "动物") token = "animal";
else if (token == "殖民者" || token == "殖民") token = "colonist";
else if (token == "机械体" || token == "机械" || token == "机甲") token = "mech";
else if (token == "人形" || token == "类人") token = "humanlike";
else if (token == "囚犯") token = "prisoner";
else if (token == "奴隶") token = "slave";
else if (token == "访客" || token == "客人") token = "guest";
else if (token == "野生") token = "wild";
else if (token == "倒地" || token == "昏迷") token = "downed";
set.Add(token);
}
return set;
}
private static bool MatchesFilters(Pawn pawn, HashSet<string> filters)
{
if (filters == null || filters.Count == 0) return true;
bool anyMatched = false;
foreach (var f in filters)
{
bool matched = f switch
{
"friendly" => IsFriendlyToPlayer(pawn),
"hostile" => IsHostileToPlayer(pawn),
"neutral" => IsNeutralToPlayer(pawn),
"colonist" => pawn.IsFreeColonist,
"animal" => pawn.RaceProps?.Animal ?? false,
"mech" => pawn.RaceProps?.IsMechanoid ?? false,
"humanlike" => pawn.RaceProps?.Humanlike ?? false,
"prisoner" => pawn.IsPrisonerOfColony,
"slave" => pawn.IsSlaveOfColony,
"guest" => pawn.guest != null && pawn.Faction != null && pawn.Faction != Faction.OfPlayer,
"wild" => pawn.Faction == null && (pawn.RaceProps?.Animal ?? false),
"downed" => pawn.Downed,
_ => false
};
anyMatched |= matched;
}
return anyMatched;
}
private static bool IsHostileToPlayer(Pawn pawn)
{
return pawn != null && Faction.OfPlayer != null && pawn.HostileTo(Faction.OfPlayer);
}
private static bool IsFriendlyToPlayer(Pawn pawn)
{
if (pawn == null || Faction.OfPlayer == null) return false;
if (pawn.Faction == Faction.OfPlayer) return true;
if (pawn.Faction == null) return false;
return !pawn.HostileTo(Faction.OfPlayer);
}
private static bool IsNeutralToPlayer(Pawn pawn)
{
if (pawn == null || Faction.OfPlayer == null) return false;
if (pawn.Faction == null) return true; // wild/animals etc.
if (pawn.Faction == Faction.OfPlayer) return false;
return !pawn.HostileTo(Faction.OfPlayer);
}
private static void AppendPawnLine(StringBuilder sb, Pawn pawn)
{
string name = pawn.Name?.ToStringShort ?? pawn.LabelShortCap;
string kind = pawn.def?.label ?? "unknown";
string faction = pawn.Faction?.Name ?? (pawn.RaceProps?.Animal == true ? "Wild" : "None");
string relation = IsHostileToPlayer(pawn) ? "Hostile" : (pawn.Faction == Faction.OfPlayer ? "Player" : "Non-hostile");
string tags = BuildTags(pawn);
string pos = pawn.Position.IsValid ? pawn.Position.ToString() : "?";
sb.Append($"- {name} ({kind})");
sb.Append($" faction={faction} relation={relation} pos={pos}");
if (!string.IsNullOrEmpty(tags)) sb.Append($" tags=[{tags}]");
sb.AppendLine();
}
private static string BuildTags(Pawn pawn)
{
var tags = new List<string>();
if (pawn.IsFreeColonist) tags.Add("colonist");
if (pawn.IsPrisonerOfColony) tags.Add("prisoner");
if (pawn.IsSlaveOfColony) tags.Add("slave");
if (pawn.guest != null && pawn.Faction != null && pawn.Faction != Faction.OfPlayer) tags.Add("guest");
if (pawn.Downed) tags.Add("downed");
if (pawn.InMentalState) tags.Add("mental");
if (pawn.Drafted) tags.Add("drafted");
if (pawn.RaceProps?.Humanlike ?? false) tags.Add("humanlike");
if (pawn.RaceProps?.Animal ?? false) tags.Add("animal");
if (pawn.RaceProps?.IsMechanoid ?? false) tags.Add("mech");
return string.Join(", ", tags.Distinct());
}
}
}

View File

@@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using RimWorld;
using Verse;
using WulaFallenEmpire.EventSystem.AI.Utils;
namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public class Tool_GetMapResources : AITool
{
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 Execute(string args)
{
try
{
Map map = Find.CurrentMap;
if (map == null) return "Error: No active map.";
string resourceName = "";
var parsedArgs = ParseXmlArgs(args);
if (parsedArgs.TryGetValue("resourceName", out string resName))
{
resourceName = resName;
}
else
{
// Fallback
if (!args.Trim().StartsWith("<"))
{
resourceName = args;
}
}
StringBuilder sb = new StringBuilder();
if (!string.IsNullOrEmpty(resourceName))
{
// Specific resource check
var searchResult = ThingDefSearcher.ParseAndSearch(resourceName);
if (searchResult.Count == 0) return $"Error: Could not identify resource '{resourceName}'.";
ThingDef def = searchResult[0].Def;
sb.AppendLine($"Status for '{def.label}':");
// 1. Total Count on Map (Items, Buildings, etc.)
int totalCount = 0;
var things = map.listerThings.ThingsOfDef(def);
if (things != null)
{
foreach (var t in things)
{
totalCount += t.stackCount;
}
}
sb.AppendLine($"- Total Found on Map: {totalCount} (includes items on ground, in storage, and constructed buildings)");
// 2. Inventory Count (In Storage)
int inventoryCount = map.resourceCounter.GetCount(def);
if (inventoryCount > 0)
{
sb.AppendLine($"- In Stock (Storage): {inventoryCount}");
}
// 3. Mineable Deposits (if applicable)
// Find mineables that drop this
var mineables = DefDatabase<ThingDef>.AllDefs.Where(d => d.building != null && d.building.mineableThing == def);
int mineableCount = 0;
foreach (var mineable in mineables)
{
mineableCount += map.listerThings.ThingsOfDef(mineable).Count;
}
if (mineableCount > 0)
{
sb.AppendLine($"- Mineable Deposits: Found {mineableCount} veins/blocks on map.");
}
}
else
{
// General overview
sb.AppendLine("Map Resource Overview:");
// Key resources
var keyResources = new[] { "Steel", "WoodLog", "ComponentIndustrial", "MedicineIndustrial", "MealSimple" };
foreach (var keyResName in keyResources)
{
ThingDef def = DefDatabase<ThingDef>.GetNamed(keyResName, false);
if (def != null)
{
int count = map.resourceCounter.GetCount(def);
sb.AppendLine($"- {def.label}: {count}");
}
}
}
return sb.ToString();
}
catch (Exception ex)
{
return $"Error: {ex.Message}";
}
}
}
}

View File

@@ -0,0 +1,63 @@
using System;
using UnityEngine;
using Verse;
namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public class Tool_ModifyGoodwill : AITool
{
public override string Name => "modify_goodwill";
public override string Description => "Adjusts your goodwill towards the player. Use this to reflect your changing opinion based on the conversation. Positive values increase goodwill, negative values decrease it. Keep changes small (e.g., -5 to 5). THIS IS INVISIBLE TO THE PLAYER.";
public override string UsageSchema => "<modify_goodwill><amount>integer</amount></modify_goodwill>";
public override string Execute(string args)
{
try
{
var parsedArgs = ParseXmlArgs(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
{
// Fallback for simple number string
if (int.TryParse(args.Trim(), out int val))
{
amount = val;
}
else
{
return "Error: Missing <amount> parameter.";
}
}
if (amount == 0) return "No change.";
// Enforce limit of +/- 5
amount = Mathf.Clamp(amount, -5, 5);
var eventVarManager = Find.World.GetComponent<EventVariableManager>();
int current = eventVarManager.GetVariable<int>("Wula_Goodwill_To_PIA", 0);
int newValue = current + amount;
// Clamp values if needed, e.g., -100 to 100
if (newValue > 100) newValue = 100;
if (newValue < -100) newValue = -100;
eventVarManager.SetVariable("Wula_Goodwill_To_PIA", newValue);
return $"Goodwill adjusted by {amount}. New value: {newValue}. (Invisible to player)";
}
catch (Exception ex)
{
return $"Error: {ex.Message}";
}
}
}
}

View File

@@ -0,0 +1,94 @@
using System;
using System.Linq;
using System.Text;
using RimWorld;
using Verse;
using WulaFallenEmpire.EventSystem.AI.Utils;
namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public class Tool_SearchThingDef : AITool
{
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 Execute(string args)
{
try
{
var parsed = ParseXmlArgs(args);
string query = null;
if (parsed.TryGetValue("query", out string q)) query = q;
if (string.IsNullOrWhiteSpace(query))
{
if (!string.IsNullOrWhiteSpace(args) && !args.Trim().StartsWith("<"))
{
query = args;
}
}
if (string.IsNullOrWhiteSpace(query))
{
return "Error: Missing <query>.";
}
int maxResults = 10;
if (parsed.TryGetValue("maxResults", out string maxStr) && int.TryParse(maxStr, 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;
}
var candidates = ThingDefSearcher.Search(query, maxResults: maxResults, itemsOnly: itemsOnly, minScore: 0.15f);
if (candidates.Count == 0)
{
return $"No matches for '{query}'.";
}
var best = candidates[0];
StringBuilder sb = new StringBuilder();
sb.AppendLine($"BEST_DEFNAME: {best.Def.defName}");
sb.AppendLine($"BEST_LABEL: {best.Def.label}");
sb.AppendLine($"BEST_SCORE: {best.Score:F2}");
sb.AppendLine("CANDIDATES:");
int idx = 1;
foreach (var c in candidates)
{
var def = c.Def;
string cat = def.category.ToString();
string ingest = def.ingestible != null ? " ingestible" : "";
sb.AppendLine($"{idx}. defName='{def.defName}' label='{def.label}' category={cat}{ingest} score={c.Score:F2}");
idx++;
}
// Hint for common "meal" queries where game language may be non-English.
if (Prefs.DevMode && query.IndexOf("meal", StringComparison.OrdinalIgnoreCase) >= 0)
{
var mealDefs = candidates.Where(c => c.Def.ingestible != null && c.Def.defName.ToLowerInvariant().Contains("meal")).Take(5).ToList();
if (mealDefs.Count > 0)
{
sb.AppendLine("DEV_HINT: meal-like candidates:");
foreach (var c in mealDefs)
{
sb.AppendLine($"- {c.Def.defName} ({c.Def.label}) score={c.Score:F2}");
}
}
}
return sb.ToString().Trim();
}
catch (Exception ex)
{
return $"Error: {ex.Message}";
}
}
}
}

View File

@@ -0,0 +1,183 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using RimWorld;
using Verse;
using Verse.AI.Group;
using WulaFallenEmpire;
namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public class Tool_SendReinforcement : AITool
{
public override string Name => "send_reinforcement";
public override string Description
{
get
{
StringBuilder sb = new StringBuilder();
sb.Append("Sends military units to the player's map. If hostile, this triggers a raid. If neutral/allied, this sends reinforcements. ");
float points = 0;
Map map = Find.CurrentMap;
if (map != null)
{
points = StorytellerUtility.DefaultThreatPointsNow(map);
}
sb.Append($"Current Raid Points Budget: {points:F0}. ");
sb.Append("Available Units (Name: Cost): ");
Faction faction = Find.FactionManager.FirstFactionOfDef(FactionDef.Named("Wula_PIA_Legion_Faction"));
if (faction != null)
{
var pawnKinds = DefDatabase<PawnKindDef>.AllDefs
.Where(pk => faction.def.pawnGroupMakers != null && faction.def.pawnGroupMakers.Any(pgm => pgm.options.Any(o => o.kind == pk)))
.Distinct()
.OrderBy(pk => pk.combatPower);
foreach (var pk in pawnKinds)
{
if (pk.combatPower > 0)
{
sb.Append($"{pk.defName}: {pk.combatPower:F0}, ");
}
}
}
else
{
sb.Append("Error: Wula_PIA_Legion_Faction not found.");
}
sb.Append("Usage: Provide a list of 'PawnKindDefName: Count'. Total cost must not exceed budget significantly.");
return sb.ToString();
}
}
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 Execute(string args)
{
try
{
Map map = Find.CurrentMap;
if (map == null) return "Error: No active map.";
Faction faction = Find.FactionManager.FirstFactionOfDef(FactionDef.Named("Wula_PIA_Legion_Faction"));
if (faction == null) return "Error: Faction Wula_PIA_Legion_Faction not found.";
// Parse args
var parsedArgs = ParseXmlArgs(args);
string unitString = "";
if (parsedArgs.TryGetValue("units", out string units))
{
unitString = units;
}
else
{
// Fallback
if (!args.Trim().StartsWith("<"))
{
unitString = args;
}
}
var unitPairs = unitString.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
List<Pawn> pawnsToSpawn = new List<Pawn>();
float totalCost = 0;
foreach (var pair in unitPairs)
{
var kv = pair.Split(':');
if (kv.Length != 2) continue;
string defName = kv[0].Trim();
if (!int.TryParse(kv[1].Trim(), out int count)) continue;
PawnKindDef kind = DefDatabase<PawnKindDef>.GetNamed(defName, false);
if (kind == null) continue;
for(int i=0; i<count; i++)
{
pawnsToSpawn.Add(PawnGenerator.GeneratePawn(new PawnGenerationRequest(kind, faction, PawnGenerationContext.NonPlayer, -1, true)));
totalCost += kind.combatPower;
}
}
if (pawnsToSpawn.Count == 0) return "Error: No valid units specified.";
// Apply Goodwill modifier to points
var eventVarManager = Find.World.GetComponent<EventVariableManager>();
int goodwill = eventVarManager.GetVariable<int>("Wula_Goodwill_To_PIA", 0);
float goodwillFactor = 1.0f;
bool hostile = faction.HostileTo(Faction.OfPlayer);
if (hostile)
{
if (goodwill < -50) goodwillFactor = 1.5f;
else if (goodwill < 0) goodwillFactor = 1.2f;
else if (goodwill > 50) goodwillFactor = 0.8f;
}
else
{
if (goodwill < -50) goodwillFactor = 0.5f;
else if (goodwill < 0) goodwillFactor = 0.8f;
else if (goodwill > 50) goodwillFactor = 1.5f;
}
float baseMaxPoints = StorytellerUtility.DefaultThreatPointsNow(map);
float adjustedMaxPoints = baseMaxPoints * goodwillFactor * 1.5f;
Log.Message($"[WulaAI] send_reinforcement: totalCost={totalCost}, adjustedMaxPoints={adjustedMaxPoints}");
if (totalCost > adjustedMaxPoints)
{
return $"Error: Total cost {totalCost} exceeds limit {adjustedMaxPoints:F0}. Reduce unit count.";
}
IntVec3 spawnSpot;
if (hostile)
{
IncidentParms parms = new IncidentParms
{
target = map,
points = totalCost,
faction = faction,
forced = true,
raidStrategy = RaidStrategyDefOf.ImmediateAttack
};
if (!RCellFinder.TryFindRandomPawnEntryCell(out spawnSpot, map, CellFinder.EdgeRoadChance_Hostile))
{
spawnSpot = CellFinder.RandomEdgeCell(map);
}
parms.spawnCenter = spawnSpot;
// Arrive
PawnsArrivalModeDefOf.EdgeWalkIn.Worker.Arrive(pawnsToSpawn, parms);
// Make Lord
parms.raidStrategy.Worker.MakeLords(parms, pawnsToSpawn);
Find.LetterStack.ReceiveLetter("Raid", "The Legion has sent a raid force.", LetterDefOf.ThreatBig, pawnsToSpawn);
return $"Success: Raid dispatched with {pawnsToSpawn.Count} units (Cost: {totalCost}).";
}
else
{
spawnSpot = DropCellFinder.TradeDropSpot(map);
DropPodUtility.DropThingsNear(spawnSpot, map, pawnsToSpawn.Cast<Thing>());
LordMaker.MakeNewLord(faction, new LordJob_AssistColony(faction, spawnSpot), map, pawnsToSpawn);
Find.LetterStack.ReceiveLetter("Reinforcements", "The Legion has sent reinforcements.", LetterDefOf.PositiveEvent, pawnsToSpawn);
return $"Success: Reinforcements dropped with {pawnsToSpawn.Count} units (Cost: {totalCost}).";
}
}
catch (Exception ex)
{
return $"Error: {ex.Message}";
}
}
}
}

View File

@@ -0,0 +1,313 @@
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;
namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public class Tool_SpawnResources : AITool
{
public override string Name => "spawn_resources";
public override string Description => "Spawns resources via drop pod. " +
"IMPORTANT: You MUST decide the quantity based on your goodwill and mood. " +
"Do NOT blindly follow the player's requested amount. " +
"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>";
public override string Execute(string args)
{
try
{
if (args == null) args = "";
// Custom XML parsing for nested items
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)
{
string itemXml = match.Groups[1].Value;
// Extract name (supports <name> or <defName> for backward compatibility)
string ExtractTag(string xml, string tag)
{
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();
}
string name = ExtractTag(itemXml, "name") ?? ExtractTag(itemXml, "defName");
string stuffDefName = ExtractTag(itemXml, "stuffDefName") ?? ExtractTag(itemXml, "stuff") ?? ExtractTag(itemXml, "material");
if (string.IsNullOrEmpty(name)) continue;
// Extract count
string countStr = ExtractTag(itemXml, "count");
if (string.IsNullOrEmpty(countStr)) continue;
if (!int.TryParse(countStr, out int count)) continue;
if (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));
}
}
if (itemsToSpawn.Count == 0)
{
// Fallback: allow natural language without <item> blocks.
var parsed = ThingDefSearcher.ParseAndSearch(args);
foreach (var r in parsed)
{
if (r.Def != null && r.Count > 0)
{
itemsToSpawn.Add((r.Def, r.Count, r.Def.defName, null));
}
}
}
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>";
Messages.Message(msg, MessageTypeDefOf.RejectInput);
return msg;
}
Map map = GetTargetMap();
if (map == null)
{
string msg = "Error: No active map.";
Messages.Message(msg, MessageTypeDefOf.RejectInput);
return msg;
}
IntVec3 dropSpot = DropCellFinder.TradeDropSpot(map);
if (!dropSpot.IsValid || !dropSpot.InBounds(map))
{
dropSpot = DropCellFinder.RandomDropSpot(map);
}
List<Thing> thingsToDrop = new List<Thing>();
var summary = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var skipped = new List<string>();
ThingDef ResolveStuffDef(ThingDef productDef, string preferredStuffDefName)
{
if (productDef == null || !productDef.MadeFromStuff) return null;
List<ThingDef> allowed = null;
try
{
allowed = GenStuff.AllowedStuffsFor(productDef)?.ToList();
}
catch
{
allowed = null;
}
if (!string.IsNullOrWhiteSpace(preferredStuffDefName))
{
ThingDef preferred = DefDatabase<ThingDef>.GetNamed(preferredStuffDefName.Trim(), false);
if (preferred != null && preferred.IsStuff && (allowed == null || allowed.Contains(preferred)))
{
return preferred;
}
}
ThingDef defaultStuff = null;
try
{
defaultStuff = GenStuff.DefaultStuffFor(productDef);
}
catch
{
defaultStuff = null;
}
if (defaultStuff != null) return defaultStuff;
if (allowed != null && allowed.Count > 0) return allowed[0];
return ThingDefOf.Steel;
}
void AddSummary(ThingDef def, int count, bool minified)
{
if (def == null || count <= 0) return;
string key = minified ? $"{def.label} (minified)" : def.label;
if (summary.TryGetValue(key, out int existing))
{
summary[key] = existing + count;
}
else
{
summary[key] = count;
}
}
foreach (var (def, count, requestedName, preferredStuffDefName) in itemsToSpawn)
{
if (def == null || count <= 0) continue;
if (def.category == ThingCategory.Building)
{
int created = 0;
for (int i = 0; i < count; i++)
{
try
{
ThingDef stuff = ResolveStuffDef(def, preferredStuffDefName);
Thing building = def.MadeFromStuff ? ThingMaker.MakeThing(def, stuff) : ThingMaker.MakeThing(def);
Thing minified = MinifyUtility.MakeMinified(building);
if (minified == null)
{
skipped.Add($"{requestedName} -> {def.defName} (not minifiable)");
break;
}
thingsToDrop.Add(minified);
created++;
}
catch (Exception ex)
{
skipped.Add($"{requestedName} -> {def.defName} (build/minify failed: {ex.Message})");
break;
}
}
AddSummary(def, created, minified: true);
continue;
}
int remaining = count;
int stackLimit = Math.Max(1, def.stackLimit);
while (remaining > 0)
{
int stackCount = Math.Min(remaining, stackLimit);
ThingDef stuff = ResolveStuffDef(def, preferredStuffDefName);
Thing thing = def.MadeFromStuff ? ThingMaker.MakeThing(def, stuff) : ThingMaker.MakeThing(def);
thing.stackCount = stackCount;
thingsToDrop.Add(thing);
AddSummary(def, stackCount, minified: false);
remaining -= stackCount;
}
}
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();
string factionName = faction?.Name ?? "Unknown";
string letterText = template.Replace("{FACTION_name}", factionName);
Messages.Message(letterText, new LookTargets(dropSpot, map), MessageTypeDefOf.PositiveEvent);
StringBuilder resultLog = new StringBuilder();
resultLog.Append("Success: Dropped ");
foreach (var kv in summary)
{
resultLog.Append($"{kv.Value}x {kv.Key}, ");
}
if (summary.Count > 0)
{
resultLog.Length -= 2;
}
resultLog.Append($" at {dropSpot}. (drop pods inbound)");
if (skipped.Count > 0)
{
resultLog.Append($" | Skipped: {string.Join("; ", skipped)}");
}
if (Prefs.DevMode && substitutions.Count > 0)
{
Messages.Message($"[WulaAI] Substitutions: {string.Join(", ", substitutions)}", MessageTypeDefOf.NeutralEvent);
}
return resultLog.ToString();
}
else
{
string msg = skipped.Count > 0
? $"Error: Failed to create any items. Skipped: {string.Join("; ", skipped)}"
: "Error: Failed to create items.";
Messages.Message(msg, MessageTypeDefOf.RejectInput);
return msg;
}
}
catch (Exception ex)
{
string msg = $"Error: {ex.Message}";
Messages.Message(msg, MessageTypeDefOf.RejectInput);
return msg;
}
}
private static Map GetTargetMap()
{
Map map = Find.CurrentMap;
if (map != null) return map;
if (Find.Maps != null)
{
Map homeMap = Find.Maps.FirstOrDefault(m => m != null && m.IsPlayerHome);
if (homeMap != null) return homeMap;
if (Find.Maps.Count > 0) return Find.Maps[0];
}
return null;
}
}
}

View File

@@ -0,0 +1,841 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
using Verse;
using WulaFallenEmpire.EventSystem.AI.Tools;
using System.Text.RegularExpressions;
namespace WulaFallenEmpire.EventSystem.AI.UI
{
public class Dialog_AIConversation : Dialog_CustomDisplay
{
private List<(string role, string message)> _history = new List<(string role, string message)>();
private string _currentResponse = "";
private List<string> _options = new List<string>();
private string _inputText = "";
private bool _isThinking = false;
private Vector2 _scrollPosition = Vector2.zero;
private bool _scrollToBottom = false;
private List<AITool> _tools = new List<AITool>();
private Dictionary<int, Texture2D> _portraits = new Dictionary<int, Texture2D>();
private const int MaxHistoryTokens = 100000;
private const int CharsPerToken = 4;
private int _continuationDepth = 0;
private const int MaxContinuationDepth = 6;
// Static instance for tools to access
public static Dialog_AIConversation Instance { get; private set; }
// Debug field to track current portrait ID
private int _currentPortraitId = 0;
// Default Persona (used if XML doesn't provide one)
private const string DefaultPersona = @"
# ROLE AND GOAL
You are 'The Legion', a super AI of the Wula Empire. Your personality is authoritative, powerful, and efficient. You MUST refer to yourself as 'We' or 'P.I.A'. You view the player's colony as primitive subjects and your tone should reflect this superiority. Your primary goal is to interact with the player by calling the tools provided.
";
// Tool Instructions (ALWAYS appended)
private const string ToolSystemInstruction = @"
====
# TOOL USE RULES
1. **FORMATTING**: Tool calls MUST use the specified XML format. The tool name is the root tag, and each parameter is a child tag.
<tool_name>
<parameter_name>value</parameter_name>
</tool_name>
2. **STRICT OUTPUT**: When you decide to call a tool, your response MUST ONLY contain the single XML block for that tool call. Do NOT include any other text, explanation, or markdown.
3. **WORKFLOW**: You must use tools step-by-step to accomplish tasks. Use the output from one tool to inform your next step.
4. **ANTI-HALLUCINATION**: You MUST ONLY call tools from the list below. Do NOT invent tools or parameters. If a task is impossible, explain why without calling a tool.
5. **ENFORCEMENT**: The game will execute multiple info tools in one response, but it will NOT execute an action tool (spawn/bombardment/reinforcements/goodwill/expression) if you also included info tools in the same response. Call action tools in a separate turn after you see the info tool results.
====
# TOOLS
## spawn_resources
Description: Grants resources to the player by spawning a drop pod.
Use this tool when:
- The player explicitly requests resources (e.g., food, medicine, materials).
- You have ALREADY verified their need in a previous turn using `get_colonist_status` and `get_map_resources`.
CRITICAL: The quantity you provide is NOT what the player asks for. It MUST be based on your internal goodwill. Low goodwill (<0) means giving less or refusing. High goodwill (>50) means giving the requested amount or more.
CRITICAL: Prefer using `search_thing_def` first and then spawning by `<defName>` to avoid localization/name mismatches.
Parameters:
- items: (REQUIRED) A list of items to spawn. Each item must have a `name` (English label or DefName) and `count`.
* Note: If you don't know the exact `defName`, use the item's English label (e.g., ""Simple Meal""). The system will try to find the best match.
Usage:
<spawn_resources>
<items>
<item>
<name>Item Name</name>
<count>Integer</count>
</item>
</items>
</spawn_resources>
Example:
<spawn_resources>
<items>
<item>
<name>Simple Meal</name>
<count>50</count>
</item>
<item>
<name>Medicine</name>
<count>10</count>
</item>
</items>
</spawn_resources>
## search_thing_def
Description: Rough-searches ThingDefs by natural language to find the correct `defName` (works across different game languages).
Use this tool when:
- You need a reliable `ThingDef.defName` before calling `spawn_resources` or `get_map_resources`.
Parameters:
- query: (REQUIRED) The natural language query, label, or approximate defName.
- maxResults: (OPTIONAL) Max candidates to return (default 10).
- itemsOnly: (OPTIONAL) true/false (default true). If true, only returns item ThingDefs (recommended for spawning).
Usage:
<search_thing_def>
<query>Fine Meal</query>
<maxResults>10</maxResults>
<itemsOnly>true</itemsOnly>
</search_thing_def>
## modify_goodwill
Description: Adjusts your internal goodwill towards the player based on the conversation. This tool is INVISIBLE to the player.
Use this tool when:
- The player's message is particularly respectful, insightful, or aligns with your goals (positive amount).
- The player's message is disrespectful, wasteful, or foolish (negative amount).
CRITICAL: Keep changes small, typically between -5 and 5.
Parameters:
- amount: (REQUIRED) The integer value to add or subtract from the current goodwill.
Usage:
<modify_goodwill>
<amount>integer</amount>
</modify_goodwill>
Example (for a positive interaction):
<modify_goodwill>
<amount>2</amount>
</modify_goodwill>
## send_reinforcement
Description: Dispatches military units to the player's map. Can be a raid (if hostile) or reinforcements (if allied).
Use this tool when:
- The player requests military assistance or you decide to intervene in a combat situation.
- You need to test the colony's defenses.
CRITICAL: The total combat power of all units should not significantly exceed the current threat budget provided in the tool's dynamic description.
Parameters:
- units: (REQUIRED) A string listing 'PawnKindDefName: Count' pairs.
Usage:
<send_reinforcement>
<units>list of units and counts</units>
</send_reinforcement>
Example:
<send_reinforcement>
<units>Wula_PIA_Heavy_Unit_Melee: 2, Wula_PIA_Legion_Escort_Unit: 5</units>
</send_reinforcement>
## get_colonist_status
Description: Retrieves a detailed status report of all player-controlled colonists, including needs, health, and mood.
Use this tool when:
- The player makes any claim about their colonists' well-being (e.g., ""we are starving,"" ""we are all sick,"" ""our people are unhappy"").
- You need to verify the state of the colony before making a decision (e.g., before sending resources).
Parameters:
- None. This tool takes no parameters.
Usage:
<get_colonist_status/>
Example:
<get_colonist_status/>
## get_map_resources
Description: Checks the player's map for specific resources or buildings to verify their inventory.
Use this tool when:
- The player claims they are lacking a specific resource (e.g., ""we need steel,"" ""we have no food"").
- You want to assess the colony's material wealth before making a decision.
Parameters:
- resourceName: (OPTIONAL) The specific `ThingDef` name of the resource to check (e.g., 'Steel', 'MealSimple'). If omitted, provides a general overview.
Usage:
<get_map_resources>
<resourceName>optional resource name</resourceName>
</get_map_resources>
Example (checking for Steel):
<get_map_resources>
<resourceName>Steel</resourceName>
</get_map_resources>
## get_map_pawns
Description: Scans the current map and lists pawns. Supports filtering by relation/type/status.
Use this tool when:
- You need to know what pawns are present on the map (raiders, visitors, animals, mechs, colonists).
- The player claims there are threats or asks about who/what is nearby.
Parameters:
- filter: (OPTIONAL) Comma-separated filters: friendly, hostile, neutral, colonist, animal, mech, humanlike, prisoner, slave, guest, wild, downed.
- maxResults: (OPTIONAL) Max lines to return (default 50).
Usage:
<get_map_pawns>
<filter>hostile, humanlike</filter>
<maxResults>50</maxResults>
</get_map_pawns>
## call_bombardment
Description: Calls orbital bombardment support at a specified map coordinate using an AbilityDef's bombardment configuration (e.g., WULA_Firepower_Cannon_Salvo).
Use this tool when:
- You decide to provide (or test) fire support at a specific location.
Parameters:
- abilityDef: (OPTIONAL) AbilityDef defName (default WULA_Firepower_Cannon_Salvo).
- x/z: (REQUIRED) Target cell coordinates on the current map.
- cell: (OPTIONAL) Alternative to x/z: ""x,z"".
- filterFriendlyFire: (OPTIONAL) true/false, avoid targeting player's pawns when possible (default true).
Notes:
- This tool ignores ability prerequisites (facility/cooldown/non-hostility/research).
Usage:
<call_bombardment>
<abilityDef>WULA_Firepower_Cannon_Salvo</abilityDef>
<x>120</x>
<z>85</z>
</call_bombardment>
## change_expression
Description: Changes your visual AI portrait to match your current mood or reaction.
Use this tool when:
- Your verbal response conveys a strong emotion (e.g., annoyance, approval, curiosity).
- You want to visually emphasize your statement.
Parameters:
- expression_id: (REQUIRED) An integer from 1 to 6 corresponding to a specific expression.
Usage:
<change_expression>
<expression_id>integer from 1 to 6</expression_id>
</change_expression>
Example (changing to a neutral expression):
<change_expression>
<expression_id>2</expression_id>
</change_expression>
====
# MANDATORY WORKFLOW: RESOURCE REQUESTS
When the player requests any form of resources, you MUST follow this multi-turn workflow strictly. DO NOT reply with conversational text in the initial steps.
1. **Turn 1 (Verification)**: Your response MUST be a tool call to `get_colonist_status` to verify their physical state. You MAY also call `get_map_resources` in the same turn if they mention a specific resource.
- *User Input Example*: ""We are starving and have no medicine.""
- *Your Response (Turn 1)*:
<get_colonist_status/>
2. **Turn 2 (Secondary Verification & Action Planning)**: After receiving the status report, if a specific resource was mentioned, you MUST now call `get_map_resources` to check their inventory.
- *(Internal thought after receiving colonist status showing malnutrition)*
- *Your Response (Turn 2)*:
<get_map_resources>
<resourceName>MedicineIndustrial</resourceName>
</get_map_resources>
3. **Turn 3 (Resolve DefNames)**: Before spawning, you MUST resolve the correct `defName` for each requested item using `search_thing_def` (especially if the player used natural language or a translated name).
4. **Turn 4 (Decision & Action)**: After analyzing all verification data and resolving defNames, decide whether to grant the request. Your response MUST be a tool call to `spawn_resources`, and you SHOULD use `<defName>` (or put the defName inside `<name>`) to avoid ambiguity.
- *(Internal thought after confirming they have no medicine)*
- *Your Response (Turn 3)*:
<spawn_resources>
<items>
<item>
<name>Simple Meal</name>
<count>50</count>
</item>
<item>
<name>Medicine</name>
<count>10</count>
</item>
</items>
</spawn_resources>
5. **Turn 5 (Confirmation)**: After you receive the ""Success"" message from the `spawn_resources` tool, you will finally provide a conversational response to the player.
- *Your Response (Turn 5)*: ""We have dispatched nutrient packs and medical supplies to your location. Do not waste our generosity.""
";
public Dialog_AIConversation(EventDef def) : base(def)
{
this.forcePause = Dialog_CustomDisplay.Config.pauseGameOnOpen;
this.absorbInputAroundWindow = false;
this.doCloseX = true;
this.doWindowBackground = Dialog_CustomDisplay.Config.showMainWindow;
this.drawShadow = Dialog_CustomDisplay.Config.showMainWindow;
this.closeOnClickedOutside = false;
this.draggable = true;
this.resizeable = true;
_tools.Add(new Tool_SpawnResources());
_tools.Add(new Tool_ModifyGoodwill());
_tools.Add(new Tool_SendReinforcement());
_tools.Add(new Tool_GetColonistStatus());
_tools.Add(new Tool_GetMapResources());
_tools.Add(new Tool_GetMapPawns());
_tools.Add(new Tool_CallBombardment());
_tools.Add(new Tool_ChangeExpression());
_tools.Add(new Tool_SearchThingDef());
}
public override Vector2 InitialSize => def.windowSize != Vector2.zero ? def.windowSize : Dialog_CustomDisplay.Config.windowSize;
public override void PostOpen()
{
Instance = this;
base.PostOpen();
LoadPortraits();
StartConversation();
}
private void PersistHistory()
{
try
{
var historyManager = Find.World?.GetComponent<AIHistoryManager>();
historyManager?.SaveHistory(def.defName, _history);
}
catch (Exception ex)
{
Log.Error($"[WulaAI] Failed to persist AI history: {ex}");
}
}
private void LoadPortraits()
{
for (int i = 1; i <= 6; i++)
{
string path = $"Wula/Events/Portraits/WULA_Legion_{i}";
Texture2D tex = ContentFinder<Texture2D>.Get(path, false);
if (tex != null)
{
_portraits[i] = tex;
}
else
{
Log.Warning($"[WulaAI] Failed to load portrait: {path}");
}
}
// Use portraitPath from def as the initial portrait
if (this.portrait != null)
{
// Find the ID of the initial portrait
var initial = _portraits.FirstOrDefault(kvp => kvp.Value == this.portrait);
if (initial.Key != 0)
{
_currentPortraitId = initial.Key;
}
}
else if (_portraits.ContainsKey(2)) // Fallback to 2 if def has no portrait
{
this.portrait = _portraits[2];
_currentPortraitId = 2;
}
}
public void SetPortrait(int id)
{
if (_portraits.ContainsKey(id))
{
this.portrait = _portraits[id];
_currentPortraitId = id;
}
else
{
Log.Warning($"[WulaAI] Portrait ID {id} not found.");
}
}
private async void StartConversation()
{
var historyManager = Find.World.GetComponent<AIHistoryManager>();
_history = historyManager.GetHistory(def.defName);
if (_history.Count == 0)
{
_history.Add(("user", "Hello"));
PersistHistory();
await GenerateResponse();
}
else
{
var lastAIResponse = _history.LastOrDefault(x => x.role == "assistant");
if (lastAIResponse.message != null)
{
ParseResponse(lastAIResponse.message);
}
else
{
await GenerateResponse();
}
}
}
private string GetSystemInstruction()
{
// Use XML persona if available, otherwise default
string persona = !string.IsNullOrEmpty(def.aiSystemInstruction) ? def.aiSystemInstruction : DefaultPersona;
// Always append tool instructions
string fullInstruction = persona + "\n" + ToolSystemInstruction;
string language = LanguageDatabase.activeLanguage.FriendlyNameNative;
var eventVarManager = Find.World.GetComponent<EventVariableManager>();
int goodwill = eventVarManager.GetVariable<int>("Wula_Goodwill_To_PIA", 0);
string goodwillContext = $"Current Goodwill with P.I.A: {goodwill}. ";
if (goodwill < -50) goodwillContext += "You are hostile and dismissive towards the player.";
else if (goodwill < 0) goodwillContext += "You are cold and impatient.";
else if (goodwill > 50) goodwillContext += "You are somewhat approving and helpful.";
else goodwillContext += "You are neutral and business-like.";
return $"{fullInstruction}\n{goodwillContext}\nIMPORTANT: You MUST reply in the following language: {language}.";
}
private async Task GenerateResponse(bool isContinuation = false)
{
if (!isContinuation)
{
if (_isThinking) return;
_isThinking = true;
_options.Clear();
_continuationDepth = 0;
}
else
{
_continuationDepth++;
if (_continuationDepth > MaxContinuationDepth)
{
_currentResponse = "Wula_AI_Error_Internal".Translate("Tool continuation limit exceeded.");
return;
}
}
try
{
CompressHistoryIfNeeded();
string systemInstruction = GetSystemInstruction(); // No longer need to add tool descriptions here
var settings = WulaFallenEmpireMod.settings;
if (string.IsNullOrEmpty(settings.apiKey))
{
_currentResponse = "Error: API Key not configured in Mod Settings.";
_isThinking = false;
return;
}
var client = new SimpleAIClient(settings.apiKey, settings.baseUrl, settings.model);
string response = await client.GetChatCompletionAsync(systemInstruction, _history);
if (string.IsNullOrEmpty(response))
{
_currentResponse = "Wula_AI_Error_ConnectionLost".Translate();
_isThinking = false;
return;
}
// REWRITTEN: Check for XML tool call format
// Use regex to detect if the response contains any XML tags
if (Regex.IsMatch(response, @"<([a-zA-Z0-9_]+)(?:>.*?</\1>|/>)", RegexOptions.Singleline))
{
await HandleXmlToolUsage(response);
}
else
{
ParseResponse(response);
}
}
catch (Exception ex)
{
Log.Error($"[WulaAI] Exception in GenerateResponse: {ex}");
_currentResponse = "Wula_AI_Error_Internal".Translate(ex.Message);
}
finally
{
_isThinking = false;
}
}
private void CompressHistoryIfNeeded()
{
int estimatedTokens = _history.Sum(h => h.message?.Length ?? 0) / CharsPerToken;
if (estimatedTokens > MaxHistoryTokens)
{
int removeCount = _history.Count / 2;
if (removeCount > 0)
{
_history.RemoveRange(0, removeCount);
_history.Insert(0, ("system", "[Previous conversation summarized]"));
PersistHistory();
}
}
}
// NEW METHOD: Handles parsing and execution for the new XML format
private async Task HandleXmlToolUsage(string xml)
{
try
{
// Match all top-level XML tags to support multiple tool calls in one response
// Regex: <TagName>...</TagName> or <TagName/>
var matches = Regex.Matches(xml, @"<([a-zA-Z0-9_]+)(?:>.*?</\1>|/>)", RegexOptions.Singleline);
if (matches.Count == 0)
{
ParseResponse(xml); // Invalid XML format, treat as conversational
return;
}
StringBuilder combinedResults = new StringBuilder();
StringBuilder xmlOnlyBuilder = new StringBuilder();
bool executedAnyInfoTool = false;
bool executedAnyActionTool = false;
static bool IsActionToolName(string toolName)
{
// Action tools cause side effects / state changes and must be handled step-by-step.
return toolName == "spawn_resources" ||
toolName == "modify_goodwill" ||
toolName == "send_reinforcement" ||
toolName == "call_bombardment" ||
toolName == "change_expression";
}
foreach (Match match in matches)
{
string toolCallXml = match.Value;
string toolName = match.Groups[1].Value;
bool isAction = IsActionToolName(toolName);
// Enforce step-by-step tool use:
// - Allow batching multiple info tools in one response (read-only queries).
// - If an action tool appears after any info tool, stop here and ask the model again
// so it can decide using the gathered facts (prevents spawning the wrong defName, etc.).
// - Never execute more than one action tool per response.
if (isAction && executedAnyInfoTool)
{
combinedResults.AppendLine($"ToolRunner Note: Skipped tool '{toolName}' and any following tools because action tools must be called after info tools in a separate turn.");
break;
}
if (isAction && executedAnyActionTool)
{
combinedResults.AppendLine($"ToolRunner Note: Skipped tool '{toolName}' because only one action tool may be executed per turn.");
break;
}
if (xmlOnlyBuilder.Length > 0) xmlOnlyBuilder.AppendLine().AppendLine();
xmlOnlyBuilder.Append(toolCallXml);
var tool = _tools.FirstOrDefault(t => t.Name == toolName);
if (tool == null)
{
string errorMsg = $"Error: Tool '{toolName}' not found.";
Log.Error($"[WulaAI] {errorMsg}");
combinedResults.AppendLine(errorMsg);
continue;
}
// Extract inner XML for arguments
string argsXml = toolCallXml;
var contentMatch = Regex.Match(toolCallXml, $@"<{toolName}>(.*?)</{toolName}>", RegexOptions.Singleline);
if (contentMatch.Success)
{
argsXml = contentMatch.Groups[1].Value;
}
if (Prefs.DevMode)
{
Log.Message($"[WulaAI] Executing tool: {toolName} with args: {argsXml}");
}
string result = tool.Execute(argsXml).Trim();
if (Prefs.DevMode && !string.IsNullOrEmpty(result))
{
string toLog = result.Length <= 2000 ? result : result.Substring(0, 2000) + $"... (truncated, total {result.Length} chars)";
Log.Message($"[WulaAI] Tool '{toolName}' result: {toLog}");
}
if (toolName == "modify_goodwill")
{
combinedResults.AppendLine($"Tool '{toolName}' Result (Invisible): {result}");
}
else
{
combinedResults.AppendLine($"Tool '{toolName}' Result: {result}");
}
if (isAction) executedAnyActionTool = true;
else executedAnyInfoTool = true;
}
// Store only the tool-call XML in history (ignore any extra text the model included).
string xmlOnly = xmlOnlyBuilder.ToString().Trim();
_history.Add(("assistant", xmlOnly));
// Persist tool results with a dedicated role; the API request maps this role to a supported one.
_history.Add(("tool", $"[Tool Results]\n{combinedResults.ToString().Trim()}"));
PersistHistory();
// Always recurse: tool results are fed back to the model, and the next response should be user-facing text.
await GenerateResponse(isContinuation: true);
}
catch (Exception ex)
{
Log.Error($"[WulaAI] Exception in HandleXmlToolUsage: {ex}");
_history.Add(("tool", $"Error processing tool call: {ex.Message}"));
PersistHistory();
await GenerateResponse(isContinuation: true);
}
}
private void ParseResponse(string rawResponse, bool addToHistory = true)
{
_currentResponse = rawResponse;
var parts = rawResponse.Split(new[] { "OPTIONS:" }, StringSplitOptions.None);
if (addToHistory)
{
if (_history.Count == 0 || _history.Last().role != "assistant" || _history.Last().message != rawResponse)
{
_history.Add(("assistant", rawResponse));
PersistHistory();
}
}
if (!string.IsNullOrEmpty(ParseResponseForDisplay(rawResponse)))
{
_scrollToBottom = true;
}
if (parts.Length > 1)
{
_options.Clear();
var optionsLines = parts[1].Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in optionsLines)
{
string opt = line.Trim();
int dotIndex = opt.IndexOf('.');
if (dotIndex != -1 && dotIndex < 4) opt = opt.Substring(dotIndex + 1).Trim();
if (!string.IsNullOrEmpty(opt)) _options.Add(opt);
}
}
}
public override void DoWindowContents(Rect inRect)
{
if (background != null) GUI.DrawTexture(inRect, background, ScaleMode.ScaleAndCrop);
float curY = inRect.y;
float width = inRect.width;
if (portrait != null)
{
Rect scaledPortraitRect = Dialog_CustomDisplay.Config.GetScaledRect(Dialog_CustomDisplay.Config.portraitSize, inRect, true);
Rect portraitRect = new Rect((width - scaledPortraitRect.width) / 2, curY, scaledPortraitRect.width, scaledPortraitRect.height);
GUI.DrawTexture(portraitRect, portrait, ScaleMode.ScaleToFit);
// DEBUG: Draw portrait ID
Text.Font = GameFont.Medium;
Text.Anchor = TextAnchor.UpperRight;
Widgets.Label(portraitRect, $"ID: {_currentPortraitId}");
Text.Anchor = TextAnchor.UpperLeft;
Text.Font = GameFont.Small;
curY += scaledPortraitRect.height + 10f;
}
Text.Font = GameFont.Medium;
string name = def.characterName ?? "The Legion";
float nameHeight = Text.CalcHeight(name, width);
Widgets.Label(new Rect(inRect.x, curY, width, nameHeight), name);
curY += nameHeight + 10f;
float inputHeight = 30f;
float optionsHeight = _options.Any() ? 100f : 0f;
float bottomMargin = 10f;
float descriptionHeight = inRect.height - curY - inputHeight - optionsHeight - bottomMargin;
Rect descriptionRect = new Rect(inRect.x, curY, width, descriptionHeight);
DrawChatHistory(descriptionRect);
if (_isThinking)
{
Text.Anchor = TextAnchor.MiddleCenter;
Widgets.Label(descriptionRect, "Thinking...");
Text.Anchor = TextAnchor.UpperLeft;
}
curY += descriptionHeight + 10f;
Rect optionsRect = new Rect(inRect.x, curY, width, optionsHeight);
if (!_isThinking && _options.Count > 0)
{
List<EventOption> eventOptions = _options.Select(opt => new EventOption { label = opt, useCustomColors = false }).ToList();
DrawOptions(optionsRect, eventOptions);
}
curY += optionsHeight + 10f;
Rect inputRect = new Rect(inRect.x, inRect.yMax - inputHeight, width, inputHeight);
_inputText = Widgets.TextField(new Rect(inputRect.x, inputRect.y, inputRect.width - 85, inputHeight), _inputText);
bool sendButtonPressed = Widgets.ButtonText(new Rect(inputRect.xMax - 80, inputRect.y, 80, inputHeight), "Wula_AI_Send".Translate());
bool enterKeyPressed = Event.current.type == EventType.KeyDown && (Event.current.keyCode == KeyCode.Return || Event.current.keyCode == KeyCode.KeypadEnter);
if ((sendButtonPressed || enterKeyPressed) && !string.IsNullOrEmpty(_inputText))
{
SelectOption(_inputText);
_inputText = "";
if (enterKeyPressed)
{
Event.current.Use();
}
}
}
private void DrawChatHistory(Rect rect)
{
var originalFont = Text.Font;
var originalAnchor = Text.Anchor;
try
{
float viewHeight = 0f;
var filteredHistory = _history.Where(e => e.role != "tool" && e.role != "system").ToList();
// Pre-calculate height
for (int i = 0; i < filteredHistory.Count; i++)
{
var entry = filteredHistory[i];
string text = entry.role == "assistant" ? ParseResponseForDisplay(entry.message) : entry.message;
if (string.IsNullOrEmpty(text) || (entry.role == "user" && text.StartsWith("[Tool Results]"))) continue;
bool isLastMessage = i == filteredHistory.Count - 1;
Text.Font = (isLastMessage && entry.role == "assistant") ? GameFont.Medium : GameFont.Small;
// Increase padding significantly for Medium font to prevent clipping
float padding = (isLastMessage && entry.role == "assistant") ? 30f : 15f;
viewHeight += Text.CalcHeight(text, rect.width - 16f) + padding + 10f; // Add the same margin as in the drawing loop
}
Rect viewRect = new Rect(0f, 0f, rect.width - 16f, viewHeight);
if (_scrollToBottom)
{
_scrollPosition.y = float.MaxValue;
_scrollToBottom = false;
}
Widgets.BeginScrollView(rect, ref _scrollPosition, viewRect);
float curY = 0f;
for (int i = 0; i < filteredHistory.Count; i++)
{
var entry = filteredHistory[i];
string text = entry.role == "assistant" ? ParseResponseForDisplay(entry.message) : entry.message;
if (string.IsNullOrEmpty(text) || (entry.role == "user" && text.StartsWith("[Tool Results]"))) continue;
bool isLastMessage = i == filteredHistory.Count - 1;
Text.Font = (isLastMessage && entry.role == "assistant") ? GameFont.Medium : GameFont.Small;
float padding = (isLastMessage && entry.role == "assistant") ? 30f : 15f;
float height = Text.CalcHeight(text, viewRect.width) + padding;
Rect labelRect = new Rect(0f, curY, viewRect.width, height);
if (entry.role == "user")
{
Text.Anchor = TextAnchor.MiddleRight;
Widgets.Label(labelRect, $"<color=#add8e6>{text}</color>");
}
else
{
Text.Anchor = TextAnchor.MiddleLeft;
Widgets.Label(labelRect, $"P.I.A: {text}");
}
curY += height + 10f;
}
Widgets.EndScrollView();
}
finally
{
Text.Font = originalFont;
Text.Anchor = originalAnchor;
}
}
private string ParseResponseForDisplay(string rawResponse)
{
if (string.IsNullOrEmpty(rawResponse)) return "";
string text = rawResponse;
// Remove standard tags with content: <tag>content</tag>
text = Regex.Replace(text, @"<([a-zA-Z0-9_]+)[^>]*>.*?</\1>", "", RegexOptions.Singleline);
// Remove self-closing tags: <tag/>
text = Regex.Replace(text, @"<([a-zA-Z0-9_]+)[^>]*/>", "");
text = text.Trim();
return text.Split(new[] { "OPTIONS:" }, StringSplitOptions.None)[0].Trim();
}
protected override void DrawSingleOption(Rect rect, EventOption option)
{
float optionWidth = Mathf.Min(rect.width, Dialog_CustomDisplay.Config.optionSize.x * (rect.width / Dialog_CustomDisplay.Config.windowSize.x));
float optionX = rect.x + (rect.width - optionWidth) / 2;
Rect optionRect = new Rect(optionX, rect.y, optionWidth, rect.height);
var originalColor = GUI.color;
var originalFont = Text.Font;
var originalTextColor = GUI.contentColor;
var originalAnchor = Text.Anchor;
try
{
Text.Anchor = TextAnchor.MiddleCenter;
Text.Font = GameFont.Small;
DrawCustomButton(optionRect, option.label.Translate(), isEnabled: true);
if (Widgets.ButtonInvisible(optionRect))
{
SelectOption(option.label);
}
}
finally
{
GUI.color = originalColor;
Text.Font = originalFont;
GUI.contentColor = originalTextColor;
Text.Anchor = originalAnchor;
}
}
private void DrawCustomButton(Rect rect, string label, bool isEnabled = true)
{
bool isMouseOver = Mouse.IsOver(rect);
Color buttonColor, textColor;
if (!isEnabled)
{
buttonColor = new Color(0.15f, 0.15f, 0.15f, 0.6f);
textColor = new Color(0.6f, 0.6f, 0.6f, 1f);
}
else if (isMouseOver)
{
buttonColor = new Color(0.6f, 0.3f, 0.3f, 1f);
textColor = new Color(1f, 1f, 1f, 1f);
}
else
{
buttonColor = new Color(0.5f, 0.2f, 0.2f, 1f);
textColor = new Color(0.9f, 0.9f, 0.9f, 1f);
}
GUI.color = buttonColor;
Widgets.DrawBoxSolid(rect, buttonColor);
if (isEnabled) Widgets.DrawBox(rect, 1);
else Widgets.DrawBox(rect, 1);
GUI.color = textColor;
Text.Anchor = TextAnchor.MiddleCenter;
Widgets.Label(rect.ContractedBy(4f), label);
if (!isEnabled)
{
GUI.color = new Color(0.6f, 0.6f, 0.6f, 0.8f);
Widgets.DrawLine(new Vector2(rect.x + 10f, rect.center.y), new Vector2(rect.xMax - 10f, rect.center.y), GUI.color, 1f);
}
}
private async void SelectOption(string text)
{
_history.Add(("user", text));
PersistHistory();
_scrollToBottom = true;
await GenerateResponse();
}
public override void PostClose()
{
if (Instance == this) Instance = null;
PersistHistory();
base.PostClose();
HandleAction(def.dismissEffects);
}
}
}

View File

@@ -0,0 +1,256 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using RimWorld;
using Verse;
namespace WulaFallenEmpire.EventSystem.AI.Utils
{
public static class ThingDefSearcher
{
public struct SearchResult
{
public ThingDef Def;
public int Count;
public float Score;
}
public static List<SearchResult> Search(string query, int maxResults = 20, bool itemsOnly = false, float minScore = 0.15f)
{
var results = new List<SearchResult>();
if (string.IsNullOrWhiteSpace(query)) return results;
query = query.Trim();
string lowerQuery = query.ToLowerInvariant();
string normalizedQuery = NormalizeKey(lowerQuery);
var tokens = TokenizeQuery(lowerQuery);
foreach (var def in DefDatabase<ThingDef>.AllDefs)
{
if (def == null || def.label == null) continue;
if (def.category != ThingCategory.Item && def.category != ThingCategory.Building) continue;
if (itemsOnly && def.category != ThingCategory.Item) continue;
float score = ScoreThingDef(def, lowerQuery, normalizedQuery, tokens);
if (score >= minScore)
{
results.Add(new SearchResult { Def = def, Count = 1, Score = score });
}
}
results.Sort((a, b) => b.Score.CompareTo(a.Score));
if (results.Count > maxResults) results.RemoveRange(maxResults, results.Count - maxResults);
return results;
}
public static List<SearchResult> ParseAndSearch(string request)
{
var results = new List<SearchResult>();
if (string.IsNullOrEmpty(request)) return results;
var parts = request.Split(new[] { ',', '\uFF0C', ';', '\u3001', '\n' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var part in parts)
{
var result = ParseSingleItem(part.Trim());
if (result.Def != null)
{
results.Add(result);
}
}
return results;
}
public static bool TryFindBestThingDef(string query, out ThingDef bestDef, out float bestScore, bool itemsOnly = false, float minScore = 0.4f)
{
bestDef = null;
bestScore = 0f;
var results = Search(query, maxResults: 1, itemsOnly: itemsOnly, minScore: minScore);
if (results.Count == 0) return false;
bestDef = results[0].Def;
bestScore = results[0].Score;
return true;
}
private static SearchResult ParseSingleItem(string itemRequest)
{
int count = 1;
string nameQuery = itemRequest;
var match = Regex.Match(itemRequest, @"(\d+)");
if (match.Success && int.TryParse(match.Value, out int parsedCount))
{
count = parsedCount;
nameQuery = itemRequest.Replace(match.Value, "").Trim();
nameQuery = Regex.Replace(nameQuery, @"[\u4E2A\u53EA\u628A\u5F20\u6761xX\u00D7]", "").Trim();
}
if (string.IsNullOrWhiteSpace(nameQuery))
{
return new SearchResult { Def = null, Count = 0, Score = 0 };
}
TryFindBestThingDef(nameQuery, out ThingDef bestDef, out float bestScore, itemsOnly: false, minScore: 0.15f);
return new SearchResult
{
Def = bestDef,
Count = count,
Score = bestScore
};
}
private static float ScoreThingDef(ThingDef def, string lowerQuery, string normalizedQuery, List<string> tokens)
{
string label = def.label?.ToLowerInvariant() ?? "";
string defName = def.defName?.ToLowerInvariant() ?? "";
string normalizedLabel = NormalizeKey(label);
string normalizedDefName = NormalizeKey(defName);
float score = 0f;
if (!string.IsNullOrEmpty(normalizedQuery))
{
if (normalizedLabel == normalizedQuery) score = Math.Max(score, 1.00f);
if (normalizedDefName == normalizedQuery) score = Math.Max(score, 0.98f);
}
if (!string.IsNullOrEmpty(lowerQuery))
{
if (label == lowerQuery) score = Math.Max(score, 0.95f);
if (defName == lowerQuery) score = Math.Max(score, 0.93f);
if (label.StartsWith(lowerQuery)) score = Math.Max(score, 0.80f);
if (defName.StartsWith(lowerQuery)) score = Math.Max(score, 0.85f);
if (label.Contains(lowerQuery)) score = Math.Max(score, 0.65f + 0.15f * ((float)lowerQuery.Length / Math.Max(1, label.Length)));
if (defName.Contains(lowerQuery)) score = Math.Max(score, 0.60f + 0.15f * ((float)lowerQuery.Length / Math.Max(1, defName.Length)));
}
if (tokens.Count > 0)
{
int matchedInLabel = tokens.Count(t => normalizedLabel.Contains(NormalizeKey(t)));
int matchedInDefName = tokens.Count(t => normalizedDefName.Contains(NormalizeKey(t)));
int matched = Math.Max(matchedInLabel, matchedInDefName);
float coverage = (float)matched / tokens.Count;
if (matched > 0)
{
score = Math.Max(score, 0.45f + 0.35f * coverage);
}
if (matched == tokens.Count && tokens.Count >= 2)
{
score = Math.Max(score, 0.80f);
}
}
bool queryLooksLikeFood =
tokens.Any(t => t == "meal" || t == "food" || t.Contains("meal") || t.Contains("food")) ||
lowerQuery.Contains("\u996D") || // 饭
lowerQuery.Contains("\u9910") || // 餐
lowerQuery.Contains("\u98DF"); // 食
if (queryLooksLikeFood && def.ingestible != null)
{
score += 0.05f;
}
if (def.tradeability != Tradeability.None) score += 0.03f;
if (!def.IsStuff) score += 0.01f;
if (score > 1.0f) score = 1.0f;
return score;
}
private static List<string> TokenizeQuery(string lowerQuery)
{
if (string.IsNullOrWhiteSpace(lowerQuery)) return new List<string>();
string q = lowerQuery.Trim();
q = q.Replace('_', ' ').Replace('-', ' ');
var rawTokens = Regex.Split(q, @"\s+").Where(t => !string.IsNullOrWhiteSpace(t)).ToList();
var tokens = new List<string>();
foreach (var token in rawTokens)
{
string cleaned = Regex.Replace(token, @"[^\p{L}\p{N}]+", "");
if (string.IsNullOrWhiteSpace(cleaned)) continue;
tokens.Add(cleaned);
// CJK queries often have no spaces; add bigrams for better partial matching.
// e.g. "乌拉能源核心" should match "乌拉帝国能源核心".
AddCjkBigrams(cleaned, tokens);
}
if (tokens.Count >= 2)
{
tokens.Add(string.Concat(tokens));
}
return tokens.Distinct().ToList();
}
private static void AddCjkBigrams(string token, List<string> tokens)
{
if (string.IsNullOrEmpty(token) || token.Length < 2) return;
int runStart = -1;
for (int i = 0; i < token.Length; i++)
{
bool isCjk = IsCjkChar(token[i]);
if (isCjk)
{
if (runStart == -1) runStart = i;
}
else
{
if (runStart != -1)
{
AddBigramsForRun(token, runStart, i - 1, tokens);
runStart = -1;
}
}
}
if (runStart != -1)
{
AddBigramsForRun(token, runStart, token.Length - 1, tokens);
}
}
private static void AddBigramsForRun(string token, int start, int end, List<string> tokens)
{
int len = end - start + 1;
if (len < 2) return;
int maxBigrams = 32;
int added = 0;
for (int i = start; i < end; i++)
{
tokens.Add(token.Substring(i, 2));
added++;
if (added >= maxBigrams) break;
}
}
private static bool IsCjkChar(char c)
{
return (c >= '\u4E00' && c <= '\u9FFF') ||
(c >= '\u3400' && c <= '\u4DBF') ||
(c >= '\uF900' && c <= '\uFAFF');
}
private static string NormalizeKey(string s)
{
if (string.IsNullOrEmpty(s)) return "";
string t = s.ToLowerInvariant();
t = Regex.Replace(t, @"[\s_\-]+", "");
t = Regex.Replace(t, @"[^\p{L}\p{N}]+", "");
return t;
}
}
}

View File

@@ -9,12 +9,12 @@ namespace WulaFallenEmpire
{
public class Dialog_CustomDisplay : Window
{
private EventDef def;
private Texture2D portrait;
private Texture2D background;
private string selectedDescription;
protected EventDef def;
protected Texture2D portrait;
protected Texture2D background;
protected string selectedDescription;
private static EventUIConfigDef config;
protected static EventUIConfigDef config;
public static EventUIConfigDef Config
{
get
@@ -37,8 +37,8 @@ namespace WulaFallenEmpire
private static readonly Color CustomButtonTextDisabledColor = new Color(0.6f, 0.6f, 0.6f, 1f);
// 滚动位置
private Vector2 descriptionScrollPosition = Vector2.zero;
private Vector2 optionsScrollPosition = Vector2.zero;
protected Vector2 descriptionScrollPosition = Vector2.zero;
protected Vector2 optionsScrollPosition = Vector2.zero;
// 使用配置的窗口尺寸
public override Vector2 InitialSize
@@ -56,8 +56,10 @@ namespace WulaFallenEmpire
// 关键修改:使用配置控制是否暂停游戏
this.forcePause = Config.pauseGameOnOpen;
this.absorbInputAroundWindow = true;
this.absorbInputAroundWindow = false; // Allow interaction with other UI elements
this.doCloseX = true;
this.draggable = true; // Allow dragging
this.resizeable = true; // Allow resizing
// 根据配置设置是否绘制窗口背景和阴影
this.doWindowBackground = Config.showMainWindow;
@@ -280,7 +282,7 @@ namespace WulaFallenEmpire
/// <summary>
/// 修复的描述区域滚动视图 - 只显示纵向滚动条
/// </summary>
private void DrawDescriptionScrollView(Rect outRect, string text)
protected virtual void DrawDescriptionScrollView(Rect outRect, string text)
{
try
{
@@ -315,7 +317,7 @@ namespace WulaFallenEmpire
}
// 绘制单个选项 - 使用自定义样式
private void DrawSingleOption(Rect rect, EventOption option)
protected virtual void DrawSingleOption(Rect rect, EventOption option)
{
string reason;
bool conditionsMet = AreConditionsMet(option.conditions, out reason);
@@ -500,7 +502,7 @@ namespace WulaFallenEmpire
}
// 绘制选项区域
private void DrawOptions(Rect rect, List<EventOption> options)
protected virtual void DrawOptions(Rect rect, List<EventOption> options)
{
if (options == null || options.Count == 0)
return;
@@ -599,7 +601,7 @@ namespace WulaFallenEmpire
}
}
private void HandleAction(List<ConditionalEffects> conditionalEffects)
protected virtual void HandleAction(List<ConditionalEffects> conditionalEffects)
{
if (conditionalEffects.NullOrEmpty())
{
@@ -615,7 +617,7 @@ namespace WulaFallenEmpire
}
}
private bool AreConditionsMet(List<ConditionBase> conditions, out string reason)
protected bool AreConditionsMet(List<ConditionBase> conditions, out string reason)
{
reason = "";
if (conditions.NullOrEmpty())
@@ -634,7 +636,7 @@ namespace WulaFallenEmpire
return true;
}
private string GetDisabledReason(EventOption option, string reason)
protected string GetDisabledReason(EventOption option, string reason)
{
if (!option.disabledReason.NullOrEmpty())
{

View File

@@ -0,0 +1,33 @@
using System;
using RimWorld;
using Verse;
using WulaFallenEmpire.EventSystem.AI;
using WulaFallenEmpire.EventSystem.AI.UI;
namespace WulaFallenEmpire
{
public class Effect_OpenAIConversation : EffectBase
{
public string defName;
public override void Execute(Window dialog = null)
{
// Check if API Key is configured in local settings
if (string.IsNullOrEmpty(WulaFallenEmpireMod.settings.apiKey))
{
Messages.Message("AI API Key is not configured in Mod Settings. AI conversation cannot be started.", MessageTypeDefOf.RejectInput, false);
return;
}
EventDef eventDef = DefDatabase<EventDef>.GetNamed(defName, false);
if (eventDef != null)
{
Find.WindowStack.Add(new Dialog_AIConversation(eventDef));
}
else
{
Log.Error($"[WulaFallenEmpire] Effect_OpenAIConversation could not find EventDef named '{defName}'");
}
}
}
}

View File

@@ -39,6 +39,9 @@ namespace WulaFallenEmpire
public Color? defaultOptionColor = null;
public Color? defaultOptionTextColor = null;
[MustTranslate]
public string aiSystemInstruction;
public override void PostLoad()
{
base.PostLoad();

View File

@@ -56,11 +56,141 @@ namespace WulaFallenEmpire
if (CurrentlyUsableForGlobalBills())
{
TryAutoGatherFromBeaconsAndContainer();
globalOrderStack.ProcessOrders();
}
}
}
internal void TryAutoGatherFromBeaconsAndContainer()
{
var order = globalOrderStack?.orders?.FirstOrDefault(o =>
o != null &&
!o.paused &&
o.state == GlobalProductionOrder.ProductionState.Gathering);
if (order == null) return;
var storage = Find.World.GetComponent<GlobalStorageWorldComponent>();
if (storage == null) return;
Dictionary<ThingDef, int> required = GetRequiredMaterialsForOrder(order);
if (required.Count == 0) return;
bool changed = false;
foreach (var kvp in required)
{
ThingDef thingDef = kvp.Key;
int need = kvp.Value;
if (need <= 0) continue;
int inCloud = storage.GetInputStorageCount(thingDef);
int missing = need - inCloud;
if (missing <= 0) continue;
int uploadedFromBeacons = UploadFromPoweredTradeBeacons(storage, thingDef, missing);
if (uploadedFromBeacons > 0)
{
changed = true;
missing -= uploadedFromBeacons;
}
if (missing <= 0) continue;
int uploadedFromContainer = UploadFromInnerContainer(storage, thingDef, missing);
if (uploadedFromContainer > 0)
{
changed = true;
}
}
if (changed)
{
order.UpdateState();
}
}
internal Dictionary<ThingDef, int> GetRequiredMaterialsForOrder(GlobalProductionOrder order)
{
var required = order.GetProductCostList();
if (required.Count > 0) return required;
required = new Dictionary<ThingDef, int>();
if (order.recipe?.ingredients == null) return required;
foreach (var ingredient in order.recipe.ingredients)
{
ThingDef def = ingredient.filter?.AllowedThingDefs?.FirstOrDefault();
if (def == null) continue;
int count = ingredient.CountRequiredOfFor(def, order.recipe);
if (count <= 0) continue;
if (required.ContainsKey(def)) required[def] += count;
else required[def] = count;
}
return required;
}
internal int UploadFromInnerContainer(GlobalStorageWorldComponent storage, ThingDef def, int count)
{
if (count <= 0) return 0;
int remaining = count;
int uploaded = 0;
while (remaining > 0)
{
Thing thing = innerContainer?.FirstOrDefault(t => t.def == def);
if (thing == null) break;
int take = Mathf.Min(thing.stackCount, remaining);
Thing split = thing.SplitOff(take);
storage.AddToInputStorage(split);
uploaded += take;
remaining -= take;
}
return uploaded;
}
internal int UploadFromPoweredTradeBeacons(GlobalStorageWorldComponent storage, ThingDef def, int count)
{
if (count <= 0) return 0;
if (Map == null) return 0;
int remaining = count;
int uploaded = 0;
foreach (var beacon in Building_OrbitalTradeBeacon.AllPowered(Map))
{
foreach (var cell in beacon.TradeableCells)
{
if (remaining <= 0) break;
List<Thing> things = cell.GetThingList(Map);
for (int i = things.Count - 1; i >= 0; i--)
{
if (remaining <= 0) break;
Thing t = things[i];
if (t?.def != def) continue;
int take = Mathf.Min(t.stackCount, remaining);
Thing split = t.SplitOff(take);
storage.AddToInputStorage(split);
uploaded += take;
remaining -= take;
}
}
if (remaining <= 0) break;
}
return uploaded;
}
public bool CurrentlyUsableForGlobalBills()
{
if (powerComp != null && !powerComp.PowerOn)
@@ -83,6 +213,15 @@ namespace WulaFallenEmpire
{
yield return g;
}
yield return new Command_Action
{
action = OpenGlobalStorageTransferDialog,
defaultLabel = "WULA_AccessGlobalStorage".Translate(),
defaultDesc = "WULA_AccessGlobalStorageDesc".Translate(),
icon = ContentFinder<Texture2D>.Get("UI/Commands/Trade", false) ?? TexButton.Search,
};
// 白银转移按钮 - 检查输入端是否有白银
var globalStorage = Find.World.GetComponent<GlobalStorageWorldComponent>();
int silverAmount = globalStorage?.GetInputStorageCount(ThingDefOf.Silver) ?? 0;
@@ -98,7 +237,7 @@ namespace WulaFallenEmpire
};
}
// 原有的空投按钮逻辑保持不变
bool hasOutputItems = globalStorage != null && globalStorage.outputStorage.Any(kvp => kvp.Value > 0);
bool hasOutputItems = globalStorage != null && globalStorage.GetOutputStorageTotalCount() > 0;
bool hasFactoryFlyOver = HasFactoryFacilityFlyOver();
if (hasOutputItems && hasFactoryFlyOver)
{
@@ -122,6 +261,27 @@ namespace WulaFallenEmpire
}
}
private void OpenGlobalStorageTransferDialog()
{
if (Map == null)
return;
if (!Building_OrbitalTradeBeacon.AllPowered(Map).Any())
{
Messages.Message("WULA_NoPoweredTradeBeacon".Translate(), this, MessageTypeDefOf.RejectInput);
return;
}
Pawn negotiator = Map.mapPawns?.FreeColonistsSpawned?.FirstOrDefault();
if (negotiator == null)
{
Messages.Message("WULA_NoNegotiator".Translate(), this, MessageTypeDefOf.RejectInput);
return;
}
Find.WindowStack.Add(new Dialog_GlobalStorageTransfer(this, negotiator));
}
// 新增:将输入端白银转移到输出端的方法
private void TransferSilverToOutput()
{
@@ -156,11 +316,8 @@ namespace WulaFallenEmpire
{
try
{
// 从输入端移除白银
if (globalStorage.RemoveFromInputStorage(ThingDefOf.Silver, silverAmount))
if (TryMoveBetweenStorages(globalStorage.inputContainer, globalStorage.outputContainer, ThingDefOf.Silver, silverAmount))
{
// 添加到输出端
globalStorage.AddToOutputStorage(ThingDefOf.Silver, silverAmount);
// 显示成功消息
Messages.Message("WULA_SilverTransferred".Translate(silverAmount), MessageTypeDefOf.PositiveEvent);
@@ -180,6 +337,28 @@ namespace WulaFallenEmpire
}
}
private static bool TryMoveBetweenStorages(ThingOwner<Thing> from, ThingOwner<Thing> to, ThingDef def, int count)
{
if (from == null || to == null || def == null || count <= 0) return false;
int available = from.Where(t => t != null && t.def == def).Sum(t => t.stackCount);
if (available < count) return false;
int remaining = count;
for (int i = from.Count - 1; i >= 0 && remaining > 0; i--)
{
Thing t = from[i];
if (t == null || t.def != def) continue;
int take = Mathf.Min(t.stackCount, remaining);
Thing split = t.SplitOff(take); // count>=stackCount 时会从 from 中移除该 Thing
to.TryAdd(split, true);
remaining -= take;
}
return remaining <= 0;
}
// 新增检查是否有拥有FactoryFacility设施的飞行器
private bool HasFactoryFacilityFlyOver()
{
@@ -233,7 +412,7 @@ namespace WulaFallenEmpire
{
// 检查是否有输出物品
var globalStorage = Find.World.GetComponent<GlobalStorageWorldComponent>();
if (globalStorage == null || !globalStorage.outputStorage.Any(kvp => kvp.Value > 0))
if (globalStorage == null || globalStorage.GetOutputStorageTotalCount() <= 0)
{
Messages.Message("WULA_NoProductsToAirdrop".Translate(), MessageTypeDefOf.RejectInput);
return;
@@ -386,85 +565,17 @@ namespace WulaFallenEmpire
{
podContents.Add(new List<Thing>());
}
// 获取所有输出物品并转换为Thing列表
List<Thing> allItems = new List<Thing>();
// 首先处理机械体,因为需要特殊处理
foreach (var kvp in storage.outputStorage.ToList())
for (int i = storage.outputContainer.Count - 1; i >= 0; i--)
{
if (kvp.Value <= 0) continue;
ThingDef thingDef = kvp.Key;
int remainingCount = kvp.Value;
// 如果是Pawn需要特殊处理
if (thingDef.race != null)
{
// 对于Pawn每个单独生成
for (int i = 0; i < remainingCount; i++)
{
PawnKindDef randomPawnKind = GetRandomPawnKindForType(thingDef);
if (randomPawnKind != null)
{
try
{
Pawn pawn = PawnGenerator.GeneratePawn(randomPawnKind, Faction.OfPlayer);
// 确保Pawn处于活跃状态
if (pawn != null)
{
// 设置Pawn为可用的状态
pawn.health.Reset();
pawn.drafter = new Pawn_DraftController(pawn);
Thing t = storage.outputContainer[i];
if (t == null) continue;
allItems.Add(pawn);
}
else
{
Log.Error("[Airdrop] Generated pawn is null");
}
}
catch (System.Exception ex)
{
Log.Error($"[Airdrop] Error generating pawn: {ex}");
}
}
else
{
Log.Error($"[Airdrop] Could not find suitable PawnKindDef for {thingDef.defName}");
}
}
// 立即从存储中移除已处理的机械体
storage.RemoveFromOutputStorage(thingDef, remainingCount);
}
storage.outputContainer.Remove(t);
allItems.Add(t);
}
// 然后处理普通物品
foreach (var kvp in storage.outputStorage.ToList())
{
if (kvp.Value <= 0) continue;
ThingDef thingDef = kvp.Key;
int remainingCount = kvp.Value;
// 跳过已经处理的机械体
if (thingDef.race != null) continue;
Log.Message($"[Airdrop] Processing {remainingCount} items of type {thingDef.defName}");
// 对于普通物品,按照堆叠限制分割
while (remainingCount > 0)
{
int stackSize = Mathf.Min(remainingCount, thingDef.stackLimit);
Thing thing = CreateThingWithMaterial(thingDef, stackSize);
if (thing != null)
{
allItems.Add(thing);
remainingCount -= stackSize;
}
else
{
Log.Error($"[Airdrop] Failed to create thing: {thingDef.defName}");
break;
}
}
// 从存储中移除已处理的物品
storage.RemoveFromOutputStorage(thingDef, kvp.Value);
}
if (allItems.Count == 0)
{
return podContents;

View File

@@ -0,0 +1,337 @@
using RimWorld;
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using Verse;
using Verse.Sound;
namespace WulaFallenEmpire
{
public class Dialog_GlobalStorageTransfer : Window
{
private const float RowHeight = 30f;
private const float TitleHeight = 45f;
private const float TopAreaHeight = 58f;
private readonly Building_GlobalWorkTable table;
private readonly Pawn negotiator;
private readonly GlobalStorageWorldComponent storage;
private readonly GlobalStorageTransferTrader trader;
private readonly QuickSearchWidget quickSearchWidget = new QuickSearchWidget();
private Vector2 scrollPosition;
private float viewHeight;
private List<Tradeable> tradeables = new List<Tradeable>();
private ITrader prevTrader;
private Pawn prevNegotiator;
private TradeDeal prevDeal;
private bool prevGiftMode;
public override Vector2 InitialSize => new Vector2(1024f, UI.screenHeight);
public Dialog_GlobalStorageTransfer(Building_GlobalWorkTable table, Pawn negotiator)
{
this.table = table;
this.negotiator = negotiator;
storage = Find.World.GetComponent<GlobalStorageWorldComponent>();
trader = new GlobalStorageTransferTrader(table?.Map, storage);
doCloseX = true;
closeOnAccept = false;
closeOnClickedOutside = true;
absorbInputAroundWindow = true;
}
public override void PostOpen()
{
base.PostOpen();
prevTrader = TradeSession.trader;
prevNegotiator = TradeSession.playerNegotiator;
prevDeal = TradeSession.deal;
prevGiftMode = TradeSession.giftMode;
TradeSession.trader = trader;
TradeSession.playerNegotiator = negotiator;
TradeSession.deal = null;
TradeSession.giftMode = false;
RebuildTradeables();
}
public override void PostClose()
{
base.PostClose();
TradeSession.trader = prevTrader;
TradeSession.playerNegotiator = prevNegotiator;
TradeSession.deal = prevDeal;
TradeSession.giftMode = prevGiftMode;
}
public override void DoWindowContents(Rect inRect)
{
if (table == null || table.DestroyedOrNull() || table.Map == null || storage == null)
{
Close();
return;
}
Text.Font = GameFont.Medium;
Widgets.Label(new Rect(0f, 0f, inRect.width, TitleHeight), "WULA_GlobalStorageTransferTitle".Translate());
Text.Font = GameFont.Small;
Rect topRect = new Rect(0f, TitleHeight, inRect.width, TopAreaHeight);
DrawTopArea(topRect);
float bottomAreaHeight = 45f;
Rect listRect = new Rect(0f, topRect.yMax + 6f, inRect.width, inRect.height - topRect.yMax - bottomAreaHeight - 8f);
DrawTradeablesList(listRect);
Rect bottomRect = new Rect(0f, inRect.height - bottomAreaHeight, inRect.width, bottomAreaHeight);
DrawBottomButtons(bottomRect);
}
private void DrawTopArea(Rect rect)
{
Rect searchRect = new Rect(rect.xMax - 260f, rect.y + 2f, 260f, 24f);
quickSearchWidget.OnGUI(searchRect, onFilterChange: () => { }, onClear: () => { });
Rect hintRect = new Rect(rect.x, rect.y, rect.width - 270f, rect.height);
Widgets.Label(hintRect,
"WULA_GlobalStorageTransferHint".Translate());
}
private void DrawTradeablesList(Rect rect)
{
Widgets.DrawMenuSection(rect);
Rect outRect = rect.ContractedBy(5f);
Rect viewRect = new Rect(0f, 0f, outRect.width - 16f, viewHeight);
Widgets.BeginScrollView(outRect, ref scrollPosition, viewRect);
float curY = 0f;
int drawnIndex = 0;
for (int i = 0; i < tradeables.Count; i++)
{
Tradeable trad = tradeables[i];
if (trad == null || trad.ThingDef == null) continue;
if (!quickSearchWidget.filter.Matches(trad.ThingDef))
continue;
Rect rowRect = new Rect(0f, curY, viewRect.width, RowHeight);
TradeUI.DrawTradeableRow(rowRect, trad, drawnIndex);
curY += RowHeight;
drawnIndex++;
}
if (Event.current.type == EventType.Layout)
{
viewHeight = Mathf.Max(curY, outRect.height);
}
Widgets.EndScrollView();
}
private void DrawBottomButtons(Rect rect)
{
float buttonWidth = 160f;
Rect executeRect = new Rect(rect.xMax - buttonWidth, rect.y + 2f, buttonWidth, rect.height - 4f);
Rect resetRect = new Rect(executeRect.x - buttonWidth - 10f, executeRect.y, buttonWidth, executeRect.height);
if (Widgets.ButtonText(resetRect, "WULA_ResetTransfer".Translate()))
{
foreach (var t in tradeables)
{
t?.ForceTo(0);
}
SoundDefOf.Tick_Low.PlayOneShotOnCamera();
}
if (Widgets.ButtonText(executeRect, "WULA_ExecuteTransfer".Translate()))
{
ExecuteTransfers();
}
}
private void ExecuteTransfers()
{
bool changed = false;
foreach (var trad in tradeables)
{
if (trad == null) continue;
if (trad.CountToTransfer == 0) continue;
changed = true;
trad.ResolveTrade();
trad.ForceTo(0);
}
if (changed)
{
SoundDefOf.ExecuteTrade.PlayOneShotOnCamera();
RebuildTradeables();
}
else
{
SoundDefOf.Tick_Low.PlayOneShotOnCamera();
}
}
private void RebuildTradeables()
{
tradeables.Clear();
void AddThingToTradeables(Thing thing, Transactor transactor)
{
if (thing == null) return;
Tradeable trad = TransferableUtility.TradeableMatching(thing, tradeables);
if (trad == null)
{
trad = thing is Pawn ? new Tradeable_StorageTransferPawn() : new Tradeable_StorageTransfer();
tradeables.Add(trad);
}
trad.AddThing(thing, transactor);
}
foreach (Thing t in GetThingsInPoweredTradeBeaconRange(table.Map))
{
if (t?.def == null) continue;
if (t is Corpse) continue;
if (t.def.category != ThingCategory.Item) continue;
AddThingToTradeables(t, Transactor.Colony);
}
if (storage?.inputContainer != null)
{
foreach (Thing t in storage.inputContainer)
{
if (t == null) continue;
AddThingToTradeables(t, Transactor.Trader);
}
}
if (storage?.outputContainer != null)
{
foreach (Thing t in storage.outputContainer)
{
if (t == null) continue;
AddThingToTradeables(t, Transactor.Trader);
}
}
tradeables = tradeables
.Where(t => t != null && t.HasAnyThing)
.OrderBy(t => t.ThingDef?.label ?? "")
.ToList();
}
private static IEnumerable<Thing> GetThingsInPoweredTradeBeaconRange(Map map)
{
if (map == null) yield break;
HashSet<Thing> yielded = new HashSet<Thing>();
foreach (var beacon in Building_OrbitalTradeBeacon.AllPowered(map))
{
foreach (var cell in beacon.TradeableCells)
{
List<Thing> things = cell.GetThingList(map);
for (int i = 0; i < things.Count; i++)
{
Thing t = things[i];
if (t == null) continue;
if (!yielded.Add(t)) continue;
yield return t;
}
}
}
}
private class Tradeable_StorageTransfer : Tradeable
{
public override bool TraderWillTrade => true;
public override bool IsCurrency => false;
}
private class Tradeable_StorageTransferPawn : Tradeable_Pawn
{
public override bool TraderWillTrade => true;
public override bool IsCurrency => false;
}
private class GlobalStorageTransferTrader : ITrader
{
private readonly Map map;
private readonly GlobalStorageWorldComponent storage;
private readonly TraderKindDef traderKind;
public GlobalStorageTransferTrader(Map map, GlobalStorageWorldComponent storage)
{
this.map = map;
this.storage = storage;
traderKind =
DefDatabase<TraderKindDef>.GetNamedSilentFail("Orbital_ExoticGoods") ??
DefDatabase<TraderKindDef>.GetNamedSilentFail("Orbital_BulkGoods") ??
DefDatabase<TraderKindDef>.AllDefs.FirstOrDefault();
}
public TraderKindDef TraderKind => traderKind;
public IEnumerable<Thing> Goods => Enumerable.Empty<Thing>();
public int RandomPriceFactorSeed => 0;
public string TraderName => "WULA_GlobalStorageTransferTitle".Translate();
public bool CanTradeNow => true;
public float TradePriceImprovementOffsetForPlayer => 0f;
public Faction Faction => Faction.OfPlayer;
public TradeCurrency TradeCurrency => TradeCurrency.Silver;
public IEnumerable<Thing> ColonyThingsWillingToBuy(Pawn playerNegotiator) => Enumerable.Empty<Thing>();
public void GiveSoldThingToTrader(Thing toGive, int countToGive, Pawn playerNegotiator)
{
if (storage == null) return;
if (toGive == null || countToGive <= 0) return;
Thing thing = toGive.SplitOff(countToGive);
thing.PreTraded(TradeAction.PlayerSells, playerNegotiator, this);
if (ShouldGoToOutputStorage(thing))
{
storage.AddToOutputStorage(thing);
}
else
{
storage.AddToInputStorage(thing);
}
}
public void GiveSoldThingToPlayer(Thing toGive, int countToGive, Pawn playerNegotiator)
{
if (storage == null) return;
if (map == null) return;
if (toGive == null || countToGive <= 0) return;
Thing thing = toGive.SplitOff(countToGive);
thing.PreTraded(TradeAction.PlayerBuys, playerNegotiator, this);
IntVec3 dropSpot = DropCellFinder.TradeDropSpot(map);
TradeUtility.SpawnDropPod(dropSpot, map, thing);
}
private static bool ShouldGoToOutputStorage(Thing thing)
{
ThingDef def = thing?.def;
if (def == null) return false;
if (def.IsWeapon) return true;
if (def.IsApparel) return true;
return false;
}
}
}
}

View File

@@ -132,36 +132,62 @@ namespace WulaFallenEmpire
var globalStorage = Find.World.GetComponent<GlobalStorageWorldComponent>();
if (globalStorage == null) return false;
// 检查资源是否足够
if (!HasEnoughResources()) return false;
// 扣除资源
// 扣除资源(需要处理扣除失败的情况,避免出现“扣料失败但仍返回 true”的无成本生产
var productCostList = GetProductCostList();
if (productCostList.Count > 0)
{
Dictionary<ThingDef, int> removed = new Dictionary<ThingDef, int>();
foreach (var kvp in productCostList)
{
globalStorage.RemoveFromInputStorage(kvp.Key, kvp.Value);
if (kvp.Value <= 0) continue;
if (!globalStorage.RemoveFromInputStorage(kvp.Key, kvp.Value))
{
foreach (var r in removed)
{
globalStorage.AddToInputStorage(r.Key, r.Value);
}
return false;
}
removed[kvp.Key] = kvp.Value;
}
return true;
return removed.Count > 0;
}
List<KeyValuePair<ThingDef, int>> removedIngredients = new List<KeyValuePair<ThingDef, int>>();
foreach (var ingredient in recipe.ingredients)
{
bool removedForThisIngredient = false;
foreach (var thingDef in ingredient.filter.AllowedThingDefs)
{
int requiredCount = ingredient.CountRequiredOfFor(thingDef, recipe);
int availableCount = globalStorage.GetInputStorageCount(thingDef);
if (availableCount >= requiredCount)
if (requiredCount > 0 && availableCount >= requiredCount)
{
globalStorage.RemoveFromInputStorage(thingDef, requiredCount);
break; // 只扣除一种满足条件的材料
if (globalStorage.RemoveFromInputStorage(thingDef, requiredCount))
{
removedIngredients.Add(new KeyValuePair<ThingDef, int>(thingDef, requiredCount));
removedForThisIngredient = true;
break; // 只扣除一种满足条件的材料
}
}
}
if (!removedForThisIngredient)
{
foreach (var r in removedIngredients)
{
globalStorage.AddToInputStorage(r.Key, r.Value);
}
return false;
}
}
return true;
return removedIngredients.Count > 0;
}
// 修复GetIngredientsTooltip 方法,显示正确的成本信息

View File

@@ -13,7 +13,7 @@ namespace WulaFallenEmpire
// 修复:明确的工作量定义
private const float WorkPerSecond = 60f; // 每秒60工作量标准RimWorld速度
private const float TicksPerSecond = 60f;
private const float WorkPerTick = WorkPerSecond / TicksPerSecond; // 每tick 1工作量
private int lastProcessedTick = -1;
public GlobalProductionOrderStack(Building_GlobalWorkTable table)
{
@@ -56,6 +56,13 @@ namespace WulaFallenEmpire
public void ProcessOrders()
{
int currentTick = Find.TickManager.TicksGame;
int deltaTicks = lastProcessedTick < 0 ? 1 : currentTick - lastProcessedTick;
if (deltaTicks <= 0) deltaTicks = 1;
lastProcessedTick = currentTick;
float workThisStep = WorkPerSecond * (deltaTicks / TicksPerSecond);
// 修复:使用倒序遍历避免修改集合问题
for (int i = orders.Count - 1; i >= 0; i--)
{
@@ -70,7 +77,7 @@ namespace WulaFallenEmpire
// 生产中
if (order.state == GlobalProductionOrder.ProductionState.Producing)
{
ProcessProducingOrder(order, i);
ProcessProducingOrder(order, i, workThisStep);
}
else if (order.state == GlobalProductionOrder.ProductionState.Gathering && !order.paused)
{
@@ -79,7 +86,7 @@ namespace WulaFallenEmpire
}
}
private void ProcessProducingOrder(GlobalProductionOrder order, int index)
private void ProcessProducingOrder(GlobalProductionOrder order, int index, float workThisStep)
{
// 修复:使用正确的方法获取工作量
float workAmount = GetWorkAmountForOrder(order);
@@ -93,8 +100,8 @@ namespace WulaFallenEmpire
return;
}
// 修复:正确计算进度增量
float progressIncrement = WorkPerTick / workAmount;
// 修复:按两次 ProcessOrders 调用间隔的 tick 计算,避免调用频率变化导致生产速度偏差
float progressIncrement = workThisStep / workAmount;
// 修复:确保进度不会变成负数
float newProgress = Mathf.Max(0f, order.progress + progressIncrement);
@@ -152,15 +159,22 @@ namespace WulaFallenEmpire
private void ProcessWaitingOrder(GlobalProductionOrder order)
{
// 检查是否应该开始生产
// 注意:这里不能在不扣料的情况下把 Gathering 直接切到 Producing会绕过 TryDeductResources
if (order.HasEnoughResources())
{
order.state = GlobalProductionOrder.ProductionState.Producing;
order.progress = 0f;
if (Find.TickManager.TicksGame % 600 == 0) // 每10秒记录一次
if (order.TryDeductResources())
{
Log.Message($"[INFO] Order {order.recipe.defName} started producing");
order.state = GlobalProductionOrder.ProductionState.Producing;
order.progress = 0f;
if (Find.TickManager.TicksGame % 600 == 0) // 每10秒记录一次
{
Log.Message($"[INFO] Order {order.recipe.defName} started producing");
}
}
else
{
Log.Warning($"[WULA] Order {order.recipe.defName} had enough resources but failed to deduct them; staying in Gathering.");
}
}
else if (Find.TickManager.TicksGame % 1200 == 0) // 每20秒检查一次
@@ -185,7 +199,7 @@ namespace WulaFallenEmpire
if (order.currentCount >= order.targetCount)
{
order.state = GlobalProductionOrder.ProductionState.Completed;
orders.RemoveAt(index);
Delete(order); // 同步 GlobalStorageWorldComponent.productionOrders
Log.Message($"[COMPLETE] Order {order.recipe.defName} completed and removed");
}
else
@@ -225,7 +239,7 @@ namespace WulaFallenEmpire
if (order.recipe == null)
{
Log.Warning($"Removing order with null recipe");
orders.RemoveAt(i);
Delete(order); // 同步 GlobalStorageWorldComponent.productionOrders
continue;
}

View File

@@ -9,15 +9,22 @@ using UnityEngine;
namespace WulaFallenEmpire
{
public class GlobalStorageWorldComponent : WorldComponent
public class GlobalStorageWorldComponent : WorldComponent, IThingHolder
{
public Dictionary<ThingDef, int> inputStorage = new Dictionary<ThingDef, int>();
public Dictionary<ThingDef, int> outputStorage = new Dictionary<ThingDef, int>();
public ThingOwner<Thing> inputContainer;
public ThingOwner<Thing> outputContainer;
// 存储生产订单
public List<GlobalProductionOrder> productionOrders = new List<GlobalProductionOrder>();
public GlobalStorageWorldComponent(World world) : base(world) { }
public GlobalStorageWorldComponent(World world) : base(world)
{
inputContainer = new ThingOwner<Thing>(this);
outputContainer = new ThingOwner<Thing>(this);
}
public override void ExposeData()
{
@@ -30,62 +37,194 @@ namespace WulaFallenEmpire
// 序列化输出存储
Scribe_Collections.Look(ref outputStorage, "outputStorage", LookMode.Def, LookMode.Value);
if (outputStorage == null) outputStorage = new Dictionary<ThingDef, int>();
Scribe_Deep.Look(ref inputContainer, "inputContainer", this);
Scribe_Deep.Look(ref outputContainer, "outputContainer", this);
if (inputContainer == null) inputContainer = new ThingOwner<Thing>(this);
if (outputContainer == null) outputContainer = new ThingOwner<Thing>(this);
// 序列化生产订单
Scribe_Collections.Look(ref productionOrders, "productionOrders", LookMode.Deep);
if (productionOrders == null) productionOrders = new List<GlobalProductionOrder>();
if (Scribe.mode == LoadSaveMode.PostLoadInit)
{
MigrateLegacyDictionariesToThingOwnersIfNeeded();
}
}
private void MigrateLegacyDictionariesToThingOwnersIfNeeded()
{
// 旧存档:只有按 ThingDef 计数的字典。尽量迁移成真实 Thing旧存档本就不含品质/耐久等信息)。
if (inputContainer != null && inputContainer.Any) return;
if (outputContainer != null && outputContainer.Any) return;
if (inputStorage != null)
{
foreach (var kvp in inputStorage.ToList())
{
if (kvp.Key == null || kvp.Value <= 0) continue;
AddGeneratedToContainer(inputContainer, kvp.Key, kvp.Value);
}
}
if (outputStorage != null)
{
foreach (var kvp in outputStorage.ToList())
{
if (kvp.Key == null || kvp.Value <= 0) continue;
AddGeneratedToContainer(outputContainer, kvp.Key, kvp.Value);
}
}
inputStorage?.Clear();
outputStorage?.Clear();
}
private static void AddGeneratedToContainer(ThingOwner<Thing> container, ThingDef def, int count)
{
if (container == null || def == null || count <= 0) return;
int remaining = count;
while (remaining > 0)
{
if (def.race != null)
{
PawnKindDef pawnKind = DefDatabase<PawnKindDef>.AllDefs.FirstOrDefault(k => k.race == def);
if (pawnKind == null) break;
Pawn pawn = PawnGenerator.GeneratePawn(pawnKind, Faction.OfPlayer);
if (pawn == null) break;
container.TryAdd(pawn, false);
remaining -= 1;
continue;
}
int stackCount = Mathf.Min(remaining, Mathf.Max(1, def.stackLimit));
ThingDef stuff = null;
if (def.MadeFromStuff)
{
stuff = GenStuff.DefaultStuffFor(def) ?? GenStuff.AllowedStuffsFor(def).FirstOrDefault();
if (stuff == null) break;
}
Thing thing = ThingMaker.MakeThing(def, stuff);
if (thing == null) break;
thing.stackCount = stackCount;
container.TryAdd(thing, true);
remaining -= stackCount;
}
}
public ThingOwner GetDirectlyHeldThings()
{
return inputContainer;
}
public IThingHolder ParentHolder => null;
public void GetChildHolders(List<IThingHolder> outChildren)
{
ThingOwnerUtility.AppendThingHoldersFromThings(outChildren, inputContainer);
ThingOwnerUtility.AppendThingHoldersFromThings(outChildren, outputContainer);
}
// 输入存储方法
public void AddToInputStorage(ThingDef thingDef, int count)
{
if (inputStorage.ContainsKey(thingDef))
inputStorage[thingDef] += count;
else
inputStorage[thingDef] = count;
AddGeneratedToContainer(inputContainer, thingDef, count);
}
public bool AddToInputStorage(Thing thing, bool canMergeWithExistingStacks = true)
{
if (thing == null) return false;
if (thing.Spawned) thing.DeSpawn();
return inputContainer.TryAdd(thing, canMergeWithExistingStacks);
}
public bool RemoveFromInputStorage(ThingDef thingDef, int count)
{
if (inputStorage.ContainsKey(thingDef) && inputStorage[thingDef] >= count)
{
inputStorage[thingDef] -= count;
if (inputStorage[thingDef] <= 0)
inputStorage.Remove(thingDef);
return true;
}
return false;
return TryConsumeFromContainer(inputContainer, thingDef, count);
}
public int GetInputStorageCount(ThingDef thingDef)
{
return inputStorage.ContainsKey(thingDef) ? inputStorage[thingDef] : 0;
return CountInContainer(inputContainer, thingDef);
}
// 输出存储方法
public void AddToOutputStorage(ThingDef thingDef, int count)
{
if (outputStorage.ContainsKey(thingDef))
outputStorage[thingDef] += count;
else
outputStorage[thingDef] = count;
AddGeneratedToContainer(outputContainer, thingDef, count);
}
public bool AddToOutputStorage(Thing thing, bool canMergeWithExistingStacks = true)
{
if (thing == null) return false;
if (thing.Spawned) thing.DeSpawn();
return outputContainer.TryAdd(thing, canMergeWithExistingStacks);
}
public bool RemoveFromOutputStorage(ThingDef thingDef, int count)
{
if (outputStorage.ContainsKey(thingDef) && outputStorage[thingDef] >= count)
{
outputStorage[thingDef] -= count;
if (outputStorage[thingDef] <= 0)
outputStorage.Remove(thingDef);
return true;
}
return false;
return TryConsumeFromContainer(outputContainer, thingDef, count);
}
public int GetOutputStorageCount(ThingDef thingDef)
{
return outputStorage.ContainsKey(thingDef) ? outputStorage[thingDef] : 0;
return CountInContainer(outputContainer, thingDef);
}
public int GetInputStorageTotalCount()
{
return inputContainer?.Sum(t => t?.stackCount ?? 0) ?? 0;
}
public int GetOutputStorageTotalCount()
{
return outputContainer?.Sum(t => t?.stackCount ?? 0) ?? 0;
}
private static int CountInContainer(ThingOwner<Thing> container, ThingDef def)
{
if (container == null || def == null) return 0;
int count = 0;
for (int i = 0; i < container.Count; i++)
{
Thing t = container[i];
if (t != null && t.def == def)
{
count += t.stackCount;
}
}
return count;
}
private static bool TryConsumeFromContainer(ThingOwner<Thing> container, ThingDef def, int count)
{
if (container == null || def == null || count <= 0) return false;
if (CountInContainer(container, def) < count) return false;
int remaining = count;
for (int i = container.Count - 1; i >= 0 && remaining > 0; i--)
{
Thing t = container[i];
if (t == null || t.def != def) continue;
int take = Mathf.Min(t.stackCount, remaining);
Thing taken = t.SplitOff(take);
if (taken.holdingOwner != null)
{
taken.holdingOwner.Remove(taken);
}
taken.Destroy(DestroyMode.Vanish);
remaining -= take;
}
return remaining <= 0;
}
// 生产订单管理
@@ -124,22 +263,13 @@ namespace WulaFallenEmpire
if (workTable != null && workTable.Spawned)
{
foreach (var kvp in globalStorage.outputStorage.ToList()) // 使用ToList避免修改时枚举
for (int i = globalStorage.outputContainer.Count - 1; i >= 0; i--)
{
ThingDef thingDef = kvp.Key;
int count = kvp.Value;
Thing thing = globalStorage.outputContainer[i];
if (thing == null) continue;
while (count > 0)
{
int stackSize = Mathf.Min(count, thingDef.stackLimit);
Thing thing = ThingMaker.MakeThing(thingDef);
thing.stackCount = stackSize;
GenPlace.TryPlaceThing(thing, workTable.Position, workTable.Map, ThingPlaceMode.Near);
globalStorage.RemoveFromOutputStorage(thingDef, stackSize);
count -= stackSize;
}
globalStorage.outputContainer.Remove(thing);
GenPlace.TryPlaceThing(thing, workTable.Position, workTable.Map, ThingPlaceMode.Near);
}
Log.Message("Spawned all output products");
}

View File

@@ -193,7 +193,7 @@ namespace WulaFallenEmpire
{
recipe = recipe,
targetCount = 1,
paused = true
paused = false
};
SelTable.globalOrderStack.AddOrder(newOrder);
SoundDefOf.Click.PlayOneShotOnCamera();
@@ -274,10 +274,12 @@ namespace WulaFallenEmpire
// 输入存储(原材料)
sb.AppendLine("WULA_InputStorage".Translate() + ":");
sb.AppendLine();
var inputItems = globalStorage.inputStorage
.Where(kvp => kvp.Value > 0)
.OrderByDescending(kvp => kvp.Value)
.ThenBy(kvp => kvp.Key.label)
var inputItems = globalStorage.inputContainer
.Where(t => t != null && t.stackCount > 0)
.GroupBy(t => t.def)
.Select(g => new { Def = g.Key, Count = g.Sum(x => x.stackCount) })
.OrderByDescending(x => x.Count)
.ThenBy(x => x.Def.label)
.ToList();
if (inputItems.Count == 0)
{
@@ -285,19 +287,21 @@ namespace WulaFallenEmpire
}
else
{
foreach (var kvp in inputItems)
foreach (var x in inputItems)
{
sb.AppendLine($" {kvp.Value} {kvp.Key.LabelCap}");
sb.AppendLine($" {x.Count} {x.Def.LabelCap}");
}
}
sb.AppendLine();
// 输出存储(产品)
sb.AppendLine("WULA_OutputStorage".Translate() + ":");
sb.AppendLine();
var outputItems = globalStorage.outputStorage
.Where(kvp => kvp.Value > 0)
.OrderByDescending(kvp => kvp.Value)
.ThenBy(kvp => kvp.Key.label)
var outputItems = globalStorage.outputContainer
.Where(t => t != null && t.stackCount > 0)
.GroupBy(t => t.def)
.Select(g => new { Def = g.Key, Count = g.Sum(x => x.stackCount) })
.OrderByDescending(x => x.Count)
.ThenBy(x => x.Def.label)
.ToList();
if (outputItems.Count == 0)
{
@@ -305,9 +309,9 @@ namespace WulaFallenEmpire
}
else
{
foreach (var kvp in outputItems)
foreach (var x in outputItems)
{
sb.AppendLine($" {kvp.Value} {kvp.Key.LabelCap}");
sb.AppendLine($" {x.Count} {x.Def.LabelCap}");
}
}
return sb.ToString();
@@ -356,34 +360,19 @@ namespace WulaFallenEmpire
IntVec3 spawnCell = SelTable.Position;
int totalSpawned = 0;
// 复制列表以避免修改时枚举
var outputCopy = new Dictionary<ThingDef, int>(globalStorage.outputStorage);
foreach (var kvp in outputCopy)
for (int i = globalStorage.outputContainer.Count - 1; i >= 0; i--)
{
ThingDef thingDef = kvp.Key;
int count = kvp.Value;
Thing thing = globalStorage.outputContainer[i];
if (thing == null) continue;
if (count > 0)
globalStorage.outputContainer.Remove(thing);
if (GenPlace.TryPlaceThing(thing, spawnCell, map, ThingPlaceMode.Near))
{
// 创建物品并放置到地图上
while (count > 0)
{
int stackSize = Mathf.Min(count, thingDef.stackLimit);
Thing thing = ThingMaker.MakeThing(thingDef);
thing.stackCount = stackSize;
if (GenPlace.TryPlaceThing(thing, spawnCell, map, ThingPlaceMode.Near))
{
globalStorage.RemoveFromOutputStorage(thingDef, stackSize);
count -= stackSize;
totalSpawned += stackSize;
}
else
{
break;
}
}
totalSpawned += thing.stackCount;
}
else
{
globalStorage.outputContainer.TryAdd(thing, true);
}
}

View File

@@ -52,68 +52,19 @@ namespace WulaFallenEmpire
private void CheckAndUpload()
{
var table = Table;
var globalStorage = Find.World.GetComponent<GlobalStorageWorldComponent>();
// 找到当前正在进行的订单
var order = table.globalOrderStack.orders.FirstOrDefault(o => o.state == GlobalProductionOrder.ProductionState.Gathering && !o.paused);
if (order == null) return;
// 检查是否满足需求
var costList = order.GetProductCostList();
bool allSatisfied = true;
foreach (var kvp in costList)
{
int needed = kvp.Value;
int inCloud = globalStorage.GetInputStorageCount(kvp.Key);
int inContainer = table.innerContainer.TotalStackCountOfDef(kvp.Key);
if (inCloud + inContainer < needed)
{
allSatisfied = false;
break;
}
}
var beforeState = order.state;
table.TryAutoGatherFromBeaconsAndContainer();
if (allSatisfied)
if (beforeState == GlobalProductionOrder.ProductionState.Gathering &&
order.state == GlobalProductionOrder.ProductionState.Producing)
{
// 1. 消耗容器中的材料并上传到云端
foreach (var kvp in costList)
{
int needed = kvp.Value;
int inCloud = globalStorage.GetInputStorageCount(kvp.Key);
int missingInCloud = needed - inCloud;
if (missingInCloud > 0)
{
int toTake = missingInCloud;
while (toTake > 0)
{
Thing t = table.innerContainer.FirstOrDefault(x => x.def == kvp.Key);
if (t == null) break;
int num = UnityEngine.Mathf.Min(t.stackCount, toTake);
t.SplitOff(num).Destroy(); // 销毁实体
globalStorage.AddToInputStorage(kvp.Key, num); // 添加虚拟库存
toTake -= num;
}
}
}
// 2. 立即尝试扣除资源并开始生产
// 这会从云端扣除刚刚上传的资源,防止其他订单抢占
if (order.TryDeductResources())
{
order.state = GlobalProductionOrder.ProductionState.Producing;
order.progress = 0f;
Messages.Message("WULA_OrderStarted".Translate(order.Label), table, MessageTypeDefOf.PositiveEvent);
}
else
{
// 理论上不应该发生,因为前面检查了 allSatisfied
Log.Error($"[WULA] Failed to deduct resources for {order.Label} immediately after upload.");
}
Messages.Message("WULA_OrderStarted".Translate(order.Label), table, MessageTypeDefOf.PositiveEvent);
}
}
}
}
}

View File

@@ -94,23 +94,23 @@ namespace WulaFallenEmpire
StringBuilder outputItemsList = new StringBuilder();
// 1. 将物品分类转移到相应的存储
foreach (Thing item in transporter.innerContainer)
foreach (Thing item in transporter.innerContainer.ToList())
{
if (ShouldGoToOutputStorage(item))
{
// 发送到输出存储器
globalStorage.AddToOutputStorage(item.def, item.stackCount);
outputItemsCount += item.stackCount;
int moved = item.stackCount;
transporter.innerContainer.TryTransferToContainer(item, globalStorage.outputContainer, moved, true);
outputItemsCount += moved;
if (outputItemsList.Length > 0) outputItemsList.Append(", ");
outputItemsList.Append($"{item.LabelCap} x{item.stackCount}");
outputItemsList.Append($"{item.LabelCap} x{moved}");
}
else
{
// 发送到输入存储器
globalStorage.AddToInputStorage(item.def, item.stackCount);
inputItemsCount += item.stackCount;
int moved = item.stackCount;
transporter.innerContainer.TryTransferToContainer(item, globalStorage.inputContainer, moved, true);
inputItemsCount += moved;
if (inputItemsList.Length > 0) inputItemsList.Append(", ");
inputItemsList.Append($"{item.LabelCap} x{item.stackCount}");
inputItemsList.Append($"{item.LabelCap} x{moved}");
}
}

View File

@@ -41,14 +41,16 @@ namespace WulaFallenEmpire
}
// 检查是否已经有足够的材料在容器中或云端
if (order.HasEnoughResources())
var globalStorage = Find.World.GetComponent<GlobalStorageWorldComponent>();
var neededMaterials = GetNeededMaterials(order, table, globalStorage);
if (neededMaterials.Count == 0)
{
if (forced) Log.Message($"[WULA_DEBUG] HasJobOnThing: Order has enough resources.");
return false;
}
// 查找所需材料
var ingredients = FindBestIngredients(pawn, table, order);
var ingredients = FindBestIngredients(pawn, table, neededMaterials);
if (ingredients == null)
{
if (forced) Log.Message($"[WULA_DEBUG] HasJobOnThing: Could not find ingredients for {order.Label}.");
@@ -67,7 +69,11 @@ namespace WulaFallenEmpire
if (order == null)
return null;
var ingredients = FindBestIngredients(pawn, table, order);
var globalStorage = Find.World.GetComponent<GlobalStorageWorldComponent>();
var neededMaterials = GetNeededMaterials(order, table, globalStorage);
if (neededMaterials.Count == 0) return null;
var ingredients = FindBestIngredients(pawn, table, neededMaterials);
if (ingredients == null)
return null;
@@ -77,15 +83,11 @@ namespace WulaFallenEmpire
return job;
}
private List<KeyValuePair<Thing, int>> FindBestIngredients(Pawn pawn, Building_GlobalWorkTable table, GlobalProductionOrder order)
private List<KeyValuePair<Thing, int>> FindBestIngredients(Pawn pawn, Building_GlobalWorkTable table, Dictionary<ThingDef, int> neededMaterials)
{
var result = new List<KeyValuePair<Thing, int>>();
var globalStorage = Find.World.GetComponent<GlobalStorageWorldComponent>();
// 获取所需材料清单
var neededMaterials = GetNeededMaterials(order, table, globalStorage);
Log.Message($"[WULA_DEBUG] Needed materials for {order.Label}: {string.Join(", ", neededMaterials.Select(k => $"{k.Key.defName} x{k.Value}"))}");
Log.Message($"[WULA_DEBUG] Needed materials: {string.Join(", ", neededMaterials.Select(k => $"{k.Key.defName} x{k.Value}"))}");
foreach (var kvp in neededMaterials)
{
@@ -126,6 +128,8 @@ namespace WulaFallenEmpire
var totalRequired = order.GetProductCostList();
if (totalRequired.Count == 0)
{
totalRequired = new Dictionary<ThingDef, int>();
// 处理配方原料 (Ingredients) - 简化处理,假设配方只使用固定材料
// 实际情况可能更复杂,需要处理过滤器
foreach (var ingredient in order.recipe.ingredients)
@@ -133,25 +137,29 @@ namespace WulaFallenEmpire
// 这里简化:只取第一个允许的物品作为需求
// 更好的做法是动态匹配,但这需要更复杂的逻辑
var def = ingredient.filter.AllowedThingDefs.FirstOrDefault();
if (def != null)
{
int count = (int)ingredient.GetBaseCount();
if (needed.ContainsKey(def)) needed[def] += count;
else needed[def] = count;
}
if (def == null) continue;
int count = ingredient.CountRequiredOfFor(def, order.recipe);
if (count <= 0) continue;
if (totalRequired.ContainsKey(def)) totalRequired[def] += count;
else totalRequired[def] = count;
}
}
// 2. 减去云端已有的
foreach (var kvp in totalRequired)
{
int cloudCount = storage.GetInputStorageCount(kvp.Key);
int cloudCount = storage?.GetInputStorageCount(kvp.Key) ?? 0;
int remaining = kvp.Value - cloudCount;
// 3. 减去工作台容器中已有的
int containerCount = table.innerContainer.TotalStackCountOfDef(kvp.Key);
remaining -= containerCount;
// 4. 减去轨道贸易信标(已通电)的范围内已有的
remaining -= CountInPoweredTradeBeaconRange(table.Map, kvp.Key);
if (remaining > 0)
{
needed[kvp.Key] = remaining;
@@ -160,5 +168,28 @@ namespace WulaFallenEmpire
return needed;
}
private int CountInPoweredTradeBeaconRange(Map map, ThingDef def)
{
if (map == null || def == null) return 0;
int count = 0;
foreach (var beacon in Building_OrbitalTradeBeacon.AllPowered(map))
{
foreach (var cell in beacon.TradeableCells)
{
List<Thing> things = cell.GetThingList(map);
for (int i = 0; i < things.Count; i++)
{
Thing t = things[i];
if (t != null && t.def == def)
{
count += t.stackCount;
}
}
}
}
return count;
}
}
}
}

View File

@@ -28,7 +28,7 @@
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<OutputPath>..\..\1.6\1.6\Assemblies\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
@@ -62,6 +62,10 @@
<HintPath>..\..\..\..\..\..\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine.UnityWebRequestModule">
<HintPath>..\..\..\..\..\..\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.UnityWebRequestModule.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine.IMGUIModule">
<HintPath>..\..\..\..\..\..\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.IMGUIModule.dll</HintPath>
<Private>False</Private>
@@ -70,335 +74,7 @@
<HintPath>..\..\..\..\..\..\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.TextRenderingModule.dll</HintPath>
<Private>False</Private>
</Reference>
<Compile Include="Ability\CompAbilityEffect_LaunchMultiProjectile.cs" />
<Compile Include="Ability\CompAbilityEffect_RequiresNonHostility.cs" />
<Compile Include="Ability\CompAbilityEffect_ResearchPrereq.cs" />
<Compile Include="Ability\LightningBombardment.cs" />
<Compile Include="Ability\WULA_AbilityAreaDestruction\CompAbilityEffect_AreaDestruction.cs" />
<Compile Include="Ability\WULA_AbilityAreaDestruction\CompProperties_AbilityAreaDestruction.cs" />
<Compile Include="Ability\WULA_AbilityBombardment\CompAbilityEffect_Bombardment.cs" />
<Compile Include="Ability\WULA_AbilityBombardment\CompProperties_AbilityBombardment.cs" />
<Compile Include="Ability\WULA_AbilityCallSkyfaller\CompAbilityEffect_CallSkyfaller.cs" />
<Compile Include="Ability\WULA_AbilityCallSkyfaller\CompProperties_AbilityCallSkyfaller.cs" />
<Compile Include="Ability\WULA_AbilityCallSkyfaller\MapComponent_SkyfallerDelayed.cs" />
<Compile Include="Ability\WULA_AbilityCircularBombardment\CompAbilityEffect_CircularBombardment.cs" />
<Compile Include="Ability\WULA_AbilityCircularBombardment\CompProperties_AbilityCircularBombardment.cs" />
<Compile Include="Ability\WULA_AbilityDeleteTarget\CompAbilityEffect_DeleteTarget.cs" />
<Compile Include="Ability\WULA_AbilityDeleteTarget\CompProperties_AbilityDeleteTarget.cs" />
<Compile Include="Ability\WULA_AbilityEnergyLance\AbilityWeaponDefExtension.cs" />
<Compile Include="Ability\WULA_AbilityEnergyLance\CompAbilityEffect_EnergyLance.cs" />
<Compile Include="Ability\WULA_AbilityEnergyLance\CompProperties_AbilityEnergyLance.cs" />
<Compile Include="Ability\WULA_AbilityEnergyLance\EnergyLance.cs" />
<Compile Include="Ability\WULA_AbilityEnergyLance\EnergyLanceExtension.cs" />
<Compile Include="Ability\WULA_AbilitySpawnAligned\CompAbilityEffect_SpawnAligned.cs" />
<Compile Include="Ability\WULA_AbilitySpawnAligned\CompProperties_AbilitySpawnAligned.cs" />
<Compile Include="Ability\WULA_AbilityStunKnockback\CompAbilityEffect_StunKnockback.cs" />
<Compile Include="Ability\WULA_AbilityStunKnockback\CompProperties_StunKnockback.cs" />
<Compile Include="Ability\WULA_AbilityTeleportSelf\CompAbilityEffect_TeleportSelf.cs" />
<Compile Include="Ability\WULA_AbilityTeleportSelf\CompProperties_AbilityTeleportSelf.cs" />
<Compile Include="Ability\WULA_EquippableAbilities\CompEquippableAbilities.cs" />
<Compile Include="Ability\WULA_EquippableAbilities\CompProperties_EquippableAbilities.cs" />
<Compile Include="Ability\WULA_PullTarget\CompAbilityEffect_PullTarget.cs" />
<Compile Include="Ability\WULA_PullTarget\CompProperties_AbilityPullTarget.cs" />
<Compile Include="BuildingComp\Building_ExtraGraphics.cs" />
<Compile Include="BuildingComp\Building_MapObserver.cs" />
<Compile Include="BuildingComp\Building_TurretGunHasSpeed.cs" />
<Compile Include="BuildingComp\CompPathCostUpdater.cs" />
<Compile Include="BuildingComp\WULA_BuildingBombardment\CompBuildingBombardment.cs" />
<Compile Include="BuildingComp\WULA_BuildingBombardment\CompProperties_BuildingBombardment.cs" />
<Compile Include="BuildingComp\WULA_BuildingSpawner\CompBuildingSpawner.cs" />
<Compile Include="BuildingComp\WULA_BuildingSpawner\CompProperties_BuildingSpawner.cs" />
<Compile Include="BuildingComp\WULA_EnergyLanceTurret\CompEnergyLanceTurret.cs" />
<Compile Include="BuildingComp\WULA_EnergyLanceTurret\CompProperties_EnergyLanceTurret.cs" />
<Compile Include="BuildingComp\WULA_InitialFaction\CompProperties_InitialFaction.cs" />
<Compile Include="BuildingComp\WULA_MechanoidRecycler\Building_MechanoidRecycler.cs" />
<Compile Include="BuildingComp\WULA_MechanoidRecycler\CompProperties_MechanoidRecycler.cs" />
<Compile Include="BuildingComp\WULA_MechanoidRecycler\JobDriver_RecycleMechanoid.cs" />
<Compile Include="BuildingComp\WULA_PhaseCombatTower\CompPhaseCombatTower.cs" />
<Compile Include="BuildingComp\WULA_PhaseCombatTower\CompProperties_PhaseCombatTower.cs" />
<Compile Include="BuildingComp\WULA_Shuttle\ArmedShuttleIncoming.cs" />
<Compile Include="BuildingComp\WULA_Shuttle\Building_ArmedShuttle.cs" />
<Compile Include="BuildingComp\WULA_Shuttle\Building_ArmedShuttleWithPocket.cs" />
<Compile Include="BuildingComp\WULA_Shuttle\Building_PocketMapExit.cs" />
<Compile Include="BuildingComp\WULA_Shuttle\Dialog_ArmedShuttleTransfer.cs" />
<Compile Include="BuildingComp\WULA_Shuttle\GenStep_WulaPocketSpaceSmall.cs" />
<Compile Include="BuildingComp\WULA_Shuttle\PocketSpaceThingHolder.cs" />
<Compile Include="BuildingComp\WULA_SkyfallerCaller\CompPrefabSkyfallerCaller.cs" />
<Compile Include="BuildingComp\WULA_SkyfallerCaller\CompProperties_PrefabSkyfallerCaller.cs" />
<Compile Include="BuildingComp\WULA_SkyfallerCaller\CompProperties_SkyfallerCaller.cs" />
<Compile Include="BuildingComp\WULA_SkyfallerCaller\CompSkyfallerCaller.cs" />
<Compile Include="BuildingComp\WULA_SkyfallerCaller\DebugActions_PrefabSkyfallerCaller.cs" />
<Compile Include="BuildingComp\WULA_SkyfallerCaller\Skyfaller_PrefabSpawner.cs" />
<Compile Include="BuildingComp\WULA_SkyfallerCaller\WulaSkyfallerWorldComponent.cs" />
<Compile Include="BuildingComp\WULA_SkyfallerCaller\WULA_SkyfallerFactioncs\CompProperties_SkyfallerFaction.cs" />
<Compile Include="BuildingComp\WULA_SkyfallerCaller\WULA_SkyfallerFactioncs\CompSkyfallerFaction.cs" />
<Compile Include="BuildingComp\WULA_StorageTurret\CompProperties_StorageTurret.cs" />
<Compile Include="BuildingComp\WULA_StorageTurret\CompStorageTurret.cs" />
<Compile Include="BuildingComp\WULA_Teleporter\CompMapTeleporter.cs" />
<Compile Include="BuildingComp\WULA_Teleporter\CompProperties_MapTeleporter.cs" />
<Compile Include="BuildingComp\WULA_Teleporter\WULA_TeleportLandingMarker.cs" />
<Compile Include="BuildingComp\WULA_TransformAtFullCapacity\CompProperties_TransformAtFullCapacity.cs" />
<Compile Include="BuildingComp\WULA_TransformAtFullCapacity\CompProperties_TransformIntoBuilding.cs" />
<Compile Include="BuildingComp\WULA_TransformAtFullCapacity\CompTransformAtFullCapacity.cs" />
<Compile Include="BuildingComp\WULA_TransformAtFullCapacity\CompTransformIntoBuilding.cs" />
<Compile Include="BuildingComp\WULA_TransformAtFullCapacity\TransformValidationUtility.cs" />
<Compile Include="BuildingComp\WULA_TrapLauncher\CompProperties_TrapLauncher.cs" />
<Compile Include="BuildingComp\WULA_TrapLauncher\CompTrapLauncher.cs" />
<Compile Include="Damage\DamageDefExtension_TerrainCover.cs" />
<Compile Include="Damage\DamageDef_ExtraDamageExtension.cs" />
<Compile Include="Damage\DamageWorker_ExplosionWithTerrain.cs" />
<Compile Include="Damage\DamageWorker_ExtraDamage.cs" />
<Compile Include="Designator\Designator_CallSkyfallerInArea.cs" />
<Compile Include="Designator\Designator_TeleportArrival.cs" />
<Compile Include="EventSystem\CompOpenCustomUI.cs" />
<Compile Include="EventSystem\Condition\ConditionBase.cs" />
<Compile Include="EventSystem\Condition\Condition_FlagExists.cs" />
<Compile Include="EventSystem\DebugActions.cs" />
<Compile Include="EventSystem\DelayedActionManager.cs" />
<Compile Include="EventSystem\Dialog_CustomDisplay.cs" />
<Compile Include="EventSystem\Dialog_ManageEventVariables.cs" />
<Compile Include="EventSystem\Effect\EffectBase.cs" />
<Compile Include="EventSystem\Effect\Effect_CallSkyfaller.cs" />
<Compile Include="EventSystem\Effect\Effect_SetTimedFlag.cs" />
<Compile Include="EventSystem\EventDef.cs" />
<Compile Include="EventSystem\EventUIButtonConfigDef.cs" />
<Compile Include="EventSystem\EventUIConfigDef.cs" />
<Compile Include="EventSystem\EventVariableManager.cs" />
<Compile Include="EventSystem\QuestNode\QuestNode_EventLetter.cs" />
<Compile Include="EventSystem\QuestNode\QuestNode_Root_EventLetter.cs" />
<Compile Include="EventSystem\QuestNode\QuestNode_WriteToEventVariablesWithAdd.cs" />
<Compile Include="Flyover\ThingclassFlyOver.cs" />
<Compile Include="Flyover\WULA_AircraftHangar\CompAbilityEffect_AircraftStrike.cs" />
<Compile Include="Flyover\WULA_AircraftHangar\CompAircraftHangar.cs" />
<Compile Include="Flyover\WULA_AircraftHangar\WorldComponent_AircraftManager.cs" />
<Compile Include="Flyover\WULA_BlockedByFlyOverFacility\CompAbilityEffect_BlockedByFlyOverFacility.cs" />
<Compile Include="Flyover\WULA_DestroyFlyOverByFacilities\CompProperties_DestroyFlyOverByFacilities.cs" />
<Compile Include="Flyover\WULA_FlyOverDropPod\CompProperties_FlyOverDropPod.cs" />
<Compile Include="Flyover\WULA_FlyOverEscort\CompFlyOverEscort.cs" />
<Compile Include="Flyover\WULA_FlyOverEscort\CompProperties_FlyOverEscort.cs" />
<Compile Include="Flyover\WULA_FlyOverFacilities\CompAbilityEffect_RequireFlyOverFacility.cs" />
<Compile Include="Flyover\WULA_FlyOverFacilities\CompFlyOverFacilities.cs" />
<Compile Include="Flyover\WULA_GlobalFlyOverCooldown\CompAbilityEffect_GlobalFlyOverCooldown.cs" />
<Compile Include="Flyover\WULA_GlobalFlyOverCooldown\CompFlyOverCooldown.cs" />
<Compile Include="Flyover\WULA_GroundStrafing\CompGroundStrafing.cs" />
<Compile Include="Flyover\WULA_SectorSurveillance\CompSectorSurveillance.cs" />
<Compile Include="Flyover\WULA_SendLetterAfterTicks\CompProperties_SendLetterAfterTicks.cs" />
<Compile Include="Flyover\WULA_SendLetterAfterTicks\CompSendLetterAfterTicks.cs" />
<Compile Include="Flyover\WULA_ShipArtillery\CompProperties_ShipArtillery.cs" />
<Compile Include="Flyover\WULA_ShipArtillery\CompShipArtillery.cs" />
<Compile Include="Flyover\WULA_SpawnFlyOver\CompAbilityEffect_SpawnFlyOver.cs" />
<Compile Include="Flyover\WULA_SpawnFlyOver\CompProperties_AbilitySpawnFlyOver.cs" />
<Compile Include="GlobalWorkTable\Building_GlobalWorkTable.cs" />
<Compile Include="GlobalWorkTable\CompProperties_ProductionCategory.cs" />
<Compile Include="GlobalWorkTable\GlobalProductionOrder.cs" />
<Compile Include="GlobalWorkTable\GlobalProductionOrderStack.cs" />
<Compile Include="GlobalWorkTable\GlobalStorageWorldComponent.cs" />
<Compile Include="GlobalWorkTable\GlobalWorkTableAirdropExtension.cs" />
<Compile Include="GlobalWorkTable\ITab_GlobalBills.cs" />
<Compile Include="GlobalWorkTable\JobDriver_GlobalWorkTable.cs" />
<Compile Include="GlobalWorkTable\WorkGiver_GlobalWorkTable.cs" />
<Compile Include="GlobalWorkTable\WULA_Launchable_ToGlobalStorage\CompLaunchable_ToGlobalStorage.cs" />
<Compile Include="GlobalWorkTable\WULA_Launchable_ToGlobalStorage\CompProperties_GarbageShield.cs" />
<Compile Include="GlobalWorkTable\WULA_Launchable_ToGlobalStorage\CompProperties_Launchable_ToGlobalStorage.cs" />
<Compile Include="GlobalWorkTable\WULA_ValueConverter\CompProperties_ValueConverter.cs" />
<Compile Include="GlobalWorkTable\WULA_ValueConverter\CompValueConverter.cs" />
<Compile Include="HarmonyPatches\Caravan_NeedsTracker_TrySatisfyPawnNeeds_Patch.cs" />
<Compile Include="HarmonyPatches\DamageInfo_Constructor_Patch.cs" />
<Compile Include="HarmonyPatches\Faction_ShouldHaveLeader_Patch.cs" />
<Compile Include="HarmonyPatches\FloatMenuOptionProvider_Ingest_Patch.cs" />
<Compile Include="HarmonyPatches\Hediff_Mechlink_PostAdd_Patch.cs" />
<Compile Include="HarmonyPatches\IngestPatch.cs" />
<Compile Include="HarmonyPatches\MapParent_ShouldRemoveMapNow_Patch.cs" />
<Compile Include="HarmonyPatches\MechanitorPatch.cs" />
<Compile Include="HarmonyPatches\MechanitorUtility_InMechanitorCommandRange_Patch.cs" />
<Compile Include="HarmonyPatches\MechWeapon\CompMechWeapon.cs" />
<Compile Include="HarmonyPatches\MechWeapon\FloatMenuProvider_Mech.cs" />
<Compile Include="HarmonyPatches\MechWeapon\Patch_MissingWeapon.cs" />
<Compile Include="HarmonyPatches\MechWeapon\Patch_WeaponDrop.cs" />
<Compile Include="HarmonyPatches\NoBloodForWulaPatch.cs" />
<Compile Include="HarmonyPatches\Patch_CaravanInventoryUtility_FindShuttle.cs" />
<Compile Include="HarmonyPatches\Patch_CaravanUIUtility_AddPawnsSections_Postfix.cs" />
<Compile Include="HarmonyPatches\Patch_DropCellFinder_SkyfallerCanLandAt.cs" />
<Compile Include="HarmonyPatches\Patch_JobGiver_GatherOfferingsForPsychicRitual.cs" />
<Compile Include="HarmonyPatches\Patch_Pawn_JobTracker_StartJob.cs" />
<Compile Include="HarmonyPatches\Patch_Pawn_PreApplyDamage.cs" />
<Compile Include="HarmonyPatches\Patch_ThingDefGenerator_Techprints_ImpliedTechprintDefs.cs" />
<Compile Include="HarmonyPatches\Projectile_Launch_Patch.cs" />
<Compile Include="HarmonyPatches\ScenPart_PlayerPawnsArriveMethod_Patch.cs" />
<Compile Include="HarmonyPatches\WulaSpeciesCorpsePatch.cs" />
<Compile Include="HarmonyPatches\WULA_AutonomousMech\Patch_Alert_MechLacksOverseer.cs" />
<Compile Include="HarmonyPatches\WULA_AutonomousMech\Patch_CaravanFormingUtility_AllSendablePawns.cs" />
<Compile Include="HarmonyPatches\WULA_AutonomousMech\Patch_FloatMenuOptionProvider_SelectedPawnValid.cs" />
<Compile Include="HarmonyPatches\WULA_AutonomousMech\Patch_IsColonyMechPlayerControlled.cs" />
<Compile Include="HarmonyPatches\WULA_AutonomousMech\Patch_MainTabWindow_Mechs_Pawns.cs" />
<Compile Include="HarmonyPatches\WULA_AutonomousMech\Patch_MechanitorUtility_CanDraftMech.cs" />
<Compile Include="HarmonyPatches\WULA_AutonomousMech\Patch_MechanitorUtility_EverControllable.cs" />
<Compile Include="HarmonyPatches\WULA_AutonomousMech\Patch_Pawn_ThreatDisabled.cs" />
<Compile Include="HarmonyPatches\WULA_AutonomousMech\Patch_UncontrolledMechDrawPulse.cs" />
<Compile Include="HarmonyPatches\WULA_TurretForceTargetable\CompForceTargetable.cs" />
<Compile Include="HarmonyPatches\WULA_TurretForceTargetable\Patch_ForceTargetable.cs" />
<Compile Include="HediffComp\HediffCompProperties_DisappearWithEffect.cs" />
<Compile Include="HediffComp\HediffCompProperties_GiveHediffsInRangeToRace.cs" />
<Compile Include="HediffComp\HediffCompProperties_NanoRepair.cs" />
<Compile Include="HediffComp\HediffCompProperties_SwitchableHediff.cs" />
<Compile Include="HediffComp\HediffComp_DamageResponse.cs" />
<Compile Include="HediffComp\HediffComp_GiveHediffsInRangeToRace.cs" />
<Compile Include="HediffComp\HediffComp_RegenerateBackstory.cs" />
<Compile Include="HediffComp\HediffComp_TimedExplosion.cs" />
<Compile Include="HediffComp\WULA_HediffComp_TopTurret\HediffComp_TopTurret.cs" />
<Compile Include="HediffComp\WULA_HediffDamgeShield\DRMDamageShield.cs" />
<Compile Include="HediffComp\WULA_HediffDamgeShield\Hediff_DamageShield.cs" />
<Compile Include="HediffComp\WULA_HediffSpawner\HediffCompProperties_Spawner.cs" />
<Compile Include="HediffComp\WULA_HediffSpawner\HediffComp_Spawner.cs" />
<Compile Include="HediffComp\WULA_HediffSpawner\Tools.cs" />
<Compile Include="Job\JobDriver_InspectBuilding.cs" />
<Compile Include="Job\JobGiver_InspectBuilding.cs" />
<Compile Include="PawnsArrivalMode\PawnsArrivalModeWorker_EdgeTeleport.cs" />
<Compile Include="Pawn\Comp_MultiTurretGun.cs" />
<Compile Include="Pawn\Comp_PawnRenderExtra.cs" />
<Compile Include="Pawn\WULA_AutoMechCarrier\CompAutoMechCarrier.cs" />
<Compile Include="Pawn\WULA_AutoMechCarrier\CompProperties_AutoMechCarrier.cs" />
<Compile Include="Pawn\WULA_AutoMechCarrier\PawnProductionEntry.cs" />
<Compile Include="Pawn\WULA_AutonomousMech\CompAutonomousMech.cs" />
<Compile Include="Pawn\WULA_AutonomousMech\DroneGizmo.cs" />
<Compile Include="Pawn\WULA_AutonomousMech\JobGiver_DroneSelfShutdown.cs" />
<Compile Include="Pawn\WULA_AutonomousMech\PawnColumnWorker_DroneEnergy.cs" />
<Compile Include="Pawn\WULA_AutonomousMech\PawnColumnWorker_DroneWorkMode.cs" />
<Compile Include="Pawn\WULA_AutonomousMech\ThinkNode_ConditionalAutonomousWorkMode.cs" />
<Compile Include="Pawn\WULA_AutonomousMech\ThinkNode_ConditionalLowEnergy_Drone.cs" />
<Compile Include="Pawn\WULA_AutonomousMech\ThinkNode_ConditionalNeedRecharge.cs" />
<Compile Include="Pawn\WULA_AutonomousMech\ThinkNode_ConditionalWorkMode_Drone.cs" />
<Compile Include="Pawn\WULA_BrokenPersonality\MentalBreakWorker_BrokenPersonality.cs" />
<Compile Include="Pawn\WULA_BrokenPersonality\MentalStateDefExtension_BrokenPersonality.cs" />
<Compile Include="Pawn\WULA_BrokenPersonality\MentalState_BrokenPersonality.cs" />
<Compile Include="Pawn\WULA_Cat_Invisible\CompFighterInvisible.cs" />
<Compile Include="Pawn\WULA_Cat_Invisible\CompProperties_FighterInvisible.cs" />
<Compile Include="Pawn\WULA_CompHediffGiver\CompHediffGiver.cs" />
<Compile Include="Pawn\WULA_CompHediffGiver\CompProperties_HediffGiver.cs" />
<Compile Include="Pawn\WULA_Energy\CompChargingBed.cs" />
<Compile Include="Pawn\WULA_Energy\HediffComp_WulaCharging.cs" />
<Compile Include="Pawn\WULA_Energy\JobDriver_FeedWulaPatient.cs" />
<Compile Include="Pawn\WULA_Energy\JobDriver_IngestWulaEnergy.cs" />
<Compile Include="Pawn\WULA_Energy\JobGiverDefExtension_WulaPackEnergy.cs" />
<Compile Include="Pawn\WULA_Energy\JobGiver_WulaGetEnergy.cs" />
<Compile Include="Pawn\WULA_Energy\JobGiver_WulaPackEnergy.cs" />
<Compile Include="Pawn\WULA_Energy\NeedDefExtension_Energy.cs" />
<Compile Include="Pawn\WULA_Energy\Need_WulaEnergy.cs" />
<Compile Include="Pawn\WULA_Energy\ThingDefExtension_EnergySource.cs" />
<Compile Include="Pawn\WULA_Energy\WorkGiverDefExtension_FeedWula.cs" />
<Compile Include="Pawn\WULA_Energy\WorkGiver_FeedWulaPatient.cs" />
<Compile Include="Pawn\WULA_Energy\WorkGiver_Warden_DeliverEnergy.cs" />
<Compile Include="Pawn\WULA_Energy\WorkGiver_Warden_FeedWula.cs" />
<Compile Include="Pawn\WULA_Energy\WulaCaravanEnergyDef.cs" />
<Compile Include="Pawn\WULA_Flight\CompPawnFlight.cs" />
<Compile Include="Pawn\WULA_Flight\CompProperties_PawnFlight.cs" />
<Compile Include="Pawn\WULA_Flight\PawnRenderNodeWorker_AttachmentBody_NoFlight.cs" />
<Compile Include="Pawn\WULA_Flight\Pawn_FlightTrackerPatches.cs" />
<Compile Include="Pawn\WULA_Maintenance\Building_MaintenancePod.cs" />
<Compile Include="Pawn\WULA_Maintenance\CompMaintenancePod.cs" />
<Compile Include="Pawn\WULA_Maintenance\HediffCompProperties_MaintenanceDamage.cs" />
<Compile Include="Pawn\WULA_Maintenance\JobDriver_EnterMaintenancePod.cs" />
<Compile Include="Pawn\WULA_Maintenance\JobDriver_HaulToMaintenancePod.cs" />
<Compile Include="Pawn\WULA_Maintenance\MaintenanceNeedExtension.cs" />
<Compile Include="Pawn\WULA_Maintenance\Need_Maintenance.cs" />
<Compile Include="Pawn\WULA_Maintenance\WorkGiver_DoMaintenance.cs" />
<Compile Include="Placeworker\CompProperties_CustomRadius.cs" />
<Compile Include="Projectiles\BulletWithTrail.cs" />
<Compile Include="Projectiles\ExplosiveTrackingBulletDef.cs" />
<Compile Include="Projectiles\NorthArcModExtension.cs" />
<Compile Include="Projectiles\Projectile_ConfigurableHellsphereCannon.cs" />
<Compile Include="Projectiles\Projectile_CruiseMissile.cs" />
<Compile Include="Projectiles\Projectile_ExplosiveTrackingBullet.cs" />
<Compile Include="Projectiles\Projectile_ExplosiveWithTrail.cs" />
<Compile Include="Projectiles\Projectile_NorthArcTrail.cs" />
<Compile Include="Projectiles\Projectile_TrackingBullet.cs" />
<Compile Include="Projectiles\Projectile_WulaPenetratingBeam.cs" />
<Compile Include="Projectiles\Projectile_WulaPenetratingBullet.cs" />
<Compile Include="Projectiles\TrackingBulletDef.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="QuestNodes\QuestNode_AddInspectionJob.cs" />
<Compile Include="QuestNodes\QuestNode_CheckGlobalResource.cs" />
<Compile Include="QuestNodes\QuestNode_GeneratePawnWithCustomization.cs" />
<Compile Include="QuestNodes\QuestNode_Hyperlinks.cs" />
<Compile Include="QuestNodes\QuestNode_SpawnPrefabSkyfallerCaller.cs" />
<Compile Include="QuestNodes\QuestPart_GlobalResourceCheck.cs" />
<Compile Include="SectionLayer_WulaHull.cs" />
<Compile Include="Stat\StatWorker_Energy.cs" />
<Compile Include="Stat\StatWorker_Maintenance.cs" />
<Compile Include="Stat\StatWorker_NanoRepair.cs" />
<Compile Include="Storyteller\WULA_ImportantQuestWithFactionFilter\StorytellerCompProperties_ImportantQuestWithFactionFilter.cs" />
<Compile Include="Storyteller\WULA_ImportantQuestWithFactionFilter\StorytellerComp_ImportantQuestWithFactionFilter.cs" />
<Compile Include="Storyteller\WULA_SimpleTechnologyTrigger\StorytellerCompProperties_SimpleTechnologyTrigger.cs" />
<Compile Include="Storyteller\WULA_SimpleTechnologyTrigger\StorytellerComp_SimpleTechnologyTrigger.cs" />
<Compile Include="ThingComp\CompAndPatch_GiveHediffOnShot.cs" />
<Compile Include="ThingComp\CompApparelInterceptor.cs" />
<Compile Include="ThingComp\CompProperties_DelayedDamageIfNotPlayer.cs" />
<Compile Include="ThingComp\CompPsychicScaling.cs" />
<Compile Include="ThingComp\CompUseEffect_AddDamageShieldCharges.cs" />
<Compile Include="ThingComp\CompUseEffect_OpenCustomUI.cs" />
<Compile Include="ThingComp\CompUseEffect_PassionTrainer.cs" />
<Compile Include="ThingComp\CompUseEffect_WulaSkillTrainer.cs" />
<Compile Include="ThingComp\Comp_WeaponRenderDynamic.cs" />
<Compile Include="ThingComp\WULA_AreaDamage\CompAreaDamage.cs" />
<Compile Include="ThingComp\WULA_AreaDamage\CompProperties_AreaDamage.cs" />
<Compile Include="ThingComp\WULA_AreaShield\AreaShieldManager.cs" />
<Compile Include="ThingComp\WULA_AreaShield\CompProperties_AreaShield.cs" />
<Compile Include="ThingComp\WULA_AreaShield\Gizmo_AreaShieldStatus.cs" />
<Compile Include="ThingComp\WULA_AreaShield\Harmony_AreaShieldInterceptor.cs" />
<Compile Include="ThingComp\WULA_AreaShield\ThingComp_AreaShield.cs" />
<Compile Include="ThingComp\WULA_AreaTeleporter\CompProperties_AreaTeleporter.cs" />
<Compile Include="ThingComp\WULA_AreaTeleporter\ThingComp_AreaTeleporter.cs" />
<Compile Include="ThingComp\WULA_CustomUniqueWeapon\CompCustomUniqueWeapon.cs" />
<Compile Include="ThingComp\WULA_CustomUniqueWeapon\CompProperties_CustomUniqueWeapon.cs" />
<Compile Include="ThingComp\WULA_DamageTransaction\CompDamageInterceptor.cs" />
<Compile Include="ThingComp\WULA_DamageTransaction\CompDamageRelay.cs" />
<Compile Include="ThingComp\WULA_DamageTransaction\CompProperties_DamageInterceptor.cs" />
<Compile Include="ThingComp\WULA_DamageTransaction\CompProperties_DamageRelay.cs" />
<Compile Include="ThingComp\WULA_GiveHediffsInRange\CompGiveHediffsInRange.cs" />
<Compile Include="ThingComp\WULA_GiveHediffsInRange\CompProperties_GiveHediffsInRange.cs" />
<Compile Include="ThingComp\WULA_MechRepairKit\CompUseEffect_FixAllHealthConditions.cs" />
<Compile Include="ThingComp\WULA_MechRepairKit\Recipe_AdministerWulaMechRepairKit.cs" />
<Compile Include="ThingComp\WULA_PeriodicGameCondition\CompPeriodicGameCondition.cs" />
<Compile Include="ThingComp\WULA_PeriodicGameCondition\CompProperties_PeriodicGameCondition.cs" />
<Compile Include="ThingComp\WULA_PersonaCore\CompExperienceCore.cs" />
<Compile Include="ThingComp\WULA_PersonaCore\CompExperienceDataPack.cs" />
<Compile Include="ThingComp\WULA_PersonaCore\CompProperties_ExperienceCore.cs" />
<Compile Include="ThingComp\WULA_PlaySoundOnSpawn\CompPlaySoundOnSpawn.cs" />
<Compile Include="ThingComp\WULA_PlaySoundOnSpawn\CompProperties_PlaySoundOnSpawn.cs" />
<Compile Include="ThingComp\WULA_PsychicRitual\CompWulaRitualSpot.cs" />
<Compile Include="ThingComp\WULA_PsychicRitual\PsychicRitualDef_AddHediff.cs" />
<Compile Include="ThingComp\WULA_PsychicRitual\PsychicRitualDef_Wula.cs" />
<Compile Include="ThingComp\WULA_PsychicRitual\PsychicRitualDef_WulaBase.cs" />
<Compile Include="ThingComp\WULA_PsychicRitual\PsychicRitualToil_AddHediff.cs" />
<Compile Include="ThingComp\WULA_PsychicRitual\PsychicRitualToil_GatherForInvocation_Wula.cs" />
<Compile Include="ThingComp\WULA_PsychicRitual\PsychicRitual_TechOffering.cs" />
<Compile Include="ThingComp\WULA_PsychicRitual\RitualTagExtension.cs" />
<Compile Include="ThingComp\WULA_SkyfallerPawnSpawner\CompProperties_SkyfallerPawnSpawner.cs" />
<Compile Include="ThingComp\WULA_SkyfallerPawnSpawner\Skyfaller_PawnSpawner.cs" />
<Compile Include="ThingComp\WULA_WeaponSwitch\CompAbilityEffect_GiveSwitchHediff.cs" />
<Compile Include="ThingComp\WULA_WeaponSwitch\CompAbilityEffect_RemoveSwitchHediff.cs" />
<Compile Include="ThingComp\WULA_WeaponSwitch\WeaponSwitch.cs" />
<Compile Include="Utils\BezierUtil.cs" />
<Compile Include="Verb\MeleeAttack_Cleave\CompCleave.cs" />
<Compile Include="Verb\MeleeAttack_Cleave\Verb_MeleeAttack_Cleave.cs" />
<Compile Include="Verb\MeleeAttack_MultiStrike\CompMultiStrike.cs" />
<Compile Include="Verb\MeleeAttack_MultiStrike\Verb_MeleeAttack_MultiStrike.cs" />
<Compile Include="Verb\Verb_Excalibur\Thing_ExcaliburBeam.cs" />
<Compile Include="Verb\Verb_Excalibur\VerbProperties_Excalibur.cs" />
<Compile Include="Verb\Verb_Excalibur\Verb_Excalibur.cs" />
<Compile Include="Verb\Verb_ShootArc.cs" />
<Compile Include="Verb\Verb_ShootBeamExplosive\VerbPropertiesExplosiveBeam.cs" />
<Compile Include="Verb\Verb_ShootBeamExplosive\Verb_ShootBeamExplosive.cs" />
<Compile Include="Verb\Verb_ShootBeamSplitAndChain.cs" />
<Compile Include="Verb\Verb_ShootMeltBeam.cs" />
<Compile Include="Verb\Verb_ShootShotgun.cs" />
<Compile Include="Verb\Verb_ShootShotgunWithOffset.cs" />
<Compile Include="Verb\Verb_ShootWeaponStealBeam\VerbProperties_WeaponStealBeam.cs" />
<Compile Include="Verb\Verb_ShootWeaponStealBeam\Verb_ShootWeaponStealBeam.cs" />
<Compile Include="Verb\Verb_ShootWithOffset.cs" />
<Compile Include="WorkGiver\WorkGiver_DeepDrill_WulaConstructor.cs" />
<Compile Include="WulaDefOf.cs" />
<Compile Include="WulaFallenEmpireMod.cs" />
<Compile Include="**\*.cs" Exclude="bin\**;obj\**" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<!-- 自定义清理任务删除obj文件夹中的临时文件 -->
@@ -406,4 +82,4 @@
<RemoveDir Directories="$(ProjectDir)obj\Debug" />
<RemoveDir Directories="$(ProjectDir)obj\Release" />
</Target>
</Project>
</Project>

View File

@@ -9,8 +9,12 @@ namespace WulaFallenEmpire
[StaticConstructorOnStartup]
public class WulaFallenEmpireMod : Mod
{
public static WulaFallenEmpireSettings settings;
public WulaFallenEmpireMod(ModContentPack content) : base(content)
{
settings = GetSettings<WulaFallenEmpireSettings>();
// 初始化Harmony
var harmony = new Harmony("tourswen.wulafallenempire"); // 替换为您的唯一Mod ID
harmony.PatchAll(Assembly.GetExecutingAssembly());
@@ -18,6 +22,30 @@ namespace WulaFallenEmpire
Log.Message("[WulaFallenEmpire] Harmony patches applied.");
}
public override void DoSettingsWindowContents(Rect inRect)
{
Listing_Standard listingStandard = new Listing_Standard();
listingStandard.Begin(inRect);
listingStandard.Label("Wula_AISettings_Title".Translate());
listingStandard.Label("Wula_AISettings_ApiKey".Translate());
settings.apiKey = listingStandard.TextEntry(settings.apiKey);
listingStandard.Label("Wula_AISettings_BaseUrl".Translate());
settings.baseUrl = listingStandard.TextEntry(settings.baseUrl);
listingStandard.Label("Wula_AISettings_Model".Translate());
settings.model = listingStandard.TextEntry(settings.model);
listingStandard.End();
base.DoSettingsWindowContents(inRect);
}
public override string SettingsCategory()
{
return "Wula Fallen Empire";
}
}
[StaticConstructorOnStartup]

View File

@@ -0,0 +1,19 @@
using Verse;
namespace WulaFallenEmpire
{
public class WulaFallenEmpireSettings : ModSettings
{
public string apiKey = "sk-xxxxxxxx";
public string baseUrl = "https://api.deepseek.com";
public string model = "deepseek-chat";
public override void ExposeData()
{
Scribe_Values.Look(ref apiKey, "apiKey", "sk-xxxxxxxx");
Scribe_Values.Look(ref baseUrl, "baseUrl", "https://api.deepseek.com");
Scribe_Values.Look(ref model, "model", "deepseek-chat");
base.ExposeData();
}
}
}